@caddis/cli 0.4.44 → 0.4.46

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 (210) hide show
  1. package/bundles/antigravity-plugin/plugin.json +1 -1
  2. package/bundles/antigravity-plugin/scripts/claude-oss.ps1 +15 -3
  3. package/bundles/antigravity-plugin/scripts/claude-oss.sh +12 -1
  4. package/bundles/antigravity-plugin/skills/_registry.md +1 -0
  5. package/bundles/antigravity-plugin/skills/flow-studio/README.md +92 -0
  6. package/bundles/antigravity-plugin/skills/flow-studio/SKILL.md +102 -0
  7. package/bundles/antigravity-plugin/skills/flow-studio/examples/README.md +19 -0
  8. package/bundles/antigravity-plugin/skills/flow-studio/examples/agent-journey.json +103 -0
  9. package/bundles/antigravity-plugin/skills/flow-studio/examples/approval-flow.json +134 -0
  10. package/bundles/antigravity-plugin/skills/flow-studio/examples/ci-cd-pipeline.json +132 -0
  11. package/bundles/antigravity-plugin/skills/flow-studio/examples/hostile.json +1783 -0
  12. package/bundles/antigravity-plugin/skills/flow-studio/examples/incident-timeline.json +109 -0
  13. package/bundles/antigravity-plugin/skills/flow-studio/examples/lineage.json +1778 -0
  14. package/bundles/antigravity-plugin/skills/flow-studio/examples/order-desk.json +99 -0
  15. package/bundles/antigravity-plugin/skills/flow-studio/examples/service-map.json +133 -0
  16. package/bundles/antigravity-plugin/skills/flow-studio/examples/state-machine.json +106 -0
  17. package/bundles/antigravity-plugin/skills/flow-studio/examples/ticket-lifecycle.json +107 -0
  18. package/bundles/antigravity-plugin/skills/flow-studio/model.schema.json +142 -0
  19. package/bundles/antigravity-plugin/skills/flow-studio/package.json +11 -0
  20. package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.test.tsx +82 -0
  21. package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.tsx +67 -0
  22. package/bundles/antigravity-plugin/skills/flow-studio/react/README.md +37 -0
  23. package/bundles/antigravity-plugin/skills/flow-studio/react/index.ts +3 -0
  24. package/bundles/antigravity-plugin/skills/flow-studio/react/native/FlowCanvas.tsx +58 -0
  25. package/bundles/antigravity-plugin/skills/flow-studio/react/native/Inspector.tsx +27 -0
  26. package/bundles/antigravity-plugin/skills/flow-studio/react/native/index.ts +6 -0
  27. package/bundles/antigravity-plugin/skills/flow-studio/react/native/native.css +18 -0
  28. package/bundles/antigravity-plugin/skills/flow-studio/react/native/native.test.tsx +161 -0
  29. package/bundles/antigravity-plugin/skills/flow-studio/react/native/useFlowGeometry.ts +22 -0
  30. package/bundles/antigravity-plugin/skills/flow-studio/react/native/useTrace.ts +29 -0
  31. package/bundles/antigravity-plugin/skills/flow-studio/react/native/useViewport.ts +75 -0
  32. package/bundles/antigravity-plugin/skills/flow-studio/react/states.tsx +13 -0
  33. package/bundles/antigravity-plugin/skills/flow-studio/references/design-language.md +169 -0
  34. package/bundles/antigravity-plugin/skills/flow-studio/references/implement-in-app.md +213 -0
  35. package/bundles/antigravity-plugin/skills/flow-studio/references/model-schema.md +254 -0
  36. package/bundles/antigravity-plugin/skills/flow-studio/references/notes-and-spec.md +74 -0
  37. package/bundles/antigravity-plugin/skills/flow-studio/references/patterns.md +415 -0
  38. package/bundles/antigravity-plugin/skills/flow-studio/references/ux-rules.md +91 -0
  39. package/bundles/antigravity-plugin/skills/flow-studio/references/verify.md +124 -0
  40. package/bundles/antigravity-plugin/skills/flow-studio/scripts/build-engine.mjs +95 -0
  41. package/bundles/antigravity-plugin/skills/flow-studio/scripts/build.mjs +54 -0
  42. package/bundles/antigravity-plugin/skills/flow-studio/scripts/check.mjs +680 -0
  43. package/bundles/antigravity-plugin/skills/flow-studio/scripts/default-notes.json +67 -0
  44. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry-guard.mjs +5 -0
  45. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry-guard.test.mjs +14 -0
  46. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry.test.mjs +31 -0
  47. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/build-engine.test.mjs +54 -0
  48. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-args.mjs +18 -0
  49. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-args.test.mjs +15 -0
  50. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-selftest.test.mjs +135 -0
  51. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/cli.test.mjs +179 -0
  52. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/demo-pack.mjs +23 -0
  53. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/demo-pack.test.mjs +34 -0
  54. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/docs.test.mjs +128 -0
  55. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/embed.mjs +24 -0
  56. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/embed.test.mjs +29 -0
  57. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/library.test.mjs +26 -0
  58. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-cleanup.mjs +12 -0
  59. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-cleanup.test.mjs +45 -0
  60. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-page.mjs +3 -0
  61. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-page.test.mjs +9 -0
  62. package/bundles/antigravity-plugin/skills/flow-studio/scripts/react-proof.mjs +92 -0
  63. package/bundles/antigravity-plugin/skills/flow-studio/scripts/validate.mjs +42 -0
  64. package/bundles/antigravity-plugin/skills/flow-studio/src/autolayout.test.ts +78 -0
  65. package/bundles/antigravity-plugin/skills/flow-studio/src/autolayout.ts +132 -0
  66. package/bundles/antigravity-plugin/skills/flow-studio/src/chrome.ts +175 -0
  67. package/bundles/antigravity-plugin/skills/flow-studio/src/controls.ts +239 -0
  68. package/bundles/antigravity-plugin/skills/flow-studio/src/ctx.ts +134 -0
  69. package/bundles/antigravity-plugin/skills/flow-studio/src/diff.test.ts +31 -0
  70. package/bundles/antigravity-plugin/skills/flow-studio/src/diff.ts +34 -0
  71. package/bundles/antigravity-plugin/skills/flow-studio/src/engine.ts +193 -0
  72. package/bundles/antigravity-plugin/skills/flow-studio/src/inspector.ts +126 -0
  73. package/bundles/antigravity-plugin/skills/flow-studio/src/interact.ts +253 -0
  74. package/bundles/antigravity-plugin/skills/flow-studio/src/layout.test.ts +144 -0
  75. package/bundles/antigravity-plugin/skills/flow-studio/src/layout.ts +95 -0
  76. package/bundles/antigravity-plugin/skills/flow-studio/src/lib.ts +20 -0
  77. package/bundles/antigravity-plugin/skills/flow-studio/src/metrics.test.ts +57 -0
  78. package/bundles/antigravity-plugin/skills/flow-studio/src/metrics.ts +29 -0
  79. package/bundles/antigravity-plugin/skills/flow-studio/src/model.test.ts +167 -0
  80. package/bundles/antigravity-plugin/skills/flow-studio/src/model.ts +250 -0
  81. package/bundles/antigravity-plugin/skills/flow-studio/src/notes.ts +100 -0
  82. package/bundles/antigravity-plugin/skills/flow-studio/src/page.ts +21 -0
  83. package/bundles/antigravity-plugin/skills/flow-studio/src/playback.test.ts +104 -0
  84. package/bundles/antigravity-plugin/skills/flow-studio/src/playback.ts +106 -0
  85. package/bundles/antigravity-plugin/skills/flow-studio/src/playbackui.ts +193 -0
  86. package/bundles/antigravity-plugin/skills/flow-studio/src/render.ts +203 -0
  87. package/bundles/antigravity-plugin/skills/flow-studio/src/routing.test.ts +33 -0
  88. package/bundles/antigravity-plugin/skills/flow-studio/src/routing.ts +43 -0
  89. package/bundles/antigravity-plugin/skills/flow-studio/src/rules.test.ts +135 -0
  90. package/bundles/antigravity-plugin/skills/flow-studio/src/rules.ts +86 -0
  91. package/bundles/antigravity-plugin/skills/flow-studio/src/selection.test.ts +12 -0
  92. package/bundles/antigravity-plugin/skills/flow-studio/src/selection.ts +4 -0
  93. package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.test.ts +73 -0
  94. package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.ts +45 -0
  95. package/bundles/antigravity-plugin/skills/flow-studio/src/styles.css +190 -0
  96. package/bundles/antigravity-plugin/skills/flow-studio/src/trace.test.ts +40 -0
  97. package/bundles/antigravity-plugin/skills/flow-studio/src/trace.ts +40 -0
  98. package/bundles/antigravity-plugin/skills/flow-studio/src/viewport.test.ts +55 -0
  99. package/bundles/antigravity-plugin/skills/flow-studio/src/viewport.ts +45 -0
  100. package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.html +2656 -0
  101. package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.shell.html +23 -0
  102. package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.css +190 -0
  103. package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.d.mts +1 -0
  104. package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.mjs +2491 -0
  105. package/bundles/antigravity-plugin/skills/flow-studio/templates/shell-body.html +50 -0
  106. package/bundles/antigravity-plugin/skills/flow-studio/tsconfig.json +17 -0
  107. package/bundles/codex/.codex/skills/frontend/flow-studio/README.md +92 -0
  108. package/bundles/codex/.codex/skills/frontend/flow-studio/SKILL.md +102 -0
  109. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/README.md +19 -0
  110. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/agent-journey.json +103 -0
  111. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/approval-flow.json +134 -0
  112. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/ci-cd-pipeline.json +132 -0
  113. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/hostile.json +1783 -0
  114. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/incident-timeline.json +109 -0
  115. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/lineage.json +1778 -0
  116. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/order-desk.json +99 -0
  117. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/service-map.json +133 -0
  118. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/state-machine.json +106 -0
  119. package/bundles/codex/.codex/skills/frontend/flow-studio/examples/ticket-lifecycle.json +107 -0
  120. package/bundles/codex/.codex/skills/frontend/flow-studio/model.schema.json +142 -0
  121. package/bundles/codex/.codex/skills/frontend/flow-studio/package.json +11 -0
  122. package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.test.tsx +82 -0
  123. package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.tsx +67 -0
  124. package/bundles/codex/.codex/skills/frontend/flow-studio/react/README.md +37 -0
  125. package/bundles/codex/.codex/skills/frontend/flow-studio/react/index.ts +3 -0
  126. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/FlowCanvas.tsx +58 -0
  127. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/Inspector.tsx +27 -0
  128. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/index.ts +6 -0
  129. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/native.css +18 -0
  130. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/native.test.tsx +161 -0
  131. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useFlowGeometry.ts +22 -0
  132. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useTrace.ts +29 -0
  133. package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useViewport.ts +75 -0
  134. package/bundles/codex/.codex/skills/frontend/flow-studio/react/states.tsx +13 -0
  135. package/bundles/codex/.codex/skills/frontend/flow-studio/references/design-language.md +169 -0
  136. package/bundles/codex/.codex/skills/frontend/flow-studio/references/implement-in-app.md +213 -0
  137. package/bundles/codex/.codex/skills/frontend/flow-studio/references/model-schema.md +254 -0
  138. package/bundles/codex/.codex/skills/frontend/flow-studio/references/notes-and-spec.md +74 -0
  139. package/bundles/codex/.codex/skills/frontend/flow-studio/references/patterns.md +415 -0
  140. package/bundles/codex/.codex/skills/frontend/flow-studio/references/ux-rules.md +91 -0
  141. package/bundles/codex/.codex/skills/frontend/flow-studio/references/verify.md +124 -0
  142. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build-engine.mjs +95 -0
  143. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build.mjs +54 -0
  144. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/check.mjs +680 -0
  145. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/default-notes.json +67 -0
  146. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry-guard.mjs +5 -0
  147. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry-guard.test.mjs +14 -0
  148. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry.test.mjs +31 -0
  149. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/build-engine.test.mjs +54 -0
  150. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-args.mjs +18 -0
  151. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-args.test.mjs +15 -0
  152. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-selftest.test.mjs +135 -0
  153. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/cli.test.mjs +179 -0
  154. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/demo-pack.mjs +23 -0
  155. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/demo-pack.test.mjs +34 -0
  156. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/docs.test.mjs +128 -0
  157. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/embed.mjs +24 -0
  158. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/embed.test.mjs +29 -0
  159. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/library.test.mjs +26 -0
  160. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-cleanup.mjs +12 -0
  161. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-cleanup.test.mjs +45 -0
  162. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-page.mjs +3 -0
  163. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-page.test.mjs +9 -0
  164. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/react-proof.mjs +92 -0
  165. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/validate.mjs +42 -0
  166. package/bundles/codex/.codex/skills/frontend/flow-studio/src/autolayout.test.ts +78 -0
  167. package/bundles/codex/.codex/skills/frontend/flow-studio/src/autolayout.ts +132 -0
  168. package/bundles/codex/.codex/skills/frontend/flow-studio/src/chrome.ts +175 -0
  169. package/bundles/codex/.codex/skills/frontend/flow-studio/src/controls.ts +239 -0
  170. package/bundles/codex/.codex/skills/frontend/flow-studio/src/ctx.ts +134 -0
  171. package/bundles/codex/.codex/skills/frontend/flow-studio/src/diff.test.ts +31 -0
  172. package/bundles/codex/.codex/skills/frontend/flow-studio/src/diff.ts +34 -0
  173. package/bundles/codex/.codex/skills/frontend/flow-studio/src/engine.ts +193 -0
  174. package/bundles/codex/.codex/skills/frontend/flow-studio/src/inspector.ts +126 -0
  175. package/bundles/codex/.codex/skills/frontend/flow-studio/src/interact.ts +253 -0
  176. package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.test.ts +144 -0
  177. package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.ts +95 -0
  178. package/bundles/codex/.codex/skills/frontend/flow-studio/src/lib.ts +20 -0
  179. package/bundles/codex/.codex/skills/frontend/flow-studio/src/metrics.test.ts +57 -0
  180. package/bundles/codex/.codex/skills/frontend/flow-studio/src/metrics.ts +29 -0
  181. package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.test.ts +167 -0
  182. package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.ts +250 -0
  183. package/bundles/codex/.codex/skills/frontend/flow-studio/src/notes.ts +100 -0
  184. package/bundles/codex/.codex/skills/frontend/flow-studio/src/page.ts +21 -0
  185. package/bundles/codex/.codex/skills/frontend/flow-studio/src/playback.test.ts +104 -0
  186. package/bundles/codex/.codex/skills/frontend/flow-studio/src/playback.ts +106 -0
  187. package/bundles/codex/.codex/skills/frontend/flow-studio/src/playbackui.ts +193 -0
  188. package/bundles/codex/.codex/skills/frontend/flow-studio/src/render.ts +203 -0
  189. package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.test.ts +33 -0
  190. package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.ts +43 -0
  191. package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.test.ts +135 -0
  192. package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.ts +86 -0
  193. package/bundles/codex/.codex/skills/frontend/flow-studio/src/selection.test.ts +12 -0
  194. package/bundles/codex/.codex/skills/frontend/flow-studio/src/selection.ts +4 -0
  195. package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.test.ts +73 -0
  196. package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.ts +45 -0
  197. package/bundles/codex/.codex/skills/frontend/flow-studio/src/styles.css +190 -0
  198. package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.test.ts +40 -0
  199. package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.ts +40 -0
  200. package/bundles/codex/.codex/skills/frontend/flow-studio/src/viewport.test.ts +55 -0
  201. package/bundles/codex/.codex/skills/frontend/flow-studio/src/viewport.ts +45 -0
  202. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.html +2656 -0
  203. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.shell.html +23 -0
  204. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.css +190 -0
  205. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.d.mts +1 -0
  206. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.mjs +2491 -0
  207. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/shell-body.html +50 -0
  208. package/bundles/codex/.codex/skills/frontend/flow-studio/tsconfig.json +17 -0
  209. package/bundles/manifest.json +2 -2
  210. package/package.json +1 -1
@@ -0,0 +1,680 @@
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 { isDeepStrictEqual } from "node:util";
8
+ import { mkdirSync, writeFileSync } from "node:fs";
9
+ import { resolve, join } from "node:path";
10
+ import { pageUrl, skippedGroups } from "./lib/check-args.mjs";
11
+
12
+ const argv = process.argv.slice(2);
13
+ const opt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : dflt; };
14
+ const flag = name => argv.includes(name);
15
+ const pageArg = argv[0];
16
+ if (!pageArg || pageArg.startsWith("--")) { console.error("usage: node scripts/check.mjs <page.html|URL> [--out dir] [--viewports WxH,...] [--channel msedge] [--hostile page.html|URL] [--json file] [--skip-perf] [--skip notes,playback,update,hostile]"); process.exit(2); }
17
+ let skip;
18
+ try { skip = skippedGroups(opt("--skip", "")); } catch (error) { console.error(error.message); process.exit(2); }
19
+ const outDir = resolve(opt("--out", "check-out"));
20
+ const viewports = opt("--viewports", "1600x900,1280x720,820x900").split(",").map(v => v.split("x").map(Number));
21
+ const channel = opt("--channel", undefined);
22
+ const hostilePath = opt("--hostile", undefined);
23
+ mkdirSync(outDir, { recursive: true });
24
+
25
+ let chromium;
26
+ for (const base of [process.cwd(), process.env.FLOW_STUDIO_NODE_MODULES && resolve(process.env.FLOW_STUDIO_NODE_MODULES, "..")].filter(Boolean)) {
27
+ try { ({ chromium } = createRequire(join(base, "x.js"))("playwright")); break; } catch { /* try next location */ }
28
+ }
29
+ if (!chromium) { console.error("Cannot resolve 'playwright'. Run this from a folder whose node_modules has it (npm i -D playwright)."); process.exit(2); }
30
+
31
+ const results = [];
32
+ const check = (group, name, ok, detail = "") => { results.push({ group, name, ok: !!ok, detail: String(detail) }); console.log(`${ok ? "PASS" : "FAIL"} [${group}] ${name}${detail ? " " + detail : ""}`); };
33
+
34
+ // ---- helpers that run in the page
35
+ const inPage = {
36
+ state: () => {
37
+ const f = window.FlowStudio;
38
+ return { view: f.view(), selected: f.selected(), mode: f.mode() };
39
+ },
40
+ };
41
+ const url = pageUrl;
42
+ const targetUrl = url(pageArg);
43
+ const localOrigin = new URL(targetUrl).protocol.startsWith("http") ? new URL(targetUrl).origin : null;
44
+
45
+ // ---- model-side helpers (run here, from the model the page reports)
46
+ function modelRules(model) {
47
+ const view = (model.views?.find(v => v.default) ?? model.views?.[0])?.id ?? "all";
48
+ const nodes = new Map(model.nodes.map(n => [n.id, n]));
49
+ const canon = id => nodes.get(id)?.ref ?? id;
50
+ const collapsed = new Set(model.nodes.filter(n => n.kind === "group" && n.contains?.length && n.collapsed !== false).map(n => n.id));
51
+ const owner = id => model.nodes.find(n => n.kind === "group" && n.contains?.includes(id));
52
+ const nv = (n, v = view) => !!n && (!n.visibleIn || n.visibleIn.includes(v))
53
+ && (n.kind === "group" && n.contains ? collapsed.has(n.id) : !collapsed.has(owner(n.id)?.id));
54
+ const endpoint = id => collapsed.has(owner(id)?.id) ? owner(id).id : id;
55
+ const lv = (l, v = view) => (!l.visibleIn || l.visibleIn.includes(v)) && nodes.get(l.from) && nodes.get(l.to)
56
+ && nv(nodes.get(endpoint(l.from)), v) && nv(nodes.get(endpoint(l.to)), v) && endpoint(l.from) !== endpoint(l.to);
57
+ const down = (start, v = view) => {
58
+ const s = new Set([canon(start)]); let grew = true;
59
+ while (grew) { grew = false; for (const l of model.links) { if (!lv(l, v)) continue; const a = canon(endpoint(l.from)), b = canon(endpoint(l.to)); if (s.has(a) && !s.has(b)) { s.add(b); grew = true; } } }
60
+ return s;
61
+ };
62
+ return { view, nodes, canon, nv, lv, down, collapsed, endpoint };
63
+ }
64
+
65
+ const browser = await chromium.launch(channel ? { channel } : {});
66
+
67
+ // ================================================================= per viewport
68
+ for (const [w, h] of viewports) {
69
+ const tag = `${w}x${h}`;
70
+ const ctx = await browser.newContext({ viewport: { width: w, height: h }, colorScheme: "light" });
71
+ const page = await ctx.newPage();
72
+ const errors = [], requests = [];
73
+ page.on("pageerror", e => errors.push(String(e)));
74
+ page.on("console", m => { if (m.type() === "error") errors.push(m.text()); });
75
+ page.on("request", r => { const u = r.url(); if (!u.startsWith("file:") && !u.startsWith("data:") && (!localOrigin || new URL(u).origin !== localOrigin)) requests.push(u); });
76
+ await page.goto(targetUrl);
77
+ await page.waitForFunction(() => !!window.FlowStudio, null, { timeout: 10000 });
78
+ await page.waitForTimeout(900);
79
+ const model = await page.evaluate(() => window.FlowStudio.model);
80
+ const R = modelRules(model);
81
+ const paintedHidden = await page.evaluate(() => [...document.querySelectorAll(".node.off")].filter(d => {
82
+ const style = getComputedStyle(d);
83
+ return style.visibility !== "hidden" && Number(style.opacity) > 0;
84
+ }).map(d => d.id.slice(2)));
85
+ check("structural", `${tag} hidden nodes are not painted`, paintedHidden.length === 0, paintedHidden.join(","));
86
+ await page.screenshot({ path: join(outDir, `01-fit-${tag}.png`) });
87
+
88
+ check("safety", `${tag} no page errors`, errors.length === 0, errors.join(" | "));
89
+ check("safety", `${tag} no network requests`, requests.length === 0, requests.join(","));
90
+
91
+ // ---------- structural
92
+ const geo = await page.evaluate(() => {
93
+ const g = window.FlowStudio.geometry();
94
+ const vis = [...document.querySelectorAll(".node")].filter(d => !d.classList.contains("off")).map(d => d.id.slice(2));
95
+ const lanes = Object.entries(g.lanes).map(([id, r]) => ({ id, ...r }));
96
+ const nodes = vis.map(id => ({ id, ...g.nodes[id] }));
97
+ return { nodes, lanes };
98
+ });
99
+ let overlaps = [];
100
+ for (let i = 0; i < geo.nodes.length; i++) for (let j = i + 1; j < geo.nodes.length; j++) {
101
+ const a = geo.nodes[i], b = geo.nodes[j];
102
+ 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}`);
103
+ }
104
+ check("structural", `${tag} zero node-box intersections`, overlaps.length === 0, overlaps.join(","));
105
+ let outside = [];
106
+ for (const n of geo.nodes) {
107
+ const def = R.nodes.get(n.id), lane = geo.lanes.find(l => l.id === def.lane);
108
+ 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);
109
+ }
110
+ check("structural", `${tag} every node sits inside its lane`, outside.length === 0, outside.join(","));
111
+ let laneOverlap = 0;
112
+ 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++; }
113
+ check("structural", `${tag} no lane overlap`, laneOverlap === 0, `overlaps=${laneOverlap}`);
114
+
115
+ const ends = await page.evaluate(() => {
116
+ const g = window.FlowStudio.geometry(), bad = [];
117
+ document.querySelectorAll("path.edge:not(.off)").forEach(p => {
118
+ const a = g.nodes[p.dataset.a], b = g.nodes[p.dataset.b], len = p.getTotalLength();
119
+ const s = p.getPointAtLength(0), e = p.getPointAtLength(len);
120
+ const loop = p.classList.contains("loop");
121
+ const okA = loop
122
+ ? a && Math.abs(s.x - (a.x + a.w / 2)) <= 2 && Math.abs(s.y - (a.y + a.h)) <= 2
123
+ : a && Math.abs(s.x - (a.x + a.w)) <= 2 && s.y >= a.y - 2 && s.y <= a.y + a.h + 2;
124
+ const okB = loop
125
+ ? b && Math.abs(e.x - (b.x + b.w / 2)) <= 2 && Math.abs(e.y - (b.y + b.h)) <= 2
126
+ : b && Math.abs(e.x - (b.x - 1)) <= 2 && e.y >= b.y - 2 && e.y <= b.y + b.h + 2;
127
+ if (!okA || !okB) bad.push(p.dataset.a + ">" + p.dataset.b);
128
+ });
129
+ return bad;
130
+ });
131
+ check("structural", `${tag} every link end touches its node box (within 2 px)`, ends.length === 0, ends.join(","));
132
+
133
+ const crossedLoops = await page.evaluate(() => {
134
+ const boxes = [...document.querySelectorAll(".node:not(.off)")].map(d => ({
135
+ id: d.id.slice(2), x: parseFloat(d.style.left), y: parseFloat(d.style.top),
136
+ w: parseFloat(d.style.width), h: parseFloat(d.style.height),
137
+ }));
138
+ const bad = [];
139
+ document.querySelectorAll("path.edge.loop:not(.off)").forEach(path => {
140
+ const len = path.getTotalLength();
141
+ if (!Number.isFinite(len)) { bad.push(`${path.dataset.a}>${path.dataset.b}:nonfinite`); return; }
142
+ for (const box of boxes) {
143
+ if (box.id === path.dataset.a || box.id === path.dataset.b) continue;
144
+ let crossed = false;
145
+ for (let distance = 0; distance <= len; distance += 2) {
146
+ const point = path.getPointAtLength(distance);
147
+ if (point.x > box.x && point.x < box.x + box.w && point.y > box.y && point.y < box.y + box.h) { crossed = true; break; }
148
+ }
149
+ if (crossed) bad.push(`${path.dataset.a}>${path.dataset.b}/${box.id}`);
150
+ }
151
+ });
152
+ return bad;
153
+ });
154
+ check("structural", `${tag} no loop-back path passes through a visible node`, crossedLoops.length === 0, crossedLoops.join(","));
155
+
156
+ const gateways = geo.nodes.filter(n => R.nodes.get(n.id)?.kind === "gateway");
157
+ if (gateways.length) {
158
+ const missingPolygons = await page.evaluate(ids => ids.filter(id => !document.getElementById("n_" + id)?.querySelector("svg.gw polygon")), gateways.map(g => g.id));
159
+ check("structural", `${tag} every visible gateway has a polygon`, missingPolygons.length === 0, missingPolygons.join(","));
160
+ const missingExitLabels = await page.evaluate(exits => exits.filter(({ i, label }) => {
161
+ const el = document.querySelector(`.elabel[data-i="${i}"]:not(.off)`);
162
+ return !label || !el || el.textContent !== label;
163
+ }).map(({ id }) => id), model.links.map((l, i) => ({ ...l, i })).filter(l => gateways.some(g => g.id === l.from) && R.lv(l)));
164
+ check("structural", `${tag} every visible gateway exit has a label`, missingExitLabels.length === 0, missingExitLabels.join(","));
165
+ }
166
+
167
+ const spill = await page.evaluate(() => {
168
+ const bad = [];
169
+ document.querySelectorAll(".node:not(.off):not(.wide)").forEach(d => {
170
+ const r = d.getBoundingClientRect();
171
+ for (const sel of [".name", ".s"]) {
172
+ const c = d.querySelector(sel);
173
+ if (c) { const cr = c.getBoundingClientRect(); if (cr.bottom > r.bottom + 1 || cr.right > r.right + 1) { bad.push(d.id.slice(2)); break; } }
174
+ }
175
+ });
176
+ return bad;
177
+ });
178
+ check("structural", `${tag} no text spills out of a card`, spill.length === 0, spill.join(","));
179
+ const metricProbe = await page.evaluate(() => {
180
+ const visible = [...document.querySelectorAll(".node:not(.off)")];
181
+ const badges = visible.flatMap(n => [...n.querySelectorAll(".metric-badge")]);
182
+ return {
183
+ badges: badges.length,
184
+ sparks: visible.reduce((count, n) => count + n.querySelectorAll(".spark").length, 0),
185
+ overlap: badges.some(b => {
186
+ const name = b.closest(".node")?.querySelector(".name");
187
+ if (!name) return false;
188
+ const a = name.getBoundingClientRect(), c = b.getBoundingClientRect();
189
+ return a.left < c.right - 1 && c.left < a.right - 1 && a.top < c.bottom - 1 && c.top < a.bottom - 1;
190
+ }),
191
+ };
192
+ });
193
+ const visibleNodes = geo.nodes.map(n => R.nodes.get(n.id)).filter(Boolean);
194
+ const metricCount = visibleNodes.filter(n => n.metrics).length;
195
+ check("structural", `${tag} metric badges show without title overlap`, metricProbe.badges === metricCount && !metricProbe.overlap);
196
+ check("structural", `${tag} metric series show a sparkline`, metricProbe.sparks === visibleNodes.filter(n => (n.metrics?.series?.length ?? 0) >= 2).length);
197
+
198
+ const visibleGroups = geo.nodes.length;
199
+ const st0 = await page.evaluate(inPage.state);
200
+ 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)}`);
201
+ const inside = await page.evaluate(() => {
202
+ const v = document.getElementById("viewport").getBoundingClientRect(); window.FlowStudio.fit(false);
203
+ return new Promise(res => setTimeout(() => {
204
+ let n = 0;
205
+ 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++; });
206
+ res(n);
207
+ }, 120));
208
+ });
209
+ check("structural", `${tag} fit shows every node`, inside === 0, `outside=${inside}`);
210
+
211
+ // ---------- accessibility (static)
212
+ const names = await page.evaluate(() => {
213
+ const bad = [];
214
+ document.querySelectorAll("button, [role=button], input, [role=application]").forEach(e => {
215
+ const name = e.getAttribute("aria-label") || e.textContent?.trim() || e.getAttribute("title") || e.getAttribute("placeholder");
216
+ if (!name) bad.push(e.id || e.className || e.tagName);
217
+ });
218
+ return bad;
219
+ });
220
+ check("a11y", `${tag} every control has an accessible name`, names.length === 0, names.slice(0, 5).join(","));
221
+ const tabs = await page.evaluate(() => ({
222
+ visibleFocusable: [...document.querySelectorAll(".node:not(.off)")].every(d => d.tabIndex === 0),
223
+ hiddenFocusable: [...document.querySelectorAll(".node.off")].some(d => d.tabIndex !== -1),
224
+ }));
225
+ check("a11y", `${tag} visible nodes are focusable, hidden ones are not`, tabs.visibleFocusable && !tabs.hiddenFocusable);
226
+ const tableRows = await page.evaluate(() => document.querySelectorAll("#tbl tbody tr").length);
227
+ const expectRows = model.nodes.filter(n => R.nv(n) && n.kind !== "chip").length;
228
+ check("a11y", `${tag} table view lists exactly the visible groups`, tableRows === expectRows, `rows=${tableRows} expected=${expectRows}`);
229
+
230
+ const fold = model.nodes.find(n => n.kind === "group" && n.contains?.length);
231
+ if (fold) {
232
+ const count = () => page.evaluate(() => document.querySelectorAll(".node:not(.off)").length);
233
+ const touchErrors = () => page.evaluate(() => {
234
+ const g = window.FlowStudio.geometry(), bad = [];
235
+ document.querySelectorAll("path.edge:not(.off)").forEach(p => {
236
+ const a = g.nodes[p.dataset.a], b = g.nodes[p.dataset.b], len = p.getTotalLength();
237
+ const s = p.getPointAtLength(0), e = p.getPointAtLength(len), loop = p.classList.contains("loop");
238
+ const okA = loop ? a && Math.abs(s.x - a.x - a.w / 2) <= 2 && Math.abs(s.y - a.y - a.h) <= 2
239
+ : a && Math.abs(s.x - a.x - a.w) <= 2 && s.y >= a.y - 2 && s.y <= a.y + a.h + 2;
240
+ const okB = loop ? b && Math.abs(e.x - b.x - b.w / 2) <= 2 && Math.abs(e.y - b.y - b.h) <= 2
241
+ : b && Math.abs(e.x - b.x + 1) <= 2 && e.y >= b.y - 2 && e.y <= b.y + b.h + 2;
242
+ if (!okA || !okB) bad.push(p.dataset.a + ">" + p.dataset.b);
243
+ });
244
+ return bad;
245
+ });
246
+ const initial = await count();
247
+ if (R.collapsed.has(fold.id)) {
248
+ await page.evaluate(id => window.FlowStudio.select(id), fold.id);
249
+ await page.locator('#inspector [data-toggle-group]').click();
250
+ check("interaction", `${tag} expanding a group changes visible nodes by contained minus one`, (await count()) - initial === fold.contains.length - 1);
251
+ check("structural", `${tag} expanded links touch their node boxes`, (await touchErrors()).length === 0);
252
+ await page.locator('#inspector [data-toggle-group]').click();
253
+ check("interaction", `${tag} collapsing restores visible nodes`, (await count()) === initial);
254
+ check("structural", `${tag} collapsed links touch their node boxes`, (await touchErrors()).length === 0);
255
+ } else {
256
+ await page.evaluate(id => window.FlowStudio.select(id), fold.contains[0]);
257
+ await page.locator('#inspector [data-toggle-group]').click();
258
+ check("interaction", `${tag} collapsing a group changes visible nodes by contained minus one`, initial - (await count()) === fold.contains.length - 1);
259
+ check("structural", `${tag} collapsed links touch their node boxes`, (await touchErrors()).length === 0);
260
+ await page.locator('#inspector [data-toggle-group]').click();
261
+ check("interaction", `${tag} expanding restores visible nodes`, (await count()) === initial);
262
+ check("structural", `${tag} expanded links touch their node boxes`, (await touchErrors()).length === 0);
263
+ }
264
+ await page.keyboard.press("Escape");
265
+ }
266
+
267
+ if (w !== 1600) { await ctx.close(); continue; }
268
+
269
+ // ---------- interaction (1600x900 only)
270
+ const vp = await page.locator("#viewport").boundingBox();
271
+ const cx = vp.x + 700, cy = vp.y + 400;
272
+ 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]);
273
+ const before = await wp();
274
+ await page.mouse.move(cx, cy); await page.mouse.wheel(0, -400); await page.waitForTimeout(120);
275
+ const after = await wp();
276
+ check("interaction", "wheel zooms in", after.k > before.k, `${before.k.toFixed(2)} -> ${after.k.toFixed(2)}`);
277
+ 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);
278
+ for (let i = 0; i < 40; i++) await page.mouse.wheel(0, -600);
279
+ await page.waitForTimeout(100);
280
+ check("interaction", "zoom clamps at 2.5", (await page.evaluate(inPage.state)).view.k <= 2.5 + 1e-9);
281
+ for (let i = 0; i < 80; i++) await page.mouse.wheel(0, 600);
282
+ await page.waitForTimeout(100);
283
+ check("interaction", "zoom clamps at 0.25", (await page.evaluate(inPage.state)).view.k >= 0.25 - 1e-9);
284
+ await page.screenshot({ path: join(outDir, "02-min-zoom.png") });
285
+ await page.locator("#viewport").focus(); await page.keyboard.press("0"); await page.waitForTimeout(450);
286
+ const fit0 = (await page.evaluate(inPage.state)).view;
287
+ check("interaction", "0 fits everything", fit0.k > 0 && fit0.k <= 1, `k=${fit0.k.toFixed(2)}`);
288
+ // the gap tile counts groups (not their member weights), as its label says
289
+ const gapTile = await page.evaluate(() => Number(document.querySelector("#kpis button.tile.warn .tn")?.textContent));
290
+ const gapExpected = model.nodes.filter(n => R.nv(n) && n.kind !== "chip" && model.states[n.stateIn?.[R.view] ?? n.state]?.bucket === "gap").length;
291
+ check("interaction", "the gap tile counts groups", gapTile === gapExpected, `tile=${gapTile} expected=${gapExpected}`);
292
+ // browser shortcuts are left alone
293
+ const kBefore = (await page.evaluate(inPage.state)).view;
294
+ await page.keyboard.press("Control+Minus"); await page.keyboard.press("Control+0"); await page.waitForTimeout(450);
295
+ const kAfter = (await page.evaluate(inPage.state)).view;
296
+ check("interaction", "Ctrl+key combinations do not move the canvas", kBefore.k === kAfter.k && kBefore.x === kAfter.x);
297
+ await page.keyboard.press("1"); await page.waitForTimeout(450);
298
+ const p0 = (await page.evaluate(inPage.state)).view;
299
+ const dragStart = await page.evaluate(() => {
300
+ const viewport = document.getElementById("viewport");
301
+ const r = viewport.getBoundingClientRect();
302
+ for (const fy of [0.85, 0.7, 0.55, 0.4, 0.25, 0.1]) for (const fx of [0.85, 0.7, 0.55, 0.4, 0.25, 0.1]) {
303
+ const x = r.left + r.width * fx, y = r.top + r.height * fy;
304
+ const el = document.elementFromPoint(x, y);
305
+ if (el && viewport.contains(el) && !el.closest(".node, .bandlabel, .elabel, #tools, #zoomLabel")) return { x, y };
306
+ }
307
+ return null;
308
+ });
309
+ check("interaction", "an empty canvas point is available for background drag", !!dragStart);
310
+ const start = dragStart ?? { x: vp.x + vp.width / 2, y: vp.y + vp.height / 2 };
311
+ const dx = start.x > vp.x + vp.width / 2 ? -200 : 200;
312
+ const dy = start.y > vp.y + vp.height / 2 ? -80 : 80;
313
+ await page.mouse.move(start.x, start.y); await page.mouse.down(); await page.mouse.move(start.x + dx, start.y + dy, { steps: 6 }); await page.mouse.up();
314
+ const p1 = (await page.evaluate(inPage.state)).view;
315
+ check("interaction", "dragging the background pans", Math.abs(p1.x - p0.x) > 100, `${p0.x.toFixed(0)} -> ${p1.x.toFixed(0)}`);
316
+ await page.keyboard.press("0"); await page.waitForTimeout(450);
317
+
318
+ // choose a card with the most links
319
+ const deg = new Map();
320
+ for (const l of model.links) if (R.lv(l)) { deg.set(R.canon(R.endpoint(l.from)), (deg.get(R.canon(R.endpoint(l.from))) ?? 0) + 1); deg.set(R.canon(R.endpoint(l.to)), (deg.get(R.canon(R.endpoint(l.to))) ?? 0) + 1); }
321
+ const pick = [...deg.entries()].filter(([id]) => R.nv(R.nodes.get(id)) && R.nodes.get(id)?.kind !== "chip" && !((R.nodes.get(id)?.colSpan ?? 1) > 1)).sort((a, b) => b[1] - a[1])[0]?.[0];
322
+ check("interaction", "the model has a selectable card with links", !!pick, pick ?? "none");
323
+ if (pick) {
324
+ await page.keyboard.press("h");
325
+ const nb = await page.locator(`[id="n_${pick}"]`).boundingBox();
326
+ const h0 = (await page.evaluate(inPage.state)).view;
327
+ 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();
328
+ const h1 = (await page.evaluate(inPage.state)).view;
329
+ 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);
330
+ await page.keyboard.press("v"); await page.keyboard.press("0"); await page.waitForTimeout(450);
331
+
332
+ await page.locator("#viewport").focus(); const a0 = (await page.evaluate(inPage.state)).view; await page.keyboard.press("ArrowLeft");
333
+ check("interaction", "an arrow key pans", (await page.evaluate(inPage.state)).view.x !== a0.x);
334
+ await page.click("#tools [data-id='plus']"); await page.waitForTimeout(400); const z1 = (await page.evaluate(inPage.state)).view.k;
335
+ await page.click("#tools [data-id='minus']"); await page.waitForTimeout(400); const z2 = (await page.evaluate(inPage.state)).view.k;
336
+ check("interaction", "the zoom buttons work", z1 > z2, `${z1.toFixed(2)} ${z2.toFixed(2)}`);
337
+
338
+ check("interaction", "the inspector is closed before a click", await page.evaluate(() => !document.getElementById("inspector").classList.contains("open")));
339
+ await page.click(`[id="n_${pick}"]`);
340
+ 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));
341
+ check("interaction", "the draw-in trail runs on select", await page.evaluate(() => document.querySelectorAll("path.trail").length > 0 || matchMedia("(prefers-reduced-motion: reduce)").matches));
342
+ await page.waitForTimeout(300);
343
+ await page.screenshot({ path: join(outDir, "03-selected.png") });
344
+ check("interaction", "the inspector has three relation buttons", (await page.locator("#inspector .counts .cbtn").count()) === 3);
345
+ await page.click("#inspector [data-hl='down']");
346
+ check("interaction", "a relation button highlights a set", await page.evaluate(() => document.querySelectorAll(".node.dim").length > 0));
347
+
348
+ const act = model.inspector?.actions?.[0];
349
+ if (act) {
350
+ await page.click(`#inspector [data-act="${act.id}"]`); await page.waitForTimeout(1700);
351
+ const expected = [...R.down(pick)].filter(id => id !== R.canon(pick) && R.nodes.get(id)).length;
352
+ const failed = await page.evaluate(() => document.querySelectorAll(".node.fail").length);
353
+ check("interaction", "the what-if action marks exactly the downstream set", failed === expected, `marked=${failed} expected=${expected}`);
354
+ await page.screenshot({ path: join(outDir, "04-simulate.png") });
355
+ await page.click("#simReset"); await page.waitForTimeout(200);
356
+ check("interaction", "reset clears the what-if", await page.evaluate(() => document.querySelectorAll(".node.fail, path.ripple").length === 0));
357
+ }
358
+ await page.keyboard.press("Escape"); await page.waitForTimeout(400);
359
+ check("interaction", "Escape clears the selection and closes the inspector", await page.evaluate(() => window.FlowStudio.selected() === null && !document.getElementById("inspector").classList.contains("open")));
360
+ }
361
+
362
+ // chip selects the real node
363
+ const chip = model.nodes.find(n => n.kind === "chip" && R.nv(n));
364
+ if (chip) {
365
+ await page.click(`[id="n_${chip.id}"]`);
366
+ check("interaction", "a portal chip selects the real node", await page.evaluate(id => window.FlowStudio.selected() === id, chip.ref));
367
+ await page.keyboard.press("Escape");
368
+ }
369
+
370
+ // search
371
+ const probe = model.nodes.find(n => R.nv(n) && (n.members?.some(Boolean) || n.title));
372
+ if (probe) {
373
+ const term = (probe.members?.find(Boolean) ?? probe.title).slice(0, 6).toLowerCase();
374
+ await page.fill("#q", term); await page.waitForTimeout(250);
375
+ check("interaction", "search dims the groups that do not match", await page.evaluate(() => document.querySelectorAll(".node.dim").length > 0));
376
+ const dim0 = await page.evaluate(() => document.querySelectorAll(".node:not(.off).dim").length);
377
+ const matched = new Set(model.nodes.filter(n => R.nv(n) && (n.title.toLowerCase().includes(term) || (n.members ?? []).some(m => m.toLowerCase().includes(term)))).map(n => n.id));
378
+ const hasNeighbours = model.links.some(l => R.lv(l) && (matched.has(R.endpoint(l.from)) !== matched.has(R.endpoint(l.to))));
379
+ await page.selectOption("#hops", "1"); await page.waitForTimeout(100);
380
+ const dim1 = await page.evaluate(() => document.querySelectorAll(".node:not(.off).dim").length);
381
+ check("interaction", "search radius +1 lights neighbouring groups", dim1 < dim0 || (dim1 === dim0 && !hasNeighbours), `hops0=${dim0} hops1=${dim1} neighbours=${hasNeighbours}`);
382
+ await page.selectOption("#hops", "0");
383
+ await page.fill("#q", ""); await page.keyboard.press("Escape");
384
+ }
385
+
386
+ // view switch
387
+ if ((model.views ?? []).length > 1) {
388
+ const other = model.views.find(v => v.id !== R.view);
389
+ await page.click(`#modeSwitch [data-mode="${other.id}"]`); await page.waitForTimeout(900);
390
+ const off = await page.evaluate(() => document.querySelectorAll(".node.off").length);
391
+ const expectOff = model.nodes.filter(n => !R.nv(n, other.id)).length;
392
+ check("interaction", "the view switch hides exactly the nodes that are not in that view", off === expectOff, `off=${off} expected=${expectOff}`);
393
+ await page.screenshot({ path: join(outDir, "05-other-view.png") });
394
+ await page.click(`#modeSwitch [data-mode="${R.view}"]`); await page.waitForTimeout(600);
395
+ }
396
+
397
+ // notes
398
+ if (!skip.has("notes")) {
399
+ await page.keyboard.press("n"); await page.waitForTimeout(500);
400
+ const markers = await page.locator(".marker").count();
401
+ check("interaction", "notes mode shows numbered markers", (model.notes?.length ?? 0) === 0 || markers > 0, `markers=${markers}`);
402
+ const flowNotes = await page.evaluate(() => {
403
+ try { return JSON.parse(document.getElementById("flow-notes")?.textContent || "[]"); }
404
+ catch { return []; }
405
+ });
406
+ const isDemoPack = Array.isArray(flowNotes) && flowNotes.some(n => n.title === "Page frame" && n.anchor === "#title");
407
+ if (isDemoPack) {
408
+ // Per note, not by total: a note whose anchor resolves to a visible element must have a
409
+ // marker; one whose anchor matches nothing (or is hidden) legitimately has none. This mirrors
410
+ // the placement rules in src/notes.ts.
411
+ const markerless = await page.evaluate(notes => {
412
+ const have = new Set([...document.querySelectorAll(".marker")].map(m => m.dataset.n));
413
+ return notes.filter(n => {
414
+ let a = null; try { a = document.querySelector(n.anchor); } catch { return false; }
415
+ if (!a) return false;
416
+ let r = a.getBoundingClientRect();
417
+ if (!r.width && !r.height) {
418
+ let d = a.closest("details");
419
+ while (d && d.open) d = d.parentElement?.closest("details") ?? null;
420
+ if (d) r = d.getBoundingClientRect();
421
+ }
422
+ if (!r.width && !r.height) return false;
423
+ if (a.closest("#world")) {
424
+ const v = document.getElementById("viewport").getBoundingClientRect();
425
+ if (r.right < v.left || r.left > v.right || r.bottom < v.top || r.top > v.bottom) return false;
426
+ }
427
+ return !have.has(String(n.id));
428
+ }).map(n => n.id);
429
+ }, flowNotes);
430
+ check("interaction", "demo pack notes mode shows a marker for each anchored note", markerless.length === 0, `missing=${markerless.join(",")}`);
431
+ }
432
+ await page.screenshot({ path: join(outDir, "06-notes.png") });
433
+ await page.keyboard.press("n");
434
+ }
435
+
436
+ // ---------- accessibility (dynamic)
437
+ await page.locator("#viewport").focus();
438
+ let trap = true;
439
+ 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; } }
440
+ check("a11y", "no keyboard trap: Tab leaves the canvas", !trap);
441
+ await page.evaluate(() => { const n = document.querySelector(".node:not(.off)"); n.focus(); });
442
+ await page.keyboard.press("Tab"); await page.keyboard.press("Shift+Tab");
443
+ 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"; });
444
+ check("a11y", "focus is visible on a node", focusVisible);
445
+
446
+ // contrast, light and dark
447
+ const contrast = async scheme => {
448
+ await page.emulateMedia({ colorScheme: scheme }); await page.waitForTimeout(250);
449
+ return page.evaluate(() => {
450
+ const parse = c => {
451
+ let m = c.match(/rgba?\(([^)]+)\)/);
452
+ if (m) { const p = m[1].split(/[ ,\/]+/).map(Number); return { r: p[0], g: p[1], b: p[2], a: p[3] ?? 1 }; }
453
+ m = c.match(/color\(srgb ([^)]+)\)/);
454
+ 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 }; }
455
+ return null;
456
+ };
457
+ 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); };
458
+ 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 }; };
459
+ const worst = []; let min = 99;
460
+ document.querySelectorAll(".node:not(.off):not(.dim) .name, .node:not(.off):not(.dim) .s").forEach(t => {
461
+ const fg = parse(getComputedStyle(t).color), bg = bgOf(t); if (!fg) return;
462
+ const a = lum(fg), b = lum(bg), r = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
463
+ if (r < min) min = r; if (r < 4.5) worst.push(t.textContent.slice(0, 24) + " " + r.toFixed(2));
464
+ });
465
+ return { min, worst: worst.slice(0, 4) };
466
+ });
467
+ };
468
+ const cl = await contrast("light"), cd = await contrast("dark");
469
+ 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(" | ")}`);
470
+ 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(" | ")}`);
471
+ await page.emulateMedia({ colorScheme: "dark" }); await page.waitForTimeout(200);
472
+ await page.screenshot({ path: join(outDir, "07-dark.png") });
473
+ if (!skip.has("playback") && model.scenarios?.length) {
474
+ const playback = await page.evaluate(async () => {
475
+ const f = window.FlowStudio;
476
+ f.play(f.model.scenarios[0].id);
477
+ await new Promise(r => setTimeout(r, 80));
478
+ const first = document.querySelector("#token").getAttribute("cx");
479
+ const link = document.querySelector("path.edge.playing")?.dataset.i;
480
+ await new Promise(r => setTimeout(r, 160));
481
+ const second = document.querySelector("#token").getAttribute("cx");
482
+ f.pause(); document.querySelector("#pbReset").click(); f.step(); const a = document.querySelector("path.edge.playing")?.dataset.i;
483
+ f.step(); const b = document.querySelector("path.edge.playing")?.dataset.i;
484
+ const branches = document.querySelectorAll("#pbChoices button").length;
485
+ const suggested = document.querySelectorAll("#pbChoices .suggested").length;
486
+ document.querySelector("#pbScrub").value = "1000";
487
+ document.querySelector("#pbScrub").dispatchEvent(new Event("input", { bubbles: true }));
488
+ const scrub = f.playback().step, scrubLink = document.querySelector("path.edge.playing")?.dataset.i;
489
+ return { moved: first !== second && getComputedStyle(document.querySelector("#token")).display !== "none", link, a, b, scrub, scrubLink, branches, suggested };
490
+ });
491
+ const s0 = model.scenarios[0];
492
+ const expectedLink0 = String(model.links.findIndex(l => l.id === s0.steps[0]?.link));
493
+ check("interaction", "Play moves the token along the current link", playback.moved && playback.link === expectedLink0);
494
+ // A scenario may legally have a single step; comparing a second link needs two of them.
495
+ if ((s0.steps?.length ?? 0) >= 2) {
496
+ const expectedLink1 = String(model.links.findIndex(l => l.id === s0.steps[1]?.link));
497
+ check("interaction", "Step follows scenario link order", playback.a === expectedLink0 && playback.b === expectedLink1);
498
+ }
499
+ check("interaction", "scrubber lands on the final scenario link", playback.scrub === s0.steps.length - 1 && playback.scrubLink === String(model.links.findIndex(l => l.id === s0.steps.at(-1)?.link)));
500
+ const step1Link = model.links.find(l => l.id === s0.steps[1]?.link);
501
+ const hasBranchAtStep2 = step1Link && model.links.filter(l => l.from === step1Link.to).length > 1;
502
+ if (hasBranchAtStep2)
503
+ check("interaction", "branch buttons show at a gateway in step mode", playback.branches > 1 && playback.suggested === 1);
504
+ const sla = await page.evaluate(() => {
505
+ const f = window.FlowStudio;
506
+ const scenarios = f.model.scenarios ?? [];
507
+ // Prefer the canonical "escalated", else the first scenario whose elapsed time passes the SLA
508
+ // of a node it lands on. A model without any breach simply skips this check.
509
+ const breaches = s => (s?.steps ?? []).some(st => {
510
+ const link = f.model.links.find(l => l.id === st?.link);
511
+ if (!link) return false;
512
+ const target = f.model.nodes.find(n => n.id === link.to);
513
+ const slaOf = f.model.nodes.find(n => n.id === (target?.ref ?? link.to))?.sla;
514
+ return slaOf !== undefined && (st.at ?? 0) >= slaOf;
515
+ });
516
+ const scenario = scenarios.find(s => s.id === "escalated" && breaches(s)) ?? scenarios.find(breaches);
517
+ if (!scenario) return { found: false, breached: false };
518
+ f.play(scenario.id); f.pause();
519
+ for (let i = 0; i < scenario.steps.length; i++) {
520
+ f.step();
521
+ if (document.querySelector("#pbCounter")?.classList.contains("breach")) {
522
+ return { found: true, breached: true };
523
+ }
524
+ }
525
+ return { found: true, breached: document.querySelector("#pbCounter")?.classList.contains("breach") ?? false };
526
+ });
527
+ // A model whose SLAs no scenario crosses is valid: the check is skipped. It fails only when a scenario does cross an SLA and the counter never reaches breach.
528
+ if (sla.found) check("interaction", "an SLA-breaching scenario reaches breach", sla.breached, sla.breached ? "" : "did not reach breach");
529
+ if (hasBranchAtStep2) {
530
+ const pausedChoices = await page.evaluate(() => {
531
+ const f = window.FlowStudio;
532
+ const visible = sel => [...document.querySelectorAll(sel)].filter(b => b.offsetParent !== null).length;
533
+ f.play(f.model.scenarios[0].id); f.pause();
534
+ f.step();
535
+ // Park the token just inside this step, so play() resumes it instead of rewinding at the plan's end.
536
+ f.seek(f.playback().t - 1);
537
+ // Play clears the branch buttons; pausing on this step must bring them back.
538
+ f.play(); f.pause();
539
+ return { step: f.playback().step, branches: visible("#pbChoices button"), suggested: visible("#pbChoices .suggested") };
540
+ });
541
+ check("interaction", "pausing at a gateway keeps the branch buttons", pausedChoices.branches > 1 && pausedChoices.suggested === 1, `step=${pausedChoices.step} branches=${pausedChoices.branches} suggested=${pausedChoices.suggested}`);
542
+ const finishedChoices = await page.evaluate(async () => {
543
+ const f = window.FlowStudio;
544
+ const visible = sel => [...document.querySelectorAll(sel)].filter(b => b.offsetParent !== null).length;
545
+ const btn = document.querySelector("#pbChoices button[data-link]");
546
+ if (!(btn instanceof HTMLButtonElement)) return { finished: false, branches: 0 };
547
+ btn.click();
548
+ await new Promise(res => { const t0 = performance.now(); const iv = setInterval(() => { if (!f.playback().playing || performance.now() - t0 > 5000) { clearInterval(iv); res(); } }, 50); });
549
+ const finished = !f.playback().playing, branches = visible("#pbChoices button");
550
+ // Leave playback in a state a live update can restore exactly (a manual route lives only in the player).
551
+ document.querySelector("#pbReset").click();
552
+ return { finished, branches };
553
+ });
554
+ check("interaction", "playback that stops on its own shows the branch buttons", finishedChoices.finished && finishedChoices.branches > 1, finishedChoices.finished ? `branches=${finishedChoices.branches}` : "did not finish");
555
+ }
556
+ }
557
+
558
+ if (!skip.has("update")) {
559
+ const updateProbe = await page.evaluate(() => {
560
+ const f = window.FlowStudio;
561
+ const m = structuredClone(f.model), first = m.nodes.find(n => !["group", "chip", "gateway"].includes(n.kind)) ?? m.nodes[0], changed = first;
562
+ f.pause(); f.select(changed.id);
563
+ const qInput = document.querySelector("#q");
564
+ if (qInput) {
565
+ qInput.value = "verify";
566
+ qInput.dispatchEvent(new Event("input", { bubbles: true }));
567
+ }
568
+ const before = { view: f.view(), selected: f.selected(), mode: f.mode(), playback: f.playback(), search: document.querySelector("#q")?.value ?? "" };
569
+ changed.state = Object.keys(m.states).find(s => s !== changed.state) ?? changed.state;
570
+ changed.title += " updated";
571
+ const newId = "check-added-node";
572
+ const newNode = { ...structuredClone(first), id: newId, title: "Added node", contains: undefined, ref: undefined, metrics: undefined };
573
+ if (m.layout === "auto") { delete newNode.col; delete newNode.row; }
574
+ if (m.layout === "columns-lanes") newNode.row = Math.max(...m.nodes.filter(n => n.col === first.col).map(n => n.row ?? 0)) + 2;
575
+ if (m.layout === "free") newNode.x = Math.max(...m.nodes.map(n => (n.x ?? 0) + (n.w ?? 190))) + 50;
576
+ m.nodes.push(newNode);
577
+ m.links.push({ id: "check-added-link", from: first.id, to: newId, state: first.state });
578
+ const good = f.update(m);
579
+ const after = { view: f.view(), selected: f.selected(), mode: f.mode(), playback: f.playback(), search: document.querySelector("#q")?.value ?? "" };
580
+ const updated = document.getElementById("n_" + changed.id)?.classList.contains("updated");
581
+ const added = !!document.getElementById("n_" + newId);
582
+ const nodeCount = document.querySelectorAll(".node").length;
583
+ const edgeCount = document.querySelectorAll("path.edge").length;
584
+ const invalid = structuredClone(m); invalid.links.push({ id: "bad", from: "missing", to: newId, state: first.state });
585
+ const bad = f.update(invalid);
586
+ const unchanged = document.querySelectorAll(".node").length === nodeCount && document.querySelectorAll("path.edge").length === edgeCount;
587
+ const banner = !document.getElementById("banner").hidden;
588
+ const recover = f.update(m);
589
+ return { good: good.ok, bad: !bad.ok, recover: recover.ok, before, after, updated, added, unchanged, banner, hidden: document.getElementById("banner").hidden };
590
+ });
591
+ const stateKept = isDeepStrictEqual(updateProbe.before, updateProbe.after);
592
+ check("interaction", "update keeps view, selection, search and playback", updateProbe.good && stateKept, stateKept ? "" : `before=${JSON.stringify(updateProbe.before)} after=${JSON.stringify(updateProbe.after)}`);
593
+ check("interaction", "update marks changed and added nodes", updateProbe.updated && updateProbe.added);
594
+ check("interaction", "invalid update keeps the DOM and shows a banner", updateProbe.bad && updateProbe.unchanged && updateProbe.banner);
595
+ check("interaction", "valid update clears the failure banner", updateProbe.recover && updateProbe.hidden);
596
+ }
597
+ await ctx.close();
598
+ }
599
+
600
+ // ---------- reduced motion
601
+ {
602
+ const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 }, reducedMotion: "reduce" });
603
+ const page = await ctx.newPage();
604
+ await page.goto(targetUrl); await page.waitForFunction(() => !!window.FlowStudio); await page.waitForTimeout(600);
605
+ const moving = await page.evaluate(() => {
606
+ let n = 0;
607
+ 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++; });
608
+ return n;
609
+ });
610
+ check("a11y", "reduced motion removes every animation and transition", moving === 0, `moving=${moving}`);
611
+ const reducedToken = await page.evaluate(() => {
612
+ const token = document.getElementById("token");
613
+ return !token || getComputedStyle(token).transitionDuration.split(",").every(d => parseFloat(d) === 0);
614
+ });
615
+ check("a11y", "reduced motion gives the token no transition", reducedToken);
616
+ await ctx.close();
617
+ }
618
+
619
+ // ---------- hostile text
620
+ if (hostilePath && !skip.has("hostile")) {
621
+ const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } });
622
+ const page = await ctx.newPage();
623
+ const errors = [];
624
+ page.on("pageerror", e => errors.push(String(e)));
625
+ await page.goto(url(hostilePath)); await page.waitForFunction(() => !!window.FlowStudio); await page.waitForTimeout(600);
626
+ const probe = await page.evaluate(() => ({
627
+ pwned: window.__pwned,
628
+ scripts: document.querySelectorAll("script").length,
629
+ imgs: document.querySelectorAll("img").length,
630
+ title: document.getElementById("title").textContent,
631
+ nodeText: document.querySelector(".node .name")?.textContent ?? "",
632
+ }));
633
+ check("safety", "hostile text executes nothing", probe.pwned === undefined && errors.length === 0, errors.join("|"));
634
+ check("safety", "hostile text adds no script or image element", probe.scripts === 3 && probe.imgs === 0, `scripts=${probe.scripts} imgs=${probe.imgs}`);
635
+ check("safety", "hostile text is shown as text", probe.title.includes("</script>") && probe.nodeText.length > 0);
636
+ await page.click(".node:not(.off)"); await page.waitForTimeout(300);
637
+ check("safety", "hostile text stays inert in the inspector", await page.evaluate(() => window.__pwned === undefined && document.querySelectorAll("#inspector img").length === 0));
638
+ await ctx.close();
639
+ }
640
+
641
+ // ---------- performance
642
+ if (!flag("--skip-perf")) {
643
+ const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } });
644
+ const page = await ctx.newPage();
645
+ await page.goto(targetUrl); await page.waitForFunction(() => !!window.FlowStudio); await page.waitForTimeout(400);
646
+ const perf = await page.evaluate(() => {
647
+ const base = window.FlowStudio.model;
648
+ const cols = base.columns, lanes = [], nodes = [], links = [];
649
+ const N = 200, perLane = 36;
650
+ for (let i = 0; i < N; i++) {
651
+ const col = i % cols.length, row = Math.floor(i / cols.length) * 1.2, lane = "p" + Math.floor(i / perLane);
652
+ if (!lanes.find(l => l.id === lane)) lanes.push({ id: lane, title: "Lane " + lane });
653
+ nodes.push({ id: "p" + i, kind: "card", lane, col, row, state: Object.keys(base.states)[0], title: "Node " + i, subtitle: "perf" });
654
+ 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] });
655
+ }
656
+ 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 };
657
+ // links go forward in columns only
658
+ 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 });
659
+ model.lanes = lanes;
660
+ window.FlowStudio.destroy();
661
+ for (const id of ["cols", "layerstrip"]) document.getElementById(id).innerHTML = "";
662
+ const t0 = performance.now();
663
+ const api = window.flowStudioMount(document, model, { notes: [] });
664
+ const first = performance.now() - t0;
665
+ const times = [];
666
+ const vp = document.getElementById("viewport");
667
+ 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); }
668
+ times.sort((a, b) => a - b);
669
+ return { nodes: nodes.length, first, p95: times[Math.floor(times.length * 0.95)], ok: !!api };
670
+ });
671
+ check("performance", `first render of ${perf.nodes} nodes under 500 ms`, perf.first < 500, `${perf.first.toFixed(0)} ms`);
672
+ check("performance", "one wheel event under 16 ms at the 95th percentile", perf.p95 < 16, `${perf.p95.toFixed(1)} ms`);
673
+ await ctx.close();
674
+ }
675
+
676
+ await browser.close();
677
+ const failed = results.filter(r => !r.ok);
678
+ console.log(`\n${results.length - failed.length} passed, ${failed.length} failed`);
679
+ if (opt("--json")) writeFileSync(resolve(opt("--json")), JSON.stringify(results, null, 2));
680
+ process.exit(failed.length ? 1 : 0);