@stacknyu/pi-glitch-lab 0.0.0-stage → 0.5.0

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 (37) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +21 -0
  3. package/README.md +87 -2
  4. package/examples/glitchy-app/README.md +14 -0
  5. package/examples/glitchy-app/THIRD_PARTY_NOTICES.md +27 -0
  6. package/examples/glitchy-app/app.jsx +62 -0
  7. package/examples/glitchy-app/public/api/credits.json +1 -0
  8. package/examples/glitchy-app/public/api/me.json +1 -0
  9. package/examples/glitchy-app/public/api/messages.json +1 -0
  10. package/examples/glitchy-app/public/app.js +14749 -0
  11. package/examples/glitchy-app/public/index.html +2 -0
  12. package/examples/glitchy-app/serve.mjs +12 -0
  13. package/extensions/index.ts +155 -0
  14. package/package.json +53 -3
  15. package/scripts/affordance_scan.mjs +235 -0
  16. package/scripts/doctor.mjs +14 -0
  17. package/scripts/glitch/analyze.mjs +274 -0
  18. package/scripts/glitch_lab.mjs +519 -0
  19. package/scripts/interactions.mjs +172 -0
  20. package/scripts/journey_runner.mjs +260 -0
  21. package/scripts/lib.mjs +260 -0
  22. package/scripts/report.mjs +144 -0
  23. package/scripts/sensors/dom_observer.mjs +96 -0
  24. package/scripts/sensors/react_sensor.mjs +167 -0
  25. package/skills/ai-agentic-usability-testing/SKILL.md +68 -0
  26. package/skills/ai-agentic-usability-testing/assets/default-personas.md +96 -0
  27. package/skills/ai-agentic-usability-testing/assets/findings.example.json +56 -0
  28. package/skills/ai-agentic-usability-testing/assets/matrix.default.json +746 -0
  29. package/skills/ai-agentic-usability-testing/assets/matrix.partners.example.json +73 -0
  30. package/skills/ai-agentic-usability-testing/assets/matrix.web.example.json +32 -0
  31. package/skills/ai-agentic-usability-testing/assets/personas-template.md +45 -0
  32. package/skills/ai-agentic-usability-testing/references/latency-reliability.md +41 -0
  33. package/skills/ai-agentic-usability-testing/references/regression-pipeline.md +41 -0
  34. package/skills/ai-agentic-usability-testing/references/tenant-persona-matrix.md +61 -0
  35. package/skills/ai-agentic-usability-testing/references/visual-affordance-guide.md +97 -0
  36. package/skills/ai-agentic-usability-testing/references/visual-glitches.md +83 -0
  37. package/skills/glitch-hunt/SKILL.md +59 -0
@@ -0,0 +1,172 @@
1
+ // Bounded exploration of rendered UI states. Never invents business acceptance criteria.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { createHash } from "node:crypto";
5
+ import { bounded, checkTarget, requireInteraction } from "./lib.mjs";
6
+
7
+ export async function inventory(page, classify, touch, maxScrolls = 12) {
8
+ const found = new Map();
9
+ const add = async () => {
10
+ const res = await page.evaluate(classify, { touch });
11
+ for (const item of res.results) found.set(item.key, item);
12
+ return res;
13
+ };
14
+ let res = await add();
15
+ // Scroll the document and bounded nested scroll containers; virtualized controls may appear lazily.
16
+ const scroll = await page.evaluate(() => {
17
+ const els = [document.scrollingElement, ...document.querySelectorAll("body *")].filter((e) => e && e.scrollHeight > e.clientHeight + 20 && e.clientHeight > 40 &&
18
+ (e === document.scrollingElement || /auto|scroll/.test(getComputedStyle(e).overflowY)));
19
+ return els.slice(0, 8).map((e, i) => { e.setAttribute("data-ux-scroll", String(i)); return { i, top: e.scrollTop }; });
20
+ });
21
+ let steps = 0, truncated = false;
22
+ for (const container of scroll) {
23
+ const loc = page.locator(`[data-ux-scroll="${container.i}"]`);
24
+ await loc.evaluate((e) => { e.scrollTop = 0; });
25
+ while (true) {
26
+ if (steps++ >= maxScrolls) { truncated = true; break; }
27
+ await page.waitForTimeout(60);
28
+ res = await add();
29
+ const more = await loc.evaluate((e) => { const before = e.scrollTop; e.scrollTop += Math.max(40, e.clientHeight * 0.8); return e.scrollTop > before; });
30
+ if (!more) break;
31
+ }
32
+ await loc.evaluate((e, top) => { e.scrollTop = top; }, container.top);
33
+ if (truncated) break;
34
+ }
35
+ await add();
36
+ return { ...res, results: [...found.values()], scrollTruncated: truncated,
37
+ limitations: "Rendered light DOM only; closed shadow roots, iframe contents and unvisited virtualized states are not covered." };
38
+ }
39
+
40
+ const stateKey = (url, items) => createHash("sha256").update(JSON.stringify([url, items.map((i) => [i.key, i.disabled, i.expanded, i.selected, i.checked])])).digest("hex");
41
+ async function snapshot(page) {
42
+ return page.evaluate(() => ({ url: location.href, text: document.body.innerText.slice(0, 40000),
43
+ controls: [...document.querySelectorAll("input,select,textarea,[aria-expanded],[aria-selected],[aria-checked],details")].map((e) =>
44
+ [e.tagName, e.id, e.type === "password" ? "<redacted>" : e.value, e.checked, e.open, e.getAttribute("aria-expanded"), e.getAttribute("aria-selected"), e.getAttribute("aria-checked")]),
45
+ invalid: [...document.querySelectorAll(":invalid")].map((e) => e.validationMessage) }));
46
+ }
47
+ function sameControl(current, target) { return current.key === target.key; }
48
+ async function locate(page, classify, touch, target) {
49
+ const current = await page.evaluate(classify, { touch });
50
+ const match = current.results.find((i) => sameControl(i, target));
51
+ if (!match) throw new Error("Control is absent or changed since discovery");
52
+ return page.locator(match.selector);
53
+ }
54
+
55
+ export async function explore(page, url, initial, cell, args, classify, dir, prefix) {
56
+ requireInteraction(args);
57
+ const maxActions = bounded(args["max-actions"] ?? args["max-clicks"], 40, 200);
58
+ const maxStates = bounded(args["max-states"], 12, 50), maxPages = bounded(args["max-pages"], 5, 25);
59
+ const maxDepth = bounded(args["max-depth"], 4, 8), seconds = bounded(args.seconds, 120, 600);
60
+ const observeMs = bounded(args["observe-ms"], 1000, 5000);
61
+ const touch = !!cell.touch, start = Date.now(), deadline = start + seconds * 1000;
62
+ const states = [], actions = [], replays = [], visited = new Set(), pages = new Set([url]);
63
+ const context = page.context();
64
+ context._uxPageBudget = { urls: pages, limit: maxPages, hit: false };
65
+ const guardHistory = () => page.evaluate(({ limit, urls }) => {
66
+ const installed = !!window.__uxNavBudget;
67
+ window.__uxNavBudget = { limit, urls, hit: false };
68
+ if (installed) return;
69
+ for (const method of ["pushState", "replaceState"]) {
70
+ const original = history[method];
71
+ history[method] = function (data, unused, target) {
72
+ const next = new URL(target ?? location.href, location.href).href, budget = window.__uxNavBudget;
73
+ if (!budget.urls.includes(next) && budget.urls.length >= budget.limit) { budget.hit = true; throw new Error("Exploration page limit reached"); }
74
+ if (!budget.urls.includes(next)) budget.urls.push(next);
75
+ window.__uxHistoryVisit(next).catch(() => {});
76
+ return original.call(this, data, unused, target);
77
+ };
78
+ }
79
+ }, { limit: maxPages, urls: [...pages] });
80
+ let actualClicks = 0;
81
+ const beforeClick = () => {
82
+ if (actualClicks >= maxActions || Date.now() >= deadline) { limited = true; throw new Error("Action/time limit reached"); }
83
+ actualClicks++;
84
+ };
85
+ const queue = [{ sequence: [], items: initial.results, url }];
86
+ let limited = initial.scrollTruncated;
87
+ while (queue.length && states.length < maxStates && actualClicks < maxActions && Date.now() < deadline) {
88
+ const state = queue.shift(), key = stateKey(state.url, state.items);
89
+ if (visited.has(key)) continue;
90
+ visited.add(key); pages.add(state.url);
91
+ const id = states.length;
92
+ states.push({ id, url: state.url, depth: state.sequence.length, controls: state.items.length });
93
+ for (const target of state.items) {
94
+ const rec = { state: id, control: target.key, text: target.text || target.name, selector: target.selector, result: "skipped", attempted: false };
95
+ if (["fake", "fake-icon"].includes(target.cat)) rec.reason = "Non-semantic candidate; human review required";
96
+ else if (target.field) rec.reason = "Input needs explicit journey values";
97
+ else if (target.disabled) rec.reason = "Disabled control";
98
+ else if (state.sequence.length >= maxDepth) rec.reason = "Depth limit";
99
+ else if (actualClicks >= maxActions || Date.now() >= deadline) { rec.reason = "Action/time limit"; limited = true; }
100
+ else {
101
+ try {
102
+ // Recreate the starting state for each independent probe; never chain unrelated clicks.
103
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: Math.max(1, Math.min(15000, deadline - Date.now())) });
104
+ await guardHistory();
105
+ for (const prior of state.sequence) {
106
+ const loc = await locate(page, classify, touch, prior);
107
+ await checkTarget(loc, cell, args, true);
108
+ beforeClick();
109
+ const replay = { control: prior.key, state: id, ok: false }; replays.push(replay);
110
+ await loc.click({ timeout: 2000 }); replay.ok = true;
111
+ await page.waitForTimeout(100);
112
+ }
113
+ const loc = await locate(page, classify, touch, target);
114
+ try { await checkTarget(loc, cell, args, true); } catch (e) { rec.reason = e.message; actions.push(rec); continue; }
115
+ await loc.scrollIntoViewIfNeeded({ timeout: 2000 });
116
+ await loc.click({ trial: true, timeout: 1500 });
117
+ const baseline = await snapshot(page);
118
+ await page.waitForTimeout(150);
119
+ const before = await snapshot(page), blocked = page.context()._uxBlocked.length;
120
+ const noisy = JSON.stringify(baseline) !== JSON.stringify(before);
121
+ const events = [], errors = [];
122
+ const dialog = async (d) => { events.push(`dialog:${d.type()}`); await d.dismiss(); };
123
+ const download = async (d) => { events.push("download"); await d.cancel(); };
124
+ const popup = async (p) => { events.push("popup"); await p.close(); };
125
+ const error = (e) => errors.push(e.message);
126
+ const consoleError = (m) => { if (m.type() === "error") errors.push(m.text().slice(0, 300)); };
127
+ page.on("dialog", dialog); page.on("download", download); page.on("popup", popup); page.on("pageerror", error); page.on("console", consoleError);
128
+ rec.before = `${prefix}_action_${actions.length}_before.png`;
129
+ await page.screenshot({ path: path.join(dir, rec.before), mask: cell.mask.map((s) => page.locator(s)) });
130
+ const t0 = Date.now();
131
+ try {
132
+ beforeClick(); rec.attempted = true;
133
+ await loc.click({ timeout: 2000 });
134
+ let after = before;
135
+ while (Date.now() - t0 < observeMs && Date.now() < deadline) {
136
+ after = await snapshot(page);
137
+ if (rec.latencyMs === undefined && (JSON.stringify(after) !== JSON.stringify(before) || events.length)) rec.latencyMs = Date.now() - t0;
138
+ await page.waitForTimeout(100);
139
+ }
140
+ rec.events = events;
141
+ rec.errors = errors;
142
+ const historyLimit = await page.evaluate(() => window.__uxNavBudget?.hit || false);
143
+ limited ||= historyLimit || context._uxPageBudget.hit;
144
+ if (historyLimit) rec.reason = "Page limit reached";
145
+ rec.result = historyLimit || page.context()._uxBlocked.length > blocked ? "blocked" : errors.length ? "error" : rec.latencyMs !== undefined ? noisy && after.url === before.url && !events.length ? "inconclusive" : "responded" : "no-observable-response";
146
+ if (rec.result === "inconclusive") rec.reason = "Page was changing before the click; response cannot be attributed reliably";
147
+ rec.after = `${prefix}_action_${actions.length}_after.png`;
148
+ await page.screenshot({ path: path.join(dir, rec.after), mask: cell.mask.map((s) => page.locator(s)) });
149
+ const next = await inventory(page, classify, touch);
150
+ limited ||= next.scrollTruncated;
151
+ if (rec.result === "responded" && !visited.has(stateKey(page.url(), next.results))) {
152
+ if (pages.has(page.url()) || pages.size < maxPages) {
153
+ pages.add(page.url()); queue.push({ sequence: [...state.sequence, target], items: next.results, url: page.url() });
154
+ } else limited = true;
155
+ }
156
+ } finally {
157
+ page.off("dialog", dialog); page.off("download", download); page.off("popup", popup); page.off("pageerror", error); page.off("console", consoleError);
158
+ }
159
+ } catch (e) { rec.result = rec.attempted ? "error" : "blocked"; rec.reason = e.message.split("\n")[0]; }
160
+ }
161
+ actions.push(rec);
162
+ }
163
+ }
164
+ limited ||= queue.length > 0 || context._uxPageBudget.hit;
165
+ delete context._uxPageBudget;
166
+ const counts = actions.reduce((a, r) => ({ ...a, [r.result]: (a[r.result] || 0) + 1 }), {});
167
+ const result = { states, actions, replays, actualClicks, maxActions, counts, discovered: actions.length, exercised: actions.filter((a) => a.attempted).length,
168
+ limited, pendingStates: queue.length, elapsedMs: Date.now() - start,
169
+ limitations: "Responded means an observable effect, NOT correct business behavior. No-response is not proof of a dead control. Clipboard and hover-only interactions need explicit journeys. Reload/replay does not roll back server data." };
170
+ fs.writeFileSync(path.join(dir, `${prefix}_coverage.json`), JSON.stringify(result, null, 2));
171
+ return result;
172
+ }
@@ -0,0 +1,260 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * journey_runner.mjs — run persona journeys in a real browser, screenshot every step,
4
+ * time what the user waits for, check what must / must not be on screen,
5
+ * and optionally save a baseline or compare against one (visual regression).
6
+ *
7
+ * node journey_runner.mjs --config .ux/matrix.json --cell teen-applicant@brightpath --out .ux/runs/2026-09-23
8
+ * node journey_runner.mjs --config .ux/matrix.json --all --baseline .ux/baseline
9
+ * node journey_runner.mjs --config .ux/matrix.json --all --compare .ux/baseline --out .ux/runs/2026-09-30
10
+ * options: --journey <id> --diff-threshold 0.001 (fraction of pixels; mask dynamic areas like AI replies and timestamps via "mask") --slow-factor 2 --auth-dir .ux/auth
11
+ *
12
+ * Step types (see assets/matrix.example.json):
13
+ * {"goto": "/chat"}
14
+ * {"click": "New chat"} visible text; or {"click": {"role":"button","name":"Send"}} / {"click": {"selector": "..."}}
15
+ * {"fill": {"label": "Message", "value": "..."}} or {"placeholder": ...} / {"selector": ...}
16
+ * {"press": "Enter"}
17
+ * {"waitForText": "Scholarship", "timeout": 30000}
18
+ * {"measureResponse": {"selector": "[data-role=assistant]:last-of-type", "timeout": 60000}}
19
+ * records time to first visible output and time until output stops changing (AI streaming)
20
+ * {"expectText": "{{partner.name}}"} {"expectNoText": ["Tax agent", "OtherPartner"]}
21
+ * {"screenshot": "after-send"} {"wait": 1000}
22
+ * Every step gets a screenshot automatically; set "shot": false to skip.
23
+ */
24
+ import fs from "node:fs";
25
+ import path from "node:path";
26
+ import { parseArgs, loadPlaywright, launchBrowser, loadConfig, resolveCells, newContext, login, throttle,
27
+ attachListeners, slugify, ensureDir, tpl, locatorFor, freshOutput, requireInteraction, checkTarget, bounded } from "./lib.mjs";
28
+
29
+ const args = parseArgs();
30
+ const cfg = loadConfig(args.config);
31
+ const cells = resolveCells(cfg, args.cell, args.all);
32
+ const baselineMode = !!args.baseline;
33
+ const OUT = baselineMode ? args.baseline : args.out || ".ux/runs/latest";
34
+ if (args.baseline && args.compare) throw new Error("Choose baseline or compare, not both");
35
+ if (args.compare && path.resolve(args.compare) === path.resolve(OUT)) throw new Error("Comparison output must differ from baseline");
36
+ freshOutput(OUT);
37
+ for (const cell of cells) {
38
+ const ids = args.journey ? [args.journey] : cell.journeys || [];
39
+ if (!ids.length || new Set(ids).size !== ids.length || ids.some((id) => !/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(id) || !cfg.journeys?.[id]?.steps?.length)) throw new Error(`Missing/empty journey for ${cell.id}`);
40
+ }
41
+ const DIFF_T = Number(args["diff-threshold"] || 0.001);
42
+ if (!Number.isFinite(DIFF_T) || DIFF_T < 0 || DIFF_T > 1) throw new Error("Invalid diff threshold");
43
+ const SLOW_F = Number(args["slow-factor"] || 2);
44
+ const { chromium } = await loadPlaywright();
45
+ const browser = await launchBrowser(chromium, args);
46
+
47
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
48
+
49
+
50
+ /** Visible text as separate chunks (innerText glues adjacent inline elements together). */
51
+ async function visibleText(page) {
52
+ return page.evaluate(() => {
53
+ const out = [document.title];
54
+ const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
55
+ for (let n; (n = w.nextNode());) {
56
+ const t = n.textContent.trim(); if (!t) continue;
57
+ const el = n.parentElement; if (!el) continue;
58
+ const cs = getComputedStyle(el), r = el.getBoundingClientRect();
59
+ if (cs.visibility === "hidden" || cs.display === "none" || +cs.opacity === 0 || !r.width || !r.height) continue;
60
+ out.push(t);
61
+ }
62
+ document.querySelectorAll("[aria-label],img[alt],[title]").forEach((e) => {
63
+ const r = e.getBoundingClientRect(); if (!r.width) return;
64
+ out.push(e.getAttribute("aria-label") || e.getAttribute("alt") || e.getAttribute("title"));
65
+ });
66
+ return out.filter(Boolean).join(" \u2502 ");
67
+ });
68
+ }
69
+
70
+ async function measureResponse(page, spec) {
71
+ const sel = spec.selector, timeout = spec.timeout || 60000, stableMs = spec.stableMs || 2000;
72
+ const armed = page._uxResponseStart?.selector === sel ? page._uxResponseStart : null;
73
+ const t0 = armed?.t0 ?? Date.now();
74
+ const initial = armed?.initial ?? await page.locator(sel).last().innerText({ timeout: 200 }).catch(() => "");
75
+ page._uxResponseStart = null;
76
+ let first = null, lastChange = null, last = initial, completed = false;
77
+ while (Date.now() - t0 < timeout) {
78
+ const cur = await page.locator(sel).last().innerText({ timeout: 200 }).catch(() => "");
79
+ if (cur !== last) { if (first === null) first = Date.now() - t0; lastChange = Date.now() - t0; last = cur; }
80
+ if (lastChange !== null && Date.now() - t0 - lastChange > stableMs) { completed = true; break; }
81
+ await page.waitForTimeout(100);
82
+ }
83
+ return { firstOutputMs: first, completeMs: lastChange, timedOut: !completed, chars: last.length };
84
+ }
85
+
86
+ async function visualDiff(page, aPath, bPath, outPath) {
87
+ const a = "data:image/png;base64," + fs.readFileSync(aPath).toString("base64");
88
+ const b = "data:image/png;base64," + fs.readFileSync(bPath).toString("base64");
89
+ const res = await page.evaluate(async ({ a, b }) => {
90
+ const load = (src) => new Promise((resolve, reject) => { const i = new Image(), timer = setTimeout(() => reject(new Error("Image decoding timed out")), 5000);
91
+ i.onload = () => { clearTimeout(timer); resolve(i); }; i.onerror = () => { clearTimeout(timer); reject(new Error("Invalid comparison image")); }; i.src = src; });
92
+ const [ia, ib] = await Promise.all([load(a), load(b)]);
93
+ const w = Math.max(ia.width, ib.width), h = Math.max(ia.height, ib.height);
94
+ const ca = new OffscreenCanvas(w, h), cb = new OffscreenCanvas(w, h), cd = new OffscreenCanvas(w, h);
95
+ const xa = ca.getContext("2d"), xb = cb.getContext("2d"), xd = cd.getContext("2d");
96
+ xa.drawImage(ia, 0, 0); xb.drawImage(ib, 0, 0);
97
+ const da = xa.getImageData(0, 0, w, h).data, db = xb.getImageData(0, 0, w, h).data;
98
+ xd.globalAlpha = 0.35; xd.drawImage(ib, 0, 0); xd.globalAlpha = 1;
99
+ const out = xd.getImageData(0, 0, w, h); let diff = 0;
100
+ for (let i = 0; i < da.length; i += 4) {
101
+ const d = Math.abs(da[i] - db[i]) + Math.abs(da[i + 1] - db[i + 1]) + Math.abs(da[i + 2] - db[i + 2]);
102
+ if (d > 60) { diff++; out.data[i] = 255; out.data[i + 1] = 0; out.data[i + 2] = 80; out.data[i + 3] = 255; }
103
+ }
104
+ xd.putImageData(out, 0, 0);
105
+ const blob = await cd.convertToBlob();
106
+ const buf = new Uint8Array(await blob.arrayBuffer());
107
+ let s = ""; for (let i = 0; i < buf.length; i += 0x8000) s += String.fromCharCode(...buf.subarray(i, i + 0x8000));
108
+ return { ratio: diff / (w * h), sizeChanged: ia.width !== ib.width || ia.height !== ib.height, png: btoa(s) };
109
+ }, { a, b });
110
+ fs.writeFileSync(outPath, Buffer.from(res.png, "base64"));
111
+ return { ratio: +res.ratio.toFixed(4), sizeChanged: res.sizeChanged };
112
+ }
113
+
114
+ async function assertEventually(check, timeout) {
115
+ const end = Date.now() + timeout;
116
+ while (true) {
117
+ if (await check()) return;
118
+ if (Date.now() >= end) throw new Error("Acceptance assertion did not hold within timeout");
119
+ await new Promise((r) => setTimeout(r, 100));
120
+ }
121
+ }
122
+
123
+ const allResults = [];
124
+ try {
125
+ const helper = args.compare ? await browser.newPage() : null;
126
+ for (const cell of cells) {
127
+ const context = await newContext(browser, cell, args);
128
+ await login(context, cell);
129
+ const jids = args.journey ? [args.journey] : cell.journeys || [];
130
+ for (const jid of jids) {
131
+ const journey = cfg.journeys?.[jid];
132
+ if (!journey) throw new Error(`Unknown journey ${jid}`);
133
+ const dir = ensureDir(path.join(OUT, cell.slug, "journeys", jid));
134
+ const page = await context.newPage();
135
+ const sink = { console: [], failed: [] };
136
+ attachListeners(page, sink);
137
+ await throttle(context, page, cell);
138
+ const steps = [];
139
+ let broken = false;
140
+ const jStart = Date.now();
141
+ for (let i = 0; i < journey.steps.length; i++) {
142
+ const s = journey.steps[i];
143
+ const kind = Object.keys(s).find((k) => !["shot", "timeout", "note", "optional"].includes(k));
144
+ const rec = { i, kind, arg: kind === "fill" ? { ...s.fill, value: "<redacted>" } : s[kind], note: s.note, required: !s.optional, ok: true };
145
+ const t0 = Date.now();
146
+ if (broken) { rec.ok = false; rec.skipped = true; steps.push(rec); continue; }
147
+ try {
148
+ const to = bounded(s.timeout, 10000, 60000);
149
+ const nextResponse = journey.steps[i + 1]?.measureResponse;
150
+ if (nextResponse) page._uxResponseStart = { selector: nextResponse.selector, t0: Date.now(), initial: await page.locator(nextResponse.selector).last().innerText({ timeout: 200 }).catch(() => "") };
151
+ switch (kind) {
152
+ case "goto":
153
+ await page.goto(cell.base + tpl(s.goto, cell), { waitUntil: "domcontentloaded", timeout: 60000 });
154
+ await page.waitForLoadState("networkidle", { timeout: to }).catch(() => {});
155
+ break;
156
+ case "click": {
157
+ const loc = locatorFor(page, s.click, cell); await checkTarget(loc, cell, args);
158
+ await loc.click({ timeout: to }); break;
159
+ }
160
+ case "fill": requireInteraction(args, true); await locatorFor(page, s.fill, cell).fill(tpl(s.fill.value, cell), { timeout: to }); break;
161
+ case "press": requireInteraction(args, !/^(Tab|Shift\+Tab|Escape|Arrow(Up|Down|Left|Right))$/.test(s.press)); await page.keyboard.press(s.press); break;
162
+ case "select": requireInteraction(args, true); await locatorFor(page, s.select, cell).selectOption(s.select.value, { timeout: to }); break;
163
+ case "check": requireInteraction(args, true); await locatorFor(page, s.check, cell).setChecked(s.check.checked ?? true, { timeout: to }); break;
164
+ case "reload": await page.reload({ waitUntil: "domcontentloaded", timeout: to }); break;
165
+ case "expectURL": await page.waitForURL((u) => u.href === new URL(tpl(s.expectURL, cell), cell.base).href, { timeout: to }); break;
166
+ case "expectVisible": await locatorFor(page, s.expectVisible, cell).waitFor({ state: "visible", timeout: to }); break;
167
+ case "expectHidden": await locatorFor(page, s.expectHidden, cell).waitFor({ state: "hidden", timeout: to }); break;
168
+ case "expectValue": await assertEventually(async () => await locatorFor(page, s.expectValue, cell).inputValue({ timeout: to }) === String(tpl(s.expectValue.value, cell)), to); break;
169
+ case "expectChecked": await assertEventually(async () => await locatorFor(page, s.expectChecked, cell).isChecked() === (s.expectChecked.checked ?? true), to); break;
170
+ case "expectEnabled": await assertEventually(async () => await locatorFor(page, s.expectEnabled, cell).isEnabled() === (s.expectEnabled.enabled ?? true), to); break;
171
+ case "expectCount": await assertEventually(async () => await page.locator(s.expectCount.selector).count() === s.expectCount.count, to); break;
172
+ case "expectAttribute": await assertEventually(async () => await locatorFor(page, s.expectAttribute, cell).getAttribute(s.expectAttribute.attribute) === String(s.expectAttribute.value), to); break;
173
+ case "wait": await page.waitForTimeout(s.wait); break;
174
+ case "waitForText": await page.getByText(tpl(s.waitForText, cell)).first().waitFor({ timeout: to }); break;
175
+ case "measureResponse": rec.response = await measureResponse(page, s.measureResponse);
176
+ if (rec.response.timedOut) throw new Error("no visible response within timeout"); break;
177
+ case "expectText": {
178
+ const need = [].concat(s.expectText).flatMap((t) => (t === "{{persona.mustSee}}" ? cell.persona.mustSee || [] : [tpl(t, cell)]));
179
+ if (!need.length || need.some((t) => !String(t).trim())) throw new Error("Empty expected text assertion");
180
+ await assertEventually(async () => { const body = await visibleText(page); return need.every((t) => body.toLowerCase().includes(t.toLowerCase())); }, to);
181
+ break;
182
+ }
183
+ case "expectNoText": {
184
+ const bad = [].concat(s.expectNoText).flatMap((t) => (t === "{{persona.mustNotSee}}" ? cell.persona.mustNotSee || [] : [tpl(t, cell)]));
185
+ if (!bad.length || bad.some((t) => !String(t).trim())) throw new Error("Empty forbidden-text assertion");
186
+ const html = await visibleText(page);
187
+ const found = bad.filter((t) => {
188
+ const pre = /^\w/.test(t) ? "\\b" : "", post = /\w$/.test(t) ? "\\b" : "";
189
+ return new RegExp(pre + esc(t) + post, "i").test(html);
190
+ });
191
+ if (found.length) { rec.leak = found; throw new Error("must NOT be visible but is: " + found.join(", ")); }
192
+ break;
193
+ }
194
+ case "screenshot": break;
195
+ default: throw new Error("unknown step " + kind);
196
+ }
197
+ } catch (e) {
198
+ rec.ok = false; rec.error = String(e.message).split("\n")[0].slice(0, 200);
199
+ if (!s.optional && !["expectText", "expectNoText"].includes(kind)) broken = true;
200
+ }
201
+ rec.ms = Date.now() - t0;
202
+ if (s.shot !== false) {
203
+ await page.waitForTimeout(300);
204
+ const f = `${String(i).padStart(2, "0")}_${slugify(kind + "_" + (typeof s[kind] === "string" ? s[kind] : s[kind]?.name || s[kind]?.label || s[kind]?.text || ""))}.png`;
205
+ const masks = cell.mask.map((m) => page.locator(m));
206
+ try {
207
+ await page.screenshot({ path: path.join(dir, f), fullPage: false, mask: masks, animations: "disabled" }); rec.shot = f;
208
+ } catch (e) { rec.ok = false; rec.error = `Evidence capture failed: ${e.message}`; }
209
+ }
210
+ steps.push(rec);
211
+ }
212
+ const result = { cell: cell.id, persona: cell.persona.name || cell.persona, partner: cell.partner.name || cell.partner, journey: jid,
213
+ title: journey.title, viewport: cell.viewport, network: cell.network, passed: steps.every((s) => s.ok || !s.required) && steps.some((s) => s.kind?.startsWith("expect") && s.ok && s.required) && !sink.console.length && !context._uxBlocked.length,
214
+ assertions: steps.filter((s) => s.kind?.startsWith("expect") && s.ok).length,
215
+ acceptanceNote: "At least one required explicit acceptance assertion must pass; action-only scripts are not UAT acceptance.",
216
+ blockedRequests: context._uxBlocked,
217
+ totalMs: Date.now() - jStart, steps, consoleErrors: sink.console.slice(0, 20), failedRequests: sink.failed.slice(0, 20) };
218
+
219
+ if (args.compare) {
220
+ const bdir = path.join(args.compare, cell.slug, "journeys", jid);
221
+ const bfile = path.join(bdir, "result.json");
222
+ const regressions = [];
223
+ if (!fs.existsSync(bfile)) regressions.push({ type: "no-baseline" });
224
+ else {
225
+ const base = JSON.parse(fs.readFileSync(bfile, "utf8"));
226
+ if (base.steps.length !== steps.length) regressions.push({ type: "step-count-changed" });
227
+ for (const st of steps) {
228
+ const bs = base.steps[st.i];
229
+ if (!bs) { regressions.push({ type: "baseline-step-missing", step: st.i }); continue; }
230
+ if (st.shot && (!bs.shot || !fs.existsSync(path.join(bdir, bs.shot)))) regressions.push({ type: "missing-baseline-evidence", step: st.i });
231
+ if (bs.ok && !st.ok) regressions.push({ type: st.leak ? "leak" : "broken", step: st.i, kind: st.kind, error: st.error });
232
+ if (bs.ms > 200 && st.ms > bs.ms * SLOW_F && st.ms - bs.ms > 1000) regressions.push({ type: "slower", step: st.i, kind: st.kind, before: bs.ms, after: st.ms });
233
+ if (bs.response?.firstOutputMs && st.response?.firstOutputMs > bs.response.firstOutputMs * SLOW_F)
234
+ regressions.push({ type: "slower-response", step: st.i, before: bs.response.firstOutputMs, after: st.response.firstOutputMs });
235
+ if (st.shot && bs.shot && fs.existsSync(path.join(bdir, bs.shot)) && fs.existsSync(path.join(dir, st.shot))) {
236
+ const d = await visualDiff(helper, path.join(bdir, bs.shot), path.join(dir, st.shot), path.join(dir, st.shot.replace(".png", "_diff.png")));
237
+ st.visualDiff = d;
238
+ if (d.ratio > DIFF_T || d.sizeChanged) regressions.push({ type: "visual-change", step: st.i, kind: st.kind, ratio: d.ratio, diff: st.shot.replace(".png", "_diff.png") });
239
+ }
240
+ }
241
+ }
242
+ result.regressions = regressions;
243
+ if (regressions.length) result.passed = false;
244
+ }
245
+ fs.writeFileSync(path.join(dir, "result.json"), JSON.stringify(result, null, 2));
246
+ allResults.push(result);
247
+ const failed = steps.filter((s) => !s.ok && !s.skipped);
248
+ console.log(`[${cell.id}] ${jid}: ${result.passed ? "PASS" : "FAIL"} in ${result.totalMs}ms` +
249
+ (failed.length ? ` · failed: ${failed.map((f) => `#${f.i} ${f.kind} (${f.error})`).join("; ")}` : "") +
250
+ (result.regressions ? ` · regressions: ${result.regressions.length}` : ""));
251
+ await page.close();
252
+ }
253
+ await context.close();
254
+ }
255
+ ensureDir(OUT);
256
+ fs.writeFileSync(path.join(OUT, baselineMode ? "baseline_summary.json" : "journeys_summary.json"), JSON.stringify(allResults.map((r) => ({
257
+ cell: r.cell, journey: r.journey, passed: r.passed, totalMs: r.totalMs, regressions: r.regressions?.length ?? null,
258
+ leaks: r.steps.filter((s) => s.leak).flatMap((s) => s.leak) })), null, 2));
259
+ } finally { await browser.close(); }
260
+ if (!allResults.length || allResults.some((r) => !r.passed)) process.exitCode = args.compare ? 2 : 1;