| 1 | import assert from "node:assert/strict"; |
| 2 | import { readFileSync } from "node:fs"; |
| 3 | import { test } from "node:test"; |
| 4 | |
| 5 | import type { ServiceBinding } from "./clients.ts"; |
| 6 | import { datasetQueryError } from "./datasets.ts"; |
| 7 | import { dashboardGridError, dashboardOpError } from "./folios-dashboard.ts"; |
| 8 | import { DESIGN_MAX_NODES, countDesignSpecs, designOpError } from "./folios-design.ts"; |
| 9 | import { SLIDE_LAYOUTS, SLIDE_LAYOUT_REGIONS, slideFragment, slidesOpError } from "./folios-slides.ts"; |
| 10 | import { |
| 11 | FOLIO_KINDS, |
| 12 | FOLIO_RPC_METHODS, |
| 13 | FOLIO_ROUTE_SEGMENTS, |
| 14 | folioAccessChangeError, |
| 15 | folioAgentEditError, |
| 16 | folioCanHaveChildren, |
| 17 | folioIdFrom, |
| 18 | folioListQueryError, |
| 19 | folioPath, |
| 20 | folioSlug, |
| 21 | foliosClient, |
| 22 | linkedFolioIds, |
| 23 | newFolioError, |
| 24 | type FolioAccessChange, |
| 25 | type NewFolio, |
| 26 | } from "./folios.ts"; |
| 27 | import type { User } from "./identity.ts"; |
| 28 | |
| 29 | const fixtures = JSON.parse(readFileSync(new URL("./folios.fixtures.json", import.meta.url), "utf8")) as { |
| 30 | new_folio: { input: unknown; error: string | null }[]; |
| 31 | access_change: { change: unknown; error: string | null }[]; |
| 32 | agent_edit: { edit: unknown; error: string | null }[]; |
| 33 | folio_ids: { segment: string; id: string | null }[]; |
| 34 | }; |
| 35 | |
| 36 | function expect(label: string, actual: string | null, error: string | null) { |
| 37 | if (error === null) assert.equal(actual, null, label); |
| 38 | else if (error === "*") assert.notEqual(actual, null, `${label} should be refused`); |
| 39 | else assert.equal(actual, error, label); |
| 40 | } |
| 41 | |
| 42 | test("agrees with the Rust validators on every shared case", () => { |
| 43 | for (const { input, error } of fixtures.new_folio) expect(JSON.stringify(input), newFolioError(input as NewFolio), error); |
| 44 | for (const { change, error } of fixtures.access_change) expect(JSON.stringify(change), folioAccessChangeError(change as FolioAccessChange), error); |
| 45 | for (const { edit, error } of fixtures.agent_edit) expect(JSON.stringify(edit), folioAgentEditError(edit), error); |
| 46 | for (const { segment, id } of fixtures.folio_ids) assert.equal(folioIdFrom(segment), id, segment); |
| 47 | }); |
| 48 | |
| 49 | test("addresses are flat, end in the id and survive renames", () => { |
| 50 | const id = "fol_01jb2k7x9hfq0b3zj0f5s2m8ra"; |
| 51 | assert.equal(folioPath("acme", "Q4 roadmap: pricing", id), `/acme/-/artifacts/q4-roadmap-pricing-${id}`); |
| 52 | assert.equal(folioSlug("", id), id); |
| 53 | assert.equal(folioSlug("Café ✨ notes", id), `cafe-notes-${id}`); |
| 54 | assert.equal(folioIdFrom(folioSlug("Anything at all", id)), id); |
| 55 | assert.deepEqual(linkedFolioIds(`see /acme/-/artifacts/x-${id} and ${id}`), [id]); |
| 56 | for (const segment of FOLIO_ROUTE_SEGMENTS) assert.equal(folioIdFrom(segment), null, segment); |
| 57 | }); |
| 58 | |
| 59 | test("only a doc holds other folios", () => { |
| 60 | assert.deepEqual(FOLIO_KINDS.filter(folioCanHaveChildren), ["doc"]); |
| 61 | }); |
| 62 | |
| 63 | test("list queries are checked", () => { |
| 64 | assert.equal(folioListQueryError({ tab: "shared", kinds: ["slides"], limit: 50 }), null); |
| 65 | assert.equal(folioListQueryError({ tab: "everything" as "all" }), "tab is all, yours or shared."); |
| 66 | assert.equal(folioListQueryError({ tab: "all", limit: 500 }), "limit is between 1 and 100."); |
| 67 | assert.equal(folioListQueryError({ tab: "all", kinds: ["sheet" as "doc"] }), "There is no kind of artifact called sheet."); |
| 68 | }); |
| 69 | |
| 70 | test("the client calls exactly the docs service's folio methods", async () => { |
| 71 | const called: string[] = []; |
| 72 | const bodies: Record<string, unknown>[] = []; |
| 73 | const binding: ServiceBinding = { |
| 74 | fetch: async (input: RequestInfo | URL, init?: RequestInit) => { |
| 75 | called.push(String(input).replace("https://service/rpc/", "")); |
| 76 | bodies.push(JSON.parse(String(init?.body))); |
| 77 | return new Response(JSON.stringify({ ok: true, value: null }), { headers: { "content-type": "application/json" } }); |
| 78 | }, |
| 79 | } as ServiceBinding; |
| 80 | const client = foliosClient(binding); |
| 81 | const viewer = { id: "usr_1" } as User; |
| 82 | const ws = "acme"; |
| 83 | const f = "fol_1"; |
| 84 | await Promise.all([ |
| 85 | client.list(ws, viewer, { tab: "all" }), |
| 86 | client.sidebar(ws, viewer), |
| 87 | client.folio(ws, viewer, f), |
| 88 | client.create(ws, viewer, { kind: "doc" }), |
| 89 | client.update(ws, viewer, f, { title: "x" }), |
| 90 | client.move(ws, viewer, f, { space_id: null, parent_id: null }), |
| 91 | client.duplicate(ws, viewer, f), |
| 92 | client.trash(ws, viewer, f), |
| 93 | client.restore(ws, viewer, f), |
| 94 | client.delete(ws, viewer, f), |
| 95 | client.trashed(ws, viewer), |
| 96 | client.favorite(ws, viewer, f, true), |
| 97 | client.content(ws, viewer, f), |
| 98 | client.edit(ws, viewer, f, { kind: "doc", target: { kind: "append" }, markdown: "x" }), |
| 99 | client.access(ws, viewer, f), |
| 100 | client.changeAccess(ws, viewer, f, { op: "grant", principal: "user:usr_2", role: "view" }), |
| 101 | client.changeAccess(ws, viewer, f, { op: "general", access: "workspace", role: "view" }), |
| 102 | client.requestAccess(ws, viewer, f), |
| 103 | client.joinSpace(ws, viewer, "spc_1"), |
| 104 | client.leaveSpace(ws, viewer, "spc_1"), |
| 105 | client.search(ws, viewer, { q: "roadmap" }), |
| 106 | client.versions(ws, viewer, f), |
| 107 | client.version(ws, viewer, f, "ver_1"), |
| 108 | client.restoreVersion(ws, viewer, f, "ver_1"), |
| 109 | client.templates(ws, viewer), |
| 110 | client.saveTemplate(ws, viewer, { folio_id: f, name: "T" }), |
| 111 | client.deleteTemplate(ws, viewer, "tpl_1"), |
| 112 | client.export(ws, viewer, f), |
| 113 | client.suggestions(ws, viewer, f), |
| 114 | client.decideSuggestion(ws, viewer, "sug_1", "accept"), |
| 115 | client.proposals(ws, viewer, f), |
| 116 | client.decideProposal(ws, viewer, "prp_1", "reject"), |
| 117 | client.thread(ws, viewer, f, { op: "resolve", thread_id: "thr_1" }), |
| 118 | client.threads(ws, viewer, f), |
| 119 | client.queryTile(ws, viewer, f, "t1"), |
| 120 | client.queryDataset(ws, viewer, { dataset: "issues", measure: { op: "count" } }), |
| 121 | client.queryDatasetForAgent(ws, "agt_1", viewer, { folio_id: f, tile_id: "t1" }), |
| 122 | client.foliosForAgent(ws, "agt_1", viewer, { tab: "all" }), |
| 123 | client.readForAgent(ws, "agt_1", viewer, f), |
| 124 | client.createAsAgent(ws, "agt_1", viewer, { kind: "doc", title: "Notes", where: "private" }), |
| 125 | client.editAsAgent(ws, "agt_1", viewer, f, { kind: "slides", ops: [{ op: "delete_slides", slide_ids: ["s1"] }] }), |
| 126 | client.shareAsAgent(ws, "agt_1", viewer, f, { user_ids: ["usr_2"], role: "view" }, { kind: "people", user_ids: ["usr_1", "usr_2"] }), |
| 127 | client.recallForAgent(ws, "agt_1", viewer, { query: "pricing" }), |
| 128 | client.staleForAgent(ws, "agt_1", viewer), |
| 129 | client.markCurrent(ws, viewer, f), |
| 130 | client.reindex(ws, viewer), |
| 131 | ]); |
| 132 | assert.deepEqual([...new Set(called)].sort(), [...FOLIO_RPC_METHODS].sort()); |
| 133 | // Every body names the workspace and the viewer, and keys are snake_case. |
| 134 | for (const body of bodies) { |
| 135 | assert.equal(body.workspace, ws); |
| 136 | assert.ok(body.viewer); |
| 137 | for (const key of Object.keys(body)) assert.match(key, /^[a-z_]+$/, key); |
| 138 | } |
| 139 | }); |
| 140 | |
| 141 | test("slides ops are checked by shape", () => { |
| 142 | assert.equal(slidesOpError({ op: "insert_slides", after_slide_id: null, markdown: "---" }), null); |
| 143 | assert.equal(slidesOpError({ op: "move_slide", slide_id: "s1", after_slide_id: "s2" }), null); |
| 144 | assert.equal(slidesOpError({ op: "set_theme", theme: "night", accent: "#8b7cf6" }), null); |
| 145 | assert.equal(slidesOpError({ op: "set_theme", theme: "night", accent: "lavender" }), "accent is a colour like #8b7cf6, or null."); |
| 146 | assert.equal(slidesOpError({ op: "replace_slide", markdown: "# x" }), "replace_slide needs slide_id."); |
| 147 | assert.equal(slidesOpError({ op: "delete_slides", slide_ids: [] }), "delete_slides needs slide_ids."); |
| 148 | assert.equal(slidesOpError({ op: "shuffle" }), "There is no slides op called shuffle."); |
| 149 | for (const layout of SLIDE_LAYOUTS) assert.ok(SLIDE_LAYOUT_REGIONS[layout].includes("notes"), layout); |
| 150 | assert.equal(slideFragment("s1", "left"), "slide:s1:left"); |
| 151 | }); |
| 152 | |
| 153 | test("design ops are checked by shape and size", () => { |
| 154 | const frame = { type: "frame", name: "Home", layout: "column", gap: 16, children: [{ type: "text", text: "Hello" }, { type: "html", html: "<b>hi</b>" }] }; |
| 155 | assert.equal(designOpError({ op: "upsert_nodes", nodes: [frame] }), null); |
| 156 | assert.deepEqual(countDesignSpecs([frame as never]), { nodes: 3, depth: 2 }); |
| 157 | assert.equal(designOpError({ op: "upsert_nodes", nodes: [{ type: "rect", children: [{ type: "text" }] }] }), "Only frames and components have children."); |
| 158 | assert.equal(designOpError({ op: "upsert_nodes", nodes: [{ type: "text", html: "<b>" }] }), "Only an html node has html."); |
| 159 | assert.equal(designOpError({ op: "upsert_nodes", nodes: [{ type: "blob" }] }), "There is no node type called blob."); |
| 160 | assert.equal(designOpError({ op: "set_html", node_id: "n1", html: "x".repeat(200 * 1024 + 1) }), "An html node holds at most 200 KB."); |
| 161 | assert.equal(designOpError({ op: "set_html", node_id: "n1", html: "é".repeat(100 * 1024) }), null); |
| 162 | assert.equal(designOpError({ op: "set_html", node_id: "n1", html: "é".repeat(100 * 1024 + 1) }), "An html node holds at most 200 KB."); |
| 163 | const many = Array.from({ length: DESIGN_MAX_NODES + 1 }, () => ({ type: "rect" })); |
| 164 | assert.equal(designOpError({ op: "upsert_nodes", nodes: many }), `A design holds at most ${DESIGN_MAX_NODES} nodes.`); |
| 165 | assert.equal(designOpError({ op: "move", ids: ["a"], dx: 1, dy: Number.NaN }), "move needs dx and dy."); |
| 166 | assert.equal(designOpError({ op: "instance" }), "There is no design op called instance."); |
| 167 | }); |
| 168 | |
| 169 | test("dashboard ops check tiles, their queries and the grid", () => { |
| 170 | const tile = { |
| 171 | id: "t1", |
| 172 | type: "line", |
| 173 | title: "Merged per week", |
| 174 | description: "", |
| 175 | query: { dataset: "pull_requests", measure: { op: "count" }, interval: "week", time: "merged_at" }, |
| 176 | viz: {}, |
| 177 | grid: { x: 0, y: 0, w: 6, h: 4 }, |
| 178 | }; |
| 179 | assert.equal(dashboardOpError({ op: "upsert_tile", tile }, datasetQueryError), null); |
| 180 | assert.equal(dashboardOpError({ op: "upsert_tile", tile: { ...tile, query: { ...tile.query, group_by: "secret" } } }, datasetQueryError), "pull_requests can't be grouped by secret."); |
| 181 | assert.equal(dashboardOpError({ op: "upsert_tile", tile: { ...tile, type: "markdown", query: null, markdown: "Notes" } }, datasetQueryError), null); |
| 182 | assert.equal(dashboardOpError({ op: "upsert_tile", tile: { ...tile, type: "markdown" } }, datasetQueryError), "A text tile has no query."); |
| 183 | assert.equal(dashboardOpError({ op: "upsert_tile", tile: { ...tile, grid: { x: 8, y: 0, w: 6, h: 4 } } }, datasetQueryError), "A tile fits in 12 columns."); |
| 184 | assert.equal(dashboardOpError({ op: "set_filters", filters: { range: "30d", repos: ["acme/web"] } }, datasetQueryError), null); |
| 185 | assert.equal(dashboardOpError({ op: "set_filters", filters: { range: { from: "2026-02-30", to: "2026-03-01" } } }, datasetQueryError), "A range's from and to are dates or RFC 3339 UTC times."); |
| 186 | assert.equal(dashboardOpError({ op: "set_layout", tiles: [{ id: "t1", grid: { x: 0, y: 0, w: 0, h: 1 } }] }, datasetQueryError), "grid starts at 0, 0 and is at least 1 by 1."); |
| 187 | assert.equal(dashboardGridError({ x: 0, y: 0, w: 12, h: 1 }), null); |
| 188 | }); |