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.
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +429 -0
- package/dist/cli.js +269 -0
- package/dist/engine/authloss.js +125 -0
- package/dist/engine/browser.js +1954 -0
- package/dist/engine/collector.js +266 -0
- package/dist/engine/design.js +716 -0
- package/dist/engine/dispatch.js +100 -0
- package/dist/engine/fingerprint.js +100 -0
- package/dist/engine/fixtures.js +162 -0
- package/dist/engine/journey.js +71 -0
- package/dist/engine/launch.js +25 -0
- package/dist/engine/memory.js +1116 -0
- package/dist/engine/oracles.js +187 -0
- package/dist/engine/ownership.js +223 -0
- package/dist/engine/policy.js +84 -0
- package/dist/engine/probes.js +293 -0
- package/dist/engine/reaper.js +72 -0
- package/dist/engine/report.js +515 -0
- package/dist/engine/uploads.js +74 -0
- package/dist/installer.js +315 -0
- package/dist/mcp-server.js +810 -0
- package/dist/scan.js +335 -0
- package/package.json +86 -0
- package/skills/scenescout/SKILL.md +96 -0
|
@@ -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
|
+
}
|