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,100 @@
1
+ /**
2
+ * Tool-call dispatch: the per-session queue and the watchdog.
3
+ *
4
+ * Extracted from mcp-server.ts because these two are the whole basis of the
5
+ * multi-role promise — "calls to DIFFERENT sessions run concurrently, calls to
6
+ * the SAME session never interleave" — and living inside the server module they
7
+ * could only be exercised by driving a real browser over stdio, so nothing in
8
+ * `npm test` touched them. A regression here (two same-session calls
9
+ * overlapping and corrupting one browser's ref table, or a watchdog timer that
10
+ * never clears) would ship green. Here they are pure and table-testable: see
11
+ * scripts/dispatch-test.ts.
12
+ */
13
+ /**
14
+ * Resolve with `onTimeout(...)` if `p` has not settled within `ms`.
15
+ *
16
+ * Two details that look incidental and are not:
17
+ * - the timer is cleared when `p` settles, so a long-lived process does not
18
+ * accumulate live timers (each would also hold the event loop open);
19
+ * - the guarded promise gets its own no-op `.catch`, because once the race has
20
+ * been won by the timeout nobody is left to handle a later rejection and
21
+ * Node would report an unhandled rejection for a call the caller was
22
+ * already told had timed out.
23
+ */
24
+ export function withWatchdog(label, p, ms, onTimeout) {
25
+ let timer;
26
+ const guarded = p.finally(() => clearTimeout(timer));
27
+ void guarded.catch(() => { });
28
+ return Promise.race([
29
+ guarded,
30
+ new Promise((resolve) => {
31
+ timer = setTimeout(() => resolve(onTimeout(label, ms)), ms);
32
+ }),
33
+ ]);
34
+ }
35
+ /**
36
+ * Serializes work per key, and only per key.
37
+ *
38
+ * One browser's ref table and fingerprint are shared mutable state, so two
39
+ * calls against the SAME session must not interleave. Two calls against
40
+ * DIFFERENT sessions share nothing, so they must not queue behind each other —
41
+ * that is what makes dispatching to several roles in one turn actually
42
+ * parallel rather than merely concurrent-looking.
43
+ */
44
+ export class SessionQueue {
45
+ chains = new Map();
46
+ /** Calls queued or running per key — a chain may not be dropped while this is above zero. */
47
+ pending = new Map();
48
+ /** Keys asked to be forgotten while still busy; dropped when they drain. */
49
+ forgotten = new Set();
50
+ /** Queue `fn` behind anything already running for `key`; returns its result. */
51
+ run(key, fn) {
52
+ const prior = this.chains.get(key) ?? Promise.resolve();
53
+ this.pending.set(key, (this.pending.get(key) ?? 0) + 1);
54
+ // Run `fn` whether the previous call resolved or REJECTED — a failed tool
55
+ // call must not wedge that session's queue forever.
56
+ const next = prior.then(fn, fn);
57
+ // The stored link swallows rejections: it exists only to sequence the next
58
+ // call, and an unhandled rejection here would crash the process for an
59
+ // error the caller is already receiving.
60
+ const settled = next.then(() => this.release(key), () => this.release(key));
61
+ this.chains.set(key, settled);
62
+ return next;
63
+ }
64
+ release(key) {
65
+ const left = (this.pending.get(key) ?? 1) - 1;
66
+ if (left > 0) {
67
+ this.pending.set(key, left);
68
+ return;
69
+ }
70
+ this.pending.delete(key);
71
+ if (this.forgotten.delete(key))
72
+ this.chains.delete(key);
73
+ }
74
+ /** Keys with work queued or in flight — diagnostics only. */
75
+ get size() {
76
+ return this.chains.size;
77
+ }
78
+ /**
79
+ * Drop a key's chain once it is idle (e.g. its session closed).
80
+ *
81
+ * Dropping it WHILE a call is in flight would be a correctness bug, not
82
+ * housekeeping: the next call for that key would find no chain, start
83
+ * immediately, and interleave with the call still running — precisely the
84
+ * overlap this class exists to prevent. `scout_close` runs on its own control
85
+ * chain, so it really can land mid-call. A still-busy key is marked instead
86
+ * and dropped when it drains.
87
+ */
88
+ forget(key) {
89
+ if ((this.pending.get(key) ?? 0) > 0) {
90
+ this.forgotten.add(key);
91
+ return;
92
+ }
93
+ this.chains.delete(key);
94
+ }
95
+ /** Forget every key (close-all), honouring the same in-flight rule. */
96
+ clear() {
97
+ for (const key of [...this.chains.keys()])
98
+ this.forget(key);
99
+ }
100
+ }
@@ -0,0 +1,100 @@
1
+ import { createHash } from "node:crypto";
2
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3
+ const HEX_RE = /^[0-9a-f]{12,}$/i;
4
+ const NUM_RE = /^\d+$/;
5
+ /** Query params that name a UI state (a different screen), not transient data. */
6
+ const UI_STATE_PARAM_RE = /^(tab|view|mode|step|panel|section)$/i;
7
+ /**
8
+ * Normalize a URL into a route-class identity:
9
+ * - path ids collapse (/orders/123 → /orders/:id)
10
+ * - hash-router paths count as the path (#/orders/5 → /orders/:id)
11
+ * - UI-state query params are KEPT (?tab=audit is a different screen;
12
+ * ?page=2 is not) so tabs are enumerable, crawlable, and countable.
13
+ *
14
+ * A NUMERIC FIRST SEGMENT IS NOT AN ID. `/404` and `/500` are the two most
15
+ * common static pages in any web app, and collapsing them to `/:id` made them
16
+ * aliases of each other: visiting the 404 page marked the 500 page covered,
17
+ * because the unvisited filter compares normalized forms. Ids in real apps
18
+ * follow a collection noun (`/documents/239`, `/orders/5`), so position 1 is
19
+ * where static numeric pages live and position 2+ is where ids live. A UUID or
20
+ * long hex string still collapses anywhere — no static page is named one.
21
+ */
22
+ export function normalizePath(rawUrl) {
23
+ let path;
24
+ let search;
25
+ try {
26
+ const u = new URL(rawUrl, "http://x");
27
+ path = u.pathname;
28
+ search = u.search;
29
+ if (u.hash.startsWith("#/")) {
30
+ const [hashPath, hashQuery] = u.hash.slice(1).split("?");
31
+ path = hashPath;
32
+ search = hashQuery ? `?${hashQuery}` : "";
33
+ }
34
+ }
35
+ catch {
36
+ [path = "/", search = ""] = rawUrl.split("?");
37
+ search = search ? `?${search}` : "";
38
+ }
39
+ // segments[0] is always "" for a leading-slash path, so the first REAL
40
+ // segment is index 1 — that is the one a bare numeric page occupies.
41
+ const segments = path.split("/").map((seg, i) => {
42
+ if (UUID_RE.test(seg) || HEX_RE.test(seg))
43
+ return ":id";
44
+ if (NUM_RE.test(seg) && i > 1)
45
+ return ":id";
46
+ return seg;
47
+ });
48
+ let normalized = segments.join("/") || "/";
49
+ if (normalized === "")
50
+ normalized = "/";
51
+ const kept = [];
52
+ for (const [k, v] of new URLSearchParams(search)) {
53
+ if (UI_STATE_PARAM_RE.test(k))
54
+ kept.push(`${k}=${v.slice(0, 40)}`);
55
+ }
56
+ if (kept.length > 0)
57
+ normalized += `?${kept.sort().join("&")}`;
58
+ return normalized;
59
+ }
60
+ /**
61
+ * Paths that are not UI pages, however they entered the route list: API
62
+ * endpoints and file downloads.
63
+ *
64
+ * `harvestRoutes` already skips these when it reads an href, but a route can
65
+ * also arrive by navigation, or from a memory written before that filter
66
+ * existed — and once in the contract it sits in the gap ledger forever, since
67
+ * "visiting" it downloads a file instead of rendering a page, so it can never
68
+ * be exercised or audited. Filtering at the CONTRACT boundary means no entry
69
+ * path can reintroduce one. The query check catches export links whose path
70
+ * looks page-like (`/reports/export?format=csv`).
71
+ */
72
+ export function isNonPageRoute(route) {
73
+ const [path, query = ""] = route.split("?");
74
+ if (/^\/api(\/|$)/i.test(path))
75
+ return true;
76
+ if (/\.(pdf|zip|csv|xlsx?|docx?|pptx?|png|jpe?g|gif|svg|ico|mp4|webm|json|xml)$/i.test(path))
77
+ return true;
78
+ return /\b(format=(csv|pdf|xlsx?|docx?)|download=(1|true))\b/i.test(query);
79
+ }
80
+ /** Stable key identifying an element class within a state (for coverage tracking). */
81
+ export function elementKey(el) {
82
+ if (el.testid)
83
+ return `tid:${el.testid}`;
84
+ const name = el.name.toLowerCase().replace(/\s+/g, " ").trim().slice(0, 60);
85
+ return `${el.role}:${name}`;
86
+ }
87
+ /**
88
+ * Fingerprint a UI state: normalized route + hash of the set of interactable
89
+ * element classes. Same page with different data → same fingerprint; a page
90
+ * whose available actions changed (modal opened, different role) → new one.
91
+ */
92
+ export function fingerprintState(url, elements) {
93
+ const route = normalizePath(url);
94
+ const keys = [...new Set(elements.map(elementKey))].sort();
95
+ const hash = createHash("sha1").update(keys.join("|")).digest("hex").slice(0, 8);
96
+ return `${route}#${hash}`;
97
+ }
98
+ export function shortHash(input) {
99
+ return createHash("sha1").update(input).digest("hex").slice(0, 10);
100
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Synthetic upload fixtures.
3
+ *
4
+ * An upload control is the one form field that cannot be exercised with text:
5
+ * `fill()` refuses `<input type="file">`, so every upload flow used to sit in
6
+ * the gap ledger forever as "filled but never submitted". Playwright can set a
7
+ * file input from an in-memory payload, which means nothing has to exist on
8
+ * disk — this module generates small files that are VALID for their type (a
9
+ * real PDF structure, a real PNG), so the app's own parser / preview / scan
10
+ * path runs against something it would accept from a user, not a renamed text
11
+ * file it rejects on the first byte.
12
+ *
13
+ * Pure module: no browser, no filesystem — see scripts/fixture-test.ts.
14
+ */
15
+ export const FIXTURE_KINDS = ["pdf", "png", "txt", "csv", "json"];
16
+ const MIME = {
17
+ pdf: "application/pdf",
18
+ png: "image/png",
19
+ txt: "text/plain",
20
+ csv: "text/csv",
21
+ json: "application/json",
22
+ };
23
+ /** Mime types for extensions a disk file may carry beyond the fixture kinds. */
24
+ const EXTRA_MIME = {
25
+ jpg: "image/jpeg",
26
+ jpeg: "image/jpeg",
27
+ gif: "image/gif",
28
+ webp: "image/webp",
29
+ svg: "image/svg+xml",
30
+ html: "text/html",
31
+ htm: "text/html",
32
+ xml: "application/xml",
33
+ zip: "application/zip",
34
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
35
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
36
+ };
37
+ export function isFixtureKind(value) {
38
+ return typeof value === "string" && FIXTURE_KINDS.includes(value);
39
+ }
40
+ /** Mime type for a filename by extension — needed when a disk file is uploaded under a custom name. */
41
+ export function mimeForName(name) {
42
+ const ext = /\.([a-z0-9]+)$/i.exec(name)?.[1]?.toLowerCase();
43
+ if (!ext)
44
+ return "application/octet-stream";
45
+ return (isFixtureKind(ext) ? MIME[ext] : EXTRA_MIME[ext]) ?? "application/octet-stream";
46
+ }
47
+ function tokens(accept) {
48
+ return (accept ?? "")
49
+ .toLowerCase()
50
+ .split(",")
51
+ .map((t) => t.trim())
52
+ .filter(Boolean);
53
+ }
54
+ /**
55
+ * Which fixture an input's `accept` attribute would admit. Tokens are read in
56
+ * the order the app listed them — the first names the primary type — and the
57
+ * first that maps to a kind we can generate wins (spreadsheet types get the
58
+ * CSV, the nearest thing we can make). No `accept`, or only types we cannot
59
+ * approximate at all (.docx, .zip), falls back to PDF, the most widely
60
+ * whitelisted document type; `generatedUpload` says which of those happened.
61
+ */
62
+ export function fixtureKindFor(accept) {
63
+ for (const token of tokens(accept)) {
64
+ if (token === ".pdf" || token === "application/pdf")
65
+ return "pdf";
66
+ if (token.startsWith("image/") || /^\.(png|jpe?g|gif|webp|bmp)$/.test(token))
67
+ return "png";
68
+ if (token === ".csv" || token === "text/csv" || /excel|spreadsheet|^\.xlsx?$/.test(token))
69
+ return "csv";
70
+ if (token === ".json" || token === "application/json")
71
+ return "json";
72
+ if (token === ".txt" || token === "text/plain" || token === "text/*")
73
+ return "txt";
74
+ }
75
+ return "pdf";
76
+ }
77
+ /**
78
+ * Does a file satisfy an `accept` attribute? `null` when there is no accept
79
+ * (nothing to satisfy). Mirrors the browser's own picker filter: `.ext`
80
+ * matches the filename's extension, `type/*` the mime's major type, `type/sub`
81
+ * exactly. A file that fails this and is accepted anyway is a validation gap —
82
+ * the caller says so.
83
+ */
84
+ export function acceptMatches(accept, fileName, mimeType) {
85
+ const list = tokens(accept);
86
+ if (list.length === 0)
87
+ return null;
88
+ const lowerName = fileName.toLowerCase();
89
+ const mime = mimeType.toLowerCase();
90
+ return list.some((token) => {
91
+ if (token.startsWith("."))
92
+ return lowerName.endsWith(token);
93
+ if (token.endsWith("/*"))
94
+ return mime.startsWith(token.slice(0, -1));
95
+ return mime === token;
96
+ });
97
+ }
98
+ /** Build a small, valid file of the given kind — in memory, nothing on disk. */
99
+ export function syntheticFile(kind, name) {
100
+ return { name: name ?? `scenescout-fixture.${kind}`, mimeType: MIME[kind], buffer: BUILDERS[kind]() };
101
+ }
102
+ /**
103
+ * The fixture an upload should send, with an honest account of how its kind
104
+ * was chosen. `accept` is the input's attribute (`null` = the input has none;
105
+ * `undefined` = the input could not be inspected), `fixture` an explicit
106
+ * kind, `name` a filename override. The wording matters: "inferred from
107
+ * accept" when the accept listed only types we cannot generate would tell the
108
+ * driver the file matches when it does not.
109
+ */
110
+ export function generatedUpload(accept, fixture, name) {
111
+ const kind = fixture ?? fixtureKindFor(accept);
112
+ const file = syntheticFile(kind, name);
113
+ let why = "";
114
+ if (!fixture) {
115
+ if (accept === undefined)
116
+ why = " (accept unknown; pdf is the default)";
117
+ else if (!accept)
118
+ why = " (no accept attribute; pdf is the default)";
119
+ else if (acceptMatches(accept, file.name, file.mimeType))
120
+ why = " (inferred from accept)";
121
+ else
122
+ why = " (accept lists no type we can generate; pdf is the fallback)";
123
+ }
124
+ return { file, source: `generated ${kind} fixture${why}` };
125
+ }
126
+ const BUILDERS = {
127
+ txt: () => Buffer.from("SceneScout synthetic upload fixture.\nGenerated for exploratory testing; contains no real data.\n", "utf8"),
128
+ csv: () => Buffer.from("id,name,status\n1,Alpha widget,active\n2,Beta widget,inactive\n3,Gamma widget,active\n", "utf8"),
129
+ json: () => Buffer.from(`${JSON.stringify({ fixture: "scenescout", generated: true, items: [{ id: 1, name: "Alpha widget" }] }, null, 2)}\n`, "utf8"),
130
+ // A tiny well-formed PNG: 1×1 RGBA, one half-transparent pixel, CRCs intact.
131
+ png: () => Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64"),
132
+ pdf: minimalPdf,
133
+ };
134
+ /**
135
+ * A one-page PDF with a line of text. The cross-reference table carries REAL
136
+ * byte offsets, computed as the objects are laid down — most viewers tolerate
137
+ * a wrong xref, but a strict parser (and a backend that validates uploads)
138
+ * does not, and "valid" is the whole point of a fixture.
139
+ */
140
+ function minimalPdf() {
141
+ const content = "BT /F1 24 Tf 72 720 Td (SceneScout synthetic upload fixture) Tj ET";
142
+ const objects = [
143
+ "<< /Type /Catalog /Pages 2 0 R >>",
144
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
145
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
146
+ `<< /Length ${Buffer.byteLength(content, "latin1")} >>\nstream\n${content}\nendstream`,
147
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
148
+ ];
149
+ let out = "%PDF-1.4\n";
150
+ const offsets = [];
151
+ objects.forEach((body, i) => {
152
+ offsets.push(Buffer.byteLength(out, "latin1"));
153
+ out += `${i + 1} 0 obj\n${body}\nendobj\n`;
154
+ });
155
+ const xrefAt = Buffer.byteLength(out, "latin1");
156
+ // Every xref entry is exactly 20 bytes: 10-digit offset, 5-digit generation, type, space, newline.
157
+ out += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
158
+ for (const offset of offsets)
159
+ out += `${String(offset).padStart(10, "0")} 00000 n \n`;
160
+ out += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefAt}\n%%EOF\n`;
161
+ return Buffer.from(out, "latin1");
162
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Task-ease measurement: what did it COST a user to get one thing done?
3
+ *
4
+ * A passing end-to-end test says a flow works; it says nothing about how many
5
+ * screens, interactions and wrong turns the flow takes. These numbers are
6
+ * derived purely from the action log, so the thresholds and the verdict wording
7
+ * are table-tested here instead of being reachable only through a browser.
8
+ */
9
+ import { normalizePath } from "./fingerprint.js";
10
+ /** More distinct screens than this for one task is worth questioning. */
11
+ const MANY_SCREENS = 4;
12
+ /** More interactions than this for one task is worth questioning. */
13
+ const MANY_INTERACTIONS = 15;
14
+ function routeOf(url) {
15
+ try {
16
+ return normalizePath(new URL(url).pathname);
17
+ }
18
+ catch {
19
+ return url;
20
+ }
21
+ }
22
+ /** Measure one journey from the slice of the action log that belongs to it. */
23
+ export function measureJourney(log, completed) {
24
+ const interactions = log.filter((e) => /^(click|type|select|press|plan:(click|type|select|press))/.test(e.action)).length;
25
+ const navigations = log.filter((e) => /^(navigate|back|plan:navigate|plan:back)/.test(e.action)).length;
26
+ const routeSeq = log.map((e) => routeOf(e.url)).filter((r, i, a) => i === 0 || r !== a[i - 1]);
27
+ const distinctScreens = new Set(routeSeq).size;
28
+ // A backtrack = returning to a route already left behind. Real users do
29
+ // this when the path wasn't obvious; it is the clearest signal that
30
+ // information scent failed, and it is invisible to a pass/fail test.
31
+ const seen = new Set();
32
+ let backtracks = 0;
33
+ for (const r of routeSeq) {
34
+ if (seen.has(r))
35
+ backtracks += 1;
36
+ seen.add(r);
37
+ }
38
+ const policyBlocks = log.filter((e) => e.action === "write-policy:blocked").length;
39
+ const refusals = log.filter((e) => /refused|REFUSED/.test(e.result ?? "")).length;
40
+ const shortcuts = log.filter((e) => /^(navigate|plan:navigate)$/.test(e.action)).length;
41
+ const verdict = [];
42
+ if (backtracks > 0)
43
+ verdict.push(`⚠ ${backtracks} backtrack(s) — the route was re-visited after leaving it, which usually means the next step wasn't discoverable from where the user was`);
44
+ if (distinctScreens > MANY_SCREENS)
45
+ verdict.push(`⚠ ${distinctScreens} distinct screens for one task — each hand-off is a chance to lose the user; consider whether steps can be combined or done in place`);
46
+ if (interactions > MANY_INTERACTIONS)
47
+ verdict.push(`⚠ ${interactions} interactions — high for a single task; check for over-asking (optional fields up-front) or repeated confirmation steps`);
48
+ if (shortcuts > 0)
49
+ verdict.push(`⚠ ${shortcuts} direct-URL jump(s) during the journey — a first-time user cannot type URLs, so the measurement is contaminated: either re-run clicking through the UI, or the destination is unreachable by UI navigation (which is itself a finding).`);
50
+ if (!completed)
51
+ verdict.push(`⚠ TASK NOT COMPLETED — this is the strongest possible finding: the journey is blocked or undiscoverable. File it.`);
52
+ if (verdict.length === 0)
53
+ verdict.push(`✓ efficient — direct path, no backtracking, proportionate interaction count`);
54
+ return { interactions, navigations, routeSeq, distinctScreens, backtracks, shortcuts, policyBlocks, refusals, verdict };
55
+ }
56
+ /** The tool result for a finished journey. */
57
+ export function formatJourney(j, m) {
58
+ return [
59
+ `JOURNEY ${j.completed ? "COMPLETED" : "ABANDONED"} — "${j.goal}"`,
60
+ ``,
61
+ `Interaction cost: ${m.interactions} interactions · ${m.navigations} navigations · ${m.distinctScreens} distinct screens · ${j.seconds}s`,
62
+ `Path: ${m.routeSeq.slice(0, 12).join(" → ")}${m.routeSeq.length > 12 ? " → …" : ""}`,
63
+ ...(m.policyBlocks > 0 || m.refusals > 0 ? [`Policy: ${m.policyBlocks} write-policy blocks, ${m.refusals} refusals (tester safety, not app defects)`] : []),
64
+ ...(j.note ? [`Note: ${j.note}`] : []),
65
+ ``,
66
+ `Efficiency read:`,
67
+ ...m.verdict.map((v) => ` ${v}`),
68
+ ``,
69
+ `Judge with product context: a 3-screen approval flow with an e-signature step is legitimately longer than "add a comment". Compare against what the task NEEDS, then file genuine friction as ux-confusing (blocked/undiscoverable) or ux-polish (works but costs more than it should), quoting these numbers.`,
70
+ ].join("\n");
71
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Turning a failed browser launch into something the person can act on.
3
+ *
4
+ * For anyone who installed from npm and never ran the setup step, the first
5
+ * attach fails because Chromium was never downloaded — and Playwright reports
6
+ * that as a multi-line box of text with a path in it. That is the single most
7
+ * likely first-run failure, so it gets one plain instruction instead.
8
+ */
9
+ /** Playwright's wording when the browser binary is not on disk. */
10
+ const MISSING_BROWSER_RE = /Executable doesn't exist|playwright install|browserType\.launch:.*(not found|ENOENT)/i;
11
+ export function isMissingBrowser(message) {
12
+ return MISSING_BROWSER_RE.test(message);
13
+ }
14
+ /** The error text for a launch that failed. `reaped` is how many orphaned browsers were cleaned up between attempts. */
15
+ export function explainLaunchFailure(message, reaped) {
16
+ if (isMissingBrowser(message)) {
17
+ return (`Chromium has not been downloaded yet (one-time, ~150 MB). Run this once, then attach again:\n` +
18
+ ` npx -y scenescout install --browser-only\n` +
19
+ `(from a clone: npm run setup). On Linux, if system libraries are missing: npx playwright install --with-deps chromium`);
20
+ }
21
+ const firstLine = message.split("\n")[0];
22
+ return (`browser launch failed twice (${firstLine})` +
23
+ (reaped > 0 ? ` — ${reaped} orphaned browser process(es) were reaped between attempts` : "") +
24
+ `. Check disk space, then run \`scenescout doctor\` to verify the setup.`);
25
+ }