@caddis/cli 0.4.45 → 0.4.47

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 (193) hide show
  1. package/bundles/antigravity-plugin/plugin.json +1 -1
  2. package/bundles/antigravity-plugin/skills/flow-studio/README.md +46 -7
  3. package/bundles/antigravity-plugin/skills/flow-studio/SKILL.md +81 -81
  4. package/bundles/antigravity-plugin/skills/flow-studio/examples/README.md +8 -0
  5. package/bundles/antigravity-plugin/skills/flow-studio/examples/agent-journey.json +103 -0
  6. package/bundles/antigravity-plugin/skills/flow-studio/examples/approval-flow.json +134 -0
  7. package/bundles/antigravity-plugin/skills/flow-studio/examples/ci-cd-pipeline.json +132 -0
  8. package/bundles/antigravity-plugin/skills/flow-studio/examples/incident-timeline.json +109 -0
  9. package/bundles/antigravity-plugin/skills/flow-studio/examples/order-desk.json +99 -0
  10. package/bundles/antigravity-plugin/skills/flow-studio/examples/service-map.json +133 -0
  11. package/bundles/antigravity-plugin/skills/flow-studio/examples/state-machine.json +106 -0
  12. package/bundles/antigravity-plugin/skills/flow-studio/examples/ticket-lifecycle.json +107 -0
  13. package/bundles/antigravity-plugin/skills/flow-studio/model.schema.json +27 -8
  14. package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.test.tsx +82 -0
  15. package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.tsx +67 -0
  16. package/bundles/antigravity-plugin/skills/flow-studio/react/README.md +37 -0
  17. package/bundles/antigravity-plugin/skills/flow-studio/react/index.ts +3 -0
  18. package/bundles/antigravity-plugin/skills/flow-studio/react/native/FlowCanvas.tsx +58 -0
  19. package/bundles/antigravity-plugin/skills/flow-studio/react/native/Inspector.tsx +27 -0
  20. package/bundles/antigravity-plugin/skills/flow-studio/react/native/index.ts +6 -0
  21. package/bundles/antigravity-plugin/skills/flow-studio/react/native/native.css +18 -0
  22. package/bundles/antigravity-plugin/skills/flow-studio/react/native/native.test.tsx +161 -0
  23. package/bundles/antigravity-plugin/skills/flow-studio/react/native/useFlowGeometry.ts +22 -0
  24. package/bundles/antigravity-plugin/skills/flow-studio/react/native/useTrace.ts +29 -0
  25. package/bundles/antigravity-plugin/skills/flow-studio/react/native/useViewport.ts +75 -0
  26. package/bundles/antigravity-plugin/skills/flow-studio/react/states.tsx +13 -0
  27. package/bundles/antigravity-plugin/skills/flow-studio/references/design-language.md +177 -0
  28. package/bundles/antigravity-plugin/skills/flow-studio/references/implement-in-app.md +213 -0
  29. package/bundles/antigravity-plugin/skills/flow-studio/references/model-schema.md +254 -0
  30. package/bundles/antigravity-plugin/skills/flow-studio/references/notes-and-spec.md +74 -0
  31. package/bundles/antigravity-plugin/skills/flow-studio/references/patterns.md +385 -49
  32. package/bundles/antigravity-plugin/skills/flow-studio/references/ux-rules.md +91 -0
  33. package/bundles/antigravity-plugin/skills/flow-studio/references/verify.md +124 -0
  34. package/bundles/antigravity-plugin/skills/flow-studio/scripts/build-engine.mjs +54 -14
  35. package/bundles/antigravity-plugin/skills/flow-studio/scripts/build.mjs +21 -4
  36. package/bundles/antigravity-plugin/skills/flow-studio/scripts/check.mjs +320 -28
  37. package/bundles/antigravity-plugin/skills/flow-studio/scripts/default-notes.json +67 -0
  38. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry-guard.mjs +5 -0
  39. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry-guard.test.mjs +14 -0
  40. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry.test.mjs +31 -0
  41. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/build-engine.test.mjs +30 -2
  42. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-args.mjs +18 -0
  43. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-args.test.mjs +15 -0
  44. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-selftest.test.mjs +64 -4
  45. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/cli.test.mjs +115 -0
  46. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/demo-pack.mjs +23 -0
  47. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/demo-pack.test.mjs +34 -0
  48. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/docs.test.mjs +128 -0
  49. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/library.test.mjs +26 -0
  50. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-cleanup.mjs +12 -0
  51. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-cleanup.test.mjs +45 -0
  52. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-page.mjs +3 -0
  53. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-page.test.mjs +9 -0
  54. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/styles.test.mjs +37 -0
  55. package/bundles/antigravity-plugin/skills/flow-studio/scripts/react-proof.mjs +92 -0
  56. package/bundles/antigravity-plugin/skills/flow-studio/scripts/validate.mjs +5 -1
  57. package/bundles/antigravity-plugin/skills/flow-studio/src/autolayout.test.ts +78 -0
  58. package/bundles/antigravity-plugin/skills/flow-studio/src/autolayout.ts +132 -0
  59. package/bundles/antigravity-plugin/skills/flow-studio/src/chrome.ts +11 -7
  60. package/bundles/antigravity-plugin/skills/flow-studio/src/controls.ts +8 -0
  61. package/bundles/antigravity-plugin/skills/flow-studio/src/ctx.ts +16 -0
  62. package/bundles/antigravity-plugin/skills/flow-studio/src/diff.test.ts +31 -0
  63. package/bundles/antigravity-plugin/skills/flow-studio/src/diff.ts +34 -0
  64. package/bundles/antigravity-plugin/skills/flow-studio/src/engine.ts +83 -32
  65. package/bundles/antigravity-plugin/skills/flow-studio/src/inspector.ts +23 -5
  66. package/bundles/antigravity-plugin/skills/flow-studio/src/interact.ts +21 -8
  67. package/bundles/antigravity-plugin/skills/flow-studio/src/layout.test.ts +75 -0
  68. package/bundles/antigravity-plugin/skills/flow-studio/src/layout.ts +49 -11
  69. package/bundles/antigravity-plugin/skills/flow-studio/src/lib.ts +20 -0
  70. package/bundles/antigravity-plugin/skills/flow-studio/src/metrics.test.ts +57 -0
  71. package/bundles/antigravity-plugin/skills/flow-studio/src/metrics.ts +29 -0
  72. package/bundles/antigravity-plugin/skills/flow-studio/src/model.test.ts +62 -2
  73. package/bundles/antigravity-plugin/skills/flow-studio/src/model.ts +101 -16
  74. package/bundles/antigravity-plugin/skills/flow-studio/src/notes.ts +8 -0
  75. package/bundles/antigravity-plugin/skills/flow-studio/src/page.ts +21 -0
  76. package/bundles/antigravity-plugin/skills/flow-studio/src/playback.test.ts +104 -0
  77. package/bundles/antigravity-plugin/skills/flow-studio/src/playback.ts +106 -0
  78. package/bundles/antigravity-plugin/skills/flow-studio/src/playbackui.ts +193 -0
  79. package/bundles/antigravity-plugin/skills/flow-studio/src/render.ts +66 -18
  80. package/bundles/antigravity-plugin/skills/flow-studio/src/routing.test.ts +21 -1
  81. package/bundles/antigravity-plugin/skills/flow-studio/src/routing.ts +32 -0
  82. package/bundles/antigravity-plugin/skills/flow-studio/src/rules.test.ts +48 -1
  83. package/bundles/antigravity-plugin/skills/flow-studio/src/rules.ts +52 -1
  84. package/bundles/antigravity-plugin/skills/flow-studio/src/selection.test.ts +12 -0
  85. package/bundles/antigravity-plugin/skills/flow-studio/src/selection.ts +4 -0
  86. package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.test.ts +7 -0
  87. package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.ts +1 -0
  88. package/bundles/antigravity-plugin/skills/flow-studio/src/styles.css +44 -10
  89. package/bundles/antigravity-plugin/skills/flow-studio/src/trace.test.ts +21 -1
  90. package/bundles/antigravity-plugin/skills/flow-studio/src/trace.ts +18 -0
  91. package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.html +995 -82
  92. package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.shell.html +1 -39
  93. package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.css +191 -0
  94. package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.d.mts +1 -0
  95. package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.mjs +2492 -0
  96. package/bundles/antigravity-plugin/skills/flow-studio/templates/shell-body.html +50 -0
  97. package/bundles/codex/.codex/skills/frontend/flow-studio/README.md +46 -7
  98. package/bundles/codex/.codex/skills/frontend/flow-studio/SKILL.md +81 -81
  99. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/README.md +8 -0
  100. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/agent-journey.json +103 -0
  101. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/approval-flow.json +134 -0
  102. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/ci-cd-pipeline.json +132 -0
  103. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/incident-timeline.json +109 -0
  104. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/order-desk.json +99 -0
  105. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/service-map.json +133 -0
  106. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/state-machine.json +106 -0
  107. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/ticket-lifecycle.json +107 -0
  108. package/bundles/codex/.codex/skills/frontend/flow-studio/model.schema.json +27 -8
  109. package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.test.tsx +82 -0
  110. package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.tsx +67 -0
  111. package/bundles/codex/.codex/skills/frontend/flow-studio/react/README.md +37 -0
  112. package/bundles/codex/.codex/skills/frontend/flow-studio/react/index.ts +3 -0
  113. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/FlowCanvas.tsx +58 -0
  114. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/Inspector.tsx +27 -0
  115. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/index.ts +6 -0
  116. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/native.css +18 -0
  117. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/native.test.tsx +161 -0
  118. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useFlowGeometry.ts +22 -0
  119. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useTrace.ts +29 -0
  120. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useViewport.ts +75 -0
  121. package/bundles/codex/.codex/skills/frontend/flow-studio/react/states.tsx +13 -0
  122. package/bundles/codex/.codex/skills/frontend/flow-studio/references/design-language.md +177 -0
  123. package/bundles/codex/.codex/skills/frontend/flow-studio/references/implement-in-app.md +213 -0
  124. package/bundles/codex/.codex/skills/frontend/flow-studio/references/model-schema.md +254 -0
  125. package/bundles/codex/.codex/skills/frontend/flow-studio/references/notes-and-spec.md +74 -0
  126. package/bundles/codex/.codex/skills/frontend/flow-studio/references/patterns.md +385 -49
  127. package/bundles/codex/.codex/skills/frontend/flow-studio/references/ux-rules.md +91 -0
  128. package/bundles/codex/.codex/skills/frontend/flow-studio/references/verify.md +124 -0
  129. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build-engine.mjs +54 -14
  130. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build.mjs +21 -4
  131. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/check.mjs +320 -28
  132. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/default-notes.json +67 -0
  133. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry-guard.mjs +5 -0
  134. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry-guard.test.mjs +14 -0
  135. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry.test.mjs +31 -0
  136. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/build-engine.test.mjs +30 -2
  137. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-args.mjs +18 -0
  138. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-args.test.mjs +15 -0
  139. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-selftest.test.mjs +64 -4
  140. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/cli.test.mjs +115 -0
  141. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/demo-pack.mjs +23 -0
  142. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/demo-pack.test.mjs +34 -0
  143. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/docs.test.mjs +128 -0
  144. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/library.test.mjs +26 -0
  145. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-cleanup.mjs +12 -0
  146. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-cleanup.test.mjs +45 -0
  147. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-page.mjs +3 -0
  148. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-page.test.mjs +9 -0
  149. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/styles.test.mjs +37 -0
  150. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/react-proof.mjs +92 -0
  151. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/validate.mjs +5 -1
  152. package/bundles/codex/.codex/skills/frontend/flow-studio/src/autolayout.test.ts +78 -0
  153. package/bundles/codex/.codex/skills/frontend/flow-studio/src/autolayout.ts +132 -0
  154. package/bundles/codex/.codex/skills/frontend/flow-studio/src/chrome.ts +11 -7
  155. package/bundles/codex/.codex/skills/frontend/flow-studio/src/controls.ts +8 -0
  156. package/bundles/codex/.codex/skills/frontend/flow-studio/src/ctx.ts +16 -0
  157. package/bundles/codex/.codex/skills/frontend/flow-studio/src/diff.test.ts +31 -0
  158. package/bundles/codex/.codex/skills/frontend/flow-studio/src/diff.ts +34 -0
  159. package/bundles/codex/.codex/skills/frontend/flow-studio/src/engine.ts +83 -32
  160. package/bundles/codex/.codex/skills/frontend/flow-studio/src/inspector.ts +23 -5
  161. package/bundles/codex/.codex/skills/frontend/flow-studio/src/interact.ts +21 -8
  162. package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.test.ts +75 -0
  163. package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.ts +49 -11
  164. package/bundles/codex/.codex/skills/frontend/flow-studio/src/lib.ts +20 -0
  165. package/bundles/codex/.codex/skills/frontend/flow-studio/src/metrics.test.ts +57 -0
  166. package/bundles/codex/.codex/skills/frontend/flow-studio/src/metrics.ts +29 -0
  167. package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.test.ts +62 -2
  168. package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.ts +101 -16
  169. package/bundles/codex/.codex/skills/frontend/flow-studio/src/notes.ts +8 -0
  170. package/bundles/codex/.codex/skills/frontend/flow-studio/src/page.ts +21 -0
  171. package/bundles/codex/.codex/skills/frontend/flow-studio/src/playback.test.ts +104 -0
  172. package/bundles/codex/.codex/skills/frontend/flow-studio/src/playback.ts +106 -0
  173. package/bundles/codex/.codex/skills/frontend/flow-studio/src/playbackui.ts +193 -0
  174. package/bundles/codex/.codex/skills/frontend/flow-studio/src/render.ts +66 -18
  175. package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.test.ts +21 -1
  176. package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.ts +32 -0
  177. package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.test.ts +48 -1
  178. package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.ts +52 -1
  179. package/bundles/codex/.codex/skills/frontend/flow-studio/src/selection.test.ts +12 -0
  180. package/bundles/codex/.codex/skills/frontend/flow-studio/src/selection.ts +4 -0
  181. package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.test.ts +7 -0
  182. package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.ts +1 -0
  183. package/bundles/codex/.codex/skills/frontend/flow-studio/src/styles.css +44 -10
  184. package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.test.ts +21 -1
  185. package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.ts +18 -0
  186. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.html +995 -82
  187. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.shell.html +1 -39
  188. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.css +191 -0
  189. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.d.mts +1 -0
  190. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.mjs +2492 -0
  191. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/shell-body.html +50 -0
  192. package/bundles/manifest.json +2 -2
  193. package/package.json +1 -1
@@ -1,9 +1,37 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { readFileSync } from "node:fs";
4
- import { buildEngine } from "../build-engine.mjs";
3
+ import { readFileSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { buildEngine, resolveEsbuild } from "../build-engine.mjs";
5
6
 
6
7
  const normalise = t => t.replace(/\r\n/g, "\n");
8
+ test("esbuild resolution chooses the first candidate and respects order", () => {
9
+ const dir = mkdtempSync(new URL("./flow-studio-esbuild-", import.meta.url));
10
+ try {
11
+ const candidates = [join(dir, "first"), join(dir, "second")];
12
+ for (const [i, candidate] of candidates.entries()) {
13
+ const pkg = join(candidate, "node_modules", "esbuild");
14
+ mkdirSync(pkg, { recursive: true });
15
+ writeFileSync(join(pkg, "package.json"), JSON.stringify({ name: "esbuild", main: "index.js" }));
16
+ writeFileSync(join(pkg, "index.js"), `module.exports = { source: ${JSON.stringify(i)} };`);
17
+ }
18
+ assert.equal(resolveEsbuild(candidates).source, 0);
19
+ assert.equal(resolveEsbuild(candidates.toReversed()).source, 1);
20
+ } finally {
21
+ rmSync(dir, { recursive: true, force: true });
22
+ }
23
+ });
24
+
25
+ test("empty candidate folders give an actionable esbuild error", () => {
26
+ const dir = mkdtempSync(new URL("./flow-studio-esbuild-", import.meta.url));
27
+ try {
28
+ const empty = [join(dir, "one"), join(dir, "two")];
29
+ empty.forEach(p => mkdirSync(p));
30
+ assert.throws(() => resolveEsbuild(empty), /FLOW_STUDIO_NODE_MODULES.*npm i -D esbuild/);
31
+ } finally {
32
+ rmSync(dir, { recursive: true, force: true });
33
+ }
34
+ });
7
35
 
8
36
  test("the generated engine has no external URL and keeps only the model and notes slots", async () => {
9
37
  const html = await buildEngine();
@@ -0,0 +1,18 @@
1
+ import { resolve } from 'node:path';
2
+ import { pathToFileURL } from 'node:url';
3
+
4
+ const allowed = new Set(['notes', 'playback', 'update', 'hostile']);
5
+
6
+ export function pageUrl(input) {
7
+ // try/catch, not URL.canParse: canParse needs Node 18.17+ and this skill runs on older tooling too.
8
+ let parsed = null;
9
+ try { parsed = new URL(input); } catch { /* not an absolute URL; treat the input as a local path */ }
10
+ if (parsed?.protocol === 'http:' || parsed?.protocol === 'https:' || parsed?.protocol === 'file:') return parsed.href;
11
+ return pathToFileURL(resolve(input)).href;
12
+ }
13
+
14
+ export function skippedGroups(value = '') {
15
+ const groups = value ? value.split(',').map(s => s.trim()) : [];
16
+ for (const group of groups) if (!allowed.has(group)) throw new Error(`unknown skip group: ${group}`);
17
+ return new Set(groups);
18
+ }
@@ -0,0 +1,15 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { pageUrl, skippedGroups } from './check-args.mjs';
4
+
5
+ test('pageUrl accepts served pages and local files', () => {
6
+ assert.equal(pageUrl('http://127.0.0.1:8123/path'), 'http://127.0.0.1:8123/path');
7
+ assert.equal(pageUrl('https://example.test/flow'), 'https://example.test/flow');
8
+ assert.equal(pageUrl('file:///tmp/page.html'), 'file:///tmp/page.html');
9
+ assert.match(pageUrl('page.html'), /^file:\/\//);
10
+ });
11
+
12
+ test('skip parser accepts only known groups', () => {
13
+ assert.deepEqual([...skippedGroups('notes,playback,update,hostile')], ['notes', 'playback', 'update', 'hostile']);
14
+ assert.throws(() => skippedGroups('safety'), /unknown skip group/);
15
+ });
@@ -3,12 +3,11 @@
3
3
  // Run it from a folder that has Playwright, for example:
4
4
  // FLOW_STUDIO_CHANNEL=msedge node --test <skill>/scripts/lib/check-selftest.test.mjs
5
5
 
6
- import { test } from "node:test";
6
+ import { test, after } from "node:test";
7
7
  import assert from "node:assert/strict";
8
8
  import { spawnSync } from "node:child_process";
9
9
  import { createRequire } from "node:module";
10
- import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
11
- import { tmpdir } from "node:os";
10
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
12
11
  import { join, resolve } from "node:path";
13
12
  import { fileURLToPath } from "node:url";
14
13
  import { embedModel } from "./embed.mjs";
@@ -20,7 +19,9 @@ const skip = hasPlaywright ? false : "playwright is not resolvable from the curr
20
19
 
21
20
  const engine = readFileSync(join(root, "templates/engine.html"), "utf8");
22
21
  const base = JSON.parse(readFileSync(join(root, "examples/lineage.json"), "utf8"));
23
- const dir = mkdtempSync(join(tmpdir(), "flow-selftest-"));
22
+ const order = JSON.parse(readFileSync(join(root, "examples/order-desk.json"), "utf8"));
23
+ const dir = mkdtempSync(join(root, ".flow-selftest-"));
24
+ after(() => rmSync(dir, { recursive: true, force: true }));
24
25
 
25
26
  function page(name, { model = base, inject = "" } = {}) {
26
27
  const html = embedModel(engine, model, []).replace("</body>", `${inject}</body>`);
@@ -43,6 +44,11 @@ test("a clean page passes every check", { skip }, () => {
43
44
  assert.equal(r.status, 0, r.stdout + r.stderr);
44
45
  });
45
46
 
47
+ test("a clean page with a metric on a collapsed child passes every check", { skip }, () => {
48
+ const r = run(page("clean-order-desk", { model: order }));
49
+ assert.equal(r.status, 0, r.stdout + r.stderr);
50
+ });
51
+
46
52
  test("two nodes in one place fail the intersection check", { skip }, () => {
47
53
  const m = structuredClone(base);
48
54
  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
@@ -73,3 +79,57 @@ test("a canvas too wide to read fails the first-view check", { skip }, () => {
73
79
  m.columns.forEach(c => { c.width = 700; });
74
80
  expectFail(run(page("wide", { model: m })), "first view is readable");
75
81
  });
82
+ test("a moved loop path fails the link-endpoint check", { skip }, () => {
83
+ const m = structuredClone(base);
84
+ const link = m.links[0];
85
+ link.from = "H_DB1";
86
+ link.to = "ORD";
87
+ link.kind = "loop-back";
88
+ const inject = `<script>window.addEventListener("load", () => setTimeout(() => { document.querySelector("path.edge.loop")?.setAttribute("d", "M0,0 H10"); }, 300));</script>`;
89
+ expectFail(run(page("moved-loop", { model: m, inject })), "every link end touches its node box");
90
+ });
91
+ test("a node moved onto a loop path fails the loop clearance check", { skip }, () => {
92
+ const m = structuredClone(base);
93
+ m.links.push({ id: "return-check", from: "H_DB1", to: "ORD", state: m.links[0].state, kind: "loop-back" });
94
+ const inject = `<script>window.addEventListener("load", () => setTimeout(() => {
95
+ const path = [...document.querySelectorAll("path.edge.loop")].find(p => !p.classList.contains("off"));
96
+ const node = [...document.querySelectorAll(".node:not(.off)")].find(n => n.id !== "n_" + path?.dataset.a && n.id !== "n_" + path?.dataset.b);
97
+ if (path && node) { const point = path.getPointAtLength(path.getTotalLength() / 2);
98
+ node.style.left = (point.x - 20) + "px"; node.style.top = (point.y - 20) + "px"; }
99
+ }, 300));</script>`;
100
+ expectFail(run(page("blocked-loop", { model: m, inject })), "no loop-back path passes through a visible node");
101
+ });
102
+ test("a gateway without its diamond fails the gateway check", { skip }, () => {
103
+ const m = structuredClone(base);
104
+ const gate = m.nodes.find(n => n.id === "H_DB1");
105
+ gate.kind = "gateway";
106
+ const inject = `<script>window.addEventListener("load", () => setTimeout(() => { document.querySelector(".node.gateway polygon")?.remove(); }, 300));</script>`;
107
+ expectFail(run(page("no-gateway-polygon", { model: m, inject })), "gateway.*polygon");
108
+ });
109
+
110
+ test("a hidden playback token fails the movement check", { skip }, () => {
111
+ expectFail(run(page("hidden-token", { model: order, inject: `<style>#token { display:none !important; }</style>` })), "Play moves the token");
112
+ });
113
+
114
+ test("an update that does nothing fails the live update check", { skip }, () => {
115
+ const inject = `<script>window.addEventListener("load", () => setTimeout(() => { window.FlowStudio.update = () => ({ ok:true, issues:[] }); }, 300));</script>`;
116
+ expectFail(run(page("noop-update", { model: order, inject })), "update marks changed and added nodes");
117
+ });
118
+
119
+ test("a first scenario with a single step still passes every check", { skip }, () => {
120
+ const m = structuredClone(order);
121
+ m.scenarios[0].steps = m.scenarios[0].steps.slice(0, 1); // one step is legal (minItems 1)
122
+ const r = run(page("one-step", { model: m }));
123
+ assert.equal(r.status, 0, r.stdout + r.stderr);
124
+ });
125
+
126
+ test("an SLA breach is found without a scenario named escalated", { skip }, () => {
127
+ const m = structuredClone(order);
128
+ m.scenarios.find(s => s.id === "escalated").id = "surged";
129
+ const r = run(page("renamed-escalated", { model: m }));
130
+ assert.equal(r.status, 0, r.stdout + r.stderr);
131
+ });
132
+
133
+ test("hidden branch buttons fail the branch-button checks", { skip }, () => {
134
+ expectFail(run(page("hidden-choices", { model: order, inject: `<style>#pbChoices { display:none !important; }</style>` })), "keeps the branch buttons");
135
+ });
@@ -5,6 +5,7 @@ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join, resolve } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
+ import { keepDefaultNotes } from "./demo-pack.mjs";
8
9
 
9
10
  const root = resolve(fileURLToPath(import.meta.url), "../../..");
10
11
  const run = (script, ...args) =>
@@ -49,6 +50,7 @@ test("build: a note file cannot override the numbering and must be an array", ()
49
50
  assert.equal(r.status, 0, r.stderr);
50
51
  const html = readFileSync(out, "utf8");
51
52
  const m = html.match(/id="flow-notes">(.*?)<\/script>/s);
53
+ assert.ok(m, "the embedded notes script tag is present");
52
54
  const parsed = JSON.parse(m[1]);
53
55
  assert.deepEqual(parsed.map(n => n.id), [1, 2]);
54
56
  const bad = write("notes-bad.json", { not: "an array" });
@@ -62,3 +64,116 @@ test("build: refuses a model with errors and writes nothing", () => {
62
64
  assert.equal(r.status, 1);
63
65
  assert.equal(existsSync(out), false);
64
66
  });
67
+
68
+ test("build --demo-pack: order and numbering (default first, model second, extra third)", () => {
69
+ const m = structuredClone(good);
70
+ m.notes = [{ anchor: "#viewport", title: "Model Note", text: "model" }];
71
+ const p = write("demo-pack-model.json", m);
72
+ const extra = write("demo-pack-extra.json", [{ anchor: "#kpis", title: "Extra Note", text: "extra" }]);
73
+ const out = join(dir, "out-demo-pack-order.html");
74
+ const r = run("build.mjs", p, out, "--demo-pack", "--notes", extra);
75
+ assert.equal(r.status, 0, r.stderr);
76
+ const html = readFileSync(out, "utf8");
77
+ const match = html.match(/id="flow-notes">(.*?)<\/script>/s);
78
+ assert.ok(match, "the embedded notes script tag is present");
79
+ const parsed = JSON.parse(match[1]);
80
+
81
+ // Numbering 1..n
82
+ assert.deepEqual(parsed.map(n => n.id), Array.from({ length: parsed.length }, (_, i) => i + 1));
83
+
84
+ // Default notes come first (e.g. Page frame)
85
+ assert.equal(parsed[0].title, "Page frame");
86
+
87
+ // Model notes come second
88
+ const modelIdx = parsed.findIndex(n => n.title === "Model Note");
89
+ assert.ok(modelIdx > 0, "model note should be present");
90
+
91
+ // Extra notes come third
92
+ const extraIdx = parsed.findIndex(n => n.title === "Extra Note");
93
+ assert.equal(extraIdx, parsed.length - 1);
94
+ assert.ok(modelIdx < extraIdx, "model notes come before extra notes");
95
+ });
96
+
97
+ test("build --demo-pack: drops #playbar without scenarios and #modeSwitch with < 2 views", () => {
98
+ const m = structuredClone(good); // no scenarios, no views
99
+ const p = write("demo-pack-drop.json", m);
100
+ const out = join(dir, "out-demo-pack-drop.html");
101
+ const r = run("build.mjs", p, out, "--demo-pack");
102
+ assert.equal(r.status, 0, r.stderr);
103
+ const html = readFileSync(out, "utf8");
104
+ const match = html.match(/id="flow-notes">(.*?)<\/script>/s);
105
+ assert.ok(match, "the embedded notes script tag is present");
106
+ const parsed = JSON.parse(match[1]);
107
+ assert.equal(parsed.some(n => n.anchor === "#playbar"), false, "should drop playbar note");
108
+ assert.equal(parsed.some(n => n.anchor === "#modeSwitch"), false, "should drop modeSwitch note");
109
+ });
110
+
111
+ test("build --demo-pack: keeps #playbar with scenarios and #modeSwitch with 2+ views", () => {
112
+ const m = structuredClone(good);
113
+ m.views = [
114
+ { id: "v1", title: "View 1" },
115
+ { id: "v2", title: "View 2" },
116
+ ];
117
+ m.scenarios = [
118
+ { id: "s1", label: "Scenario 1", steps: [{ link: "a-b" }] },
119
+ ];
120
+ const p = write("demo-pack-keep.json", m);
121
+ const out = join(dir, "out-demo-pack-keep.html");
122
+ const r = run("build.mjs", p, out, "--demo-pack");
123
+ assert.equal(r.status, 0, r.stderr);
124
+ const html = readFileSync(out, "utf8");
125
+ const match = html.match(/id="flow-notes">(.*?)<\/script>/s);
126
+ assert.ok(match, "the embedded notes script tag is present");
127
+ const parsed = JSON.parse(match[1]);
128
+ assert.equal(parsed.some(n => n.anchor === "#playbar"), true, "should keep playbar note");
129
+ assert.equal(parsed.some(n => n.anchor === "#modeSwitch"), true, "should keep modeSwitch note");
130
+ });
131
+
132
+ test("build: hostile anchors in model notes are preserved safely", () => {
133
+ const m = structuredClone(good);
134
+ m.notes = [
135
+ { anchor: "<script>alert('xss')</script>", title: "Hostile Tag", text: "t" },
136
+ { anchor: "[invalid:css(selector", title: "Invalid Selector", text: "t" },
137
+ { anchor: "\"></script><script>alert(1)</script>", title: "Breakout", text: "t" },
138
+ ];
139
+ const p = write("hostile-notes.json", m);
140
+ const out = join(dir, "out-hostile-notes.html");
141
+ const r = run("build.mjs", p, out);
142
+ assert.equal(r.status, 0, r.stderr);
143
+ const html = readFileSync(out, "utf8");
144
+ const match = html.match(/id="flow-notes">(.*?)<\/script>/s);
145
+ assert.ok(match, "notes script tag should be present and intact");
146
+ const parsed = JSON.parse(match[1]);
147
+ assert.equal(parsed.length, 3);
148
+ assert.equal(parsed[0].anchor, "<script>alert('xss')</script>");
149
+ assert.equal(parsed[1].anchor, "[invalid:css(selector");
150
+ assert.equal(parsed[2].anchor, "\"></script><script>alert(1)</script>");
151
+ });
152
+
153
+ test("build --demo-pack: examples/lineage.json gets kept defaults first, then its own model notes", () => {
154
+ // Expectations come from the example and the shared filter, so the test does not break when
155
+ // the example changes. The filter's own logic is pinned by demo-pack.test.mjs.
156
+ const lineage = JSON.parse(readFileSync(join(root, "examples", "lineage.json"), "utf8"));
157
+ const defaults = JSON.parse(readFileSync(join(root, "scripts", "default-notes.json"), "utf8"));
158
+ const shell = readFileSync(join(root, "templates", "engine.html"), "utf8");
159
+ const kept = keepDefaultNotes(defaults, lineage, shell);
160
+ const expected = kept.length + (lineage.notes?.length ?? 0);
161
+ const outPack = join(dir, "out-lineage-pack.html"); // temp folder: the build must not leave repo artifacts
162
+ const r = run("build.mjs", join(root, "examples", "lineage.json"), outPack, "--demo-pack");
163
+ assert.equal(r.status, 0, r.stderr);
164
+ assert.equal(existsSync(outPack), true);
165
+ const html = readFileSync(outPack, "utf8");
166
+ const match = html.match(/id="flow-notes">(.*?)<\/script>/s);
167
+ assert.ok(match, "the embedded notes script tag is present");
168
+ const parsed = JSON.parse(match[1]);
169
+ assert.equal(parsed.length, expected, `expected ${expected} notes, got ${parsed.length}`);
170
+ for (let i = 0; i < kept.length; i++) {
171
+ assert.equal(parsed[i].anchor, kept[i].anchor, `note ${i + 1} should be the kept default ${kept[i].anchor}`);
172
+ }
173
+ if (lineage.notes?.length) {
174
+ assert.equal(parsed[kept.length].anchor, lineage.notes[0].anchor, "the first model note follows the kept defaults");
175
+ assert.equal(parsed[kept.length].title, lineage.notes[0].title, "the first model note follows the kept defaults");
176
+ }
177
+ assert.deepEqual(parsed.map(n => n.id), Array.from({ length: expected }, (_, i) => i + 1));
178
+ });
179
+
@@ -0,0 +1,23 @@
1
+ // Which default demo-pack notes survive for one model and one built shell. Shared by build.mjs
2
+ // and its tests. Only default notes are filtered here: notes from the model and from --notes are
3
+ // appended unfiltered (at runtime a note whose selector matches nothing simply gets no marker).
4
+
5
+ // An exact attribute match, not a pattern: an id with regex metacharacters must never match a
6
+ // different id, and a data-id="..." substring must not count as an id.
7
+ const hasId = (shell, id) => shell.includes(` id="${id}"`) || shell.includes(` id='${id}'`);
8
+
9
+ export function keepDefaultNotes(defaults, model, shell) {
10
+ return defaults.filter(n => {
11
+ if (n.anchor === "#playbar" && (!Array.isArray(model.scenarios) || model.scenarios.length === 0)) {
12
+ return false;
13
+ }
14
+ if (n.anchor === "#modeSwitch" && (!Array.isArray(model.views) || model.views.length < 2)) {
15
+ return false;
16
+ }
17
+ if (typeof n.anchor === "string" && n.anchor.startsWith("#")) {
18
+ const id = n.anchor.slice(1);
19
+ if (!hasId(shell, id)) return false;
20
+ }
21
+ return true;
22
+ });
23
+ }
@@ -0,0 +1,34 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { keepDefaultNotes } from "./demo-pack.mjs";
4
+
5
+ // The shell here is a fixture, not the real engine.html: the filter only reads id attributes.
6
+ const shell = '<div id="q"></div><span data-id="title"></span><i id="qxy"></i><section id="playbar"></section><nav id="modeSwitch"></nav>';
7
+ const note = anchor => ({ anchor, title: anchor, text: "t" });
8
+
9
+ test("demo-pack: keeps a default note whose id exists in the shell", () => {
10
+ assert.equal(keepDefaultNotes([note("#q")], {}, shell).length, 1);
11
+ });
12
+
13
+ test("demo-pack: drops a default note whose id is missing; a data-id substring is not an id", () => {
14
+ assert.deepEqual(keepDefaultNotes([note("#title")], {}, shell), []);
15
+ });
16
+
17
+ test("demo-pack: does not treat regex metacharacters in an id as a pattern", () => {
18
+ // "qx+y" read as a regular expression would match the shell's id="qxy"; read as an id it must not.
19
+ assert.deepEqual(keepDefaultNotes([note("#qx+y")], {}, shell), []);
20
+ });
21
+
22
+ test("demo-pack: drops #playbar without scenarios and keeps it with at least one", () => {
23
+ assert.deepEqual(keepDefaultNotes([note("#playbar")], {}, shell), []);
24
+ assert.equal(keepDefaultNotes([note("#playbar")], { scenarios: [{ id: "s1", steps: [{ link: "l1" }] }] }, shell).length, 1);
25
+ });
26
+
27
+ test("demo-pack: drops #modeSwitch with fewer than two views and keeps it with two", () => {
28
+ assert.deepEqual(keepDefaultNotes([note("#modeSwitch")], { views: [{ id: "v1", label: "V" }] }, shell), []);
29
+ assert.equal(keepDefaultNotes([note("#modeSwitch")], { views: [{ id: "v1", label: "V" }, { id: "v2", label: "W" }] }, shell).length, 1);
30
+ });
31
+
32
+ test("demo-pack: leaves anchors that are not ids (class and attribute selectors) unfiltered", () => {
33
+ assert.equal(keepDefaultNotes([note(".anything"), note("[data-x='y']")], {}, shell).length, 2);
34
+ });
@@ -0,0 +1,128 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const here = dirname(fileURLToPath(import.meta.url));
8
+ const root = resolve(here, "../..");
9
+
10
+ test("docs: (1) every property in model.schema.json appears in references/model-schema.md", () => {
11
+ const schemaPath = join(root, "model.schema.json");
12
+ const docPath = join(root, "references", "model-schema.md");
13
+ assert.ok(existsSync(schemaPath), "model.schema.json exists");
14
+ assert.ok(existsSync(docPath), "references/model-schema.md exists");
15
+
16
+ const schema = JSON.parse(readFileSync(schemaPath, "utf8"));
17
+ const doc = readFileSync(docPath, "utf8");
18
+
19
+ function collectProperties(obj, props = new Set()) {
20
+ if (!obj || typeof obj !== "object") return props;
21
+ if (obj.properties && typeof obj.properties === "object") {
22
+ for (const p of Object.keys(obj.properties)) {
23
+ props.add(p);
24
+ collectProperties(obj.properties[p], props);
25
+ }
26
+ }
27
+ // tuple-style schemas put subschemas in an items array
28
+ if (Array.isArray(obj.items)) obj.items.forEach(x => collectProperties(x, props));
29
+ else if (obj.items) collectProperties(obj.items, props);
30
+ // definitions/$defs are name->schema maps: walk the schemas, never the names
31
+ if (obj.definitions) for (const def of Object.values(obj.definitions)) collectProperties(def, props);
32
+ if (obj.$defs) for (const def of Object.values(obj.$defs)) collectProperties(def, props);
33
+ if (Array.isArray(obj.allOf)) obj.allOf.forEach(x => collectProperties(x, props));
34
+ if (Array.isArray(obj.anyOf)) obj.anyOf.forEach(x => collectProperties(x, props));
35
+ if (Array.isArray(obj.oneOf)) obj.oneOf.forEach(x => collectProperties(x, props));
36
+ return props;
37
+ }
38
+
39
+ // a whole word, not a substring: `id` must not be satisfied by `identifier`
40
+ const wordIn = (text, word) =>
41
+ new RegExp(`(?<![A-Za-z0-9_$-])${word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![A-Za-z0-9_$-])`).test(text);
42
+
43
+ const allProps = Array.from(collectProperties(schema));
44
+ const missing = allProps.filter(p => !wordIn(doc, p));
45
+ assert.deepEqual(missing, [], `Schema properties missing from model-schema.md: ${missing.join(", ")}`);
46
+ });
47
+
48
+ test("docs: (2) every example in examples/ is listed in examples/README.md", () => {
49
+ const examplesDir = join(root, "examples");
50
+ const readmePath = join(examplesDir, "README.md");
51
+ assert.ok(existsSync(readmePath), "examples/README.md exists");
52
+
53
+ const examples = readdirSync(examplesDir).filter(f => f.endsWith(".json"));
54
+ const readme = readFileSync(readmePath, "utf8");
55
+ const missing = examples.filter(e => !readme.includes(e));
56
+ assert.deepEqual(missing, [], `Examples missing from examples/README.md: ${missing.join(", ")}`);
57
+ });
58
+
59
+ test("docs: (3) every check group name used in scripts/check.mjs appears in references/verify.md", () => {
60
+ const checkScript = join(root, "scripts", "check.mjs");
61
+ const verifyDoc = join(root, "references", "verify.md");
62
+ assert.ok(existsSync(checkScript), "scripts/check.mjs exists");
63
+ assert.ok(existsSync(verifyDoc), "references/verify.md exists");
64
+
65
+ const checkCode = readFileSync(checkScript, "utf8");
66
+ const docText = readFileSync(verifyDoc, "utf8");
67
+
68
+ const groups = new Set();
69
+ for (const m of checkCode.matchAll(/\bcheck\(\s*["\x27]([^"\x27]+)["\x27]/g)) {
70
+ groups.add(m[1]);
71
+ }
72
+ const allGroups = Array.from(groups);
73
+ const missing = allGroups.filter(g => !docText.includes(g));
74
+ assert.deepEqual(missing, [], `Check groups missing from references/verify.md: ${missing.join(", ")}`);
75
+ });
76
+
77
+ test("docs: (4) SKILL.md frontmatter has name and description, name is flow-studio, under 220 lines", () => {
78
+ const skillPath = join(root, "SKILL.md");
79
+ assert.ok(existsSync(skillPath), "SKILL.md exists");
80
+
81
+ const content = readFileSync(skillPath, "utf8");
82
+ const lines = content.split(/\r?\n/);
83
+ if (lines.length && lines[lines.length - 1] === "") lines.pop(); // a trailing newline is not a line
84
+ assert.ok(lines.length < 220, `SKILL.md has ${lines.length} lines, must be under 220`);
85
+
86
+ const fmM = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
87
+ assert.ok(fmM, "SKILL.md has valid frontmatter block");
88
+ const fm = fmM[1];
89
+ assert.match(fm, /^name:\s*flow-studio\s*$/m, "frontmatter name is flow-studio");
90
+ assert.match(fm, /^description:\s*.+$/m, "frontmatter description is present");
91
+ });
92
+
93
+ test("docs: (5) every relative path in backticks starting with scripts/, src/, references/, examples/, templates/ exists", () => {
94
+ const refDir = join(root, "references");
95
+ const files = [
96
+ join(root, "SKILL.md"),
97
+ ...readdirSync(refDir).map(f => join(refDir, f))
98
+ ];
99
+ const prefixes = ["scripts/", "src/", "references/", "examples/", "templates/"];
100
+ const missing = [];
101
+
102
+ for (const file of files) {
103
+ const rawContent = readFileSync(file, "utf8");
104
+ // Strip fenced code blocks so we only check inline backtick code references
105
+ const stripped = rawContent.replace(/```[\s\S]*?```/g, "");
106
+ const matches = stripped.match(/`([^`\r\n]+)`/g) || [];
107
+
108
+ for (const m of matches) {
109
+ const token = m.slice(1, -1).trim();
110
+ // A span may hold a command (`scripts/check.mjs --json`) or a line reference
111
+ // (`scripts/check.mjs:23`), so check each whitespace-separated part, stripped of
112
+ // surrounding punctuation and a trailing :line suffix.
113
+ for (const part of token.split(/\s+/)) {
114
+ const candidate = part.replace(/^[("']+/, "").replace(/[.,;:)"']+$/, "").replace(/:\d+$/, "");
115
+ for (const prefix of prefixes) {
116
+ if (candidate.startsWith(prefix)) {
117
+ const resolved = resolve(root, candidate);
118
+ if (!existsSync(resolved)) {
119
+ missing.push({ file: file.replace(root, ""), path: candidate });
120
+ }
121
+ }
122
+ }
123
+ }
124
+ }
125
+ }
126
+
127
+ assert.deepEqual(missing, [], `Referenced paths do not exist: ${JSON.stringify(missing)}`);
128
+ });
@@ -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 { buildLibrary, buildEngine } from "../build-engine.mjs";
5
+
6
+ test("the library bundle imports without page auto-start", async () => {
7
+ const { js, css } = await buildLibrary();
8
+ assert.doesNotMatch(js, /addEventListener\(["']DOMContentLoaded["']/);
9
+ assert.doesNotMatch(js, /window\.flowStudioMount\s*=/);
10
+ assert.match(js, /SHELL_BODY_HTML/);
11
+ assert.equal(css, readFileSync(new URL("../../src/styles.css", import.meta.url), "utf8"));
12
+ const lib = await import("data:text/javascript," + encodeURIComponent(js));
13
+ assert.equal(typeof lib.mount, "function");
14
+ assert.equal(typeof lib.validateModel, "function");
15
+ assert.equal(typeof lib.zoomAt, "function");
16
+ assert.equal(typeof lib.elbow, "function");
17
+ assert.match(lib.SHELL_BODY_HTML, /id="viewport"/);
18
+ });
19
+
20
+ test("page build includes the shared shell body once", async () => {
21
+ const normalise = text => text.replace(/\r\n/g, "\n");
22
+ const body = readFileSync(new URL("../../templates/shell-body.html", import.meta.url), "utf8");
23
+ const page = await buildEngine();
24
+ assert.ok(normalise(page).includes(normalise(body)));
25
+ assert.equal(page.split('id="viewport"').length - 1, 1);
26
+ });
@@ -0,0 +1,12 @@
1
+ // On Windows, killing a child does not release its open files until it closes.
2
+ export async function stopPreview(preview, closed, graceMs = 5000) {
3
+ if (!preview) return;
4
+ if (preview.exitCode === null && preview.signalCode === null && !preview.kill())
5
+ throw new Error('Could not stop vite preview');
6
+ // A child that ignores the term signal must not block the teardown forever: force-kill it.
7
+ const finished = await Promise.race([closed.then(() => true), new Promise(resolve => setTimeout(resolve, graceMs, false))]);
8
+ if (!finished) {
9
+ preview.kill('SIGKILL');
10
+ await closed;
11
+ }
12
+ }
@@ -0,0 +1,45 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { EventEmitter } from 'node:events';
4
+ import { stopPreview } from './react-proof-cleanup.mjs';
5
+
6
+ test('stopPreview waits for process closure before temporary files can be removed', async () => {
7
+ const preview = new EventEmitter();
8
+ preview.exitCode = null;
9
+ preview.signalCode = null;
10
+ preview.kill = () => true;
11
+ const closed = new Promise(resolve => preview.once('close', resolve));
12
+ let stopped = false;
13
+ const stopping = stopPreview(preview, closed).then(() => { stopped = true; });
14
+ await Promise.resolve();
15
+ assert.equal(stopped, false);
16
+ preview.emit('close', 0);
17
+ await stopping;
18
+ assert.equal(stopped, true);
19
+ });
20
+
21
+ test('stopPreview waits for close after process exit', async () => {
22
+ const preview = new EventEmitter();
23
+ preview.exitCode = 0;
24
+ preview.signalCode = null;
25
+ preview.kill = () => { throw new Error('already exited'); };
26
+ const closed = new Promise(resolve => preview.once('close', resolve));
27
+ let stopped = false;
28
+ const stopping = stopPreview(preview, closed).then(() => { stopped = true; });
29
+ await Promise.resolve();
30
+ assert.equal(stopped, false);
31
+ preview.emit('close', 0);
32
+ await stopping;
33
+ assert.equal(stopped, true);
34
+ });
35
+
36
+ test('stopPreview force-kills a child that ignores the first kill', async () => {
37
+ const preview = new EventEmitter();
38
+ preview.exitCode = null;
39
+ preview.signalCode = null;
40
+ const kills = [];
41
+ preview.kill = signal => { kills.push(signal); if (signal === 'SIGKILL') preview.emit('close'); return true; };
42
+ const closed = new Promise(resolve => preview.once('close', resolve));
43
+ await stopPreview(preview, closed, 10);
44
+ assert.deepEqual(kills, [undefined, 'SIGKILL']);
45
+ });
@@ -0,0 +1,3 @@
1
+ export function proofIndexHtml() {
2
+ return '<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="icon" href="data:,"></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>';
3
+ }
@@ -0,0 +1,9 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { proofIndexHtml } from './react-proof-page.mjs';
4
+
5
+ test('proof page supplies an inline favicon so the browser makes no missing-file request', () => {
6
+ const html = proofIndexHtml();
7
+ assert.match(html, /<link rel="icon" href="data:,">/);
8
+ assert.match(html, /<script type="module" src="\/src\/main\.tsx"><\/script>/);
9
+ });
@@ -0,0 +1,37 @@
1
+ // The canvas look is part of the skill's contract: thin lines and a dotted background, both set by tokens
2
+ // an app can override. These tests pin the defaults so a later edit cannot quietly bring back the grid.
3
+ import test from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { readFileSync } from "node:fs";
6
+ import { fileURLToPath } from "node:url";
7
+ import { join, dirname } from "node:path";
8
+
9
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
10
+ const css = readFileSync(join(root, "src/styles.css"), "utf8");
11
+
12
+ test("the canvas background is dots, not grid lines", () => {
13
+ assert.match(css, /#viewport[\s\S]*radial-gradient\(circle, var\(--dot\) var\(--dot-size\)/);
14
+ assert.doesNotMatch(css, /linear-gradient\(var\(--grid\)/, "grid lines must not come back");
15
+ });
16
+
17
+ test("dots are set by tokens: size, spacing and a colour for light and dark", () => {
18
+ assert.match(css, /--dot-size:\s*1\.2px/);
19
+ assert.match(css, /--dot-gap:\s*24px/);
20
+ const colours = css.match(/--dot:\s*rgba\([^)]*\)/g) ?? [];
21
+ assert.ok(colours.length >= 3, "a dot colour for light, dark by media query and dark by attribute");
22
+ });
23
+
24
+ test("links are thin by default and every width comes from a token", () => {
25
+ assert.match(css, /--edge-w:\s*1\.1px/);
26
+ assert.match(css, /path\.edge \{ fill:none; stroke-width:var\(--edge-w\)/);
27
+ assert.match(css, /path\.edge\.hot \{[^}]*stroke-width:var\(--edge-w-hot\)/);
28
+ assert.match(css, /path\.trail \{[^}]*stroke-width:calc\(var\(--trail-w\)/);
29
+ assert.match(css, /path\.ripple \{[^}]*stroke-width:calc\(var\(--ripple-w\)/);
30
+ // Thin means at most 1.5 px for a plain link and 2 px for the highlighted one.
31
+ const w = (name) => Number(css.match(new RegExp(`${name}:\s*([0-9.]+)px`))[1]);
32
+ assert.ok(w("--edge-w") <= 1.5 && w("--edge-w-hot") <= 2 && w("--ripple-w") <= 2.5 && w("--trail-w") <= 2.5);
33
+ });
34
+
35
+ test("forced-colours mode drops the dot pattern", () => {
36
+ assert.match(css, /@media \(forced-colors: active\) \{ #viewport \{ background: Canvas; \} \}/);
37
+ });