scenescout 1.0.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.
@@ -0,0 +1,293 @@
1
+ import { DIALOG_LIKE_SEL } from "./collector.js";
2
+ /** Per-action Playwright timeout, shared with the engine so a scroll into view fails as fast as a click would. */
3
+ export const ACTION_TIMEOUT_MS = 5000;
4
+ /**
5
+ * Scroll ONE named region rather than the page. The page-level heuristic
6
+ * picks the largest scrollable pane, so a smaller independently-scrolling
7
+ * region — a sidebar nav beside a taller main pane — is otherwise
8
+ * unreachable, and its content reads as truncated when it is merely scrolled
9
+ * away. Resolves the element, then scrolls the nearest scrollable ancestor
10
+ * (the target itself is usually the content, not the scroll port).
11
+ */
12
+ export async function scrollContainer(page, target, to, by) {
13
+ let locator;
14
+ if (target.startsWith("testid="))
15
+ locator = page.locator(`[data-testid=${JSON.stringify(target.slice(7))}]`).first();
16
+ else if (target.startsWith("text="))
17
+ locator = page.getByText(target.slice(5), { exact: false }).first();
18
+ else if (target.startsWith("label="))
19
+ locator = page.getByLabel(target.slice(6)).first();
20
+ else
21
+ return { refused: `Scroll target must be "testid=…", "text=…" or "label=…" (got: ${target})`, note: "" };
22
+ const amount = Math.trunc(by ?? 600);
23
+ const outcome = await locator
24
+ .evaluate((el, args) => {
25
+ const scrollable = (n) => {
26
+ const s = getComputedStyle(n);
27
+ const oy = s.overflowY;
28
+ return (oy === "auto" || oy === "scroll") && n.scrollHeight > n.clientHeight + 4;
29
+ };
30
+ let node = el;
31
+ while (node && node !== document.body && !scrollable(node))
32
+ node = node.parentElement;
33
+ if (!node || node === document.body)
34
+ return null;
35
+ const before = node.scrollTop;
36
+ if (args.edge === "top")
37
+ node.scrollTo(0, 0);
38
+ else if (args.edge === "bottom")
39
+ node.scrollTo(0, node.scrollHeight);
40
+ else
41
+ node.scrollBy(0, args.delta);
42
+ const tid = node.getAttribute("data-testid");
43
+ const cls = typeof node.className === "string" && node.className.trim() ? "." + node.className.trim().split(/\s+/)[0] : "";
44
+ return {
45
+ name: node.tagName.toLowerCase() + (tid ? `[data-testid="${tid}"]` : cls),
46
+ before,
47
+ y: Math.round(node.scrollTop),
48
+ max: Math.max(0, node.scrollHeight - node.clientHeight),
49
+ };
50
+ }, { edge: to ?? null, delta: amount }, { timeout: ACTION_TIMEOUT_MS })
51
+ .catch(() => undefined);
52
+ if (outcome === undefined)
53
+ return { refused: `Scroll target not found: ${target}`, note: "" };
54
+ if (!outcome) {
55
+ return {
56
+ note: `\nNothing to scroll: ${target} has no scrollable ancestor — its content is not clipped by a scroll port, so everything it holds is already laid out on the page.`,
57
+ };
58
+ }
59
+ const pct = outcome.max > 0 ? Math.round((outcome.y / outcome.max) * 100) : 100;
60
+ const edge = outcome.y >= outcome.max - 4 ? " — at its bottom" : outcome.y <= 4 ? " — at its top" : "";
61
+ const moved = Math.abs(outcome.y - outcome.before) > 4;
62
+ return {
63
+ note: `\nScrolled ${outcome.name}: ${outcome.y}px of ${outcome.max}px (${pct}%)${edge}.` + (moved ? "" : ` It did not move — already at that position.`),
64
+ };
65
+ }
66
+ /**
67
+ * The user-emulating scroll core, shared by scout_scroll and plan steps. Does
68
+ * NOT run afterAction — plan steps drain oracles themselves, and draining
69
+ * here would empty the queue their abort-on-fresh-violation check reads.
70
+ */
71
+ export async function performScroll(page, to, by) {
72
+ // `lock` matters because overflow:hidden only blocks USER scrolling
73
+ // (wheel/keys/touch) — window.scrollTo sails right through it. Emulating a
74
+ // native user means refusing to scroll where they couldn't. A lock is
75
+ // legitimate while ANY overlay is up — dialog-like panel (DIALOG_LIKE_SEL,
76
+ // so role-less hand-rolled modals count) or a full-viewport backdrop.
77
+ const read = `({y: window.scrollY, dh: Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0), vh: window.innerHeight,
78
+ lock: [getComputedStyle(document.documentElement).overflowY, document.body ? getComputedStyle(document.body).overflowY : ""].some((o) => o === "hidden" || o === "clip"),
79
+ ov: [...document.querySelectorAll('${DIALOG_LIKE_SEL}')].some((d) => { const r = d.getBoundingClientRect(); return r.width > 0 && r.height > 0; })
80
+ || [...document.querySelectorAll("body *")].some((e) => { const s = getComputedStyle(e); if (s.position !== "fixed") return false; const r = e.getBoundingClientRect(); return r.width >= window.innerWidth * 0.9 && r.height >= window.innerHeight * 0.9; })})`;
81
+ const before = (await page.evaluate(read));
82
+ const amount = Math.trunc(by ?? 600);
83
+ const wantedDown = to === "bottom" || (!to && amount > 0);
84
+ const hadRoomDown = before.dh > before.vh + 50 && before.y < before.dh - before.vh - 4;
85
+ if (before.lock && !before.ov && wantedDown && hadRoomDown) {
86
+ return {
87
+ refused: `⚠ SCROLL LOCKED: the document is ${Math.round(before.dh - before.vh)}px taller than the viewport but page scrolling is disabled (overflow hidden on body/html) with NO open dialog — ` +
88
+ `a real user cannot reach anything below the fold (classic leaked modal scroll-lock; check the snapshot's OVERLAY lines and file it). Did not scroll.`,
89
+ note: "",
90
+ };
91
+ }
92
+ if (before.dh <= before.vh + 50) {
93
+ // App-shell layout: the document fits the viewport and real scrolling
94
+ // happens inside an inner pane. Reporting "at the bottom (100%)" here
95
+ // would tell the brain it has seen a whole page it never scrolled.
96
+ const inner = (await page.evaluate(`(() => {
97
+ let best = null;
98
+ for (const e of document.querySelectorAll("body *")) {
99
+ const s = getComputedStyle(e);
100
+ if (s.overflowY !== "auto" && s.overflowY !== "scroll") continue;
101
+ if (e.scrollHeight <= e.clientHeight + 50) continue;
102
+ const r = e.getBoundingClientRect();
103
+ if (r.width < 100 || r.height < 100) continue;
104
+ if (!best || r.width * r.height > best.a) best = { a: r.width * r.height, e };
105
+ }
106
+ if (!best) return null;
107
+ const e = best.e;
108
+ const to = ${JSON.stringify(to ?? null)};
109
+ if (to === "top") e.scrollTo(0, 0);
110
+ else if (to === "bottom") e.scrollTo(0, e.scrollHeight);
111
+ else e.scrollBy(0, ${amount});
112
+ const tid = e.getAttribute("data-testid");
113
+ const cls = typeof e.className === "string" && e.className.trim() ? "." + e.className.trim().split(/\s+/)[0] : "";
114
+ return { name: e.tagName.toLowerCase() + (tid ? '[data-testid="' + tid + '"]' : cls), y: Math.round(e.scrollTop), max: Math.max(0, e.scrollHeight - e.clientHeight) };
115
+ })()`));
116
+ if (!inner)
117
+ return { note: `\nPage does not scroll — the content fits the viewport and no scrollable inner container was found.` };
118
+ const pct = inner.max > 0 ? Math.round((inner.y / inner.max) * 100) : 100;
119
+ return {
120
+ note: `\nThe document itself does not scroll (app-shell layout) — scrolled the inner container ${inner.name} instead: ${inner.y}px of ${inner.max}px (${pct}%)${inner.y >= inner.max - 4 ? " — at its bottom" : inner.y <= 4 ? " — at its top" : ""}.`,
121
+ };
122
+ }
123
+ if (to === "top")
124
+ await page.evaluate("window.scrollTo(0, 0)");
125
+ else if (to === "bottom")
126
+ await page.evaluate("window.scrollTo(0, document.documentElement.scrollHeight)");
127
+ else
128
+ await page.evaluate(`window.scrollBy(0, ${amount})`);
129
+ const after = (await page.evaluate(read));
130
+ const max = Math.max(0, after.dh - after.vh);
131
+ const pct = max > 0 ? Math.round((after.y / max) * 100) : 100;
132
+ let note = `\nScroll position: ${Math.round(after.y)}px of ${max}px (${pct}%)${after.y >= max - 4 ? " — at the bottom" : after.y <= 4 ? " — at the top" : ""}.`;
133
+ if (wantedDown && hadRoomDown && Math.abs(after.y - before.y) <= 4) {
134
+ note +=
135
+ before.lock && before.ov
136
+ ? `\nPage scroll is locked by an open overlay (normal modal behaviour).`
137
+ : `\n⚠ SCROLL LOCKED: the document is ${Math.round(before.dh - before.vh)}px taller than the viewport but the page did not scroll — content below the fold is unreachable (file it).`;
138
+ }
139
+ return { note };
140
+ }
141
+ /**
142
+ * Overlay/modal defect probe, run on every snapshot. Native `page.on("dialog")`
143
+ * only sees browser dialogs; APP modals (backdrop + positioned panel) are just
144
+ * DOM, and their canonical failure modes — a grayed-out page with an EMPTY
145
+ * dialog, a dialog shoved off-centre leaving a blank band, a backdrop with no
146
+ * dialog at all, a dialog taller than the viewport with no way to reach its
147
+ * buttons — all look perfectly healthy to the interactables collector.
148
+ * Best-effort: probe failure must never break the snapshot.
149
+ */
150
+ export async function probeOverlays(page) {
151
+ try {
152
+ return (await page.evaluate(`(() => {
153
+ const issues = [];
154
+ const vw = window.innerWidth, vh = window.innerHeight;
155
+ const visible = (el) => {
156
+ const r = el.getBoundingClientRect();
157
+ const s = getComputedStyle(el);
158
+ return r.width > 0 && r.height > 0 && s.visibility !== "hidden" && s.display !== "none";
159
+ };
160
+ // Backdrops: fixed, near-full-viewport, visually darkening/blurring.
161
+ const backdrops = [];
162
+ for (const el of document.querySelectorAll("body *")) {
163
+ if (!visible(el)) continue;
164
+ const s = getComputedStyle(el);
165
+ if (s.position !== "fixed") continue;
166
+ const r = el.getBoundingClientRect();
167
+ if (r.width < vw * 0.9 || r.height < vh * 0.9) continue;
168
+ const m = (s.backgroundColor || "").match(/rgba?\\((\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)(?:\\s*,\\s*([0-9.]+))?/);
169
+ const alpha = m ? (m[4] === undefined ? 1 : Number(m[4])) : 0;
170
+ const darkens = (alpha > 0.05 && alpha < 0.98) || ((s.backdropFilter || "") + "").includes("blur");
171
+ if (darkens) backdrops.push(el);
172
+ }
173
+ const hasContent = (el) => {
174
+ const t = (el.textContent || "").trim();
175
+ return t.length >= 10 || el.querySelectorAll("button, a[href], input, select, textarea").length > 0;
176
+ };
177
+ // Dialogs: aria/role/class-marked panels (role-less hand-rolled modals
178
+ // count). Do NOT exclude backdrops here — a modal that carries its own
179
+ // dim background (a full-screen [role=alertdialog] that IS the backdrop
180
+ // and centres its card inside) is both, and excluding it would wrongly
181
+ // read as "backdrop with no dialog".
182
+ const dialogsAll = [...document.querySelectorAll('${DIALOG_LIKE_SEL}')].filter((d) => visible(d));
183
+ // Leaked modal scroll-lock: content extends past the fold, the page
184
+ // itself cannot scroll (overflow hidden on body/html), and NO overlay
185
+ // of any kind — dialog-like panel OR backdrop — is up to justify the
186
+ // lock; everything below the fold is unreachable and the page looks
187
+ // perfectly healthy otherwise.
188
+ const docH = Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0);
189
+ const ovLock = [getComputedStyle(document.documentElement).overflowY, document.body ? getComputedStyle(document.body).overflowY : ""].some((o) => o === "hidden" || o === "clip");
190
+ if (ovLock && docH > vh + 50 && dialogsAll.length === 0 && backdrops.length === 0) {
191
+ issues.push("OVERLAY: page scrolling is DISABLED (overflow hidden on body/html) with " + Math.round(docH - vh) + "px of content below the fold and NO open dialog to justify it — likely a leaked modal scroll-lock; users cannot reach the rest of the page");
192
+ }
193
+ if (backdrops.length === 0) return issues;
194
+ if (dialogsAll.length === 0) {
195
+ // No marked dialog anywhere. Only a genuine stuck-grey-screen if the
196
+ // backdrop region itself holds nothing to interact with.
197
+ if (!backdrops.some(hasContent)) {
198
+ issues.push("OVERLAY: page is covered by a modal backdrop but NO dialog content was found — the page is grayed out with nothing to interact with (user is stuck)");
199
+ }
200
+ return issues;
201
+ }
202
+ // The actual CARD, for geometry/emptiness checks: a full-viewport dialog
203
+ // is a centring wrapper, not the panel — descend to its largest content
204
+ // child that is smaller than the viewport.
205
+ const panelOf = (el) => {
206
+ const r = el.getBoundingClientRect();
207
+ if (r.width < vw * 0.9 || r.height < vh * 0.9) return el;
208
+ let best = null;
209
+ for (const c of el.querySelectorAll("*")) {
210
+ if (!visible(c)) continue;
211
+ const cr = c.getBoundingClientRect();
212
+ if (cr.width >= vw * 0.9 && cr.height >= vh * 0.9) continue;
213
+ if (cr.width < 40 || cr.height < 40 || !hasContent(c)) continue;
214
+ if (!best || cr.width * cr.height > best.a) best = { a: cr.width * cr.height, c };
215
+ }
216
+ return best ? best.c : el;
217
+ };
218
+ const panels = [];
219
+ for (const d of dialogsAll) { const p = panelOf(d); if (p && panels.indexOf(p) < 0) panels.push(p); }
220
+ for (const d of panels.slice(0, 3)) {
221
+ const r = d.getBoundingClientRect();
222
+ const text = (d.textContent || "").trim();
223
+ const controls = d.querySelectorAll("button, a[href], input, select, textarea").length;
224
+ const name = d.getAttribute("data-testid") ? "[" + d.getAttribute("data-testid") + "]" : "<" + d.tagName.toLowerCase() + ">";
225
+ if (text.length < 10 && controls === 0) {
226
+ issues.push("OVERLAY: open dialog " + name + " appears EMPTY (" + text.length + " chars, 0 controls) over a grayed-out page — likely failed content load or broken conditional render");
227
+ continue;
228
+ }
229
+ const topGap = r.top, bottomGap = vh - r.bottom;
230
+ if (r.height < vh && Math.abs(topGap - bottomGap) > vh * 0.35 && (topGap > vh * 0.4 || bottomGap > vh * 0.4)) {
231
+ issues.push("OVERLAY: dialog " + name + " is far off-centre — " + Math.round(Math.max(topGap, bottomGap)) + "px empty band " + (topGap > bottomGap ? "above" : "below") + " it while the page is grayed out (broken centering)");
232
+ }
233
+ if (r.bottom > vh + 8 && d.scrollHeight <= d.clientHeight + 8) {
234
+ issues.push("OVERLAY: dialog " + name + " extends " + Math.round(r.bottom - vh) + "px below the viewport with NO internal scroll — its lower controls may be unreachable");
235
+ }
236
+ }
237
+ return issues;
238
+ })()`));
239
+ }
240
+ catch {
241
+ return [];
242
+ }
243
+ }
244
+ /**
245
+ * Keyboard-focus sampling for the design audit. Uses TRUSTED Tab presses:
246
+ * programmatic el.focus() does not match :focus-visible on buttons/links in
247
+ * Chromium, so an in-page probe would flag every default-styled control as
248
+ * focusless. As Tab advances, the previous stop is naturally blurred, so
249
+ * each stop's focused style (captured at visit time) can be diffed against
250
+ * its blurred style (captured in one pass at the end) without fighting the
251
+ * tab order. Best-effort: any failure returns an empty sample set rather
252
+ * than failing the audit.
253
+ */
254
+ export async function probeFocusIndicators(page) {
255
+ const styleSig = "s.outlineStyle + '|' + s.outlineWidth + '|' + s.outlineColor + '|' + s.boxShadow + '|' + s.borderColor + '|' + s.backgroundColor";
256
+ const stops = [];
257
+ try {
258
+ for (let i = 0; i < 15; i++) {
259
+ await page.keyboard.press("Tab");
260
+ const info = (await page.evaluate(`(() => {
261
+ const el = document.activeElement;
262
+ if (!el || el === document.body || el === document.documentElement) return null;
263
+ if (el.hasAttribute("data-scout-focus-probe")) return "wrapped";
264
+ el.setAttribute("data-scout-focus-probe", "${i}");
265
+ const s = getComputedStyle(el);
266
+ const tid = el.getAttribute("data-testid");
267
+ const name = ((el.textContent || el.getAttribute("aria-label") || "").trim().replace(/\\s+/g, " ").slice(0, 30));
268
+ return { label: tid ? "[" + tid + "]" : "<" + el.tagName.toLowerCase() + "> " + JSON.stringify(name), focused: ${styleSig} };
269
+ })()`));
270
+ if (info === null || info === "wrapped")
271
+ break;
272
+ stops.push({ i, ...info });
273
+ }
274
+ await page.evaluate("document.activeElement && document.activeElement.blur && document.activeElement.blur()");
275
+ const blurred = (await page.evaluate(`(() => {
276
+ const out = {};
277
+ for (const el of document.querySelectorAll("[data-scout-focus-probe]")) {
278
+ const s = getComputedStyle(el);
279
+ out[el.getAttribute("data-scout-focus-probe")] = ${styleSig};
280
+ el.removeAttribute("data-scout-focus-probe");
281
+ }
282
+ return out;
283
+ })()`));
284
+ // A stop that vanished between passes gets the benefit of the doubt.
285
+ return stops.map((st) => ({
286
+ label: st.label,
287
+ indicator: blurred[String(st.i)] === undefined ? true : st.focused !== blurred[String(st.i)],
288
+ }));
289
+ }
290
+ catch {
291
+ return [];
292
+ }
293
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Cleanup of browsers orphaned by a crashed run.
3
+ *
4
+ * Deciding which processes to SIGKILL is a pure function of `ps` output, so it
5
+ * lives here rather than in browser.ts and is table-tested: the cost of a wrong
6
+ * answer is killing somebody else's browser.
7
+ */
8
+ import { execFileSync } from "node:child_process";
9
+ /** Launch flag stamped into our browsers' command lines so the reaper can recognise them. */
10
+ export const BROWSER_MARKER = "scenescout-session";
11
+ /** Markers earlier versions stamped. Launch uses only the current one; the reaper must still recognise a browser orphaned by a crash just before an upgrade. */
12
+ const REAPABLE_MARKERS = [BROWSER_MARKER, "scenecraft-session"];
13
+ /**
14
+ * Which pids in a `ps -eo pid=,ppid=,command=` listing are ours to reap.
15
+ * Conservative on three axes: the command line must point into the
16
+ * ms-playwright cache, the parent must be gone (re-parented to pid 1), AND the
17
+ * command line must carry our marker (passed as a launch flag precisely so it
18
+ * shows up in `ps`).
19
+ *
20
+ * That last check is why this is narrow enough to run at startup. Matching on
21
+ * "orphaned Playwright browser" alone reaches every Playwright process on the
22
+ * machine — someone else's test suite, an unrelated automation job, a browser
23
+ * whose wrapper died while its owner lived — and SIGKILLs it. Only reap what
24
+ * this tool launched.
25
+ */
26
+ export function orphanPids(psOutput) {
27
+ const pids = [];
28
+ for (const line of psOutput.split("\n")) {
29
+ const m = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);
30
+ if (!m)
31
+ continue;
32
+ const [, pidStr, ppidStr, command] = m;
33
+ if (!command.includes("ms-playwright") || !/chrom|firefox|webkit/i.test(command))
34
+ continue;
35
+ if (!REAPABLE_MARKERS.some((marker) => command.includes(marker)))
36
+ continue;
37
+ if (Number(ppidStr) !== 1)
38
+ continue;
39
+ pids.push(Number(pidStr));
40
+ }
41
+ return pids;
42
+ }
43
+ /**
44
+ * Kill orphaned Playwright browser processes left behind by a crashed or
45
+ * SIGKILL'd previous run (a dead parent can't close its browser, and the
46
+ * leftover has been observed to wedge subsequent launches).
47
+ *
48
+ * Best-effort and POSIX-only; returns how many were reaped.
49
+ */
50
+ export function reapOrphanBrowsers() {
51
+ if (process.platform === "win32")
52
+ return 0;
53
+ let reaped = 0;
54
+ try {
55
+ const psOut = execFileSync("ps", ["-eo", "pid=,ppid=,command="], { encoding: "utf8", timeout: 5000 });
56
+ for (const pid of orphanPids(psOut)) {
57
+ try {
58
+ process.kill(pid, "SIGKILL");
59
+ reaped += 1;
60
+ }
61
+ catch {
62
+ /* already gone or not ours to kill */
63
+ }
64
+ }
65
+ }
66
+ catch {
67
+ /* ps unavailable or timed out — reaping is best-effort */
68
+ }
69
+ if (reaped > 0)
70
+ console.error(`[scenescout] reaped ${reaped} orphaned browser process(es) from a previous run`);
71
+ return reaped;
72
+ }