@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.
- package/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.md +87 -2
- package/examples/glitchy-app/README.md +14 -0
- package/examples/glitchy-app/THIRD_PARTY_NOTICES.md +27 -0
- package/examples/glitchy-app/app.jsx +62 -0
- package/examples/glitchy-app/public/api/credits.json +1 -0
- package/examples/glitchy-app/public/api/me.json +1 -0
- package/examples/glitchy-app/public/api/messages.json +1 -0
- package/examples/glitchy-app/public/app.js +14749 -0
- package/examples/glitchy-app/public/index.html +2 -0
- package/examples/glitchy-app/serve.mjs +12 -0
- package/extensions/index.ts +155 -0
- package/package.json +53 -3
- package/scripts/affordance_scan.mjs +235 -0
- package/scripts/doctor.mjs +14 -0
- package/scripts/glitch/analyze.mjs +274 -0
- package/scripts/glitch_lab.mjs +519 -0
- package/scripts/interactions.mjs +172 -0
- package/scripts/journey_runner.mjs +260 -0
- package/scripts/lib.mjs +260 -0
- package/scripts/report.mjs +144 -0
- package/scripts/sensors/dom_observer.mjs +96 -0
- package/scripts/sensors/react_sensor.mjs +167 -0
- package/skills/ai-agentic-usability-testing/SKILL.md +68 -0
- package/skills/ai-agentic-usability-testing/assets/default-personas.md +96 -0
- package/skills/ai-agentic-usability-testing/assets/findings.example.json +56 -0
- package/skills/ai-agentic-usability-testing/assets/matrix.default.json +746 -0
- package/skills/ai-agentic-usability-testing/assets/matrix.partners.example.json +73 -0
- package/skills/ai-agentic-usability-testing/assets/matrix.web.example.json +32 -0
- package/skills/ai-agentic-usability-testing/assets/personas-template.md +45 -0
- package/skills/ai-agentic-usability-testing/references/latency-reliability.md +41 -0
- package/skills/ai-agentic-usability-testing/references/regression-pipeline.md +41 -0
- package/skills/ai-agentic-usability-testing/references/tenant-persona-matrix.md +61 -0
- package/skills/ai-agentic-usability-testing/references/visual-affordance-guide.md +97 -0
- package/skills/ai-agentic-usability-testing/references/visual-glitches.md +83 -0
- package/skills/glitch-hunt/SKILL.md +59 -0
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* glitch_lab.mjs — find subtle visual glitches the way a QA engineer would, but with sensors.
|
|
4
|
+
*
|
|
5
|
+
* Modes
|
|
6
|
+
* nav click through the app's navigation (sidebar/header links) and watch every page-to-page transition
|
|
7
|
+
* loads open each screen from scratch and watch it load
|
|
8
|
+
* journey run a scripted journey (e.g. open chat, send a message) and watch after every action
|
|
9
|
+
* idle sit on each screen without touching anything (catches polling/refresh glitches, e.g. a chat box rebuilding itself)
|
|
10
|
+
*
|
|
11
|
+
* Examples
|
|
12
|
+
* node glitch_lab.mjs nav --url http://localhost:3000/dashboard --out .ux/glitch
|
|
13
|
+
* node glitch_lab.mjs nav --config .ux/matrix.json --cell indian-germany-student --mode full --repeat 3
|
|
14
|
+
* node glitch_lab.mjs journey --config .ux/matrix.json --cell us-ivy-applicant --journey emily-plan
|
|
15
|
+
* node glitch_lab.mjs idle --url http://localhost:3000/chat --idle-ms 8000
|
|
16
|
+
*
|
|
17
|
+
* Options
|
|
18
|
+
* --repeat N repeat every scenario N times (default 2); real glitches repeat, noise doesn't
|
|
19
|
+
* --cpu N CPU slowdown factor (default 4); stretches 30ms blinks into visible, detectable ones
|
|
20
|
+
* --after-ms N how long to watch after each action (default 2500); --watch-ms for page loads (default 5000)
|
|
21
|
+
* --mode ring|full nav: ring = A→B→C→…→A, full = every ordered pair (capped by --max-transitions, default 20)
|
|
22
|
+
* --items "Chat,Notes" nav: use these link texts instead of auto-discovering nav links
|
|
23
|
+
* --paint also record a video with Chrome's paint-flashing + layout-shift overlays
|
|
24
|
+
* --viewport, --network, --channel chrome, --headed, --grid 24
|
|
25
|
+
*
|
|
26
|
+
* Sensors (all at once): every painted frame (Chrome screencast), React commits (which component mounted,
|
|
27
|
+
* re-rendered, was removed, remounted; which props changed), DOM mutations (element rebuilds, text flaps),
|
|
28
|
+
* layout shifts, running animations, network responses.
|
|
29
|
+
*
|
|
30
|
+
* Output (per cell, under --out): findings.json, summary.md (short, for the agent), evidence images, raw data.
|
|
31
|
+
*/
|
|
32
|
+
import fs from "node:fs";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
import { parseArgs, loadPlaywright, launchBrowser, loadConfig, resolveCells, newContext, login, throttle,
|
|
35
|
+
attachListeners, slugify, ensureDir, tpl, VIEWPORTS, locatorFor, freshOutput, requireInteraction, checkTarget, protectContext, bounded } from "./lib.mjs";
|
|
36
|
+
import { DOM_OBSERVER } from "./sensors/dom_observer.mjs";
|
|
37
|
+
import { REACT_SENSOR } from "./sensors/react_sensor.mjs";
|
|
38
|
+
import { analyzeScenario, record, ignoreRectsFor, composite, overlapFrac, regionChange } from "./glitch/analyze.mjs";
|
|
39
|
+
|
|
40
|
+
const mode = process.argv[2] && !process.argv[2].startsWith("--") ? process.argv[2] : "nav";
|
|
41
|
+
const args = parseArgs(process.argv.slice(mode === process.argv[2] ? 3 : 2));
|
|
42
|
+
const OUT = args.out || ".ux/glitch";
|
|
43
|
+
freshOutput(OUT);
|
|
44
|
+
if (["nav", "journey"].includes(mode)) requireInteraction(args);
|
|
45
|
+
const REPEAT = bounded(args.repeat, 2, 5);
|
|
46
|
+
const CPU = bounded(args.cpu, 4, 10);
|
|
47
|
+
const AFTER_MS = Number(args["after-ms"] || 2500);
|
|
48
|
+
const WATCH_MS = Number(args["watch-ms"] || 5000);
|
|
49
|
+
const IDLE_MS = bounded(args["idle-ms"], 8000, 60000);
|
|
50
|
+
const GRID = Number(args.grid || 24);
|
|
51
|
+
const MAX_T = bounded(args["max-transitions"], 20, 50);
|
|
52
|
+
const MAX_ITEMS = bounded(args["max-items"], 8, 20);
|
|
53
|
+
const DEFAULT_UNSAFE = ["logout", "log out", "sign out", "delete", "remove", "pay", "billing", "upgrade", "subscribe", "deactivate"];
|
|
54
|
+
|
|
55
|
+
const FRAMEWORK = new Set(["InnerLayoutRouter", "OuterLayoutRouter", "LayoutRouter", "RenderFromTemplateContext", "ScrollAndFocusHandler",
|
|
56
|
+
"InnerScrollAndFocusHandler", "RedirectBoundary", "RedirectErrorBoundary", "NotFoundBoundary", "NotFoundErrorBoundary", "LoadingBoundary",
|
|
57
|
+
"ErrorBoundary", "ErrorBoundaryHandler", "HTTPAccessFallbackBoundary", "HTTPAccessFallbackErrorBoundary", "Router", "AppRouter", "HotReload",
|
|
58
|
+
"ServerRoot", "Root", "ReactDevOverlay", "PathnameContextProviderAdapter", "SegmentViewNode", "ClientPageRoot", "ClientSegmentRoot",
|
|
59
|
+
"MetadataBoundary", "ViewportBoundary", "OutletBoundary", "AsyncMetadata", "Head", "Suspense", "Fragment", "StrictMode", "Provider",
|
|
60
|
+
"Link", "LinkComponent", "Image", "Script"]);
|
|
61
|
+
|
|
62
|
+
const { chromium } = await loadPlaywright();
|
|
63
|
+
|
|
64
|
+
// ---------------- cells ----------------
|
|
65
|
+
let cfg = {}, cells;
|
|
66
|
+
if (args.url) {
|
|
67
|
+
const u = new URL(args.url);
|
|
68
|
+
cells = [{ id: "adhoc", slug: "adhoc", base: u.origin, start: u.pathname + u.search, screens: [u.pathname + u.search],
|
|
69
|
+
viewport: args.viewport || "desktop", network: args.network || "fast", unsafe: [], persona: {}, partner: {}, mask: [] }];
|
|
70
|
+
} else {
|
|
71
|
+
cfg = loadConfig(args.config);
|
|
72
|
+
cells = resolveCells(cfg, args.cell, args.all);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------- helpers ----------------
|
|
76
|
+
const discoverNav = (unsafe) => {
|
|
77
|
+
const sels = "nav a[href], aside a[href], [role=navigation] a[href], header a[href], [data-sidebar] a[href]";
|
|
78
|
+
const seen = new Set(), out = [];
|
|
79
|
+
for (const a of document.querySelectorAll(sels)) {
|
|
80
|
+
let u; try { u = new URL(a.getAttribute("href"), location.href); } catch { continue; }
|
|
81
|
+
if (u.origin !== location.origin || a.getAttribute("href").startsWith("#")) continue;
|
|
82
|
+
const r = a.getBoundingClientRect(); if (!r.width || !r.height) continue;
|
|
83
|
+
const text = (a.innerText || a.getAttribute("aria-label") || a.title || "").replace(/\s+/g, " ").trim().slice(0, 40);
|
|
84
|
+
if (unsafe.some((w) => (text + " " + u.pathname).toLowerCase().includes(w))) continue;
|
|
85
|
+
const key = u.pathname + u.search; if (seen.has(key)) continue; seen.add(key);
|
|
86
|
+
out.push({ href: a.getAttribute("href"), path: key, text });
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
};
|
|
90
|
+
const here = (page) => { try { const u = new URL(page.url()); return u.pathname + u.search; } catch { return ""; } };
|
|
91
|
+
async function clickItem(page, item, cell) {
|
|
92
|
+
if (item.href) {
|
|
93
|
+
const loc = page.locator(`a[href="${item.href.replace(/"/g, '\\"')}"]`).filter({ visible: true }).first();
|
|
94
|
+
if (await loc.count()) { await checkTarget(loc, cell, args, true); return loc.click({ timeout: 10000 }); }
|
|
95
|
+
}
|
|
96
|
+
const loc = locatorFor(page, item.text || item.label, cell);
|
|
97
|
+
await checkTarget(loc, cell, args, true);
|
|
98
|
+
return loc.click({ timeout: 10000 });
|
|
99
|
+
}
|
|
100
|
+
async function prepPage(context, page, cell) {
|
|
101
|
+
await throttle(context, page, cell);
|
|
102
|
+
const cdp = await context.newCDPSession(page); // keep attached: emulation lasts as long as the session
|
|
103
|
+
if (CPU > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU }).catch(() => {});
|
|
104
|
+
page._glCdp = cdp;
|
|
105
|
+
const net = [];
|
|
106
|
+
page.on("response", (r) => { try { const u = new URL(r.url()); if (/\.(png|jpe?g|gif|webp|svg|woff2?|ttf|css|ico)$/i.test(u.pathname)) return;
|
|
107
|
+
net.push({ wall: Date.now(), method: r.request().method(), path: (u.pathname + u.search).slice(0, 80), status: r.status() }); } catch {} });
|
|
108
|
+
page._glNet = net;
|
|
109
|
+
}
|
|
110
|
+
const resetSensors = (page) => page.evaluate(() => {
|
|
111
|
+
if (window.__gl) { window.__gl.events = []; window.__gl.shifts = []; }
|
|
112
|
+
if (window.__glReact) window.__glReact.events = [];
|
|
113
|
+
return performance.timeOrigin + performance.now();
|
|
114
|
+
}).catch(() => 0);
|
|
115
|
+
const readSensors = (page) => page.evaluate(() => ({
|
|
116
|
+
timeOrigin: performance.timeOrigin,
|
|
117
|
+
dom: window.__gl ? { events: window.__gl.events, shifts: window.__gl.shifts, anim: window.__gl.anim } : { events: [], shifts: [], anim: [] },
|
|
118
|
+
react: window.__glReact ? { available: window.__glReact.available, events: window.__glReact.events.slice(-4000), commits: window.__glReact.commits, error: window.__glReact.error, dropped: window.__glReact.dropped } : { available: false, events: [] },
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
function rebase(sensors, perf0) {
|
|
122
|
+
const sh = (e) => ({ ...e, t: Math.round(sensors.timeOrigin + e.t - perf0) });
|
|
123
|
+
return {
|
|
124
|
+
dom: { events: sensors.dom.events.map(sh).filter((e) => e.t >= -50), shifts: sensors.dom.shifts.map(sh).filter((e) => e.t >= -50), anim: sensors.dom.anim.map(sh) },
|
|
125
|
+
react: { ...sensors.react, events: sensors.react.events.map(sh).filter((e) => e.t >= -50) },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Record one scenario: run `act`, watch, analyze, correlate. */
|
|
130
|
+
async function runScenario({ page, cell, dir, helper, name, label, kind, act, watchMs, rep, targetRectFn }) {
|
|
131
|
+
const perf0 = await resetSensors(page);
|
|
132
|
+
const netStart = page._glNet.length;
|
|
133
|
+
const startWall = Date.now();
|
|
134
|
+
let err = null;
|
|
135
|
+
const frames = await record(page, async () => { try { await act(); } catch (e) { err = String(e.message).split("\n")[0]; } }, watchMs);
|
|
136
|
+
const sensors = rebase(await readSensors(page), perf0 || startWall);
|
|
137
|
+
if (!frames.length) err ||= "No painted frames captured; visual coverage unavailable";
|
|
138
|
+
const vp = page.viewportSize();
|
|
139
|
+
const targetRect = targetRectFn ? await targetRectFn() : null;
|
|
140
|
+
const res = await analyzeScenario({ helper, frames, dom: sensors.dom, viewport: vp, ignoreRects: await ignoreRectsFor(page, cell),
|
|
141
|
+
kind: kind === "load" ? "load" : "interaction", targetRect, dir, name: `${name}_r${rep}`, grid: GRID });
|
|
142
|
+
for (const g of res.glitches) if (g.type === "remount") g.type = "remount-dom"; // DOM-level rebuild (React ones come from the React sensor)
|
|
143
|
+
const net = page._glNet.slice(netStart).map((n) => ({ ...n, t: n.wall - startWall }));
|
|
144
|
+
const rx = sensors.react.events;
|
|
145
|
+
const valid = res._valid || [];
|
|
146
|
+
const frameAt = (t) => { let best = valid[0]; for (const i of valid) if (frames[i].t <= t) best = i; return best; };
|
|
147
|
+
const firstIdx = valid[0], lastIdx = valid[valid.length - 1];
|
|
148
|
+
|
|
149
|
+
// React-derived findings
|
|
150
|
+
const reactFindings = [];
|
|
151
|
+
const remounts = rx.filter((e) => e.type === "remount");
|
|
152
|
+
for (const e of remounts) {
|
|
153
|
+
const change = e.rect && firstIdx !== undefined ? regionChange(res, frameAt(e.t - 20), lastIdx, e.rect, GRID) : null;
|
|
154
|
+
const sameLook = change !== null && change < 0.25;
|
|
155
|
+
reactFindings.push({ type: "remount", source: "react", t: e.t, name: e.name, css: e.rect, gapMs: e.gapMs,
|
|
156
|
+
framework: FRAMEWORK.has(e.name), regionChanged: change === null ? null : +change.toFixed(2),
|
|
157
|
+
note: sameLook ? "rebuilt although it looks the same before and after (persistent UI being thrown away)" :
|
|
158
|
+
change === null ? "" : "its area changed, so this may be expected (content replaced)" });
|
|
159
|
+
}
|
|
160
|
+
// render storms: many renders of the same component within this scenario
|
|
161
|
+
const renders = {};
|
|
162
|
+
for (const e of rx.filter((e) => e.type === "render")) {
|
|
163
|
+
const r = (renders[e.name] ||= { n: 0, changed: {}, rect: e.rect, t: e.t });
|
|
164
|
+
r.n++; for (const c of e.changed || []) r.changed[c] = (r.changed[c] || 0) + 1;
|
|
165
|
+
}
|
|
166
|
+
for (const [nm, r] of Object.entries(renders)) {
|
|
167
|
+
if (r.n >= 4 && !FRAMEWORK.has(nm)) reactFindings.push({ type: "render-storm", source: "react", t: r.t, name: nm, css: r.rect, count: r.n,
|
|
168
|
+
changed: Object.entries(r.changed).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([k, v]) => `${k}×${v}`) });
|
|
169
|
+
}
|
|
170
|
+
// correlate every glitch with components and network
|
|
171
|
+
const all = [...res.glitches, ...reactFindings];
|
|
172
|
+
for (const g of all) {
|
|
173
|
+
if (g.source !== "react" && g.css) {
|
|
174
|
+
const near = rx.filter((e) => e.rect && Math.abs(e.t - g.t) <= 300 && overlapFrac(e.rect, g.css) > 0.2 && e.type !== "unmount");
|
|
175
|
+
const vpArea = vp.width * vp.height;
|
|
176
|
+
// several components can share one DOM element (wrappers); keep the one closest to the element
|
|
177
|
+
const byEl = new Map();
|
|
178
|
+
for (const e of near) { const kk = `${e.rect.x},${e.rect.y},${e.rect.w},${e.rect.h}`; const cur = byEl.get(kk); if (!cur || (e.rect.d ?? 99) < (cur.rect.d ?? 99)) byEl.set(kk, e); }
|
|
179
|
+
near.length = 0; near.push(...byEl.values());
|
|
180
|
+
near.sort((x, y) => x.rect.w * x.rect.h - y.rect.w * y.rect.h);
|
|
181
|
+
const specific = near.filter((e) => e.rect.w * e.rect.h < 0.4 * vpArea && !FRAMEWORK.has(e.name));
|
|
182
|
+
const comps = {};
|
|
183
|
+
for (const e of specific.length ? specific : near) { const k = `${e.type}:${e.name}`; comps[k] ||= { type: e.type, name: e.name, changed: e.changed }; }
|
|
184
|
+
g.components = Object.values(comps).slice(0, 3);
|
|
185
|
+
}
|
|
186
|
+
g.network = net.filter((n) => n.t >= g.t - 450 && n.t <= g.t + 60).map((n) => `${n.method} ${n.path} ${n.status} (+${n.t}ms)`).slice(0, 3);
|
|
187
|
+
}
|
|
188
|
+
// evidence images for react findings (before/after frames with the component boxed)
|
|
189
|
+
let k = 0;
|
|
190
|
+
for (const g of reactFindings) {
|
|
191
|
+
if (!g.css || firstIdx === undefined || k >= 6) continue;
|
|
192
|
+
const b = frameAt(g.t - 20), a = frameAt(g.t + 250);
|
|
193
|
+
const box = { x: g.css.x / res.imgToCss, y: g.css.y / res.imgToCss, w: g.css.w / res.imgToCss, h: g.css.h / res.imgToCss };
|
|
194
|
+
g.image = `${name}_r${rep}_react_${String(++k).padStart(2, "0")}.png`;
|
|
195
|
+
await composite(helper, [{ data: frames[b].data, label: `before · ${frames[b].t} ms`, boxes: [box] },
|
|
196
|
+
{ data: frames[a].data, label: `after · ${frames[a].t} ms`, boxes: [box] }], path.join(dir, g.image), { thumbW: 380 });
|
|
197
|
+
}
|
|
198
|
+
const out = { scenario: name, label, kind, rep, error: err, frames: res.frames, settledAtMs: res.settledAtMs, cls: res.cls,
|
|
199
|
+
react: { available: sensors.react.available, commits: sensors.react.commits, events: rx.length, error: sensors.react.error },
|
|
200
|
+
glitches: all, renders: Object.fromEntries(Object.entries(renders).sort((a, b) => b[1].n - a[1].n).slice(0, 15).map(([n, r]) => [n, r.n])),
|
|
201
|
+
network: net.slice(0, 30) };
|
|
202
|
+
fs.writeFileSync(path.join(dir, `${name}_r${rep}.json`), JSON.stringify(out, null, 1));
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------------- scoring & aggregation ----------------
|
|
207
|
+
const bucket = (r) => (r ? `${Math.round(r.x / 80)},${Math.round(r.y / 80)},${Math.round(r.w / 160)},${Math.round(r.h / 160)}` : "-");
|
|
208
|
+
function describe(g) {
|
|
209
|
+
switch (g.type) {
|
|
210
|
+
case "remount": return g.framework ? `${g.name} (framework) is rebuilt` : `${g.name} is thrown away and rebuilt`;
|
|
211
|
+
case "render-storm": return `${g.name} re-renders ${g.count}× (changed: ${(g.changed || []).join(", ") || "?"})`;
|
|
212
|
+
case "flicker": return `${g.components?.length ? g.components[0].name + " area" : "a region"} blinks for ${g.visibleMs}ms${g.components?.length > 1 ? ` (also: ${g.components.slice(1).map((c) => c.name).join(", ")})` : !g.components?.length && g.likelyElements?.length ? ` (${g.likelyElements[0]})` : ""}`;
|
|
213
|
+
case "text-flap": return `text changes and changes back: ${(g.sequence || []).map((s) => `“${String(s).replace(/\s+/g, " ").trim().slice(0, 40) || "(empty)"}”`).join(" → ")}`;
|
|
214
|
+
case "late-change": return `part of the page changes after it looked finished${g.components?.length ? ` (${g.components.map((c) => c.name).join(", ")})` : ""}`;
|
|
215
|
+
case "layout-shift": return `content jumps (shift ${g.value})${g.sources?.[0]?.desc ? `: ${g.sources[0].desc}` : ""}`;
|
|
216
|
+
case "remount-dom": return `element rebuilt: ${g.desc}`;
|
|
217
|
+
default: return `${g.type}${g.desc ? ": " + g.desc : ""}`;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function score(f, totalRuns) {
|
|
221
|
+
const g = f.example;
|
|
222
|
+
let s = { "remount": 4, "flicker": 4, "text-flap": 3, "late-change": 2.5, "layout-shift": 2, "render-storm": 1.5, "remount-dom": 2 }[f.type] ?? 1;
|
|
223
|
+
if (f.type === "remount") { if (g.framework) s -= 3; if (g.regionChanged !== null && g.regionChanged < 0.25) s += 2; if (g.regionChanged !== null && g.regionChanged >= 0.25) s -= 3.5; }
|
|
224
|
+
if (f.type === "flicker") s += Math.min(2, (g.areaPct || 0) / 5) + (g.visibleMs >= 100 ? 1 : 0) - (g.probablyIntentional ? 4 : 0);
|
|
225
|
+
if (f.type === "layout-shift") s += Math.min(3, (g.value || 0) * 30);
|
|
226
|
+
if (f.type === "render-storm") s += Math.min(2, (g.count || 0) / 10);
|
|
227
|
+
s += Math.min(2, (f.runs.size / totalRuns) * 2); // repeats reliably
|
|
228
|
+
s += Math.min(1.5, (f.scenarios.size - 1) * 0.5); // happens in many places
|
|
229
|
+
if (f.visibleToo && !(f.type === "remount" && g.regionChanged >= 0.25)) s += 1.5; // a React event that also produced a visible blink
|
|
230
|
+
return +s.toFixed(1);
|
|
231
|
+
}
|
|
232
|
+
function aggregate(results) {
|
|
233
|
+
const map = new Map();
|
|
234
|
+
const runs = new Set(results.map((r) => r.rep));
|
|
235
|
+
for (const r of results) for (const g of r.glitches) {
|
|
236
|
+
if (g.probablyIntentional) continue;
|
|
237
|
+
const reactType = g.type === "remount" || g.type === "render-storm";
|
|
238
|
+
const who = g.name || (g.components?.[0]?.name) || (g.likelyElements?.[0]?.split(":").slice(1).join(":").trim()) || g.desc || "";
|
|
239
|
+
const key = reactType ? `${g.type}|${who}` : `${g.type}|${bucket(g.css)}`;
|
|
240
|
+
const f = map.get(key) || { key, type: g.type, who, example: g, scenarios: new Set(), runs: new Set(), occurrences: 0, images: [], network: new Set(), visibleToo: false, names: {} };
|
|
241
|
+
f.occurrences++; f.scenarios.add(r.label); f.runs.add(r.rep);
|
|
242
|
+
for (const c of g.components || []) f.names[c.name] = (f.names[c.name] || 0) + 1;
|
|
243
|
+
if (g.image && f.images.length < 3) f.images.push(g.image);
|
|
244
|
+
for (const n of g.network || []) if (f.network.size < 3) f.network.add(n.replace(/ \(\+-?\d+ms\)$/, ""));
|
|
245
|
+
map.set(key, f);
|
|
246
|
+
}
|
|
247
|
+
// mark React remounts/storms that coincide with a visible blink in the same scenario
|
|
248
|
+
for (const r of results) for (const g of r.glitches) if (g.type === "flicker" || g.type === "late-change") for (const c of g.components || []) {
|
|
249
|
+
for (const f of map.values()) if ((f.type === "remount" || f.type === "render-storm") && f.who === c.name) f.visibleToo = true;
|
|
250
|
+
}
|
|
251
|
+
for (const f of map.values()) {
|
|
252
|
+
const top = Object.entries(f.names).sort((a, b) => b[1] - a[1]).map(([n]) => n);
|
|
253
|
+
if (top.length) { f.who = top[0]; f.example = { ...f.example, components: top.slice(0, 3).map((n) => ({ name: n })) }; }
|
|
254
|
+
}
|
|
255
|
+
const list = [...map.values()].map((f) => ({ ...f, score: score(f, runs.size) }));
|
|
256
|
+
list.sort((a, b) => b.score - a.score);
|
|
257
|
+
return list.map((f, i) => ({
|
|
258
|
+
id: `G-${String(i + 1).padStart(2, "0")}`,
|
|
259
|
+
level: f.score >= 7 ? "high" : f.score >= 4.5 ? "medium" : "low",
|
|
260
|
+
score: f.score, type: f.type, what: describe(f.example), component: f.who || null,
|
|
261
|
+
seen: `${f.occurrences}× in ${f.scenarios.size} scenario(s), ${f.runs.size}/${runs.size} runs`,
|
|
262
|
+
scenarios: [...f.scenarios].slice(0, 8), visibleBlink: f.visibleToo || f.type === "flicker",
|
|
263
|
+
region: f.example.css || null, why: f.example.changed || f.example.components?.find((c) => c.changed)?.changed || null,
|
|
264
|
+
note: f.example.note || null, network: [...f.network], images: f.images,
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
267
|
+
function summaryMd(cell, findings, meta) {
|
|
268
|
+
const L = [];
|
|
269
|
+
L.push(`# Glitch Lab — ${cell.id} · ${meta.mode} · ${meta.scenarios} scenarios × ${REPEAT} runs · CPU ${CPU}× · ${cell.viewport}`);
|
|
270
|
+
L.push(meta.react ? `React sensor: on (${meta.commits} commits observed)` : `React sensor: no React detected (visual + DOM sensors only)`);
|
|
271
|
+
if (meta.errors.length) L.push(`Errors: ${meta.errors.slice(0, 3).join(" | ")}`);
|
|
272
|
+
const names = findings.map((f) => f.component).filter(Boolean);
|
|
273
|
+
if (names.length >= 2 && names.filter((n) => n.length <= 2).length / names.length >= 0.5)
|
|
274
|
+
L.push(`Note: component names look minified (production build). Describe components by their screen area (e.g. "the sidebar"), not by these names; a staging build with readable names would identify them.`);
|
|
275
|
+
const show = findings.filter((f) => f.level !== "low").slice(0, 15);
|
|
276
|
+
L.push(`\n## Findings (${findings.length} total, ${show.length} high/medium shown)`);
|
|
277
|
+
for (const f of show) {
|
|
278
|
+
L.push(`\n**${f.id} [${f.level.toUpperCase()}] ${f.what}** — ${f.seen}${f.visibleBlink ? " · visible blink" : ""}`);
|
|
279
|
+
if (f.note) L.push(`- ${f.note}`);
|
|
280
|
+
if (f.why) L.push(`- why it rendered: ${[].concat(f.why).join(", ")}`);
|
|
281
|
+
if (f.network.length) L.push(`- right after: ${f.network.join("; ")}`);
|
|
282
|
+
L.push(`- where: ${f.scenarios.slice(0, 4).join("; ")}${f.region ? ` · region x${f.region.x} y${f.region.y} ${f.region.w}×${f.region.h}` : ""}`);
|
|
283
|
+
if (f.images.length) L.push(`- evidence: ${f.images.map((i) => path.join(meta.dir, i)).join(" , ")}`);
|
|
284
|
+
if (f.paintImage) L.push(`- repaint frames (green = repainted${f.repaintedPct != null ? `, up to ${f.repaintedPct}% of this area repainted` : ""}): ${path.join(meta.dir, f.paintImage)}`);
|
|
285
|
+
}
|
|
286
|
+
const low = findings.filter((f) => f.level === "low");
|
|
287
|
+
if (low.length) L.push(`\n${low.length} low-priority item(s) in findings.json (framework internals, one-off events, minor shifts).`);
|
|
288
|
+
if (meta.renders.length) L.push(`\nMost re-rendered components: ${meta.renders.slice(0, 8).map(([n, c]) => `${n} ${c}`).join(", ")}`);
|
|
289
|
+
if (meta.paint) L.push(`\nPaint-flashing video: ${meta.paint}`);
|
|
290
|
+
L.push(`\nFull data: ${path.join(meta.dir, "findings.json")}`);
|
|
291
|
+
return L.join("\n");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---------------- paint-flashing video pass ----------------
|
|
295
|
+
async function paintPass(browser, cell, scenarios, dir, storageState) {
|
|
296
|
+
const vp = (VIEWPORTS[cell.viewport] || VIEWPORTS.desktop);
|
|
297
|
+
const vdir = ensureDir(path.join(dir, "paint"));
|
|
298
|
+
const context = await browser.newContext({ ...vp, serviceWorkers: "block", recordVideo: { dir: vdir, size: vp.viewport }, storageState });
|
|
299
|
+
await protectContext(context, cell, args);
|
|
300
|
+
const page = await context.newPage();
|
|
301
|
+
const t0 = Date.now(); // the video starts with the page
|
|
302
|
+
const cdp = await context.newCDPSession(page);
|
|
303
|
+
await cdp.send("DOM.enable").catch(() => {}); await cdp.send("Overlay.enable").catch(() => {});
|
|
304
|
+
await cdp.send("Overlay.setShowPaintRects", { result: true }).catch(() => {});
|
|
305
|
+
await cdp.send("Overlay.setShowLayoutShiftRegions", { result: true }).catch(() => {});
|
|
306
|
+
if (CPU > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU }).catch(() => {});
|
|
307
|
+
const timeline = [];
|
|
308
|
+
for (const s of scenarios) {
|
|
309
|
+
// a 14px magenta square flashed for ~150ms marks the exact moment of the action in the video
|
|
310
|
+
const mark = async () => {
|
|
311
|
+
timeline.push({ at: +((Date.now() - t0) / 1000).toFixed(2), scenario: s.label });
|
|
312
|
+
await page.evaluate(() => { const d = document.createElement("div"); d.id = "__gl_sync";
|
|
313
|
+
d.style.cssText = "position:fixed;left:0;top:0;width:14px;height:14px;background:#ff00ff;z-index:2147483647;pointer-events:none";
|
|
314
|
+
document.documentElement.appendChild(d); setTimeout(() => d.remove(), 150); }).catch(() => {});
|
|
315
|
+
await page.waitForTimeout(60);
|
|
316
|
+
};
|
|
317
|
+
await s.paintAct(page, mark);
|
|
318
|
+
await page.waitForTimeout(s.watchMs);
|
|
319
|
+
}
|
|
320
|
+
const video = page.video();
|
|
321
|
+
const closeAt = (Date.now() - t0) / 1000;
|
|
322
|
+
await context.close();
|
|
323
|
+
const file = path.join(vdir, `${cell.slug}_paint.webm`);
|
|
324
|
+
if (video) { await video.saveAs(file).catch(() => {}); try { const raw = await video.path(); if (raw !== file) fs.unlinkSync(raw); } catch {} }
|
|
325
|
+
fs.writeFileSync(path.join(vdir, "timeline.json"), JSON.stringify({ closeAt, scenarios: timeline }, null, 1));
|
|
326
|
+
return file;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Cut frames from the paint-flashing video around the top findings (green = repainted area). */
|
|
330
|
+
async function paintEvidence(helper, videoFile, timelineFile, findings, results, dir, cellViewport) {
|
|
331
|
+
const tl = JSON.parse(fs.readFileSync(timelineFile, "utf8"));
|
|
332
|
+
const timeline = tl.scenarios;
|
|
333
|
+
const data = fs.readFileSync(videoFile).toString("base64");
|
|
334
|
+
const page = await helper.context().newPage();
|
|
335
|
+
await page.setContent(`<video id=v muted src="data:video/webm;base64,${data}"></video>`);
|
|
336
|
+
const duration = await page.evaluate(() => new Promise((r) => { const v = document.getElementById("v"); const done = () => r(v.duration); if (v.readyState >= 2) done(); else v.onloadeddata = done; }));
|
|
337
|
+
// the video ends when the page closes, so align wall-clock marks to the video from the end
|
|
338
|
+
const offset = Number.isFinite(duration) ? duration - tl.closeAt : 0;
|
|
339
|
+
const vpW = (VIEWPORTS[cellViewport] || VIEWPORTS.desktop).viewport.width;
|
|
340
|
+
const vScale = (await page.evaluate(() => document.getElementById("v").videoWidth)) / vpW;
|
|
341
|
+
const findSync = (from, to) => page.evaluate(async ({ from, to }) => {
|
|
342
|
+
const v = document.getElementById("v"); const c = document.createElement("canvas"); c.width = 4; c.height = 4; const x = c.getContext("2d");
|
|
343
|
+
for (let t = Math.max(0, from); t <= Math.min(to, v.duration); t += 0.04) {
|
|
344
|
+
v.currentTime = t; await new Promise((r) => (v.onseeked = r));
|
|
345
|
+
x.drawImage(v, 2, 2, 8, 8, 0, 0, 4, 4); const p = x.getImageData(1, 1, 1, 1).data;
|
|
346
|
+
if (p[0] > 200 && p[1] < 80 && p[2] > 200) return t;
|
|
347
|
+
}
|
|
348
|
+
return null;
|
|
349
|
+
}, { from, to });
|
|
350
|
+
// how much of `rect` (css px) is covered by the green paint-flash overlay at each moment in [from, to]
|
|
351
|
+
const scanGreen = (from, to, rect, vpW) => page.evaluate(async ({ from, to, rect, vpW }) => {
|
|
352
|
+
const v = document.getElementById("v"); const k = v.videoWidth / vpW;
|
|
353
|
+
const r = rect ? { x: rect.x * k, y: rect.y * k, w: Math.max(4, rect.w * k), h: Math.max(4, rect.h * k) } : { x: 0, y: 0, w: v.videoWidth, h: v.videoHeight };
|
|
354
|
+
const c = document.createElement("canvas"); c.width = 48; c.height = 48; const x = c.getContext("2d"); const out = [];
|
|
355
|
+
for (let t = Math.max(0, from); t <= Math.min(to, v.duration - 0.02); t += 0.04) {
|
|
356
|
+
v.currentTime = t; await new Promise((res) => (v.onseeked = res));
|
|
357
|
+
x.drawImage(v, r.x, r.y, r.w, r.h, 0, 0, 48, 48); const d = x.getImageData(0, 0, 48, 48).data; let n = 0;
|
|
358
|
+
for (let i = 0; i < d.length; i += 4) if (d[i + 1] - d[i] > 28 && d[i + 1] - d[i + 2] > 22) n++;
|
|
359
|
+
out.push({ t: +t.toFixed(2), g: n / 2304 });
|
|
360
|
+
}
|
|
361
|
+
return out;
|
|
362
|
+
}, { from, to, rect, vpW });
|
|
363
|
+
const grab = (t) => page.evaluate(async (t) => {
|
|
364
|
+
const v = document.getElementById("v"); v.currentTime = Math.max(0, Math.min(t, v.duration - 0.05));
|
|
365
|
+
await new Promise((r) => (v.onseeked = r));
|
|
366
|
+
const c = document.createElement("canvas"); c.width = v.videoWidth; c.height = v.videoHeight;
|
|
367
|
+
c.getContext("2d").drawImage(v, 0, 0); return c.toDataURL("image/png").split(",")[1];
|
|
368
|
+
}, t);
|
|
369
|
+
for (const f of findings.filter((x) => x.level !== "low").slice(0, 6)) {
|
|
370
|
+
const scen = timeline.find((s) => f.scenarios.includes(s.scenario)); if (!scen) continue;
|
|
371
|
+
const r1 = results.find((r) => r.label === scen.scenario && r.rep === 1);
|
|
372
|
+
const g = r1?.glitches.find((x) => x.type === f.type && (!f.component || x.name === f.component || x.components?.some((c) => c.name === f.component)));
|
|
373
|
+
const guess = scen.at + offset;
|
|
374
|
+
const sync = (await findSync(guess - 2, guess + 2)) ?? guess;
|
|
375
|
+
const series = await scanGreen(sync, sync + 3, f.region, vpW);
|
|
376
|
+
const firstG = series.find((x) => x.g > 0.08);
|
|
377
|
+
let times;
|
|
378
|
+
if (firstG) {
|
|
379
|
+
const peak = series.filter((x) => x.t <= firstG.t + 1).reduce((a, b) => (b.g > a.g ? b : a), firstG);
|
|
380
|
+
times = [firstG.t - 0.12, firstG.t, peak.t, peak.t + 0.2, Math.min(sync + 2.9, peak.t + 1.2)];
|
|
381
|
+
f.repaintedPct = Math.round(peak.g * 100);
|
|
382
|
+
} else times = [sync + 0.2, sync + 0.5, sync + 0.8, sync + 1.2, sync + 2];
|
|
383
|
+
const panels = [];
|
|
384
|
+
for (const tt of times) panels.push({ data: await grab(tt), label: `${f.id} · +${Math.round((tt - sync) * 1000)}ms`,
|
|
385
|
+
boxes: f.region ? [{ x: f.region.x * vScale, y: f.region.y * vScale, w: f.region.w * vScale, h: f.region.h * vScale }] : [] });
|
|
386
|
+
f.paintImage = path.join("paint", `${f.id}_paint.png`);
|
|
387
|
+
await composite(helper, panels, path.join(dir, f.paintImage), { thumbW: 280, perRow: 5 });
|
|
388
|
+
}
|
|
389
|
+
await page.close();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ---------------- main ----------------
|
|
393
|
+
const browser = await launchBrowser(chromium, args);
|
|
394
|
+
const helperCtx = await browser.newContext();
|
|
395
|
+
const helper = await helperCtx.newPage();
|
|
396
|
+
const index = [];
|
|
397
|
+
try {
|
|
398
|
+
for (const cell of cells) {
|
|
399
|
+
const dir = ensureDir(path.join(OUT, cell.slug));
|
|
400
|
+
const context = await newContext(browser, cell, args);
|
|
401
|
+
await login(context, cell);
|
|
402
|
+
await context.addInitScript(REACT_SENSOR);
|
|
403
|
+
await context.addInitScript(DOM_OBSERVER);
|
|
404
|
+
const unsafe = [...DEFAULT_UNSAFE, ...(cell.unsafe || []).map((w) => w.toLowerCase())];
|
|
405
|
+
const results = [], errors = [], paintScenarios = [];
|
|
406
|
+
const start = args.start || cell.start || (cell.screens || ["/"])[0];
|
|
407
|
+
|
|
408
|
+
if (mode === "nav") {
|
|
409
|
+
const page = await context.newPage(); attachListeners(page, { console: [], failed: [] });
|
|
410
|
+
await prepPage(context, page, cell);
|
|
411
|
+
await page.goto(cell.base + tpl(start, cell), { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
412
|
+
await page.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => {});
|
|
413
|
+
await page.waitForTimeout(1500);
|
|
414
|
+
let items;
|
|
415
|
+
const given = args.items ? String(args.items).split(",").map((s) => s.trim()).filter(Boolean) : cell.nav?.items || cfg.nav?.items;
|
|
416
|
+
if (given?.length) items = given.map((x) => (typeof x === "string" ? { text: x } : x));
|
|
417
|
+
else items = (await page.evaluate(discoverNav, unsafe)).slice(0, MAX_ITEMS);
|
|
418
|
+
if (items.length < 2) { errors.push(`found only ${items.length} navigation link(s); pass --items "A,B,C" or set nav.items in the config`); }
|
|
419
|
+
const pairs = [];
|
|
420
|
+
if ((args.mode || "ring") === "full") { for (const a of items) for (const b of items) if (a !== b) pairs.push([a, b]); }
|
|
421
|
+
else for (let i = 0; i < items.length; i++) pairs.push([items[i], items[(i + 1) % items.length]]);
|
|
422
|
+
const chosen = pairs.slice(0, MAX_T);
|
|
423
|
+
console.log(`[${cell.id}] nav items: ${items.map((i) => i.text || i.path).join(" · ")} → ${chosen.length} transitions × ${REPEAT} runs, CPU ${CPU}×`);
|
|
424
|
+
for (let rep = 1; rep <= REPEAT; rep++) {
|
|
425
|
+
for (const [a, b] of chosen) {
|
|
426
|
+
const label = `nav ${a.text || a.path} → ${b.text || b.path}`;
|
|
427
|
+
if (a.path ? here(page) !== a.path : true) { await clickItem(page, a, cell).catch(() => {}); await page.waitForTimeout(1500); }
|
|
428
|
+
const r = await runScenario({ page, cell, dir, helper, rep, label, kind: "nav", watchMs: AFTER_MS,
|
|
429
|
+
name: slugify(`nav_${a.text || a.path}_to_${b.text || b.path}`), act: () => clickItem(page, b, cell) });
|
|
430
|
+
if (r.error) errors.push(`${label}: ${r.error}`);
|
|
431
|
+
results.push(r);
|
|
432
|
+
console.log(`[${cell.id}] r${rep} ${label}: ${r.glitches.length} signal(s)${r.react.available ? "" : " (no React)"}`);
|
|
433
|
+
if (rep === 1) paintScenarios.push({ label, watchMs: AFTER_MS, paintAct: async (p, mark) => {
|
|
434
|
+
if (p.url() === "about:blank") { await p.goto(cell.base + tpl(start, cell)); await p.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => {}); await p.waitForTimeout(1500); }
|
|
435
|
+
if (a.path ? here(p) !== a.path : true) { await clickItem(p, a, cell); await p.waitForTimeout(1500); }
|
|
436
|
+
await mark(); await clickItem(p, b, cell); } });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
await page.close();
|
|
440
|
+
} else if (mode === "loads" || mode === "idle") {
|
|
441
|
+
const screens = args.url ? [cell.start] : cell.screens || ["/"];
|
|
442
|
+
for (let rep = 1; rep <= REPEAT; rep++) for (const screen of screens) {
|
|
443
|
+
const page = await context.newPage(); attachListeners(page, { console: [], failed: [] });
|
|
444
|
+
await prepPage(context, page, cell);
|
|
445
|
+
const url = cell.base + tpl(screen, cell);
|
|
446
|
+
let r;
|
|
447
|
+
if (mode === "loads") {
|
|
448
|
+
r = await runScenario({ page, cell, dir, helper, rep, label: `load ${screen}`, kind: "load", watchMs: WATCH_MS,
|
|
449
|
+
name: slugify(`load_${screen}`), act: () => page.goto(url, { waitUntil: "commit", timeout: 60000 }) });
|
|
450
|
+
} else {
|
|
451
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
452
|
+
await page.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => {});
|
|
453
|
+
await page.waitForTimeout(2000);
|
|
454
|
+
r = await runScenario({ page, cell, dir, helper, rep, label: `idle ${screen}`, kind: "idle", watchMs: IDLE_MS,
|
|
455
|
+
name: slugify(`idle_${screen}`), act: async () => {} });
|
|
456
|
+
}
|
|
457
|
+
if (r.error) errors.push(`${r.label}: ${r.error}`);
|
|
458
|
+
results.push(r);
|
|
459
|
+
console.log(`[${cell.id}] r${rep} ${r.label}: ${r.glitches.length} signal(s)`);
|
|
460
|
+
if (rep === 1) paintScenarios.push({ label: r.label, watchMs: mode === "loads" ? WATCH_MS : IDLE_MS, paintAct: async (p, mark) => { if (mode === "idle") { await p.goto(url); await p.waitForTimeout(2000); await mark(); } else { if (p.url() === "about:blank") await p.goto("data:text/html,<body></body>"); await mark(); await p.goto(url); } } });
|
|
461
|
+
await page.close();
|
|
462
|
+
}
|
|
463
|
+
} else if (mode === "journey") {
|
|
464
|
+
const jid = args.journey; const j = cfg.journeys?.[jid];
|
|
465
|
+
if (!j) { console.error(`Unknown journey "${jid}". Available: ${Object.keys(cfg.journeys || {}).join(", ")}`); process.exit(1); }
|
|
466
|
+
const replySel = j.steps.filter((st) => st.measureResponse?.selector).map((st) => st.measureResponse.selector);
|
|
467
|
+
const jCell = { ...cell, mask: [...cell.mask, ...replySel] };
|
|
468
|
+
for (let rep = 1; rep <= REPEAT; rep++) {
|
|
469
|
+
const page = await context.newPage(); attachListeners(page, { console: [], failed: [] });
|
|
470
|
+
await prepPage(context, page, cell);
|
|
471
|
+
for (let i = 0; i < j.steps.length; i++) {
|
|
472
|
+
const s = j.steps[i];
|
|
473
|
+
const kind = Object.keys(s).find((k) => !["shot", "timeout", "note", "optional"].includes(k));
|
|
474
|
+
if (!["goto", "click", "fill", "press"].includes(kind)) continue;
|
|
475
|
+
let tr = null;
|
|
476
|
+
const act = async () => {
|
|
477
|
+
if (kind === "goto") return page.goto(cell.base + tpl(s.goto, cell), { waitUntil: "commit", timeout: 60000 });
|
|
478
|
+
if (kind === "press") { requireInteraction(args, true); return page.keyboard.press(s.press); }
|
|
479
|
+
const loc = locatorFor(page, kind === "click" ? s.click : s.fill, cell);
|
|
480
|
+
const bb = await loc.boundingBox({ timeout: s.timeout || 15000 }).catch(() => null);
|
|
481
|
+
if (bb) tr = { x: bb.x - 40, y: bb.y - 40, w: bb.width + 80, h: bb.height + 80 };
|
|
482
|
+
if (kind === "click") await checkTarget(loc, cell, args);
|
|
483
|
+
else requireInteraction(args, true);
|
|
484
|
+
return kind === "click" ? loc.click({ timeout: s.timeout || 15000 }) : loc.fill(tpl(s.fill.value, cell), { timeout: s.timeout || 15000 });
|
|
485
|
+
};
|
|
486
|
+
const what = typeof s[kind] === "string" ? s[kind] : s[kind]?.name || s[kind]?.label || s[kind]?.placeholder || "";
|
|
487
|
+
const r = await runScenario({ page, cell: jCell, dir, helper, rep, label: `${jid} #${i} ${kind} ${what}`.trim(), kind: kind === "goto" ? "load" : "interaction",
|
|
488
|
+
watchMs: kind === "goto" ? WATCH_MS : AFTER_MS, name: slugify(`${jid}_${String(i).padStart(2, "0")}_${kind}_${what}`), act, targetRectFn: () => tr });
|
|
489
|
+
results.push(r);
|
|
490
|
+
console.log(`[${cell.id}] r${rep} ${r.label}: ${r.error ? "ERROR " + r.error + " · " : ""}${r.glitches.length} signal(s)`);
|
|
491
|
+
if (r.error) { errors.push(`${r.label}: ${r.error}`); if (!s.optional) break; }
|
|
492
|
+
}
|
|
493
|
+
await page.close();
|
|
494
|
+
}
|
|
495
|
+
} else {
|
|
496
|
+
console.error(`Unknown mode "${mode}". Use nav | loads | idle | journey.`); process.exit(1);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
let paintFile = null;
|
|
500
|
+
if (args.paint && paintScenarios.length) { paintFile = await paintPass(browser, cell, paintScenarios, dir, await context.storageState()).catch((e) => (errors.push("paint: " + e.message), null)); }
|
|
501
|
+
if (!results.length) errors.push("No scenarios executed");
|
|
502
|
+
if (context._uxBlocked.length) errors.push(`${context._uxBlocked.length} requests blocked by safety policy; coverage is incomplete`);
|
|
503
|
+
const findings = aggregate(results);
|
|
504
|
+
if (paintFile && fs.existsSync(paintFile)) await paintEvidence(helper, paintFile, path.join(dir, "paint", "timeline.json"), findings, results, dir, cell.viewport).catch((e) => errors.push("paint frames: " + e.message));
|
|
505
|
+
const renderTotals = {};
|
|
506
|
+
for (const r of results) for (const [n, c] of Object.entries(r.renders || {})) renderTotals[n] = (renderTotals[n] || 0) + c;
|
|
507
|
+
const meta = { mode, scenarios: new Set(results.map((r) => r.label)).size, react: results.some((r) => r.react.available),
|
|
508
|
+
commits: results.reduce((a, r) => a + (r.react.commits || 0), 0), errors, dir,
|
|
509
|
+
renders: Object.entries(renderTotals).filter(([n]) => !FRAMEWORK.has(n)).sort((a, b) => b[1] - a[1]), paint: paintFile };
|
|
510
|
+
fs.writeFileSync(path.join(dir, "findings.json"), JSON.stringify({ cell: cell.id, mode, repeat: REPEAT, cpu: CPU, viewport: cell.viewport, meta, findings }, null, 2));
|
|
511
|
+
const md = summaryMd(cell, findings, meta);
|
|
512
|
+
fs.writeFileSync(path.join(dir, "summary.md"), md);
|
|
513
|
+
index.push({ cell: cell.id, dir, ok: !errors.length, findings: findings.length, high: findings.filter((f) => f.level === "high").length });
|
|
514
|
+
console.log("\n" + md + "\n");
|
|
515
|
+
await context.close();
|
|
516
|
+
}
|
|
517
|
+
fs.writeFileSync(path.join(ensureDir(OUT), "index.json"), JSON.stringify(index, null, 2));
|
|
518
|
+
} finally { await browser.close(); }
|
|
519
|
+
if (!index.length || index.some((r) => !r.ok)) process.exitCode = 1;
|