@caddis/cli 0.4.43 → 0.4.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/bundles/antigravity-plugin/plugin.json +1 -1
  2. package/bundles/antigravity-plugin/scripts/caddis_file_lock.py +48 -7
  3. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +27 -0
  4. package/bundles/antigravity-plugin/scripts/caddis_todo.py +7 -5
  5. package/bundles/antigravity-plugin/scripts/claude-oss.ps1 +15 -3
  6. package/bundles/antigravity-plugin/scripts/claude-oss.sh +12 -1
  7. package/bundles/antigravity-plugin/skills/_registry.md +5 -4
  8. package/bundles/antigravity-plugin/skills/adopt-app/SKILL.md +6 -1
  9. package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +7 -1
  10. package/bundles/antigravity-plugin/skills/decide/SKILL.md +1 -1
  11. package/bundles/antigravity-plugin/skills/flow-studio/README.md +53 -0
  12. package/bundles/antigravity-plugin/skills/flow-studio/SKILL.md +103 -0
  13. package/bundles/antigravity-plugin/skills/flow-studio/examples/README.md +11 -0
  14. package/bundles/antigravity-plugin/skills/flow-studio/examples/hostile.json +1783 -0
  15. package/bundles/antigravity-plugin/skills/flow-studio/examples/lineage.json +1778 -0
  16. package/bundles/antigravity-plugin/skills/flow-studio/model.schema.json +123 -0
  17. package/bundles/antigravity-plugin/skills/flow-studio/package.json +11 -0
  18. package/bundles/antigravity-plugin/skills/flow-studio/references/patterns.md +79 -0
  19. package/bundles/antigravity-plugin/skills/flow-studio/scripts/build-engine.mjs +55 -0
  20. package/bundles/antigravity-plugin/skills/flow-studio/scripts/build.mjs +37 -0
  21. package/bundles/antigravity-plugin/skills/flow-studio/scripts/check.mjs +388 -0
  22. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/build-engine.test.mjs +26 -0
  23. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-selftest.test.mjs +75 -0
  24. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/cli.test.mjs +64 -0
  25. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/embed.mjs +24 -0
  26. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/embed.test.mjs +29 -0
  27. package/bundles/antigravity-plugin/skills/flow-studio/scripts/validate.mjs +38 -0
  28. package/bundles/antigravity-plugin/skills/flow-studio/src/chrome.ts +171 -0
  29. package/bundles/antigravity-plugin/skills/flow-studio/src/controls.ts +231 -0
  30. package/bundles/antigravity-plugin/skills/flow-studio/src/ctx.ts +118 -0
  31. package/bundles/antigravity-plugin/skills/flow-studio/src/engine.ts +142 -0
  32. package/bundles/antigravity-plugin/skills/flow-studio/src/inspector.ts +108 -0
  33. package/bundles/antigravity-plugin/skills/flow-studio/src/interact.ts +240 -0
  34. package/bundles/antigravity-plugin/skills/flow-studio/src/layout.test.ts +69 -0
  35. package/bundles/antigravity-plugin/skills/flow-studio/src/layout.ts +57 -0
  36. package/bundles/antigravity-plugin/skills/flow-studio/src/model.test.ts +107 -0
  37. package/bundles/antigravity-plugin/skills/flow-studio/src/model.ts +165 -0
  38. package/bundles/antigravity-plugin/skills/flow-studio/src/notes.ts +92 -0
  39. package/bundles/antigravity-plugin/skills/flow-studio/src/render.ts +155 -0
  40. package/bundles/antigravity-plugin/skills/flow-studio/src/routing.test.ts +13 -0
  41. package/bundles/antigravity-plugin/skills/flow-studio/src/routing.ts +11 -0
  42. package/bundles/antigravity-plugin/skills/flow-studio/src/rules.test.ts +88 -0
  43. package/bundles/antigravity-plugin/skills/flow-studio/src/rules.ts +35 -0
  44. package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.test.ts +66 -0
  45. package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.ts +44 -0
  46. package/bundles/antigravity-plugin/skills/flow-studio/src/styles.css +157 -0
  47. package/bundles/antigravity-plugin/skills/flow-studio/src/trace.test.ts +20 -0
  48. package/bundles/antigravity-plugin/skills/flow-studio/src/trace.ts +22 -0
  49. package/bundles/antigravity-plugin/skills/flow-studio/src/viewport.test.ts +55 -0
  50. package/bundles/antigravity-plugin/skills/flow-studio/src/viewport.ts +45 -0
  51. package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.html +1744 -0
  52. package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.shell.html +61 -0
  53. package/bundles/antigravity-plugin/skills/flow-studio/tsconfig.json +17 -0
  54. package/bundles/antigravity-plugin/skills/implement/SKILL.md +8 -0
  55. package/bundles/antigravity-plugin/skills/particle-art/SKILL.md +6 -1
  56. package/bundles/antigravity-plugin/skills/port-to-github/SKILL.md +6 -1
  57. package/bundles/antigravity-plugin/skills/tech-explainer/SKILL.md +5 -6
  58. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  59. package/bundles/antigravity-plugin-extras/skills/_registry.md +2 -0
  60. package/bundles/antigravity-plugin-extras/skills/adopt-rbac/SKILL.md +26 -0
  61. package/bundles/antigravity-plugin-extras/skills/case-management/SKILL.md +24 -0
  62. package/bundles/codex/.codex/skills/devops/adopt-app/SKILL.md +6 -1
  63. package/bundles/codex/.codex/skills/devops/port-to-github/SKILL.md +6 -1
  64. package/bundles/codex/.codex/skills/docs/tech-explainer/SKILL.md +5 -6
  65. package/bundles/codex/.codex/skills/frontend/flow-studio/README.md +53 -0
  66. package/bundles/codex/.codex/skills/frontend/flow-studio/SKILL.md +103 -0
  67. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/README.md +11 -0
  68. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/hostile.json +1783 -0
  69. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/lineage.json +1778 -0
  70. package/bundles/codex/.codex/skills/frontend/flow-studio/model.schema.json +123 -0
  71. package/bundles/codex/.codex/skills/frontend/flow-studio/package.json +11 -0
  72. package/bundles/codex/.codex/skills/frontend/flow-studio/references/patterns.md +79 -0
  73. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build-engine.mjs +55 -0
  74. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build.mjs +37 -0
  75. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/check.mjs +388 -0
  76. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/build-engine.test.mjs +26 -0
  77. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-selftest.test.mjs +75 -0
  78. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/cli.test.mjs +64 -0
  79. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/embed.mjs +24 -0
  80. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/embed.test.mjs +29 -0
  81. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/validate.mjs +38 -0
  82. package/bundles/codex/.codex/skills/frontend/flow-studio/src/chrome.ts +171 -0
  83. package/bundles/codex/.codex/skills/frontend/flow-studio/src/controls.ts +231 -0
  84. package/bundles/codex/.codex/skills/frontend/flow-studio/src/ctx.ts +118 -0
  85. package/bundles/codex/.codex/skills/frontend/flow-studio/src/engine.ts +142 -0
  86. package/bundles/codex/.codex/skills/frontend/flow-studio/src/inspector.ts +108 -0
  87. package/bundles/codex/.codex/skills/frontend/flow-studio/src/interact.ts +240 -0
  88. package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.test.ts +69 -0
  89. package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.ts +57 -0
  90. package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.test.ts +107 -0
  91. package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.ts +165 -0
  92. package/bundles/codex/.codex/skills/frontend/flow-studio/src/notes.ts +92 -0
  93. package/bundles/codex/.codex/skills/frontend/flow-studio/src/render.ts +155 -0
  94. package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.test.ts +13 -0
  95. package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.ts +11 -0
  96. package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.test.ts +88 -0
  97. package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.ts +35 -0
  98. package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.test.ts +66 -0
  99. package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.ts +44 -0
  100. package/bundles/codex/.codex/skills/frontend/flow-studio/src/styles.css +157 -0
  101. package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.test.ts +20 -0
  102. package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.ts +22 -0
  103. package/bundles/codex/.codex/skills/frontend/flow-studio/src/viewport.test.ts +55 -0
  104. package/bundles/codex/.codex/skills/frontend/flow-studio/src/viewport.ts +45 -0
  105. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.html +1744 -0
  106. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.shell.html +61 -0
  107. package/bundles/codex/.codex/skills/frontend/flow-studio/tsconfig.json +17 -0
  108. package/bundles/codex/.codex/skills/media/particle-art/SKILL.md +6 -1
  109. package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +7 -1
  110. package/bundles/manifest.json +3 -3
  111. package/package.json +1 -1
@@ -0,0 +1,388 @@
1
+ // Playwright check harness for a flow-studio page. Model-agnostic: it reads the model from the page.
2
+ // node scripts/check.mjs <page.html> [--out dir] [--viewports 1600x900,1280x720,820x900]
3
+ // [--channel msedge] [--hostile hostile.html] [--json report.json] [--skip-perf]
4
+ // Run it from a folder that can resolve "playwright" (the project's node_modules). Exit 0: every check passed.
5
+
6
+ import { createRequire } from "node:module";
7
+ import { mkdirSync, writeFileSync } from "node:fs";
8
+ import { resolve, join } from "node:path";
9
+ import { pathToFileURL } from "node:url";
10
+
11
+ const argv = process.argv.slice(2);
12
+ const opt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : dflt; };
13
+ const flag = name => argv.includes(name);
14
+ const pageArg = argv[0];
15
+ if (!pageArg || pageArg.startsWith("--")) { console.error("usage: node scripts/check.mjs <page.html> [--out dir] [--viewports WxH,...] [--channel msedge] [--hostile page.html] [--json file] [--skip-perf]"); process.exit(2); }
16
+ const pagePath = resolve(pageArg ?? "");
17
+ const outDir = resolve(opt("--out", "check-out"));
18
+ const viewports = opt("--viewports", "1600x900,1280x720,820x900").split(",").map(v => v.split("x").map(Number));
19
+ const channel = opt("--channel", undefined);
20
+ const hostilePath = opt("--hostile", undefined);
21
+ mkdirSync(outDir, { recursive: true });
22
+
23
+ let chromium;
24
+ try { ({ chromium } = createRequire(join(process.cwd(), "x.js"))("playwright")); }
25
+ catch { console.error("Cannot resolve 'playwright'. Run this from a folder whose node_modules has it (npm i -D playwright)."); process.exit(2); }
26
+
27
+ const results = [];
28
+ const check = (group, name, ok, detail = "") => { results.push({ group, name, ok: !!ok, detail: String(detail) }); console.log(`${ok ? "PASS" : "FAIL"} [${group}] ${name}${detail ? " " + detail : ""}`); };
29
+
30
+ // ---- helpers that run in the page
31
+ const inPage = {
32
+ state: () => {
33
+ const f = window.FlowStudio;
34
+ return { view: f.view(), selected: f.selected(), mode: f.mode() };
35
+ },
36
+ };
37
+ const url = p => pathToFileURL(p).href;
38
+
39
+ // ---- model-side helpers (run here, from the model the page reports)
40
+ function modelRules(model) {
41
+ const view = (model.views?.find(v => v.default) ?? model.views?.[0])?.id ?? "all";
42
+ const nodes = new Map(model.nodes.map(n => [n.id, n]));
43
+ const canon = id => nodes.get(id)?.ref ?? id;
44
+ const nv = (n, v = view) => !n.visibleIn || n.visibleIn.includes(v);
45
+ const lv = (l, v = view) => (!l.visibleIn || l.visibleIn.includes(v)) && nodes.get(l.from) && nodes.get(l.to) && nv(nodes.get(l.from), v) && nv(nodes.get(l.to), v);
46
+ const down = (start, v = view) => {
47
+ const s = new Set([canon(start)]); let grew = true;
48
+ while (grew) { grew = false; for (const l of model.links) { if (!lv(l, v)) continue; const a = canon(l.from), b = canon(l.to); if (s.has(a) && !s.has(b)) { s.add(b); grew = true; } } }
49
+ return s;
50
+ };
51
+ return { view, nodes, canon, nv, lv, down };
52
+ }
53
+
54
+ const browser = await chromium.launch(channel ? { channel } : {});
55
+
56
+ // ================================================================= per viewport
57
+ for (const [w, h] of viewports) {
58
+ const tag = `${w}x${h}`;
59
+ const ctx = await browser.newContext({ viewport: { width: w, height: h }, colorScheme: "light" });
60
+ const page = await ctx.newPage();
61
+ const errors = [], requests = [];
62
+ page.on("pageerror", e => errors.push(String(e)));
63
+ page.on("console", m => { if (m.type() === "error") errors.push(m.text()); });
64
+ page.on("request", r => { const u = r.url(); if (!u.startsWith("file:") && !u.startsWith("data:")) requests.push(u); });
65
+ await page.goto(url(pagePath));
66
+ await page.waitForFunction(() => !!window.FlowStudio, null, { timeout: 10000 });
67
+ await page.waitForTimeout(900);
68
+ const model = await page.evaluate(() => window.FlowStudio.model);
69
+ const R = modelRules(model);
70
+ await page.screenshot({ path: join(outDir, `01-fit-${tag}.png`) });
71
+
72
+ check("safety", `${tag} no page errors`, errors.length === 0, errors.join(" | "));
73
+ check("safety", `${tag} no network requests`, requests.length === 0, requests.join(","));
74
+
75
+ // ---------- structural
76
+ const geo = await page.evaluate(() => {
77
+ const g = window.FlowStudio.geometry();
78
+ const vis = [...document.querySelectorAll(".node")].filter(d => !d.classList.contains("off")).map(d => d.id.slice(2));
79
+ const lanes = Object.entries(g.lanes).map(([id, r]) => ({ id, ...r }));
80
+ const nodes = vis.map(id => ({ id, ...g.nodes[id] }));
81
+ return { nodes, lanes };
82
+ });
83
+ let overlaps = [];
84
+ for (let i = 0; i < geo.nodes.length; i++) for (let j = i + 1; j < geo.nodes.length; j++) {
85
+ const a = geo.nodes[i], b = geo.nodes[j];
86
+ if (a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h) overlaps.push(`${a.id}/${b.id}`);
87
+ }
88
+ check("structural", `${tag} zero node-box intersections`, overlaps.length === 0, overlaps.join(","));
89
+ let outside = [];
90
+ for (const n of geo.nodes) {
91
+ const def = R.nodes.get(n.id), lane = geo.lanes.find(l => l.id === def.lane);
92
+ if (!lane || n.x < lane.x || n.y < lane.y || n.x + n.w > lane.x + lane.w || n.y + n.h > lane.y + lane.h) outside.push(n.id);
93
+ }
94
+ check("structural", `${tag} every node sits inside its lane`, outside.length === 0, outside.join(","));
95
+ let laneOverlap = 0;
96
+ for (let i = 0; i < geo.lanes.length; i++) for (let j = i + 1; j < geo.lanes.length; j++) { const a = geo.lanes[i], b = geo.lanes[j]; if (a.y < b.y + b.h && b.y < a.y + a.h) laneOverlap++; }
97
+ check("structural", `${tag} no lane overlap`, laneOverlap === 0, `overlaps=${laneOverlap}`);
98
+
99
+ const ends = await page.evaluate(() => {
100
+ const g = window.FlowStudio.geometry(), bad = [];
101
+ document.querySelectorAll("path.edge:not(.off)").forEach(p => {
102
+ const a = g.nodes[p.dataset.a], b = g.nodes[p.dataset.b], len = p.getTotalLength();
103
+ const s = p.getPointAtLength(0), e = p.getPointAtLength(len);
104
+ const okA = a && Math.abs(s.x - (a.x + a.w)) <= 2 && s.y >= a.y - 2 && s.y <= a.y + a.h + 2;
105
+ const okB = b && Math.abs(e.x - (b.x - 1)) <= 2 && e.y >= b.y - 2 && e.y <= b.y + b.h + 2;
106
+ if (!okA || !okB) bad.push(p.dataset.a + ">" + p.dataset.b);
107
+ });
108
+ return bad;
109
+ });
110
+ check("structural", `${tag} every link end touches its node box (within 2 px)`, ends.length === 0, ends.join(","));
111
+
112
+ const spill = await page.evaluate(() => {
113
+ const bad = [];
114
+ document.querySelectorAll(".node:not(.off):not(.wide)").forEach(d => {
115
+ const r = d.getBoundingClientRect();
116
+ for (const sel of [".name", ".s"]) {
117
+ const c = d.querySelector(sel);
118
+ if (c) { const cr = c.getBoundingClientRect(); if (cr.bottom > r.bottom + 1 || cr.right > r.right + 1) { bad.push(d.id.slice(2)); break; } }
119
+ }
120
+ });
121
+ return bad;
122
+ });
123
+ check("structural", `${tag} no text spills out of a card`, spill.length === 0, spill.join(","));
124
+
125
+ const visibleGroups = geo.nodes.length;
126
+ const st0 = await page.evaluate(inPage.state);
127
+ if (w === 1600) check("structural", `${tag} first view is readable (zoom at least 0.6 for up to 60 groups)`, visibleGroups > 60 || st0.view.k >= 0.6, `groups=${visibleGroups} k=${st0.view.k.toFixed(2)}`);
128
+ const inside = await page.evaluate(() => {
129
+ const v = document.getElementById("viewport").getBoundingClientRect(); window.FlowStudio.fit(false);
130
+ return new Promise(res => setTimeout(() => {
131
+ let n = 0;
132
+ document.querySelectorAll(".node:not(.off)").forEach(d => { const r = d.getBoundingClientRect(); if (r.left < v.left - 2 || r.right > v.right + 2 || r.top < v.top - 2 || r.bottom > v.bottom + 2) n++; });
133
+ res(n);
134
+ }, 120));
135
+ });
136
+ check("structural", `${tag} fit shows every node`, inside === 0, `outside=${inside}`);
137
+
138
+ // ---------- accessibility (static)
139
+ const names = await page.evaluate(() => {
140
+ const bad = [];
141
+ document.querySelectorAll("button, [role=button], input, [role=application]").forEach(e => {
142
+ const name = e.getAttribute("aria-label") || e.textContent?.trim() || e.getAttribute("title") || e.getAttribute("placeholder");
143
+ if (!name) bad.push(e.id || e.className || e.tagName);
144
+ });
145
+ return bad;
146
+ });
147
+ check("a11y", `${tag} every control has an accessible name`, names.length === 0, names.slice(0, 5).join(","));
148
+ const tabs = await page.evaluate(() => ({
149
+ visibleFocusable: [...document.querySelectorAll(".node:not(.off)")].every(d => d.tabIndex === 0),
150
+ hiddenFocusable: [...document.querySelectorAll(".node.off")].some(d => d.tabIndex !== -1),
151
+ }));
152
+ check("a11y", `${tag} visible nodes are focusable, hidden ones are not`, tabs.visibleFocusable && !tabs.hiddenFocusable);
153
+ const tableRows = await page.evaluate(() => document.querySelectorAll("#tbl tbody tr").length);
154
+ const expectRows = model.nodes.filter(n => R.nv(n) && n.kind !== "chip").length;
155
+ check("a11y", `${tag} table view lists exactly the visible groups`, tableRows === expectRows, `rows=${tableRows} expected=${expectRows}`);
156
+
157
+ if (w !== 1600) { await ctx.close(); continue; }
158
+
159
+ // ---------- interaction (1600x900 only)
160
+ const vp = await page.locator("#viewport").boundingBox();
161
+ const cx = vp.x + 700, cy = vp.y + 400;
162
+ const wp = () => page.evaluate(([x, y]) => { const r = document.getElementById("viewport").getBoundingClientRect(), v = window.FlowStudio.view(); return { wx: (x - r.left - v.x) / v.k, wy: (y - r.top - v.y) / v.k, k: v.k }; }, [cx, cy]);
163
+ const before = await wp();
164
+ await page.mouse.move(cx, cy); await page.mouse.wheel(0, -400); await page.waitForTimeout(120);
165
+ const after = await wp();
166
+ check("interaction", "wheel zooms in", after.k > before.k, `${before.k.toFixed(2)} -> ${after.k.toFixed(2)}`);
167
+ check("interaction", "the point under the cursor stays fixed (within 1 px)", Math.abs(after.wx - before.wx) < 1 && Math.abs(after.wy - before.wy) < 1);
168
+ for (let i = 0; i < 40; i++) await page.mouse.wheel(0, -600);
169
+ await page.waitForTimeout(100);
170
+ check("interaction", "zoom clamps at 2.5", (await page.evaluate(inPage.state)).view.k <= 2.5 + 1e-9);
171
+ for (let i = 0; i < 80; i++) await page.mouse.wheel(0, 600);
172
+ await page.waitForTimeout(100);
173
+ check("interaction", "zoom clamps at 0.25", (await page.evaluate(inPage.state)).view.k >= 0.25 - 1e-9);
174
+ await page.screenshot({ path: join(outDir, "02-min-zoom.png") });
175
+ await page.locator("#viewport").focus(); await page.keyboard.press("0"); await page.waitForTimeout(450);
176
+ const fit0 = (await page.evaluate(inPage.state)).view;
177
+ check("interaction", "0 fits everything", fit0.k > 0 && fit0.k <= 1, `k=${fit0.k.toFixed(2)}`);
178
+ // the gap tile counts groups (not their member weights), as its label says
179
+ const gapTile = await page.evaluate(() => Number(document.querySelector("#kpis button.tile.warn .tn")?.textContent));
180
+ const gapExpected = model.nodes.filter(n => R.nv(n) && n.kind !== "chip" && model.states[n.stateIn?.[R.view] ?? n.state]?.bucket === "gap").length;
181
+ check("interaction", "the gap tile counts groups", gapTile === gapExpected, `tile=${gapTile} expected=${gapExpected}`);
182
+ // browser shortcuts are left alone
183
+ const kBefore = (await page.evaluate(inPage.state)).view;
184
+ await page.keyboard.press("Control+Minus"); await page.keyboard.press("Control+0"); await page.waitForTimeout(450);
185
+ const kAfter = (await page.evaluate(inPage.state)).view;
186
+ check("interaction", "Ctrl+key combinations do not move the canvas", kBefore.k === kAfter.k && kBefore.x === kAfter.x);
187
+ await page.keyboard.press("1"); await page.waitForTimeout(450);
188
+ const p0 = (await page.evaluate(inPage.state)).view;
189
+ await page.mouse.move(vp.x + 900, vp.y + 600); await page.mouse.down(); await page.mouse.move(vp.x + 700, vp.y + 520, { steps: 6 }); await page.mouse.up();
190
+ const p1 = (await page.evaluate(inPage.state)).view;
191
+ check("interaction", "dragging the background pans", Math.abs(p1.x - p0.x) > 100, `${p0.x.toFixed(0)} -> ${p1.x.toFixed(0)}`);
192
+ await page.keyboard.press("0"); await page.waitForTimeout(450);
193
+
194
+ // choose a card with the most links
195
+ const deg = new Map();
196
+ for (const l of model.links) if (R.lv(l)) { deg.set(R.canon(l.from), (deg.get(R.canon(l.from)) ?? 0) + 1); deg.set(R.canon(l.to), (deg.get(R.canon(l.to)) ?? 0) + 1); }
197
+ const pick = [...deg.entries()].filter(([id]) => R.nodes.get(id)?.kind !== "chip" && !((R.nodes.get(id)?.colSpan ?? 1) > 1)).sort((a, b) => b[1] - a[1])[0]?.[0];
198
+ check("interaction", "the model has a selectable card with links", !!pick, pick ?? "none");
199
+ if (pick) {
200
+ await page.keyboard.press("h");
201
+ const nb = await page.locator(`[id="n_${pick}"]`).boundingBox();
202
+ const h0 = (await page.evaluate(inPage.state)).view;
203
+ await page.mouse.move(nb.x + 20, nb.y + 20); await page.mouse.down(); await page.mouse.move(nb.x + 80, nb.y + 60, { steps: 5 }); await page.mouse.up();
204
+ const h1 = (await page.evaluate(inPage.state)).view;
205
+ check("interaction", "the hand tool pans from a node and does not select", Math.abs(h1.x - h0.x) > 20 && (await page.evaluate(inPage.state)).selected === null);
206
+ await page.keyboard.press("v"); await page.keyboard.press("0"); await page.waitForTimeout(450);
207
+
208
+ await page.locator("#viewport").focus(); const a0 = (await page.evaluate(inPage.state)).view; await page.keyboard.press("ArrowLeft");
209
+ check("interaction", "an arrow key pans", (await page.evaluate(inPage.state)).view.x !== a0.x);
210
+ await page.click("#tools [data-id='plus']"); await page.waitForTimeout(400); const z1 = (await page.evaluate(inPage.state)).view.k;
211
+ await page.click("#tools [data-id='minus']"); await page.waitForTimeout(400); const z2 = (await page.evaluate(inPage.state)).view.k;
212
+ check("interaction", "the zoom buttons work", z1 > z2, `${z1.toFixed(2)} ${z2.toFixed(2)}`);
213
+
214
+ check("interaction", "the inspector is closed before a click", await page.evaluate(() => !document.getElementById("inspector").classList.contains("open")));
215
+ await page.click(`[id="n_${pick}"]`);
216
+ check("interaction", "a click selects the card and opens the inspector", await page.evaluate(id => window.FlowStudio.selected() === id && document.getElementById("inspector").classList.contains("open"), pick));
217
+ check("interaction", "the draw-in trail runs on select", await page.evaluate(() => document.querySelectorAll("path.trail").length > 0 || matchMedia("(prefers-reduced-motion: reduce)").matches));
218
+ await page.waitForTimeout(300);
219
+ await page.screenshot({ path: join(outDir, "03-selected.png") });
220
+ check("interaction", "the inspector has three relation buttons", (await page.locator("#inspector .counts .cbtn").count()) === 3);
221
+ await page.click("#inspector [data-hl='down']");
222
+ check("interaction", "a relation button highlights a set", await page.evaluate(() => document.querySelectorAll(".node.dim").length > 0));
223
+
224
+ const act = model.inspector?.actions?.[0];
225
+ if (act) {
226
+ await page.click(`#inspector [data-act="${act.id}"]`); await page.waitForTimeout(1700);
227
+ const expected = [...R.down(pick)].filter(id => id !== R.canon(pick) && R.nodes.get(id)).length;
228
+ const failed = await page.evaluate(() => document.querySelectorAll(".node.fail").length);
229
+ check("interaction", "the what-if action marks exactly the downstream set", failed === expected, `marked=${failed} expected=${expected}`);
230
+ await page.screenshot({ path: join(outDir, "04-simulate.png") });
231
+ await page.click("#simReset"); await page.waitForTimeout(200);
232
+ check("interaction", "reset clears the what-if", await page.evaluate(() => document.querySelectorAll(".node.fail, path.ripple").length === 0));
233
+ }
234
+ await page.keyboard.press("Escape"); await page.waitForTimeout(400);
235
+ check("interaction", "Escape clears the selection and closes the inspector", await page.evaluate(() => window.FlowStudio.selected() === null && !document.getElementById("inspector").classList.contains("open")));
236
+ }
237
+
238
+ // chip selects the real node
239
+ const chip = model.nodes.find(n => n.kind === "chip" && R.nv(n));
240
+ if (chip) {
241
+ await page.click(`[id="n_${chip.id}"]`);
242
+ check("interaction", "a portal chip selects the real node", await page.evaluate(id => window.FlowStudio.selected() === id, chip.ref));
243
+ await page.keyboard.press("Escape");
244
+ }
245
+
246
+ // search
247
+ const probe = model.nodes.find(n => n.members?.length && R.nv(n));
248
+ if (probe) {
249
+ const term = probe.members[0].slice(0, 6).toLowerCase();
250
+ await page.fill("#q", term); await page.waitForTimeout(250);
251
+ check("interaction", "search dims the groups that do not match", await page.evaluate(() => document.querySelectorAll(".node.dim").length > 0));
252
+ await page.fill("#q", ""); await page.keyboard.press("Escape");
253
+ }
254
+
255
+ // view switch
256
+ if ((model.views ?? []).length > 1) {
257
+ const other = model.views.find(v => v.id !== R.view);
258
+ await page.click(`#modeSwitch [data-mode="${other.id}"]`); await page.waitForTimeout(900);
259
+ const off = await page.evaluate(() => document.querySelectorAll(".node.off").length);
260
+ const expectOff = model.nodes.filter(n => !R.nv(n, other.id)).length;
261
+ check("interaction", "the view switch hides exactly the nodes that are not in that view", off === expectOff, `off=${off} expected=${expectOff}`);
262
+ await page.screenshot({ path: join(outDir, "05-other-view.png") });
263
+ await page.click(`#modeSwitch [data-mode="${R.view}"]`); await page.waitForTimeout(600);
264
+ }
265
+
266
+ // notes
267
+ await page.keyboard.press("n"); await page.waitForTimeout(500);
268
+ const markers = await page.locator(".marker").count();
269
+ check("interaction", "notes mode shows numbered markers", (model.notes?.length ?? 0) === 0 || markers > 0, `markers=${markers}`);
270
+ await page.screenshot({ path: join(outDir, "06-notes.png") });
271
+ await page.keyboard.press("n");
272
+
273
+ // ---------- accessibility (dynamic)
274
+ await page.locator("#viewport").focus();
275
+ let trap = true;
276
+ for (let i = 0; i < model.nodes.length + 60; i++) { await page.keyboard.press("Tab"); const inCanvas = await page.evaluate(() => !!document.activeElement?.closest("#viewport, #inspector")); if (!inCanvas) { trap = false; break; } }
277
+ check("a11y", "no keyboard trap: Tab leaves the canvas", !trap);
278
+ await page.evaluate(() => { const n = document.querySelector(".node:not(.off)"); n.focus(); });
279
+ await page.keyboard.press("Tab"); await page.keyboard.press("Shift+Tab");
280
+ const focusVisible = await page.evaluate(() => { const d = document.activeElement; if (!d) return false; const s = getComputedStyle(d); return (s.outlineStyle !== "none" && parseFloat(s.outlineWidth) > 0) || s.boxShadow !== "none"; });
281
+ check("a11y", "focus is visible on a node", focusVisible);
282
+
283
+ // contrast, light and dark
284
+ const contrast = async scheme => {
285
+ await page.emulateMedia({ colorScheme: scheme }); await page.waitForTimeout(250);
286
+ return page.evaluate(() => {
287
+ const parse = c => {
288
+ let m = c.match(/rgba?\(([^)]+)\)/);
289
+ if (m) { const p = m[1].split(/[ ,\/]+/).map(Number); return { r: p[0], g: p[1], b: p[2], a: p[3] ?? 1 }; }
290
+ m = c.match(/color\(srgb ([^)]+)\)/);
291
+ if (m) { const p = m[1].split(/[ \/]+/).map(Number); return { r: p[0] * 255, g: p[1] * 255, b: p[2] * 255, a: p[3] ?? 1 }; }
292
+ return null;
293
+ };
294
+ const lum = ({ r, g, b }) => { const f = v => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); };
295
+ const bgOf = el => { let e = el; while (e) { const c = parse(getComputedStyle(e).backgroundColor); if (c && c.a > 0.5) return c; e = e.parentElement; } return { r: 255, g: 255, b: 255, a: 1 }; };
296
+ const worst = []; let min = 99;
297
+ document.querySelectorAll(".node:not(.off):not(.dim) .name, .node:not(.off):not(.dim) .s").forEach(t => {
298
+ const fg = parse(getComputedStyle(t).color), bg = bgOf(t); if (!fg) return;
299
+ const a = lum(fg), b = lum(bg), r = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
300
+ if (r < min) min = r; if (r < 4.5) worst.push(t.textContent.slice(0, 24) + " " + r.toFixed(2));
301
+ });
302
+ return { min, worst: worst.slice(0, 4) };
303
+ });
304
+ };
305
+ const cl = await contrast("light"), cd = await contrast("dark");
306
+ check("a11y", "text contrast is at least 4.5 in the light theme", cl.min >= 4.5, `min=${cl.min.toFixed(2)} ${cl.worst.join(" | ")}`);
307
+ check("a11y", "text contrast is at least 4.5 in the dark theme", cd.min >= 4.5, `min=${cd.min.toFixed(2)} ${cd.worst.join(" | ")}`);
308
+ await page.emulateMedia({ colorScheme: "dark" }); await page.waitForTimeout(200);
309
+ await page.screenshot({ path: join(outDir, "07-dark.png") });
310
+ await ctx.close();
311
+ }
312
+
313
+ // ---------- reduced motion
314
+ {
315
+ const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 }, reducedMotion: "reduce" });
316
+ const page = await ctx.newPage();
317
+ await page.goto(url(pagePath)); await page.waitForFunction(() => !!window.FlowStudio); await page.waitForTimeout(600);
318
+ const moving = await page.evaluate(() => {
319
+ let n = 0;
320
+ document.querySelectorAll("#viewport *, .tile, .lcell").forEach(e => { const s = getComputedStyle(e); if (s.animationName !== "none" && s.animationDuration !== "0s") n++; if (s.transitionDuration.split(",").some(d => parseFloat(d) > 0)) n++; });
321
+ return n;
322
+ });
323
+ check("a11y", "reduced motion removes every animation and transition", moving === 0, `moving=${moving}`);
324
+ await ctx.close();
325
+ }
326
+
327
+ // ---------- hostile text
328
+ if (hostilePath) {
329
+ const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } });
330
+ const page = await ctx.newPage();
331
+ const errors = [];
332
+ page.on("pageerror", e => errors.push(String(e)));
333
+ await page.goto(url(resolve(hostilePath))); await page.waitForFunction(() => !!window.FlowStudio); await page.waitForTimeout(600);
334
+ const probe = await page.evaluate(() => ({
335
+ pwned: window.__pwned,
336
+ scripts: document.querySelectorAll("script").length,
337
+ imgs: document.querySelectorAll("img").length,
338
+ title: document.getElementById("title").textContent,
339
+ nodeText: document.querySelector(".node .name")?.textContent ?? "",
340
+ }));
341
+ check("safety", "hostile text executes nothing", probe.pwned === undefined && errors.length === 0, errors.join("|"));
342
+ check("safety", "hostile text adds no script or image element", probe.scripts === 3 && probe.imgs === 0, `scripts=${probe.scripts} imgs=${probe.imgs}`);
343
+ check("safety", "hostile text is shown as text", probe.title.includes("</script>") && probe.nodeText.length > 0);
344
+ await page.click(".node:not(.off)"); await page.waitForTimeout(300);
345
+ check("safety", "hostile text stays inert in the inspector", await page.evaluate(() => window.__pwned === undefined && document.querySelectorAll("#inspector img").length === 0));
346
+ await ctx.close();
347
+ }
348
+
349
+ // ---------- performance
350
+ if (!flag("--skip-perf")) {
351
+ const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } });
352
+ const page = await ctx.newPage();
353
+ await page.goto(url(pagePath)); await page.waitForFunction(() => !!window.FlowStudio); await page.waitForTimeout(400);
354
+ const perf = await page.evaluate(() => {
355
+ const base = window.FlowStudio.model;
356
+ const cols = base.columns, lanes = [], nodes = [], links = [];
357
+ const N = 200, perLane = 36;
358
+ for (let i = 0; i < N; i++) {
359
+ const col = i % cols.length, row = Math.floor(i / cols.length) * 1.2, lane = "p" + Math.floor(i / perLane);
360
+ if (!lanes.find(l => l.id === lane)) lanes.push({ id: lane, title: "Lane " + lane });
361
+ nodes.push({ id: "p" + i, kind: "card", lane, col, row, state: Object.keys(base.states)[0], title: "Node " + i, subtitle: "perf" });
362
+ if (i >= cols.length) links.push({ id: "pl" + i, from: "p" + (i - cols.length + 1 > 0 ? i - 1 : i), to: "p" + i, state: Object.keys(base.states)[0] });
363
+ }
364
+ const model = { ...base, lanes, nodes: nodes.map(n => ({ ...n, lane: lanes[Math.min(lanes.length - 1, Math.floor(n.row / 7))].id })), links: [], views: undefined, notes: [], inspector: undefined };
365
+ // links go forward in columns only
366
+ for (let i = 0; i < nodes.length - 1; i++) if (nodes[i].col + 1 === nodes[i + 1].col) model.links.push({ id: "pl" + i, from: nodes[i].id, to: nodes[i + 1].id, state: nodes[i].state });
367
+ model.lanes = lanes;
368
+ window.FlowStudio.destroy();
369
+ for (const id of ["cols", "layerstrip"]) document.getElementById(id).innerHTML = "";
370
+ const t0 = performance.now();
371
+ const api = window.flowStudioMount(document, model, { notes: [] });
372
+ const first = performance.now() - t0;
373
+ const times = [];
374
+ const vp = document.getElementById("viewport");
375
+ for (let i = 0; i < 50; i++) { const t = performance.now(); vp.dispatchEvent(new WheelEvent("wheel", { deltaY: i % 2 ? 120 : -120, clientX: 600, clientY: 400, bubbles: true, cancelable: true })); times.push(performance.now() - t); }
376
+ times.sort((a, b) => a - b);
377
+ return { nodes: nodes.length, first, p95: times[Math.floor(times.length * 0.95)], ok: !!api };
378
+ });
379
+ check("performance", `first render of ${perf.nodes} nodes under 500 ms`, perf.first < 500, `${perf.first.toFixed(0)} ms`);
380
+ check("performance", "one wheel event under 16 ms at the 95th percentile", perf.p95 < 16, `${perf.p95.toFixed(1)} ms`);
381
+ await ctx.close();
382
+ }
383
+
384
+ await browser.close();
385
+ const failed = results.filter(r => !r.ok);
386
+ console.log(`\n${results.length - failed.length} passed, ${failed.length} failed`);
387
+ if (opt("--json")) writeFileSync(resolve(opt("--json")), JSON.stringify(results, null, 2));
388
+ process.exit(failed.length ? 1 : 0);
@@ -0,0 +1,26 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import { buildEngine } from "../build-engine.mjs";
5
+
6
+ const normalise = t => t.replace(/\r\n/g, "\n");
7
+
8
+ test("the generated engine has no external URL and keeps only the model and notes slots", async () => {
9
+ const html = await buildEngine();
10
+ const urls = html.match(/https?:\/\/[^\s"'<>)\\]+/g) ?? [];
11
+ const external = urls.filter(u => u !== "http://www.w3.org/2000/svg");
12
+ assert.deepEqual(external, [], "external URL found");
13
+ assert.ok(!html.includes("/*__CSS__*/") && !html.includes("/*__ENGINE__*/"));
14
+ assert.equal(html.split("/*__MODEL__*/{}").length - 1, 1);
15
+ assert.equal(html.split("/*__NOTES__*/[]").length - 1, 1);
16
+ });
17
+ test("the engine bundle cannot end its own script tag", async () => {
18
+ const html = await buildEngine();
19
+ const scripts = html.match(/<\/script>/g) ?? [];
20
+ assert.equal(scripts.length, 3, "exactly the three script tags of the shell");
21
+ });
22
+ test("the committed engine matches the sources", async () => {
23
+ const fresh = await buildEngine();
24
+ const committed = normalise(readFileSync(new URL("../../templates/engine.html", import.meta.url), "utf8"));
25
+ assert.equal(committed, fresh, "run: node --experimental-strip-types scripts/build-engine.mjs");
26
+ });
@@ -0,0 +1,75 @@
1
+ // Proves the checks can fail: each fixture is a page with one deliberate defect, and check.mjs must exit 1
2
+ // and name the matching check. Skipped when Playwright cannot be resolved from the current folder.
3
+ // Run it from a folder that has Playwright, for example:
4
+ // FLOW_STUDIO_CHANNEL=msedge node --test <skill>/scripts/lib/check-selftest.test.mjs
5
+
6
+ import { test } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { spawnSync } from "node:child_process";
9
+ import { createRequire } from "node:module";
10
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join, resolve } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { embedModel } from "./embed.mjs";
15
+
16
+ const root = resolve(fileURLToPath(import.meta.url), "../../..");
17
+ let hasPlaywright = true;
18
+ try { createRequire(join(process.cwd(), "x.js")).resolve("playwright"); } catch { hasPlaywright = false; }
19
+ const skip = hasPlaywright ? false : "playwright is not resolvable from the current folder";
20
+
21
+ const engine = readFileSync(join(root, "templates/engine.html"), "utf8");
22
+ const base = JSON.parse(readFileSync(join(root, "examples/lineage.json"), "utf8"));
23
+ const dir = mkdtempSync(join(tmpdir(), "flow-selftest-"));
24
+
25
+ function page(name, { model = base, inject = "" } = {}) {
26
+ const html = embedModel(engine, model, []).replace("</body>", `${inject}</body>`);
27
+ const p = join(dir, name + ".html");
28
+ writeFileSync(p, html);
29
+ return p;
30
+ }
31
+ function run(p) {
32
+ const args = [join(root, "scripts/check.mjs"), p, "--out", join(dir, "shots"), "--viewports", "1600x900", "--skip-perf"];
33
+ if (process.env.FLOW_STUDIO_CHANNEL) args.push("--channel", process.env.FLOW_STUDIO_CHANNEL);
34
+ return spawnSync(process.execPath, args, { encoding: "utf8", cwd: process.cwd(), timeout: 120000 });
35
+ }
36
+ const expectFail = (r, pattern) => {
37
+ assert.equal(r.status, 1, r.stdout + r.stderr);
38
+ assert.match(r.stdout, new RegExp("FAIL.*" + pattern, "i"), r.stdout);
39
+ };
40
+
41
+ test("a clean page passes every check", { skip }, () => {
42
+ const r = run(page("clean"));
43
+ assert.equal(r.status, 0, r.stdout + r.stderr);
44
+ });
45
+
46
+ test("two nodes in one place fail the intersection check", { skip }, () => {
47
+ const m = structuredClone(base);
48
+ m.nodes.find(n => n.id === "T_WH").row = m.nodes.find(n => n.id === "D_WH").row; // same row, other column: move it onto a neighbour
49
+ const a = m.nodes.find(n => n.id === "J_W47"), b = m.nodes.find(n => n.id === "J_BILL");
50
+ b.row = a.row; // same column, same row
51
+ expectFail(run(page("overlap", { model: m })), "zero node-box intersections");
52
+ });
53
+
54
+ test("a control with no name fails the accessible-name check", { skip }, () => {
55
+ const inject = `<script>window.addEventListener("load", () => setTimeout(() => { const b = document.querySelector("#tools button"); b.removeAttribute("aria-label"); b.removeAttribute("title"); }, 300));</script>`;
56
+ expectFail(run(page("noname", { inject })), "accessible name");
57
+ });
58
+
59
+ test("a remote image fails the no-network check", { skip }, () => {
60
+ expectFail(run(page("remote", { inject: `<img src="https://example.invalid/x.png" alt="">` })), "no network requests");
61
+ });
62
+
63
+ test("faint text fails the contrast check", { skip }, () => {
64
+ expectFail(run(page("faint", { inject: `<style>.node .s, .node .name { color:#d8d8d8 !important; }</style>` })), "contrast");
65
+ });
66
+
67
+ test("a looping animation under reduced motion fails the reduced-motion check", { skip }, () => {
68
+ expectFail(run(page("motion", { inject: `<style>@media (prefers-reduced-motion: reduce) { .node { animation: rise 1s infinite !important; } }</style>` })), "reduced motion");
69
+ });
70
+
71
+ test("a canvas too wide to read fails the first-view check", { skip }, () => {
72
+ const m = structuredClone(base);
73
+ m.columns.forEach(c => { c.width = 700; });
74
+ expectFail(run(page("wide", { model: m })), "first view is readable");
75
+ });
@@ -0,0 +1,64 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { spawnSync } from "node:child_process";
4
+ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const root = resolve(fileURLToPath(import.meta.url), "../../..");
10
+ const run = (script, ...args) =>
11
+ spawnSync(process.execPath, ["--experimental-strip-types", "--no-warnings", join(root, "scripts", script), ...args], { encoding: "utf8" });
12
+
13
+ const good = {
14
+ version: 1,
15
+ meta: { title: "T", help: "h", asOf: "2026-01-01", source: "s" },
16
+ layout: "columns-lanes",
17
+ columns: [{ id: "c0", title: "A", width: 150 }, { id: "c1", title: "B", width: 150 }],
18
+ lanes: [{ id: "l1", title: "Lane" }],
19
+ states: { ok: { word: "OK", tone: "ok", border: "solid", edge: "flow", motion: "flow", bucket: "good" } },
20
+ nodes: [
21
+ { id: "a", kind: "card", lane: "l1", col: 0, row: 0, state: "ok", title: "A" },
22
+ { id: "b", kind: "card", lane: "l1", col: 1, row: 0, state: "ok", title: "B" },
23
+ ],
24
+ links: [{ id: "a-b", from: "a", to: "b", state: "ok" }],
25
+ };
26
+ const dir = mkdtempSync(join(tmpdir(), "flow-studio-"));
27
+ const write = (name, value) => { const p = join(dir, name); writeFileSync(p, typeof value === "string" ? value : JSON.stringify(value)); return p; };
28
+
29
+ test("validate: a good model exits 0 and prints OK", () => {
30
+ const r = run("validate.mjs", write("good.json", good));
31
+ assert.equal(r.status, 0, r.stderr);
32
+ assert.match(r.stdout, /OK/);
33
+ });
34
+ test("validate: a missing file exits 2", () => assert.equal(run("validate.mjs", join(dir, "nope.json")).status, 2));
35
+ test("validate: invalid JSON exits 2", () => assert.equal(run("validate.mjs", write("bad.json", "{not json")).status, 2));
36
+ test("validate: a link to a missing node exits 1 and names the link", () => {
37
+ const m = structuredClone(good);
38
+ m.links[0].to = "zzz";
39
+ const r = run("validate.mjs", write("broken.json", m));
40
+ assert.equal(r.status, 1);
41
+ assert.match(r.stdout, /links\[a-b\]/);
42
+ });
43
+ test("build: a note file cannot override the numbering and must be an array", () => {
44
+ const out = join(dir, "out-notes.html");
45
+ const p = write("notes-model.json", good);
46
+ const notes = write("notes.json", [{ id: 99, anchor: "#viewport", title: "One", text: "t" }, { anchor: "#tools", title: "Two", text: "t" }]);
47
+ // the build needs the generated engine; this test only needs it to exist
48
+ const r = run("build.mjs", p, out, "--notes", notes);
49
+ assert.equal(r.status, 0, r.stderr);
50
+ const html = readFileSync(out, "utf8");
51
+ const m = html.match(/id="flow-notes">(.*?)<\/script>/s);
52
+ const parsed = JSON.parse(m[1]);
53
+ assert.deepEqual(parsed.map(n => n.id), [1, 2]);
54
+ const bad = write("notes-bad.json", { not: "an array" });
55
+ assert.equal(run("build.mjs", p, join(dir, "out-bad.html"), "--notes", bad).status, 2);
56
+ });
57
+ test("build: refuses a model with errors and writes nothing", () => {
58
+ const m = structuredClone(good);
59
+ m.nodes[0].lane = "nope";
60
+ const out = join(dir, "out-refused.html");
61
+ const r = run("build.mjs", write("refused.json", m), out);
62
+ assert.equal(r.status, 1);
63
+ assert.equal(existsSync(out), false);
64
+ });
@@ -0,0 +1,24 @@
1
+ // Safe embedding of a model into the engine template.
2
+ // JSON placed inside a <script> tag must not be able to close it or start markup, so the
3
+ // characters < > & and the two Unicode line separators are written as \u escapes. The result is
4
+ // still valid JSON and parses back to the same value.
5
+
6
+ export function escapeJson(value) {
7
+ return JSON.stringify(value)
8
+ .replace(/</g, "\\u003c")
9
+ .replace(/>/g, "\\u003e")
10
+ .replace(/&/g, "\\u0026")
11
+ .replace(/\u2028/g, "\\u2028")
12
+ .replace(/\u2029/g, "\\u2029");
13
+ }
14
+
15
+ const MODEL_SLOT = "/*__MODEL__*/{}";
16
+ const NOTES_SLOT = "/*__NOTES__*/[]";
17
+
18
+ export function embedModel(shell, model, notes = []) {
19
+ if (!shell.includes(MODEL_SLOT) || !shell.includes(NOTES_SLOT)) {
20
+ throw new Error("The shell is missing a model slot or a notes slot.");
21
+ }
22
+ // Replacer functions: a "$" in the data must never act as a replacement pattern.
23
+ return shell.replace(MODEL_SLOT, () => escapeJson(model)).replace(NOTES_SLOT, () => escapeJson(notes));
24
+ }
@@ -0,0 +1,29 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { escapeJson, embedModel } from "./embed.mjs";
4
+
5
+ const hostile = { t: "</script><script>window.pwned=1</script>", u: "<img src=x onerror=alert(1)>", q: `"'&`, ls: "a\u2028b\u2029c" };
6
+
7
+ test("escapeJson leaves no markup characters and round-trips", () => {
8
+ const s = escapeJson(hostile);
9
+ assert.ok(!/[<>&\u2028\u2029]/.test(s));
10
+ assert.deepEqual(JSON.parse(s), hostile);
11
+ });
12
+
13
+ test("embedModel fills both slots and adds no script tag", () => {
14
+ const shell = `<script id="m" type="application/json">/*__MODEL__*/{}</script><script id="n" type="application/json">/*__NOTES__*/[]</script>`;
15
+ const out = embedModel(shell, hostile, [{ anchor: "#x", title: "<b>", text: "</script>" }]);
16
+ assert.equal((out.match(/<\/script>/g) ?? []).length, 2);
17
+ assert.equal((out.match(/<script/g) ?? []).length, 2);
18
+ assert.ok(!out.includes("/*__MODEL__*/") && !out.includes("/*__NOTES__*/"));
19
+ });
20
+
21
+ test("a dollar sign in the model is not treated as a replacement pattern", () => {
22
+ const shell = `<script>/*__MODEL__*/{}</script><script>/*__NOTES__*/[]</script>`;
23
+ const out = embedModel(shell, { p: "$1 and $$ and $` and $'" }, []);
24
+ assert.ok(out.includes("$1 and $$ and $` and $'"));
25
+ });
26
+
27
+ test("embedModel refuses a shell without the slots", () => {
28
+ assert.throws(() => embedModel("<html></html>", {}, []), /slot/i);
29
+ });