scenescout 1.0.0 → 1.2.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.
@@ -1,18 +1,20 @@
1
- import { chromium } from "playwright";
1
+ import { chromium, firefox, webkit } from "playwright";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { elementKey, fingerprintState, isNonPageRoute, normalizePath } from "./fingerprint.js";
5
5
  import { AUTH_LOSS_PREFIX, MemoryStore } from "./memory.js";
6
6
  import { AuthLossTracker } from "./authloss.js";
7
- import { COLLECT_INTERACTABLES_SCRIPT, VISIBLE_SRC, geometryIssues } from "./collector.js";
7
+ import { COLLECT_INTERACTABLES_SCRIPT, VISIBLE_SRC, geometryIssues, BROKEN_IMAGES_SCRIPT, brokenImageIssues, } from "./collector.js";
8
8
  import { OracleMonitor, formatViolations } from "./oracles.js";
9
9
  import { extractCreatedIds, isOwnedResource, normalizeId } from "./ownership.js";
10
10
  import { formatJourney, measureJourney } from "./journey.js";
11
+ import { defaultEngine, focusAdvanceKey, REMOVE_SHARED_WORKER_SCRIPT, serviceWorkerPolicy, sharedWorkersAllowed } from "../browsers.js";
12
+ import { revealedLines } from "./hover.js";
11
13
  import { explainLaunchFailure, isMissingBrowser } from "./launch.js";
12
14
  import { ACTION_TIMEOUT_MS, performScroll, probeFocusIndicators, probeOverlays, scrollContainer } from "./probes.js";
13
15
  import { BROWSER_MARKER, reapOrphanBrowsers } from "./reaper.js";
14
16
  import { planUploadOptions, resolveDiskUpload } from "./uploads.js";
15
- import { AUTH_FLOW_RE, destructiveRefusal, isDestructive, isDestructiveWire } from "./policy.js";
17
+ import { destructiveRefusal, isDestructive, isDestructiveWire, allowsWrite, isAuthExempt } from "./policy.js";
16
18
  import { scanProject } from "../scan.js";
17
19
  import { analyzeDesign, DESIGN_COLLECT_SCRIPT } from "./design.js";
18
20
  import { acceptMatches, generatedUpload } from "./fixtures.js";
@@ -125,7 +127,8 @@ export class BrowserEngine {
125
127
  authLoss = new AuthLossTracker();
126
128
  /** UI-label blocking applies only in read-only mode (safe-write enforces at the network layer instead). */
127
129
  get readOnly() {
128
- return this.mode === "read-only";
130
+ // observe is read-only and then some: every UI-level refusal applies to it too.
131
+ return this.mode === "read-only" || this.mode === "observe";
129
132
  }
130
133
  /** Append to the shared action log, stamped with THIS session so per-session
131
134
  * reads (journey paths) can separate concurrent roles' interleaved actions. */
@@ -134,6 +137,14 @@ export class BrowserEngine {
134
137
  }
135
138
  /** Requests blocked by the write policy since the last action (timestamped for attribution). */
136
139
  blockedRequests = [];
140
+ /**
141
+ * WebSockets this session's pages opened. The write policy works on HTTP
142
+ * requests; frames sent over a socket are not inspected. In observe mode that
143
+ * is a hole in "nothing leaves the page", so it is said out loud rather than
144
+ * left for the reader to discover.
145
+ */
146
+ openSockets = new Set();
147
+ socketsWarned = false;
137
148
  /** When the current action began — requests recorded before this are late arrivals from a previous action. */
138
149
  actionStartedAt = 0;
139
150
  /**
@@ -219,6 +230,8 @@ export class BrowserEngine {
219
230
  }
220
231
  /** Whether the browser window is visible — headed hover results carry a physical-cursor caveat. */
221
232
  headed = false;
233
+ /** The browser this engine launched; reported by attach so a finding can say where it was seen. */
234
+ engineName = "chromium";
222
235
  /** Non-GET requests fired since the last action — surfaces silent state mutation in read-only runs (timestamped for attribution). */
223
236
  mutationRequests = [];
224
237
  /**
@@ -284,11 +297,15 @@ export class BrowserEngine {
284
297
  this.knownRoutes = [];
285
298
  }
286
299
  try {
287
- this.browser = await this.launchWithRecovery(opts.headed ?? false);
300
+ this.engineName = opts.browser ?? defaultEngine(process.env);
301
+ this.browser = await this.launchWithRecovery(this.engineName, opts.headed ?? false);
288
302
  this.context = await this.browser.newContext({
289
303
  storageState: opts.storageStatePath,
290
304
  viewport: opts.viewport ?? { width: 1280, height: 900 },
305
+ serviceWorkers: serviceWorkerPolicy(this.engineName),
291
306
  });
307
+ if (!sharedWorkersAllowed(this.mode))
308
+ await this.context.addInitScript(REMOVE_SHARED_WORKER_SCRIPT);
292
309
  this.page = await this.context.newPage();
293
310
  }
294
311
  catch (err) {
@@ -299,6 +316,13 @@ export class BrowserEngine {
299
316
  // Label-based read-only blocking can't catch every mutation (an innocuous
300
317
  // "Add to Cart" fires a POST). Track non-GET traffic so actions that
301
318
  // changed server state are at least REPORTED in read-only runs.
319
+ this.openSockets.clear();
320
+ this.socketsWarned = false;
321
+ const watchSockets = (p) => void p.on("websocket", (ws) => this.openSockets.add(ws.url().slice(0, 120)));
322
+ // The first page already exists by now; later ones (popups) arrive as events.
323
+ if (this.page)
324
+ watchSockets(this.page);
325
+ this.context.on("page", watchSockets);
302
326
  this.context.on("request", (req) => {
303
327
  const type = req.resourceType();
304
328
  if (type === "xhr" || type === "fetch")
@@ -321,6 +345,9 @@ export class BrowserEngine {
321
345
  // here let a REFUSED destructive POST mark the route as mutated — a form
322
346
  // that was never submitted reading as tested, in read-only mode where by
323
347
  // definition nothing is.
348
+ // Same test the route handler uses: in observe only an exempt auth request goes out.
349
+ if (this.mode === "observe" && !isAuthExempt(this.mode, method, pathnameOf(req.url()), isDestructiveWire(pathnameOf(req.url()), req.postData())))
350
+ return;
324
351
  if (this.readOnly && isDestructiveWire(pathnameOf(req.url()), req.postData()))
325
352
  return;
326
353
  const pageUrl = this.page?.url();
@@ -342,10 +369,11 @@ export class BrowserEngine {
342
369
  return route.continue();
343
370
  const url = req.url();
344
371
  const pathname = pathnameOf(url);
345
- // Auth/session flows (login, refresh, logout) must work in every mode.
346
- if (AUTH_FLOW_RE.test(pathname) && method === "POST")
347
- return route.continue();
348
372
  const destructiveWire = isDestructiveWire(pathname, req.postData());
373
+ // Auth/session flows must work in every mode — but never a destructive
374
+ // one, and in observe only the requests a login itself needs.
375
+ if (isAuthExempt(this.mode, method, pathname, destructiveWire))
376
+ return route.continue();
349
377
  let owned = this.isOwnedResource(pathname);
350
378
  // A single UI action commonly fires create-then-immediately-save
351
379
  // (POST gets an id, PUT saves content under it) faster than the
@@ -362,7 +390,7 @@ export class BrowserEngine {
362
390
  }
363
391
  // POST: creation/RPC passes unless it smells destructive and isn't ours.
364
392
  // PUT/PATCH/DELETE: only in safe-write, only on our own resources.
365
- const allow = method === "POST" ? !destructiveWire || owned : this.mode === "safe-write" && owned;
393
+ const allow = allowsWrite(this.mode, method, destructiveWire, owned);
366
394
  if (allow) {
367
395
  // Ownership tracking (safe-write): register the creation-tracking
368
396
  // task BEFORE the POST goes out. Registering from a context
@@ -441,6 +469,8 @@ export class BrowserEngine {
441
469
  `Continuing now tests a logged-out app.`
442
470
  : "";
443
471
  return (`Attached to ${this.page.url()} (mode=${this.mode}` +
472
+ `${this.engineName === "chromium" ? "" : `, browser=${this.engineName}, service workers blocked because their requests cannot be intercepted here`}` +
473
+ `${focusAdvanceKey(this.engineName, process.platform) === "Tab" ? "" : `, keyboard: Tab stops only at text fields in this browser — press Alt+Tab to reach buttons and links`}` +
444
474
  `${opts.storageStatePath ? `, auth=${opts.storageStatePath}` : ""}). ` +
445
475
  `Memory: ${this.memory.dir}.${this.memory.loadWarning ? ` WARNING: ${this.memory.loadWarning}` : ""}` +
446
476
  `${this.memory.legacyDirNote ? ` ${this.memory.legacyDirNote}` : ""}` +
@@ -668,6 +698,7 @@ export class BrowserEngine {
668
698
  const geometry = geometryIssues(elements, page.viewportSize() ?? { width: 1280, height: 900 });
669
699
  geometry.push(...(await probeOverlays(page)));
670
700
  const hiddenFileInputs = await this.hiddenFileInputs(page);
701
+ const brokenImages = brokenImageIssues((await page.evaluate(BROKEN_IMAGES_SCRIPT).catch(() => null)) ?? { images: [], total: 0 }, url);
671
702
  const cov = memory.coverage();
672
703
  const unvisited = this.unvisitedKnownRoutes();
673
704
  const title = await page.title();
@@ -677,10 +708,12 @@ export class BrowserEngine {
677
708
  `\n` +
678
709
  body +
679
710
  (geometry.length > 0 ? `\nGEOMETRY issues:\n` + geometry.map((g) => ` ⚠ ${g}`).join("\n") : "") +
711
+ (brokenImages.length > 0 ? `\nBROKEN IMAGES:\n` + brokenImages.map((b) => ` ⚠ ${b}`).join("\n") : "") +
680
712
  (hiddenFileInputs.length > 0
681
713
  ? `\nFILE INPUTS not listed above (hidden behind a styled control — a user never sees the input itself): ${hiddenFileInputs.join("; ")}. ` +
682
714
  `scout_upload {ref} on the control that opens one, or scout_upload {} when it is the page's only file input.`
683
715
  : "") +
716
+ this.socketNotice() +
684
717
  formatViolations(this.oracles.drain()) +
685
718
  (elements.length === 0 ? "\n⚠ DEAD END: no interactable elements found on this page." : ""));
686
719
  }
@@ -732,7 +765,7 @@ export class BrowserEngine {
732
765
  if (el.role === "textbox" || el.role === "file")
733
766
  return null;
734
767
  if (el.destructive || isDestructive(liveLabel)) {
735
- return destructiveRefusal(liveLabel || el.name || el.testid || el.ref);
768
+ return destructiveRefusal(liveLabel || el.name || el.testid || el.ref, this.mode);
736
769
  }
737
770
  return null;
738
771
  }
@@ -741,13 +774,22 @@ export class BrowserEngine {
741
774
  await this.settle();
742
775
  let url = page.url();
743
776
  if (url !== "about:blank" && !this.isSameOrigin(url)) {
744
- // A click carried us off the app's origin bounce back and say so.
777
+ // Either a click carried us off the app's origin, or the write policy
778
+ // aborted a NAVIGATION (a native form post) and the browser is showing
779
+ // its error page. The second is the tester's own doing and must say so:
780
+ // reported as an off-origin bounce, it hid the block, and the caller then
781
+ // read the unchanged URL as "the app silently discarded the data".
782
+ const policyAbortedNavigation = url.startsWith("chrome-error://") && this.blockedRequests.length > 0;
745
783
  this.logAction({ action, target, url });
746
- this.logAction({ action: "origin-fence:bounced", target: url.slice(0, 200), url });
784
+ this.logAction({ action: policyAbortedNavigation ? "write-policy:navigation-blocked" : "origin-fence:bounced", target: url.slice(0, 200), url });
747
785
  await page.goBack({ waitUntil: "domcontentloaded", timeout: 10000 }).catch(() => { });
748
786
  url = page.url();
749
787
  this.refs.clear();
750
- return (`OK: ${action} ${target}\nNavigated off-origin and was bounced back to ${url}. Exploration is fenced to ${this.baseUrl}.` +
788
+ const blocked = this.drainBlocked();
789
+ return ((policyAbortedNavigation
790
+ ? `OK: ${action} ${target}\nThe page tried to navigate with a request the write policy blocked, so the browser showed an error page; returned to ${url}.`
791
+ : `OK: ${action} ${target}\nNavigated off-origin and was bounced back to ${url}. Exploration is fenced to ${this.baseUrl}.`) +
792
+ blocked +
751
793
  formatViolations(this.oracles.drain()));
752
794
  }
753
795
  this.logAction({ action, target, url });
@@ -821,9 +863,20 @@ export class BrowserEngine {
821
863
  this.blockedRequests = [];
822
864
  return (`\n🛡 WRITE-POLICY blocked (${this.mode}): ${list}${extra}. ` +
823
865
  `This is the tester's safety policy, NOT an app bug — do not file a finding for the resulting error UI. ` +
824
- (this.mode === "read-only"
825
- ? `Re-attach with mode="safe-write" to test create/edit flows, or "destructive" (user-approved disposable env only).`
826
- : `In safe-write, updates/deletes are only allowed on resources this session created (${this.createdResources.length} so far).`));
866
+ (this.mode === "observe"
867
+ ? `observe mode blocks every request that is not a GET, so no form submission reaches the server. Re-attach with mode="read-only" ONLY if the user confirms that ordinary form submissions are acceptable on this target.`
868
+ : this.mode === "read-only"
869
+ ? `Re-attach with mode="safe-write" to test create/edit flows, or "destructive" (user-approved disposable env only).`
870
+ : `In safe-write, updates/deletes are only allowed on resources this session created (${this.createdResources.length} so far).`));
871
+ }
872
+ /** Once per session, in observe mode only: say that socket frames are outside the policy. */
873
+ socketNotice() {
874
+ if (this.mode !== "observe" || this.socketsWarned || this.openSockets.size === 0)
875
+ return "";
876
+ this.socketsWarned = true;
877
+ return (`\n⚠ OBSERVE LIMIT: this app holds an open WebSocket (${[...this.openSockets].slice(0, 2).join(", ")}). ` +
878
+ `The write policy blocks HTTP requests; frames sent over a socket are NOT inspected. ` +
879
+ `Do not perform actions that send data over it (chat messages, live edits, presence) — look, do not type, in socket-driven widgets — and say so in your summary.`);
827
880
  }
828
881
  /** xhr/fetch requests seen this session — lets clicks detect silent no-op submits. */
829
882
  xhrCount = 0;
@@ -851,7 +904,7 @@ export class BrowserEngine {
851
904
  .join("; ");
852
905
  const extra = fresh.length > 5 ? ` (+${fresh.length - 5} more)` : "";
853
906
  return this.readOnly
854
- ? `\n⚠ READ-ONLY notice: this action fired state-changing requests — server state may have mutated despite read-only mode: ${list}${extra}. Consider whether this flow should be avoided or the environment confirmed disposable.`
907
+ ? `\n⚠ READ-ONLY notice: this action fired state-changing requests — server state may have mutated despite ${this.mode} mode: ${list}${extra}. Consider whether this flow should be avoided or the environment confirmed disposable.`
855
908
  : `\n(state-changing requests: ${list}${extra})`;
856
909
  }
857
910
  /**
@@ -934,7 +987,10 @@ export class BrowserEngine {
934
987
  const forcedNote = forced
935
988
  ? `\nℹ NOTE: the strict click timed out waiting for this element to be the stable, unobstructed top hit at its coordinates, so a forced click was used instead (which still landed — this succeeded). Something is likely rendered on top of it (an icon, a decorative layer, an animating wrapper) or it delegates via a label; cross-check against any GEOMETRY overlap on this element before treating that as a real bug.`
936
989
  : "";
937
- if (submitLike && this.xhrCount === xhrBefore && page.url() === this.snapshotUrl) {
990
+ // Not when the write policy blocked the submission: a native form POST or a
991
+ // beacon is not counted as xhr/fetch, so an aborted one looks exactly like
992
+ // "fired nothing" — and the note would blame the app for the tool's block.
993
+ if (submitLike && this.xhrCount === xhrBefore && this.lastActionBlocked === 0 && page.url() === this.snapshotUrl) {
938
994
  return (result +
939
995
  `\nℹ NOTE: this submit-style click fired ZERO network requests and no navigation — if the UI showed success, the data may have been silently discarded (worth verifying; category: other/silent-failure).` +
940
996
  forcedNote);
@@ -1033,7 +1089,7 @@ export class BrowserEngine {
1033
1089
  .catch(() => ""));
1034
1090
  if (isDestructive(submitLabel)) {
1035
1091
  this.logAction({ action: "type:enter-refused", target: submitLabel, url: page.url() });
1036
- return `Filled ${el.role} "${el.name}" but did NOT press Enter. ` + destructiveRefusal(submitLabel);
1092
+ return `Filled ${el.role} "${el.name}" but did NOT press Enter. ` + destructiveRefusal(submitLabel, this.mode);
1037
1093
  }
1038
1094
  }
1039
1095
  await locator.press("Enter", { timeout: ACTION_TIMEOUT_MS });
@@ -1232,16 +1288,7 @@ export class BrowserEngine {
1232
1288
  const bodyAfter = (await page.evaluate(`document.body ? document.body.innerText : ""`).catch(() => null));
1233
1289
  if (bodyAfter === null)
1234
1290
  return { revealed: [], fallbackUsed: false };
1235
- const beforeLines = new Set(bodyBefore
1236
- .split("\n")
1237
- .map((l) => l.trim())
1238
- .filter(Boolean));
1239
- revealed = bodyAfter
1240
- .split("\n")
1241
- .map((l) => l.trim())
1242
- .filter((l) => l && !beforeLines.has(l))
1243
- .slice(0, 5)
1244
- .map((l) => l.slice(0, 300));
1291
+ revealed = revealedLines(bodyBefore, bodyAfter);
1245
1292
  return { revealed, fallbackUsed: revealed.length > 0 };
1246
1293
  }
1247
1294
  /** Pre-hover baselines: overlay texts, body text, and whether the page is already churning on its own. */
@@ -1307,7 +1354,7 @@ export class BrowserEngine {
1307
1354
  .catch(() => ""));
1308
1355
  if (isDestructive(value) || isDestructive(optionLabel)) {
1309
1356
  this.logAction({ action: "select:refused", target: optionLabel || value, url: page.url() });
1310
- return destructiveRefusal(optionLabel || value);
1357
+ return destructiveRefusal(optionLabel || value, this.mode);
1311
1358
  }
1312
1359
  }
1313
1360
  await page.locator(`xpath=${el.xpath}`).selectOption(value, { timeout: ACTION_TIMEOUT_MS });
@@ -1329,7 +1376,7 @@ export class BrowserEngine {
1329
1376
  .catch(() => "");
1330
1377
  if (typeof focusedLabel === "string" && isDestructive(focusedLabel)) {
1331
1378
  this.logAction({ action: "press:refused", target: focusedLabel, url: page.url() });
1332
- return destructiveRefusal(focusedLabel);
1379
+ return destructiveRefusal(focusedLabel, this.mode);
1333
1380
  }
1334
1381
  return null;
1335
1382
  }
@@ -1689,7 +1736,7 @@ export class BrowserEngine {
1689
1736
  const label = await liveLabel(loc);
1690
1737
  preLabel = label;
1691
1738
  if (this.readOnly && (step.action === "click" || step.action === "select" || step.action === "upload") && isDestructive(label, step.value)) {
1692
- transcript.push(`${desc} → ${destructiveRefusal(label || step.target)}`);
1739
+ transcript.push(`${desc} → ${destructiveRefusal(label || step.target, this.mode)}`);
1693
1740
  break;
1694
1741
  }
1695
1742
  if (step.action === "click")
@@ -1711,7 +1758,7 @@ export class BrowserEngine {
1711
1758
  const submit = loc.locator("xpath=ancestor::form[1]").locator('[type="submit"], button:not([type="button"]):not([type="reset"])').first();
1712
1759
  const submitLabel = (await submit.textContent({ timeout: 1000 }).catch(() => "")) ?? "";
1713
1760
  if (isDestructive(submitLabel)) {
1714
- transcript.push(`${desc} → filled, Enter withheld: ${destructiveRefusal(submitLabel.trim())}`);
1761
+ transcript.push(`${desc} → filled, Enter withheld: ${destructiveRefusal(submitLabel.trim(), this.mode)}`);
1715
1762
  break;
1716
1763
  }
1717
1764
  }
@@ -1878,15 +1925,18 @@ export class BrowserEngine {
1878
1925
  * On the first failure or timeout, reap orphaned Playwright processes and
1879
1926
  * try once more before giving up with a diagnosable error.
1880
1927
  */
1881
- async launchWithRecovery(headed) {
1928
+ async launchWithRecovery(engine, headed) {
1929
+ const types = { chromium, firefox, webkit };
1882
1930
  const attempt = async () => {
1883
1931
  // The marker is what makes reapOrphanBrowsers safe to run at startup:
1884
1932
  // it appears in the child's command line, so the sweep can tell a browser
1885
1933
  // WE leaked from one belonging to somebody else's Playwright run.
1886
1934
  // `--enable-features` takes arbitrary names and ignores unknown ones.
1887
- const launch = chromium.launch({
1935
+ // It is a Chromium switch: Firefox and WebKit are launched without it,
1936
+ // so a leaked one of those is not reaped and has to be closed by hand.
1937
+ const launch = types[engine].launch({
1888
1938
  headless: !headed,
1889
- args: [`--enable-features=${BROWSER_MARKER}`],
1939
+ args: engine === "chromium" ? [`--enable-features=${BROWSER_MARKER}`] : [],
1890
1940
  });
1891
1941
  let timer;
1892
1942
  try {
@@ -1913,13 +1963,13 @@ export class BrowserEngine {
1913
1963
  const firstMessage = firstErr instanceof Error ? firstErr.message : String(firstErr);
1914
1964
  // A browser that was never downloaded will not appear on a second try.
1915
1965
  if (isMissingBrowser(firstMessage))
1916
- throw new Error(explainLaunchFailure(firstMessage, 0));
1966
+ throw new Error(explainLaunchFailure(firstMessage, 0, { engine, headed }));
1917
1967
  const reaped = reapOrphanBrowsers();
1918
1968
  try {
1919
1969
  return await attempt();
1920
1970
  }
1921
1971
  catch {
1922
- throw new Error(explainLaunchFailure(firstMessage, reaped));
1972
+ throw new Error(explainLaunchFailure(firstMessage, reaped, { engine, headed }));
1923
1973
  }
1924
1974
  }
1925
1975
  }
@@ -18,6 +18,14 @@ export const VISIBLE_SRC = `(el) => {
18
18
  const style = window.getComputedStyle(el);
19
19
  return style.visibility !== "hidden" && style.display !== "none";
20
20
  }`;
21
+ /**
22
+ * Anything that plausibly presents as a modal/dialog panel. Deliberately wider
23
+ * than the ARIA set: a hand-rolled role-less modal must still count as "an
24
+ * overlay is up", or the scroll-lock oracle files a false leaked-lock finding
25
+ * against every healthy modal that locks the page behind it.
26
+ */
27
+ export const DIALOG_LIKE_SEL = '[role="dialog"], [role="alertdialog"], dialog[open], [aria-modal="true"], [class*="modal" i], [class*="dialog" i]';
28
+ // Declared above the collector script because that script interpolates it.
21
29
  /**
22
30
  * Page-side interactable collector. Shipped as a STRING, not a function:
23
31
  * loader transforms (tsx/vitest esbuild hooks inject a `__name` helper) break
@@ -61,6 +69,10 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
61
69
  if (named) return named.replace(/\\s+/g, " ").slice(0, 80);
62
70
  }
63
71
  const tag = el.tagName.toLowerCase();
72
+ // An image's name is its alt text. Without this an <img> read as
73
+ // "(unnamed)" even when it was labelled, and a missing alt looked the same
74
+ // as a present one.
75
+ if (tag === "img") return (el.getAttribute("alt") || "").trim().slice(0, 80);
64
76
  if (tag === "input" || tag === "textarea") {
65
77
  const id = el.getAttribute("id");
66
78
  if (id) {
@@ -119,6 +131,75 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
119
131
  }
120
132
  return { layer, chrome };
121
133
  };
134
+ // A pinned control (inside position:fixed/sticky chrome) whose centre is
135
+ // owned by ANOTHER piece of pinned chrome. Two pieces of chrome overlapping
136
+ // is usually intended layering, which is why the box-overlap oracle skips
137
+ // that pair — but boxes cannot tell which one is on top. A hit test can: if
138
+ // the point at the control's centre belongs to a different pinned element,
139
+ // a click aimed at the control lands on that element instead.
140
+ // Deliberately narrow, to stay quiet on intended layering:
141
+ // - only interactive controls, and only while their centre is in the viewport;
142
+ // - the control must be pinned with NO scrollable pane anywhere above it, or
143
+ // scrolling that pane would simply bring it out from under;
144
+ // - dialogs, menus, consent banners, toasts and anything covering half the
145
+ // viewport are overlays, not chrome.
146
+ const INTERACTIVE_ROLES = ["button", "link", "textbox", "combobox", "checkbox", "radio", "switch", "tab", "menuitem", "file"];
147
+ const isScroller = (n) => {
148
+ const cs = window.getComputedStyle(n);
149
+ return /(auto|scroll)/.test(cs.overflowY + cs.overflowX) && (n.scrollHeight > n.clientHeight + 1 || n.scrollWidth > n.clientWidth + 1);
150
+ };
151
+ /** Nearest fixed/sticky ancestor-or-self. */
152
+ const pinnedRootOf = (node) => {
153
+ for (let n = node; n && n !== document.documentElement; n = n.parentElement) {
154
+ const pos = window.getComputedStyle(n).position;
155
+ if (pos === "fixed" || pos === "sticky") return n;
156
+ }
157
+ return null;
158
+ };
159
+ /**
160
+ * Is there a scrollable pane anywhere between this node and the document?
161
+ * Checked over the WHOLE chain, above the pinned root as well as below it: a
162
+ * sticky first-column cell inside a scrolling grid is pinned within that
163
+ * grid, and scrolling the grid brings it out from under the sticky header.
164
+ * position:fixed escapes every ancestor's scrolling, so the walk stops there.
165
+ */
166
+ const insideScrollablePane = (node) => {
167
+ for (let n = node; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
168
+ if (n !== node && isScroller(n)) return true;
169
+ if (window.getComputedStyle(n).position === "fixed") return false;
170
+ }
171
+ return false;
172
+ };
173
+ // Pinned things that are overlays by nature, not layout: consent banners sit
174
+ // over everything until dismissed, toasts are gone in seconds. A click they
175
+ // intercept is real but it is not an app defect, and the consent case would
176
+ // otherwise fire on the first snapshot of nearly every site.
177
+ const TRANSIENT_SEL = '[role="alert"], [role="status"], [aria-live], [class*="toast" i], [class*="snackbar" i], [class*="cookie" i], [class*="consent" i], [id*="cookie" i], [id*="consent" i]';
178
+ const describe = (node) => {
179
+ const tid = node.getAttribute("data-testid");
180
+ if (tid) return "[" + tid + "]";
181
+ const text = (node.innerText || node.textContent || "").trim().replace(/\\s+/g, " ").slice(0, 40);
182
+ return "<" + node.tagName.toLowerCase() + ">" + (text ? ' "' + text + '"' : "");
183
+ };
184
+ const coveredByPinnedChrome = (el, rect, role) => {
185
+ if (INTERACTIVE_ROLES.indexOf(role) === -1) return null;
186
+ const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
187
+ if (cx < 0 || cy < 0 || cx >= window.innerWidth || cy >= window.innerHeight) return null;
188
+ const ownRoot = pinnedRootOf(el);
189
+ if (!ownRoot || insideScrollablePane(el)) return null;
190
+ const top = document.elementFromPoint(cx, cy);
191
+ if (!top || top === el || el.contains(top) || top.contains(el)) return null;
192
+ const coverRoot = pinnedRootOf(top);
193
+ if (!coverRoot || coverRoot === ownRoot || coverRoot.contains(ownRoot) || ownRoot.contains(coverRoot)) return null;
194
+ // A dialog's fixed wrapper often carries no role or class itself; the
195
+ // role="dialog" is on a child. Look both ways.
196
+ const OVERLAY_SEL = '${DIALOG_LIKE_SEL}, ' + TRANSIENT_SEL + ', [role="menu"], [role="listbox"], [role="tooltip"]';
197
+ if (coverRoot.closest(OVERLAY_SEL) || coverRoot.querySelector(OVERLAY_SEL) || top.closest(OVERLAY_SEL)) return null;
198
+ const cr = coverRoot.getBoundingClientRect();
199
+ if (cr.width * cr.height > window.innerWidth * window.innerHeight * 0.5) return null;
200
+ return describe(coverRoot);
201
+ };
202
+
122
203
  for (const el of Array.from(document.querySelectorAll(selector))) {
123
204
  if (seen.has(el) || !visible(el)) continue;
124
205
  seen.add(el);
@@ -136,6 +217,7 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
136
217
  // Its own role routes it to scout_upload instead.
137
218
  : inputType === "file" ? "file"
138
219
  : "textbox")
220
+ : tag === "img" ? "image"
139
221
  : "generic");
140
222
  const rect = el.getBoundingClientRect();
141
223
  // Below-the-fold is reachable (scroll); clipped INSIDE an overflow-hidden
@@ -171,6 +253,7 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
171
253
  }
172
254
  }
173
255
  out.push({
256
+ coveredBy: coveredByPinnedChrome(el, rect, role),
174
257
  tag,
175
258
  role,
176
259
  name: accessibleName(el),
@@ -193,13 +276,6 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
193
276
  }
194
277
  return out;
195
278
  })()`;
196
- /**
197
- * Anything that plausibly presents as a modal/dialog panel. Deliberately wider
198
- * than the ARIA set: a hand-rolled role-less modal must still count as "an
199
- * overlay is up", or the scroll-lock oracle files a false leaked-lock finding
200
- * against every healthy modal that locks the page behind it.
201
- */
202
- export const DIALOG_LIKE_SEL = '[role="dialog"], [role="alertdialog"], dialog[open], [aria-modal="true"], [class*="modal" i], [class*="dialog" i]';
203
279
  /**
204
280
  * Deterministic geometry oracles — the checks people reach for screenshots to
205
281
  * do, computed from layout boxes instead: interactables rendered fully outside
@@ -231,6 +307,20 @@ export function geometryIssues(elements, viewport) {
231
307
  if (clippedTotal > 3) {
232
308
  issues.push(`…and ${clippedTotal - 3} more controls clipped inside overflow-hidden ancestors`);
233
309
  }
310
+ // Pinned controls sitting underneath other pinned chrome (hit-tested in the
311
+ // page; see coveredByPinnedChrome). Reported before the box overlaps because
312
+ // an unclickable Save button outranks two badges touching.
313
+ let coveredTotal = 0;
314
+ for (const el of elements) {
315
+ if (!el.coveredBy)
316
+ continue;
317
+ coveredTotal += 1;
318
+ if (coveredTotal <= 3) {
319
+ issues.push(`${el.ref} ${el.role} "${el.name}" is COVERED by pinned chrome ${el.coveredBy} at this scroll position — a click aimed at it lands on that element instead`);
320
+ }
321
+ }
322
+ if (coveredTotal > 3)
323
+ issues.push(`…and ${coveredTotal - 3} more pinned controls covered by other pinned chrome`);
234
324
  const overlapArea = (a, b) => {
235
325
  const w = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
236
326
  const h = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
@@ -264,3 +354,54 @@ export function geometryIssues(elements, viewport) {
264
354
  }
265
355
  return issues;
266
356
  }
357
+ /**
358
+ * Images that failed to load, read from the DOM rather than from the network.
359
+ *
360
+ * A 404 on an image already shows up as an HTTP violation, but a broken image
361
+ * is not always a failed request: a 200 that returns an HTML error page, a
362
+ * truncated upload, a wrong content type or a blocked cross-origin file all
363
+ * respond successfully and still render as the browser's broken-image icon.
364
+ * `complete` with no intrinsic size is what "the browser gave up" looks like.
365
+ * SVGs are skipped: one without intrinsic dimensions reports 0×0 while
366
+ * rendering correctly.
367
+ */
368
+ export const BROKEN_IMAGES_SCRIPT = `(() => {
369
+ const images = [];
370
+ let total = 0;
371
+ for (const img of Array.from(document.images)) {
372
+ const src = img.currentSrc || img.getAttribute("src") || "";
373
+ if (!src || src.indexOf("data:") === 0 || /\\.svg(\\?|#|$)/i.test(src)) continue;
374
+ if (!img.complete || img.naturalWidth > 0 || img.naturalHeight > 0) continue;
375
+ // Visible means it occupies space. The box test is what catches an image
376
+ // inside a display:none ANCESTOR (a closed modal, an inactive tab): display
377
+ // is not inherited, so the image's own computed style still says "inline".
378
+ // It also drops tracking pixels, whose endpoint answers 204 by design.
379
+ const r = img.getBoundingClientRect();
380
+ if (r.width <= 2 || r.height <= 2) continue;
381
+ const cs = window.getComputedStyle(img);
382
+ if (cs.display === "none" || cs.visibility === "hidden") continue;
383
+ total += 1;
384
+ if (images.length < 20) images.push({ alt: (img.getAttribute("alt") || "").trim().slice(0, 80), src: src.slice(0, 160), testid: img.getAttribute("data-testid") });
385
+ }
386
+ return { images, total };
387
+ })()`;
388
+ /** Snapshot lines for images that failed to load. The origin is dropped when it is the page's own, to keep the line short. */
389
+ export function brokenImageIssues(scan, pageUrl) {
390
+ let origin = "";
391
+ try {
392
+ origin = new URL(pageUrl).origin;
393
+ }
394
+ catch {
395
+ /* an unparseable page URL just means the full src is shown */
396
+ }
397
+ // Only a real origin match: "http://x" is also a prefix of "http://x.other.test/a.png".
398
+ const short = (src) => (origin && (src === origin || src.startsWith(`${origin}/`)) ? src.slice(origin.length) || "/" : src);
399
+ const lines = scan.images.slice(0, 5).map((img) => {
400
+ const name = img.alt ? `"${img.alt}"` : "(no alt text)";
401
+ return `image ${name}${img.testid ? ` [testid=${img.testid}]` : ""} FAILED TO LOAD — ${short(img.src)}`;
402
+ });
403
+ const total = Math.max(scan.total, scan.images.length);
404
+ if (total > 5)
405
+ lines.push(`…and ${total - 5} more images that failed to load`);
406
+ return lines;
407
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The page-text fallback for hover: which text is new after the pointer moved.
3
+ *
4
+ * Pure, so the rule can be table-tested. The browser side only supplies the
5
+ * two `innerText` readings.
6
+ */
7
+ const squash = (s) => s.replace(/\s+/g, " ").trim();
8
+ /** How many times `needle` occurs in `haystack`, overlaps not counted. */
9
+ function occurrences(haystack, needle) {
10
+ let n = 0;
11
+ for (let at = haystack.indexOf(needle); at >= 0; at = haystack.indexOf(needle, at + needle.length))
12
+ n++;
13
+ return n;
14
+ }
15
+ /**
16
+ * Lines present after the hover whose text was not on the page before it.
17
+ *
18
+ * Comparing line by line is not enough. When something that was showing goes
19
+ * away — the previous hover's tooltip closing as the pointer leaves it — the
20
+ * text around it can re-flow onto a line of its own, and that line is "new"
21
+ * only as a line: every word of it was already visible. How text re-flows
22
+ * differs between browsers. So a line counts as revealed when its text occurs
23
+ * MORE often after the hover than before: text that only moved occurs as often
24
+ * as it did, while a tooltip reading "Delete" on a page that already says
25
+ * "Delete account" occurs once more and is still reported.
26
+ */
27
+ export function revealedLines(bodyBefore, bodyAfter, limit = 5) {
28
+ const beforeText = squash(bodyBefore);
29
+ const afterText = squash(bodyAfter);
30
+ const seen = new Set();
31
+ return bodyAfter
32
+ .split("\n")
33
+ .map(squash)
34
+ .filter((l) => {
35
+ if (!l || seen.has(l))
36
+ return false;
37
+ seen.add(l);
38
+ return occurrences(afterText, l) > occurrences(beforeText, l);
39
+ })
40
+ .slice(0, limit)
41
+ .map((l) => l.slice(0, 300));
42
+ }
@@ -2,21 +2,28 @@
2
2
  * Turning a failed browser launch into something the person can act on.
3
3
  *
4
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
5
+ * attach fails because the browser was never downloaded — and Playwright reports
6
6
  * that as a multi-line box of text with a path in it. That is the single most
7
7
  * likely first-run failure, so it gets one plain instruction instead.
8
8
  */
9
+ import { APPROX_DISK_MB, launchTarget } from "../browsers.js";
9
10
  /** Playwright's wording when the browser binary is not on disk. */
10
11
  const MISSING_BROWSER_RE = /Executable doesn't exist|playwright install|browserType\.launch:.*(not found|ENOENT)/i;
11
12
  export function isMissingBrowser(message) {
12
13
  return MISSING_BROWSER_RE.test(message);
13
14
  }
14
15
  /** 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
+ export function explainLaunchFailure(message, reaped, need = { engine: "chromium", headed: false }) {
16
17
  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`);
18
+ const target = launchTarget(need.engine, need.headed);
19
+ // Someone who installed only the headless shell did run install, so say what is different about a headed run.
20
+ const why = target === "chromium" && need.headed
21
+ ? "A headed run needs the full Chromium browser, which has not been downloaded (the headless shell alone cannot open a window)"
22
+ : `The ${target} build has not been downloaded yet`;
23
+ return (`${why} (one-time, about ${APPROX_DISK_MB[target]} MB on disk). Run this once, then attach again:\n` +
24
+ ` npx -y scenescout install --browser-only --browsers ${target}\n` +
25
+ `(from a clone: node dist/cli.js install --browser-only --browsers ${target}). ` +
26
+ `On Linux, if system libraries are missing: npx playwright install --with-deps ${target}`);
20
27
  }
21
28
  const firstLine = message.split("\n")[0];
22
29
  return (`browser launch failed twice (${firstLine})` +