@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.
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/skills/flow-studio/README.md +46 -7
- package/bundles/antigravity-plugin/skills/flow-studio/SKILL.md +81 -81
- package/bundles/antigravity-plugin/skills/flow-studio/examples/README.md +8 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/agent-journey.json +103 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/approval-flow.json +134 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/ci-cd-pipeline.json +132 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/incident-timeline.json +109 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/order-desk.json +99 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/service-map.json +133 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/state-machine.json +106 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/ticket-lifecycle.json +107 -0
- package/bundles/antigravity-plugin/skills/flow-studio/model.schema.json +27 -8
- package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.test.tsx +82 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.tsx +67 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/README.md +37 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/index.ts +3 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/FlowCanvas.tsx +58 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/Inspector.tsx +27 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/index.ts +6 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/native.css +18 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/native.test.tsx +161 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/useFlowGeometry.ts +22 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/useTrace.ts +29 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/useViewport.ts +75 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/states.tsx +13 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/design-language.md +177 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/implement-in-app.md +213 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/model-schema.md +254 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/notes-and-spec.md +74 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/patterns.md +385 -49
- package/bundles/antigravity-plugin/skills/flow-studio/references/ux-rules.md +91 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/verify.md +124 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/build-engine.mjs +54 -14
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/build.mjs +21 -4
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/check.mjs +320 -28
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/default-notes.json +67 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry-guard.mjs +5 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry-guard.test.mjs +14 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry.test.mjs +31 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/build-engine.test.mjs +30 -2
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-args.mjs +18 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-args.test.mjs +15 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-selftest.test.mjs +64 -4
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/cli.test.mjs +115 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/demo-pack.mjs +23 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/demo-pack.test.mjs +34 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/docs.test.mjs +128 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/library.test.mjs +26 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-cleanup.mjs +12 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-cleanup.test.mjs +45 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-page.mjs +3 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-page.test.mjs +9 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/styles.test.mjs +37 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/react-proof.mjs +92 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/validate.mjs +5 -1
- package/bundles/antigravity-plugin/skills/flow-studio/src/autolayout.test.ts +78 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/autolayout.ts +132 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/chrome.ts +11 -7
- package/bundles/antigravity-plugin/skills/flow-studio/src/controls.ts +8 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/ctx.ts +16 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/diff.test.ts +31 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/diff.ts +34 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/engine.ts +83 -32
- package/bundles/antigravity-plugin/skills/flow-studio/src/inspector.ts +23 -5
- package/bundles/antigravity-plugin/skills/flow-studio/src/interact.ts +21 -8
- package/bundles/antigravity-plugin/skills/flow-studio/src/layout.test.ts +75 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/layout.ts +49 -11
- package/bundles/antigravity-plugin/skills/flow-studio/src/lib.ts +20 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/metrics.test.ts +57 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/metrics.ts +29 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/model.test.ts +62 -2
- package/bundles/antigravity-plugin/skills/flow-studio/src/model.ts +101 -16
- package/bundles/antigravity-plugin/skills/flow-studio/src/notes.ts +8 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/page.ts +21 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/playback.test.ts +104 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/playback.ts +106 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/playbackui.ts +193 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/render.ts +66 -18
- package/bundles/antigravity-plugin/skills/flow-studio/src/routing.test.ts +21 -1
- package/bundles/antigravity-plugin/skills/flow-studio/src/routing.ts +32 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/rules.test.ts +48 -1
- package/bundles/antigravity-plugin/skills/flow-studio/src/rules.ts +52 -1
- package/bundles/antigravity-plugin/skills/flow-studio/src/selection.test.ts +12 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/selection.ts +4 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.test.ts +7 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.ts +1 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/styles.css +44 -10
- package/bundles/antigravity-plugin/skills/flow-studio/src/trace.test.ts +21 -1
- package/bundles/antigravity-plugin/skills/flow-studio/src/trace.ts +18 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.html +995 -82
- package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.shell.html +1 -39
- package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.css +191 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.d.mts +1 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.mjs +2492 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/shell-body.html +50 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/README.md +46 -7
- package/bundles/codex/.codex/skills/frontend/flow-studio/SKILL.md +81 -81
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/README.md +8 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/agent-journey.json +103 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/approval-flow.json +134 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/ci-cd-pipeline.json +132 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/incident-timeline.json +109 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/order-desk.json +99 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/service-map.json +133 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/state-machine.json +106 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/ticket-lifecycle.json +107 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/model.schema.json +27 -8
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.test.tsx +82 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.tsx +67 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/README.md +37 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/index.ts +3 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/FlowCanvas.tsx +58 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/Inspector.tsx +27 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/index.ts +6 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/native.css +18 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/native.test.tsx +161 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useFlowGeometry.ts +22 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useTrace.ts +29 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useViewport.ts +75 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/states.tsx +13 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/design-language.md +177 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/implement-in-app.md +213 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/model-schema.md +254 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/notes-and-spec.md +74 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/patterns.md +385 -49
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/ux-rules.md +91 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/verify.md +124 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build-engine.mjs +54 -14
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build.mjs +21 -4
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/check.mjs +320 -28
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/default-notes.json +67 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry-guard.mjs +5 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry-guard.test.mjs +14 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry.test.mjs +31 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/build-engine.test.mjs +30 -2
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-args.mjs +18 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-args.test.mjs +15 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-selftest.test.mjs +64 -4
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/cli.test.mjs +115 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/demo-pack.mjs +23 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/demo-pack.test.mjs +34 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/docs.test.mjs +128 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/library.test.mjs +26 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-cleanup.mjs +12 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-cleanup.test.mjs +45 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-page.mjs +3 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-page.test.mjs +9 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/styles.test.mjs +37 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/react-proof.mjs +92 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/validate.mjs +5 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/autolayout.test.ts +78 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/autolayout.ts +132 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/chrome.ts +11 -7
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/controls.ts +8 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/ctx.ts +16 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/diff.test.ts +31 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/diff.ts +34 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/engine.ts +83 -32
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/inspector.ts +23 -5
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/interact.ts +21 -8
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.test.ts +75 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.ts +49 -11
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/lib.ts +20 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/metrics.test.ts +57 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/metrics.ts +29 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.test.ts +62 -2
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.ts +101 -16
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/notes.ts +8 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/page.ts +21 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/playback.test.ts +104 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/playback.ts +106 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/playbackui.ts +193 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/render.ts +66 -18
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.test.ts +21 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.ts +32 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.test.ts +48 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.ts +52 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/selection.test.ts +12 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/selection.ts +4 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.test.ts +7 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.ts +1 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/styles.css +44 -10
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.test.ts +21 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.ts +18 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.html +995 -82
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.shell.html +1 -39
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.css +191 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.d.mts +1 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.mjs +2492 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/shell-body.html +50 -0
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -4,16 +4,18 @@
|
|
|
4
4
|
// Run it from a folder that can resolve "playwright" (the project's node_modules). Exit 0: every check passed.
|
|
5
5
|
|
|
6
6
|
import { createRequire } from "node:module";
|
|
7
|
+
import { isDeepStrictEqual } from "node:util";
|
|
7
8
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
8
9
|
import { resolve, join } from "node:path";
|
|
9
|
-
import {
|
|
10
|
+
import { pageUrl, skippedGroups } from "./lib/check-args.mjs";
|
|
10
11
|
|
|
11
12
|
const argv = process.argv.slice(2);
|
|
12
13
|
const opt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : dflt; };
|
|
13
14
|
const flag = name => argv.includes(name);
|
|
14
15
|
const pageArg = argv[0];
|
|
15
|
-
if (!pageArg || pageArg.startsWith("--")) { console.error("usage: node scripts/check.mjs <page.html> [--out dir] [--viewports WxH,...] [--channel msedge] [--hostile page.html] [--json file] [--skip-perf]"); process.exit(2); }
|
|
16
|
-
|
|
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); }
|
|
17
19
|
const outDir = resolve(opt("--out", "check-out"));
|
|
18
20
|
const viewports = opt("--viewports", "1600x900,1280x720,820x900").split(",").map(v => v.split("x").map(Number));
|
|
19
21
|
const channel = opt("--channel", undefined);
|
|
@@ -21,8 +23,10 @@ const hostilePath = opt("--hostile", undefined);
|
|
|
21
23
|
mkdirSync(outDir, { recursive: true });
|
|
22
24
|
|
|
23
25
|
let chromium;
|
|
24
|
-
|
|
25
|
-
|
|
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); }
|
|
26
30
|
|
|
27
31
|
const results = [];
|
|
28
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 : ""}`); };
|
|
@@ -34,21 +38,28 @@ const inPage = {
|
|
|
34
38
|
return { view: f.view(), selected: f.selected(), mode: f.mode() };
|
|
35
39
|
},
|
|
36
40
|
};
|
|
37
|
-
const url =
|
|
41
|
+
const url = pageUrl;
|
|
42
|
+
const targetUrl = url(pageArg);
|
|
43
|
+
const localOrigin = new URL(targetUrl).protocol.startsWith("http") ? new URL(targetUrl).origin : null;
|
|
38
44
|
|
|
39
45
|
// ---- model-side helpers (run here, from the model the page reports)
|
|
40
46
|
function modelRules(model) {
|
|
41
47
|
const view = (model.views?.find(v => v.default) ?? model.views?.[0])?.id ?? "all";
|
|
42
48
|
const nodes = new Map(model.nodes.map(n => [n.id, n]));
|
|
43
49
|
const canon = id => nodes.get(id)?.ref ?? id;
|
|
44
|
-
const
|
|
45
|
-
const
|
|
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);
|
|
46
57
|
const down = (start, v = view) => {
|
|
47
58
|
const s = new Set([canon(start)]); let grew = true;
|
|
48
|
-
while (grew) { grew = false; for (const l of model.links) { if (!lv(l, v)) continue; const a = canon(l.from), b = canon(l.to); if (s.has(a) && !s.has(b)) { s.add(b); grew = true; } } }
|
|
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; } } }
|
|
49
60
|
return s;
|
|
50
61
|
};
|
|
51
|
-
return { view, nodes, canon, nv, lv, down };
|
|
62
|
+
return { view, nodes, canon, nv, lv, down, collapsed, endpoint };
|
|
52
63
|
}
|
|
53
64
|
|
|
54
65
|
const browser = await chromium.launch(channel ? { channel } : {});
|
|
@@ -61,12 +72,17 @@ for (const [w, h] of viewports) {
|
|
|
61
72
|
const errors = [], requests = [];
|
|
62
73
|
page.on("pageerror", e => errors.push(String(e)));
|
|
63
74
|
page.on("console", m => { if (m.type() === "error") errors.push(m.text()); });
|
|
64
|
-
page.on("request", r => { const u = r.url(); if (!u.startsWith("file:") && !u.startsWith("data:")) requests.push(u); });
|
|
65
|
-
await page.goto(
|
|
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);
|
|
66
77
|
await page.waitForFunction(() => !!window.FlowStudio, null, { timeout: 10000 });
|
|
67
78
|
await page.waitForTimeout(900);
|
|
68
79
|
const model = await page.evaluate(() => window.FlowStudio.model);
|
|
69
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(","));
|
|
70
86
|
await page.screenshot({ path: join(outDir, `01-fit-${tag}.png`) });
|
|
71
87
|
|
|
72
88
|
check("safety", `${tag} no page errors`, errors.length === 0, errors.join(" | "));
|
|
@@ -101,14 +117,53 @@ for (const [w, h] of viewports) {
|
|
|
101
117
|
document.querySelectorAll("path.edge:not(.off)").forEach(p => {
|
|
102
118
|
const a = g.nodes[p.dataset.a], b = g.nodes[p.dataset.b], len = p.getTotalLength();
|
|
103
119
|
const s = p.getPointAtLength(0), e = p.getPointAtLength(len);
|
|
104
|
-
const
|
|
105
|
-
const
|
|
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;
|
|
106
127
|
if (!okA || !okB) bad.push(p.dataset.a + ">" + p.dataset.b);
|
|
107
128
|
});
|
|
108
129
|
return bad;
|
|
109
130
|
});
|
|
110
131
|
check("structural", `${tag} every link end touches its node box (within 2 px)`, ends.length === 0, ends.join(","));
|
|
111
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
|
+
|
|
112
167
|
const spill = await page.evaluate(() => {
|
|
113
168
|
const bad = [];
|
|
114
169
|
document.querySelectorAll(".node:not(.off):not(.wide)").forEach(d => {
|
|
@@ -121,6 +176,24 @@ for (const [w, h] of viewports) {
|
|
|
121
176
|
return bad;
|
|
122
177
|
});
|
|
123
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);
|
|
124
197
|
|
|
125
198
|
const visibleGroups = geo.nodes.length;
|
|
126
199
|
const st0 = await page.evaluate(inPage.state);
|
|
@@ -154,6 +227,43 @@ for (const [w, h] of viewports) {
|
|
|
154
227
|
const expectRows = model.nodes.filter(n => R.nv(n) && n.kind !== "chip").length;
|
|
155
228
|
check("a11y", `${tag} table view lists exactly the visible groups`, tableRows === expectRows, `rows=${tableRows} expected=${expectRows}`);
|
|
156
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
|
+
|
|
157
267
|
if (w !== 1600) { await ctx.close(); continue; }
|
|
158
268
|
|
|
159
269
|
// ---------- interaction (1600x900 only)
|
|
@@ -186,15 +296,29 @@ for (const [w, h] of viewports) {
|
|
|
186
296
|
check("interaction", "Ctrl+key combinations do not move the canvas", kBefore.k === kAfter.k && kBefore.x === kAfter.x);
|
|
187
297
|
await page.keyboard.press("1"); await page.waitForTimeout(450);
|
|
188
298
|
const p0 = (await page.evaluate(inPage.state)).view;
|
|
189
|
-
|
|
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();
|
|
190
314
|
const p1 = (await page.evaluate(inPage.state)).view;
|
|
191
315
|
check("interaction", "dragging the background pans", Math.abs(p1.x - p0.x) > 100, `${p0.x.toFixed(0)} -> ${p1.x.toFixed(0)}`);
|
|
192
316
|
await page.keyboard.press("0"); await page.waitForTimeout(450);
|
|
193
317
|
|
|
194
318
|
// choose a card with the most links
|
|
195
319
|
const deg = new Map();
|
|
196
|
-
for (const l of model.links) if (R.lv(l)) { deg.set(R.canon(l.from), (deg.get(R.canon(l.from)) ?? 0) + 1); deg.set(R.canon(l.to), (deg.get(R.canon(l.to)) ?? 0) + 1); }
|
|
197
|
-
const pick = [...deg.entries()].filter(([id]) => R.nodes.get(id)?.kind !== "chip" && !((R.nodes.get(id)?.colSpan ?? 1) > 1)).sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
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];
|
|
198
322
|
check("interaction", "the model has a selectable card with links", !!pick, pick ?? "none");
|
|
199
323
|
if (pick) {
|
|
200
324
|
await page.keyboard.press("h");
|
|
@@ -244,11 +368,18 @@ for (const [w, h] of viewports) {
|
|
|
244
368
|
}
|
|
245
369
|
|
|
246
370
|
// search
|
|
247
|
-
const probe = model.nodes.find(n => n.members?.
|
|
371
|
+
const probe = model.nodes.find(n => R.nv(n) && (n.members?.some(Boolean) || n.title));
|
|
248
372
|
if (probe) {
|
|
249
|
-
const term = probe.members
|
|
373
|
+
const term = (probe.members?.find(Boolean) ?? probe.title).slice(0, 6).toLowerCase();
|
|
250
374
|
await page.fill("#q", term); await page.waitForTimeout(250);
|
|
251
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");
|
|
252
383
|
await page.fill("#q", ""); await page.keyboard.press("Escape");
|
|
253
384
|
}
|
|
254
385
|
|
|
@@ -264,11 +395,43 @@ for (const [w, h] of viewports) {
|
|
|
264
395
|
}
|
|
265
396
|
|
|
266
397
|
// notes
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
+
}
|
|
272
435
|
|
|
273
436
|
// ---------- accessibility (dynamic)
|
|
274
437
|
await page.locator("#viewport").focus();
|
|
@@ -307,6 +470,130 @@ for (const [w, h] of viewports) {
|
|
|
307
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(" | ")}`);
|
|
308
471
|
await page.emulateMedia({ colorScheme: "dark" }); await page.waitForTimeout(200);
|
|
309
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
|
+
}
|
|
310
597
|
await ctx.close();
|
|
311
598
|
}
|
|
312
599
|
|
|
@@ -314,23 +601,28 @@ for (const [w, h] of viewports) {
|
|
|
314
601
|
{
|
|
315
602
|
const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 }, reducedMotion: "reduce" });
|
|
316
603
|
const page = await ctx.newPage();
|
|
317
|
-
await page.goto(
|
|
604
|
+
await page.goto(targetUrl); await page.waitForFunction(() => !!window.FlowStudio); await page.waitForTimeout(600);
|
|
318
605
|
const moving = await page.evaluate(() => {
|
|
319
606
|
let n = 0;
|
|
320
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++; });
|
|
321
608
|
return n;
|
|
322
609
|
});
|
|
323
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);
|
|
324
616
|
await ctx.close();
|
|
325
617
|
}
|
|
326
618
|
|
|
327
619
|
// ---------- hostile text
|
|
328
|
-
if (hostilePath) {
|
|
620
|
+
if (hostilePath && !skip.has("hostile")) {
|
|
329
621
|
const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } });
|
|
330
622
|
const page = await ctx.newPage();
|
|
331
623
|
const errors = [];
|
|
332
624
|
page.on("pageerror", e => errors.push(String(e)));
|
|
333
|
-
await page.goto(url(
|
|
625
|
+
await page.goto(url(hostilePath)); await page.waitForFunction(() => !!window.FlowStudio); await page.waitForTimeout(600);
|
|
334
626
|
const probe = await page.evaluate(() => ({
|
|
335
627
|
pwned: window.__pwned,
|
|
336
628
|
scripts: document.querySelectorAll("script").length,
|
|
@@ -350,7 +642,7 @@ if (hostilePath) {
|
|
|
350
642
|
if (!flag("--skip-perf")) {
|
|
351
643
|
const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } });
|
|
352
644
|
const page = await ctx.newPage();
|
|
353
|
-
await page.goto(
|
|
645
|
+
await page.goto(targetUrl); await page.waitForFunction(() => !!window.FlowStudio); await page.waitForTimeout(400);
|
|
354
646
|
const perf = await page.evaluate(() => {
|
|
355
647
|
const base = window.FlowStudio.model;
|
|
356
648
|
const cols = base.columns, lanes = [], nodes = [], links = [];
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"anchor": "#title",
|
|
4
|
+
"title": "Page frame",
|
|
5
|
+
"text": "Display the main scenario headline, subtitle, and timestamp. Render all text using textContent or esc() to prevent script injection."
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"anchor": "#layerstrip",
|
|
9
|
+
"title": "Completeness strip",
|
|
10
|
+
"text": "Show completeness per column with stacked horizontal bars for good, neutral, and gap counts. Render dotted tracks for empty columns."
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"anchor": "#kpis",
|
|
14
|
+
"title": "Summary tiles",
|
|
15
|
+
"text": "Show total links, gap count, and upgrade opportunities. Make tiles interactive buttons that filter canvas focus when clicked."
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"anchor": "#modeSwitch",
|
|
19
|
+
"title": "View switch",
|
|
20
|
+
"text": "Provide segmented buttons to switch perspectives. Update visible nodes and overridden link states from a single underlying data model."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"anchor": "#q",
|
|
24
|
+
"title": "Search and radius",
|
|
25
|
+
"text": "Filter nodes by name or identifier. Use the hops selector to reveal upstream and downstream connections within N degrees."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"anchor": "#viewport",
|
|
29
|
+
"title": "Canvas viewport",
|
|
30
|
+
"text": "Support smooth pan and zoom around cursor. Fit initial view to width so users see all phases immediately without manual zooming."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"anchor": "#tools",
|
|
34
|
+
"title": "Canvas controls",
|
|
35
|
+
"text": "Provide quick zoom in, zoom out, fit-to-screen, 100% scale, and pan hand controls. Bind standard keyboard shortcuts V, H, +, -, 0, and F."
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"anchor": "#inspector",
|
|
39
|
+
"title": "Side inspector",
|
|
40
|
+
"text": "Slide out an inspector card on node selection. Display incoming links, outgoing links, metadata, and what-if simulation buttons."
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"anchor": "#legend",
|
|
44
|
+
"title": "State legend",
|
|
45
|
+
"text": "Display color chips and state labels for good, neutral, warning, and critical states. Keep states distinguishable without color alone."
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"anchor": "#ledger",
|
|
49
|
+
"title": "Change ledger",
|
|
50
|
+
"text": "List recent schema updates, audit entries, or unverified links beneath the canvas. Render tabular details cleanly."
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"anchor": "#tbl",
|
|
54
|
+
"title": "Accessible table view",
|
|
55
|
+
"text": "Provide a collapsible HTML table containing all graph nodes, states, and relations. Ensures screen reader parity with canvas visuals."
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"anchor": "#playbar",
|
|
59
|
+
"title": "Scenario playback",
|
|
60
|
+
"text": "Provide play, pause, step-by-step, reset, and scrubbing controls. Animate token flow across links during scenario execution."
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"anchor": "#notesBtn",
|
|
64
|
+
"title": "Implementation notes",
|
|
65
|
+
"text": "Toggle developer guidance cards and anchored target markers. Map numeric badges directly to corresponding drawer documentation cards."
|
|
66
|
+
}
|
|
67
|
+
]
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Keep malformed auto models intact so validation reports their actual errors. */
|
|
2
|
+
export function prepareModel(input, autoLayout) {
|
|
3
|
+
return input?.layout === "auto" && Array.isArray(input.nodes) && Array.isArray(input.links)
|
|
4
|
+
&& Array.isArray(input.columns) && Array.isArray(input.lanes) ? autoLayout(input) : input;
|
|
5
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { prepareModel } from "./auto-entry-guard.mjs";
|
|
4
|
+
|
|
5
|
+
test("auto layout runs only when its four input arrays exist", () => {
|
|
6
|
+
const model = { layout: "auto", nodes: [], links: [], columns: [], lanes: [] };
|
|
7
|
+
const laidOut = { ...model, applied: true };
|
|
8
|
+
const layout = input => { assert.equal(input, model); return laidOut; };
|
|
9
|
+
assert.equal(prepareModel(model, layout), laidOut);
|
|
10
|
+
for (const field of ["nodes", "links", "columns", "lanes"]) {
|
|
11
|
+
assert.equal(prepareModel({ ...model, [field]: null }, () => { throw Error("called"); })[field], null);
|
|
12
|
+
}
|
|
13
|
+
assert.equal(prepareModel({ ...model, layout: "free" }, () => { throw Error("called"); }).layout, "free");
|
|
14
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const validateScript = fileURLToPath(new URL("../validate.mjs", import.meta.url));
|
|
9
|
+
const buildScript = fileURLToPath(new URL("../build.mjs", import.meta.url));
|
|
10
|
+
|
|
11
|
+
test("validate and build lay out an auto model before checking it", () => {
|
|
12
|
+
const dir = mkdtempSync(join(fileURLToPath(new URL(".", import.meta.url)), ".auto-entry-"));
|
|
13
|
+
try {
|
|
14
|
+
const modelPath = join(dir, "model.json"), pagePath = join(dir, "page.html");
|
|
15
|
+
const model = {
|
|
16
|
+
version: 1, meta: { title: "Auto", help: "", asOf: "", source: "" }, layout: "auto", columns: [], lanes: [],
|
|
17
|
+
states: { ok: { word: "OK", tone: "ok", border: "solid", edge: "flow", motion: "flow", bucket: "good" } },
|
|
18
|
+
nodes: ["a", "b"].map(id => ({ id, kind: "card", state: "ok", title: id })),
|
|
19
|
+
links: [{ id: "ab", from: "a", to: "b", state: "ok" }],
|
|
20
|
+
};
|
|
21
|
+
writeFileSync(modelPath, JSON.stringify(model));
|
|
22
|
+
const run = (script, extra = []) => spawnSync(process.execPath, ["--experimental-strip-types", "--no-warnings", script, modelPath, ...extra], { cwd: dir, encoding: "utf8" });
|
|
23
|
+
const valid = run(validateScript);
|
|
24
|
+
assert.equal(valid.status, 0, valid.stdout + valid.stderr);
|
|
25
|
+
const built = run(buildScript, [pagePath]);
|
|
26
|
+
assert.equal(built.status, 0, built.stdout + built.stderr);
|
|
27
|
+
const page = readFileSync(pagePath, "utf8");
|
|
28
|
+
assert.match(page, /"layout":"auto"/);
|
|
29
|
+
assert.match(page, /"title":"Stage 2"/);
|
|
30
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
31
|
+
});
|