Skip to content
190 linesCodeBlameRaw
1import assert from "node:assert/strict";
2import { readFileSync } from "node:fs";
3import { test } from "node:test";
4
5import type { ServiceBinding } from "./clients.ts";
6import { datasetQueryError } from "./datasets.ts";
7import { dashboardGridError, dashboardOpError } from "./folios-dashboard.ts";
8import { DESIGN_MAX_NODES, countDesignSpecs, designOpError } from "./folios-design.ts";
9import { SLIDE_LAYOUTS, SLIDE_LAYOUT_REGIONS, slideFragment, slidesOpError } from "./folios-slides.ts";
10import {
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";
27import type { User } from "./identity.ts";
28
29const 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
36function 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
42test("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
49test("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
59test("only a doc holds other folios", () => {
60 assert.deepEqual(FOLIO_KINDS.filter(folioCanHaveChildren), ["doc"]);
61});
62
63test("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
70test("the client calls exactly the artifacts 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.page(ws, viewer, f),
89 client.create(ws, viewer, { kind: "doc" }),
90 client.update(ws, viewer, f, { title: "x" }),
91 client.move(ws, viewer, f, { space_id: null, parent_id: null }),
92 client.duplicate(ws, viewer, f),
93 client.trash(ws, viewer, f),
94 client.restore(ws, viewer, f),
95 client.delete(ws, viewer, f),
96 client.trashed(ws, viewer),
97 client.favorite(ws, viewer, f, true),
98 client.content(ws, viewer, f),
99 client.edit(ws, viewer, f, { kind: "doc", target: { kind: "append" }, markdown: "x" }),
100 client.access(ws, viewer, f),
101 client.changeAccess(ws, viewer, f, { op: "grant", principal: "user:usr_2", role: "view" }),
102 client.changeAccess(ws, viewer, f, { op: "general", access: "workspace", role: "view" }),
103 client.requestAccess(ws, viewer, f),
104 client.joinSpace(ws, viewer, "spc_1"),
105 client.leaveSpace(ws, viewer, "spc_1"),
106 client.search(ws, viewer, { q: "roadmap" }),
107 client.versions(ws, viewer, f),
108 client.version(ws, viewer, f, "ver_1"),
109 client.restoreVersion(ws, viewer, f, "ver_1"),
110 client.templates(ws, viewer),
111 client.saveTemplate(ws, viewer, { folio_id: f, name: "T" }),
112 client.deleteTemplate(ws, viewer, "tpl_1"),
113 client.export(ws, viewer, f),
114 client.suggestions(ws, viewer, f),
115 client.decideSuggestion(ws, viewer, "sug_1", "accept"),
116 client.proposals(ws, viewer, f),
117 client.decideProposal(ws, viewer, "prp_1", "reject"),
118 client.thread(ws, viewer, f, { op: "resolve", thread_id: "thr_1" }),
119 client.threads(ws, viewer, f),
120 client.queryTile(ws, viewer, f, "t1"),
121 client.queryDataset(ws, viewer, { dataset: "issues", measure: { op: "count" } }),
122 client.queryDatasetForAgent(ws, "agt_1", viewer, { folio_id: f, tile_id: "t1" }),
123 client.foliosForAgent(ws, "agt_1", viewer, { tab: "all" }),
124 client.readForAgent(ws, "agt_1", viewer, f),
125 client.createAsAgent(ws, "agt_1", viewer, { kind: "doc", title: "Notes", where: "private" }),
126 client.editAsAgent(ws, "agt_1", viewer, f, { kind: "slides", ops: [{ op: "delete_slides", slide_ids: ["s1"] }] }),
127 client.shareAsAgent(ws, "agt_1", viewer, f, { user_ids: ["usr_2"], role: "view" }, { kind: "people", user_ids: ["usr_1", "usr_2"] }),
128 client.attachAsAgent(ws, "agt_1", viewer, f, { name: "q3.pdf", content_type: "application/pdf", data: "JVBERi0=" }),
129 client.recallForAgent(ws, "agt_1", viewer, { query: "pricing" }),
130 client.staleForAgent(ws, "agt_1", viewer),
131 client.markCurrent(ws, viewer, f),
132 client.reindex(ws, viewer),
133 ]);
134 assert.deepEqual([...new Set(called)].sort(), [...FOLIO_RPC_METHODS].sort());
135 // Every body names the workspace and the viewer, and keys are snake_case.
136 for (const body of bodies) {
137 assert.equal(body.workspace, ws);
138 assert.ok(body.viewer);
139 for (const key of Object.keys(body)) assert.match(key, /^[a-z_]+$/, key);
140 }
141});
142
143test("slides ops are checked by shape", () => {
144 assert.equal(slidesOpError({ op: "insert_slides", after_slide_id: null, markdown: "---" }), null);
145 assert.equal(slidesOpError({ op: "move_slide", slide_id: "s1", after_slide_id: "s2" }), null);
146 assert.equal(slidesOpError({ op: "set_theme", theme: "night", accent: "#8b7cf6" }), null);
147 assert.equal(slidesOpError({ op: "set_theme", theme: "night", accent: "lavender" }), "accent is a colour like #8b7cf6, or null.");
148 assert.equal(slidesOpError({ op: "replace_slide", markdown: "# x" }), "replace_slide needs slide_id.");
149 assert.equal(slidesOpError({ op: "delete_slides", slide_ids: [] }), "delete_slides needs slide_ids.");
150 assert.equal(slidesOpError({ op: "shuffle" }), "There is no slides op called shuffle.");
151 for (const layout of SLIDE_LAYOUTS) assert.ok(SLIDE_LAYOUT_REGIONS[layout].includes("notes"), layout);
152 assert.equal(slideFragment("s1", "left"), "slide:s1:left");
153});
154
155test("design ops are checked by shape and size", () => {
156 const frame = { type: "frame", name: "Home", layout: "column", gap: 16, children: [{ type: "text", text: "Hello" }, { type: "html", html: "<b>hi</b>" }] };
157 assert.equal(designOpError({ op: "upsert_nodes", nodes: [frame] }), null);
158 assert.deepEqual(countDesignSpecs([frame as never]), { nodes: 3, depth: 2 });
159 assert.equal(designOpError({ op: "upsert_nodes", nodes: [{ type: "rect", children: [{ type: "text" }] }] }), "Only frames and components have children.");
160 assert.equal(designOpError({ op: "upsert_nodes", nodes: [{ type: "text", html: "<b>" }] }), "Only an html node has html.");
161 assert.equal(designOpError({ op: "upsert_nodes", nodes: [{ type: "blob" }] }), "There is no node type called blob.");
162 assert.equal(designOpError({ op: "set_html", node_id: "n1", html: "x".repeat(200 * 1024 + 1) }), "An html node holds at most 200 KB.");
163 assert.equal(designOpError({ op: "set_html", node_id: "n1", html: "é".repeat(100 * 1024) }), null);
164 assert.equal(designOpError({ op: "set_html", node_id: "n1", html: "é".repeat(100 * 1024 + 1) }), "An html node holds at most 200 KB.");
165 const many = Array.from({ length: DESIGN_MAX_NODES + 1 }, () => ({ type: "rect" }));
166 assert.equal(designOpError({ op: "upsert_nodes", nodes: many }), `A design holds at most ${DESIGN_MAX_NODES} nodes.`);
167 assert.equal(designOpError({ op: "move", ids: ["a"], dx: 1, dy: Number.NaN }), "move needs dx and dy.");
168 assert.equal(designOpError({ op: "instance" }), "There is no design op called instance.");
169});
170
171test("dashboard ops check tiles, their queries and the grid", () => {
172 const tile = {
173 id: "t1",
174 type: "line",
175 title: "Merged per week",
176 description: "",
177 query: { dataset: "pull_requests", measure: { op: "count" }, interval: "week", time: "merged_at" },
178 viz: {},
179 grid: { x: 0, y: 0, w: 6, h: 4 },
180 };
181 assert.equal(dashboardOpError({ op: "upsert_tile", tile }, datasetQueryError), null);
182 assert.equal(dashboardOpError({ op: "upsert_tile", tile: { ...tile, query: { ...tile.query, group_by: "secret" } } }, datasetQueryError), "pull_requests can't be grouped by secret.");
183 assert.equal(dashboardOpError({ op: "upsert_tile", tile: { ...tile, type: "markdown", query: null, markdown: "Notes" } }, datasetQueryError), null);
184 assert.equal(dashboardOpError({ op: "upsert_tile", tile: { ...tile, type: "markdown" } }, datasetQueryError), "A text tile has no query.");
185 assert.equal(dashboardOpError({ op: "upsert_tile", tile: { ...tile, grid: { x: 8, y: 0, w: 6, h: 4 } } }, datasetQueryError), "A tile fits in 12 columns.");
186 assert.equal(dashboardOpError({ op: "set_filters", filters: { range: "30d", repos: ["acme/web"] } }, datasetQueryError), null);
187 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.");
188 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.");
189 assert.equal(dashboardGridError({ x: 0, y: 0, w: 12, h: 1 }), null);
190});