scenescout 1.0.0 → 1.1.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.
@@ -4,7 +4,7 @@ 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";
@@ -12,7 +12,7 @@ import { explainLaunchFailure, isMissingBrowser } from "./launch.js";
12
12
  import { ACTION_TIMEOUT_MS, performScroll, probeFocusIndicators, probeOverlays, scrollContainer } from "./probes.js";
13
13
  import { BROWSER_MARKER, reapOrphanBrowsers } from "./reaper.js";
14
14
  import { planUploadOptions, resolveDiskUpload } from "./uploads.js";
15
- import { AUTH_FLOW_RE, destructiveRefusal, isDestructive, isDestructiveWire } from "./policy.js";
15
+ import { destructiveRefusal, isDestructive, isDestructiveWire, allowsWrite, isAuthExempt } from "./policy.js";
16
16
  import { scanProject } from "../scan.js";
17
17
  import { analyzeDesign, DESIGN_COLLECT_SCRIPT } from "./design.js";
18
18
  import { acceptMatches, generatedUpload } from "./fixtures.js";
@@ -125,7 +125,8 @@ export class BrowserEngine {
125
125
  authLoss = new AuthLossTracker();
126
126
  /** UI-label blocking applies only in read-only mode (safe-write enforces at the network layer instead). */
127
127
  get readOnly() {
128
- return this.mode === "read-only";
128
+ // observe is read-only and then some: every UI-level refusal applies to it too.
129
+ return this.mode === "read-only" || this.mode === "observe";
129
130
  }
130
131
  /** Append to the shared action log, stamped with THIS session so per-session
131
132
  * reads (journey paths) can separate concurrent roles' interleaved actions. */
@@ -134,6 +135,14 @@ export class BrowserEngine {
134
135
  }
135
136
  /** Requests blocked by the write policy since the last action (timestamped for attribution). */
136
137
  blockedRequests = [];
138
+ /**
139
+ * WebSockets this session's pages opened. The write policy works on HTTP
140
+ * requests; frames sent over a socket are not inspected. In observe mode that
141
+ * is a hole in "nothing leaves the page", so it is said out loud rather than
142
+ * left for the reader to discover.
143
+ */
144
+ openSockets = new Set();
145
+ socketsWarned = false;
137
146
  /** When the current action began — requests recorded before this are late arrivals from a previous action. */
138
147
  actionStartedAt = 0;
139
148
  /**
@@ -299,6 +308,13 @@ export class BrowserEngine {
299
308
  // Label-based read-only blocking can't catch every mutation (an innocuous
300
309
  // "Add to Cart" fires a POST). Track non-GET traffic so actions that
301
310
  // changed server state are at least REPORTED in read-only runs.
311
+ this.openSockets.clear();
312
+ this.socketsWarned = false;
313
+ const watchSockets = (p) => void p.on("websocket", (ws) => this.openSockets.add(ws.url().slice(0, 120)));
314
+ // The first page already exists by now; later ones (popups) arrive as events.
315
+ if (this.page)
316
+ watchSockets(this.page);
317
+ this.context.on("page", watchSockets);
302
318
  this.context.on("request", (req) => {
303
319
  const type = req.resourceType();
304
320
  if (type === "xhr" || type === "fetch")
@@ -321,6 +337,9 @@ export class BrowserEngine {
321
337
  // here let a REFUSED destructive POST mark the route as mutated — a form
322
338
  // that was never submitted reading as tested, in read-only mode where by
323
339
  // definition nothing is.
340
+ // Same test the route handler uses: in observe only an exempt auth request goes out.
341
+ if (this.mode === "observe" && !isAuthExempt(this.mode, method, pathnameOf(req.url()), isDestructiveWire(pathnameOf(req.url()), req.postData())))
342
+ return;
324
343
  if (this.readOnly && isDestructiveWire(pathnameOf(req.url()), req.postData()))
325
344
  return;
326
345
  const pageUrl = this.page?.url();
@@ -342,10 +361,11 @@ export class BrowserEngine {
342
361
  return route.continue();
343
362
  const url = req.url();
344
363
  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
364
  const destructiveWire = isDestructiveWire(pathname, req.postData());
365
+ // Auth/session flows must work in every mode — but never a destructive
366
+ // one, and in observe only the requests a login itself needs.
367
+ if (isAuthExempt(this.mode, method, pathname, destructiveWire))
368
+ return route.continue();
349
369
  let owned = this.isOwnedResource(pathname);
350
370
  // A single UI action commonly fires create-then-immediately-save
351
371
  // (POST gets an id, PUT saves content under it) faster than the
@@ -362,7 +382,7 @@ export class BrowserEngine {
362
382
  }
363
383
  // POST: creation/RPC passes unless it smells destructive and isn't ours.
364
384
  // PUT/PATCH/DELETE: only in safe-write, only on our own resources.
365
- const allow = method === "POST" ? !destructiveWire || owned : this.mode === "safe-write" && owned;
385
+ const allow = allowsWrite(this.mode, method, destructiveWire, owned);
366
386
  if (allow) {
367
387
  // Ownership tracking (safe-write): register the creation-tracking
368
388
  // task BEFORE the POST goes out. Registering from a context
@@ -668,6 +688,7 @@ export class BrowserEngine {
668
688
  const geometry = geometryIssues(elements, page.viewportSize() ?? { width: 1280, height: 900 });
669
689
  geometry.push(...(await probeOverlays(page)));
670
690
  const hiddenFileInputs = await this.hiddenFileInputs(page);
691
+ const brokenImages = brokenImageIssues((await page.evaluate(BROKEN_IMAGES_SCRIPT).catch(() => null)) ?? { images: [], total: 0 }, url);
671
692
  const cov = memory.coverage();
672
693
  const unvisited = this.unvisitedKnownRoutes();
673
694
  const title = await page.title();
@@ -677,10 +698,12 @@ export class BrowserEngine {
677
698
  `\n` +
678
699
  body +
679
700
  (geometry.length > 0 ? `\nGEOMETRY issues:\n` + geometry.map((g) => ` ⚠ ${g}`).join("\n") : "") +
701
+ (brokenImages.length > 0 ? `\nBROKEN IMAGES:\n` + brokenImages.map((b) => ` ⚠ ${b}`).join("\n") : "") +
680
702
  (hiddenFileInputs.length > 0
681
703
  ? `\nFILE INPUTS not listed above (hidden behind a styled control — a user never sees the input itself): ${hiddenFileInputs.join("; ")}. ` +
682
704
  `scout_upload {ref} on the control that opens one, or scout_upload {} when it is the page's only file input.`
683
705
  : "") +
706
+ this.socketNotice() +
684
707
  formatViolations(this.oracles.drain()) +
685
708
  (elements.length === 0 ? "\n⚠ DEAD END: no interactable elements found on this page." : ""));
686
709
  }
@@ -732,7 +755,7 @@ export class BrowserEngine {
732
755
  if (el.role === "textbox" || el.role === "file")
733
756
  return null;
734
757
  if (el.destructive || isDestructive(liveLabel)) {
735
- return destructiveRefusal(liveLabel || el.name || el.testid || el.ref);
758
+ return destructiveRefusal(liveLabel || el.name || el.testid || el.ref, this.mode);
736
759
  }
737
760
  return null;
738
761
  }
@@ -741,13 +764,22 @@ export class BrowserEngine {
741
764
  await this.settle();
742
765
  let url = page.url();
743
766
  if (url !== "about:blank" && !this.isSameOrigin(url)) {
744
- // A click carried us off the app's origin — bounce back and say so.
767
+ // Either a click carried us off the app's origin, or the write policy
768
+ // aborted a NAVIGATION (a native form post) and the browser is showing
769
+ // its error page. The second is the tester's own doing and must say so:
770
+ // reported as an off-origin bounce, it hid the block, and the caller then
771
+ // read the unchanged URL as "the app silently discarded the data".
772
+ const policyAbortedNavigation = url.startsWith("chrome-error://") && this.blockedRequests.length > 0;
745
773
  this.logAction({ action, target, url });
746
- this.logAction({ action: "origin-fence:bounced", target: url.slice(0, 200), url });
774
+ this.logAction({ action: policyAbortedNavigation ? "write-policy:navigation-blocked" : "origin-fence:bounced", target: url.slice(0, 200), url });
747
775
  await page.goBack({ waitUntil: "domcontentloaded", timeout: 10000 }).catch(() => { });
748
776
  url = page.url();
749
777
  this.refs.clear();
750
- return (`OK: ${action} ${target}\nNavigated off-origin and was bounced back to ${url}. Exploration is fenced to ${this.baseUrl}.` +
778
+ const blocked = this.drainBlocked();
779
+ return ((policyAbortedNavigation
780
+ ? `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}.`
781
+ : `OK: ${action} ${target}\nNavigated off-origin and was bounced back to ${url}. Exploration is fenced to ${this.baseUrl}.`) +
782
+ blocked +
751
783
  formatViolations(this.oracles.drain()));
752
784
  }
753
785
  this.logAction({ action, target, url });
@@ -821,9 +853,20 @@ export class BrowserEngine {
821
853
  this.blockedRequests = [];
822
854
  return (`\n🛡 WRITE-POLICY blocked (${this.mode}): ${list}${extra}. ` +
823
855
  `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).`));
856
+ (this.mode === "observe"
857
+ ? `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.`
858
+ : this.mode === "read-only"
859
+ ? `Re-attach with mode="safe-write" to test create/edit flows, or "destructive" (user-approved disposable env only).`
860
+ : `In safe-write, updates/deletes are only allowed on resources this session created (${this.createdResources.length} so far).`));
861
+ }
862
+ /** Once per session, in observe mode only: say that socket frames are outside the policy. */
863
+ socketNotice() {
864
+ if (this.mode !== "observe" || this.socketsWarned || this.openSockets.size === 0)
865
+ return "";
866
+ this.socketsWarned = true;
867
+ return (`\n⚠ OBSERVE LIMIT: this app holds an open WebSocket (${[...this.openSockets].slice(0, 2).join(", ")}). ` +
868
+ `The write policy blocks HTTP requests; frames sent over a socket are NOT inspected. ` +
869
+ `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
870
  }
828
871
  /** xhr/fetch requests seen this session — lets clicks detect silent no-op submits. */
829
872
  xhrCount = 0;
@@ -851,7 +894,7 @@ export class BrowserEngine {
851
894
  .join("; ");
852
895
  const extra = fresh.length > 5 ? ` (+${fresh.length - 5} more)` : "";
853
896
  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.`
897
+ ? `\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
898
  : `\n(state-changing requests: ${list}${extra})`;
856
899
  }
857
900
  /**
@@ -934,7 +977,10 @@ export class BrowserEngine {
934
977
  const forcedNote = forced
935
978
  ? `\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
979
  : "";
937
- if (submitLike && this.xhrCount === xhrBefore && page.url() === this.snapshotUrl) {
980
+ // Not when the write policy blocked the submission: a native form POST or a
981
+ // beacon is not counted as xhr/fetch, so an aborted one looks exactly like
982
+ // "fired nothing" — and the note would blame the app for the tool's block.
983
+ if (submitLike && this.xhrCount === xhrBefore && this.lastActionBlocked === 0 && page.url() === this.snapshotUrl) {
938
984
  return (result +
939
985
  `\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
986
  forcedNote);
@@ -1033,7 +1079,7 @@ export class BrowserEngine {
1033
1079
  .catch(() => ""));
1034
1080
  if (isDestructive(submitLabel)) {
1035
1081
  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);
1082
+ return `Filled ${el.role} "${el.name}" but did NOT press Enter. ` + destructiveRefusal(submitLabel, this.mode);
1037
1083
  }
1038
1084
  }
1039
1085
  await locator.press("Enter", { timeout: ACTION_TIMEOUT_MS });
@@ -1307,7 +1353,7 @@ export class BrowserEngine {
1307
1353
  .catch(() => ""));
1308
1354
  if (isDestructive(value) || isDestructive(optionLabel)) {
1309
1355
  this.logAction({ action: "select:refused", target: optionLabel || value, url: page.url() });
1310
- return destructiveRefusal(optionLabel || value);
1356
+ return destructiveRefusal(optionLabel || value, this.mode);
1311
1357
  }
1312
1358
  }
1313
1359
  await page.locator(`xpath=${el.xpath}`).selectOption(value, { timeout: ACTION_TIMEOUT_MS });
@@ -1329,7 +1375,7 @@ export class BrowserEngine {
1329
1375
  .catch(() => "");
1330
1376
  if (typeof focusedLabel === "string" && isDestructive(focusedLabel)) {
1331
1377
  this.logAction({ action: "press:refused", target: focusedLabel, url: page.url() });
1332
- return destructiveRefusal(focusedLabel);
1378
+ return destructiveRefusal(focusedLabel, this.mode);
1333
1379
  }
1334
1380
  return null;
1335
1381
  }
@@ -1689,7 +1735,7 @@ export class BrowserEngine {
1689
1735
  const label = await liveLabel(loc);
1690
1736
  preLabel = label;
1691
1737
  if (this.readOnly && (step.action === "click" || step.action === "select" || step.action === "upload") && isDestructive(label, step.value)) {
1692
- transcript.push(`${desc} → ${destructiveRefusal(label || step.target)}`);
1738
+ transcript.push(`${desc} → ${destructiveRefusal(label || step.target, this.mode)}`);
1693
1739
  break;
1694
1740
  }
1695
1741
  if (step.action === "click")
@@ -1711,7 +1757,7 @@ export class BrowserEngine {
1711
1757
  const submit = loc.locator("xpath=ancestor::form[1]").locator('[type="submit"], button:not([type="button"]):not([type="reset"])').first();
1712
1758
  const submitLabel = (await submit.textContent({ timeout: 1000 }).catch(() => "")) ?? "";
1713
1759
  if (isDestructive(submitLabel)) {
1714
- transcript.push(`${desc} → filled, Enter withheld: ${destructiveRefusal(submitLabel.trim())}`);
1760
+ transcript.push(`${desc} → filled, Enter withheld: ${destructiveRefusal(submitLabel.trim(), this.mode)}`);
1715
1761
  break;
1716
1762
  }
1717
1763
  }
@@ -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
+ }
@@ -77,8 +77,60 @@ export const AUTH_FLOW_RE = /\/(auth|login|logout|signin|sign-in|signup|sign-up|
77
77
  export function isDestructive(...labels) {
78
78
  return labels.some((label) => typeof label === "string" && label.length > 0 && DESTRUCTIVE_PATTERNS.some((re) => re.test(label)));
79
79
  }
80
- export function destructiveRefusal(label) {
81
- return (`REFUSED by read-only policy: "${label}" matches a destructive-action pattern. ` +
82
- `This run is read-only; do not attempt this element again. If destructive flows must be tested, ` +
80
+ export function destructiveRefusal(label, mode = "read-only") {
81
+ return (`REFUSED by ${mode} policy: "${label}" matches a destructive-action pattern. ` +
82
+ `This run is ${mode}; do not attempt this element again. If destructive flows must be tested, ` +
83
83
  `the user has to re-attach with mode="destructive" against a disposable/seeded environment.`);
84
84
  }
85
+ export const WRITE_MODES = ["observe", "read-only", "safe-write", "destructive"];
86
+ /**
87
+ * May this non-GET request leave the page? Auth-flow requests are let through
88
+ * before this is asked. `owned` means the request addresses a record this run
89
+ * created (always false outside safe-write, where nothing is tracked).
90
+ */
91
+ /**
92
+ * In observe mode, the only auth requests let through are the ones a session
93
+ * needs in order to exist: logging in, logging out, refreshing a token. Whole
94
+ * path SEGMENTS, never substrings — `/users/login-history/clear` and
95
+ * `/api/tokens` (mint an API token) are not logins — and `session(s)` only as
96
+ * the last segment, where a POST means "log in", not "act on session 123".
97
+ */
98
+ const OBSERVE_AUTH_SEGMENT_RE = /^(login|log-in|signin|sign-in|logout|log-out|signout|sign-out|refresh|token|oauth|oauth2|sso|callback|authorize|authenticate)$/i;
99
+ /**
100
+ * Is this request an auth flow that must work even though the mode would
101
+ * otherwise block it?
102
+ *
103
+ * Never for a destructive-looking request, in any mode: the exemption used to
104
+ * be tested first, so `POST /api/session/123/delete` went through in read-only
105
+ * because its path contains "session".
106
+ *
107
+ * In observe mode the exemption is much narrower than elsewhere. Signing up,
108
+ * changing or resetting a password, verifying an email and creating a user all
109
+ * change data on the target, and observe promises that nothing is created.
110
+ */
111
+ export function isAuthExempt(mode, method, pathname, destructiveWire) {
112
+ if (method !== "POST" || destructiveWire)
113
+ return false;
114
+ if (mode !== "observe")
115
+ return AUTH_FLOW_RE.test(pathname);
116
+ const segments = pathname.split("/").filter(Boolean);
117
+ if (segments.length === 0)
118
+ return false;
119
+ const last = segments[segments.length - 1];
120
+ if (/^sessions?$/i.test(last))
121
+ return true;
122
+ // The matching segment must be at, or next to, the end: /auth/token/refresh, /oauth/token, /login.
123
+ return (segments.slice(-2).some((seg) => OBSERVE_AUTH_SEGMENT_RE.test(seg)) &&
124
+ !/^(users?|accounts?|members?|password|signup|sign-up|register|verify|invite|invitations?)$/i.test(last));
125
+ }
126
+ export function allowsWrite(mode, method, destructiveWire, owned) {
127
+ if (mode === "destructive")
128
+ return true;
129
+ if (mode === "observe")
130
+ return false;
131
+ // POST: creation/RPC passes unless it looks destructive and is not ours.
132
+ if (method === "POST")
133
+ return !destructiveWire || owned;
134
+ // PUT/PATCH/DELETE: only in safe-write, only on this run's own records.
135
+ return mode === "safe-write" && owned;
136
+ }
@@ -289,7 +289,12 @@ export function computeGaps(memory, extras) {
289
289
  // and let a mutation anywhere in a wizard clear its sibling steps.
290
290
  const { unsubmitted } = classifyFilledStates(memory, facts);
291
291
  if (unsubmitted.length > 0) {
292
- gaps.push(`${unsubmitted.length} route(s) had a form filled but NEVER submitted (no state-changing request left the page): ${unsubmitted.slice(0, 8).join(", ")}${unsubmitted.length > 8 ? " …" : ""}`);
292
+ gaps.push(`${unsubmitted.length} route(s) had a form filled but NEVER submitted (no state-changing request left the page): ${unsubmitted.slice(0, 8).join(", ")}${unsubmitted.length > 8 ? " …" : ""}` +
293
+ // In observe mode this is the mode working, not the run falling short —
294
+ // but it is still untested surface, so it stays in the ledger, explained.
295
+ (extras?.mode === "observe"
296
+ ? ` — expected in observe mode, which blocks every form submission by design; what the server does with these forms is untested. Cover them in read-only mode against an environment where creating records is acceptable.`
297
+ : ""));
293
298
  }
294
299
  const journeyTotal = Object.values(facts).reduce((a, f) => a + (f.journeysCompleted ?? 0), 0);
295
300
  if (journeyTotal === 0) {
package/dist/installer.js CHANGED
@@ -243,24 +243,36 @@ export function parseRegistration(listing) {
243
243
  const field = (name) => new RegExp(`^[ \\t]*${name}:[ \\t]*(\\S.*?)[ \\t]*$`, "m").exec(listing)?.[1] ?? null;
244
244
  return { command: field("Command"), serverPath: field("Args") };
245
245
  }
246
+ /**
247
+ * The command that repairs a setup, for THIS kind of install. A source checkout
248
+ * has `npm run setup`; someone who installed from npm has no such script, and
249
+ * telling them to run it sends them looking for a package.json they never had.
250
+ */
251
+ export function repairCommands(packageRoot) {
252
+ const isCheckout = fs.existsSync(path.join(packageRoot, "tsconfig.json")) && fs.existsSync(path.join(packageRoot, "src"));
253
+ return isCheckout
254
+ ? { setup: "npm run setup", build: "npm run build" }
255
+ : { setup: "npx -y scenescout install", build: "npx -y scenescout@latest install (the installed package is incomplete; fetch it again)" };
256
+ }
246
257
  /** Everything a working setup needs, each with the command that repairs it. */
247
258
  export function diagnose(opts) {
248
259
  const checks = [];
260
+ const repair = repairCommands(opts.packageRoot);
249
261
  const major = Number(opts.nodeVersion.replace(/^v/, "").split(".")[0]);
250
262
  checks.push({ name: "node >= 20", ok: major >= 20, detail: opts.nodeVersion, fix: "install Node 20 or newer" });
251
263
  const server = path.join(opts.packageRoot, "dist", "mcp-server.js");
252
- checks.push({ name: "engine built", ok: fs.existsSync(server), detail: server, fix: "npm run build" });
264
+ checks.push({ name: "engine built", ok: fs.existsSync(server), detail: server, fix: repair.build });
253
265
  const chromiumOk = !!opts.chromiumPath && fs.existsSync(opts.chromiumPath);
254
266
  checks.push({
255
267
  name: "chromium downloaded",
256
268
  ok: chromiumOk,
257
269
  detail: opts.chromiumPath ?? "playwright could not name a browser path",
258
- fix: "npm run setup (or: npx playwright install chromium)",
270
+ fix: `${repair.setup} (or: npx playwright install chromium)`,
259
271
  });
260
272
  if (opts.scope === "engine")
261
273
  return checks;
262
274
  const skill = path.join(opts.claudeDir, "skills", SKILL_NAME, "SKILL.md");
263
- checks.push({ name: "skill installed", ok: fs.existsSync(skill), detail: skill, fix: "npm run setup" });
275
+ checks.push({ name: "skill installed", ok: fs.existsSync(skill), detail: skill, fix: repair.setup });
264
276
  const got = opts.run("claude", ["mcp", "get", MCP_NAME]);
265
277
  if (got.missing) {
266
278
  checks.push({
@@ -273,7 +285,7 @@ export function diagnose(opts) {
273
285
  else {
274
286
  const listing = got.stdout + got.stderr;
275
287
  if (got.status !== 0) {
276
- checks.push({ name: "MCP server registered", ok: false, detail: "no server named scenescout", fix: "npm run setup" });
288
+ checks.push({ name: "MCP server registered", ok: false, detail: "no server named scenescout", fix: repair.setup });
277
289
  }
278
290
  else {
279
291
  const { command, serverPath } = parseRegistration(listing);
@@ -289,12 +301,12 @@ export function diagnose(opts) {
289
301
  name: "MCP server registered",
290
302
  ok: absolute,
291
303
  detail: absolute ? `via ${command} ${serverPath}` : `registered with a bare \`${command}\` command, which Claude Code may not find on its PATH`,
292
- fix: "scenescout install",
304
+ fix: repair.setup,
293
305
  });
294
306
  }
295
307
  else if (!samePath(serverPath, server)) {
296
308
  // The usual aftermath of moving or deleting a checkout.
297
- checks.push({ name: "MCP server registered", ok: false, detail: `registered, but pointing at ${serverPath} — not this install`, fix: "npm run setup" });
309
+ checks.push({ name: "MCP server registered", ok: false, detail: `registered, but pointing at ${serverPath} — not this install`, fix: repair.setup });
298
310
  }
299
311
  else if (command !== null && !path.isAbsolute(command)) {
300
312
  // A bare "node" resolves in your shell and then fails inside Claude
@@ -303,7 +315,7 @@ export function diagnose(opts) {
303
315
  name: "MCP server registered",
304
316
  ok: false,
305
317
  detail: `registered with a bare \`${command}\` command, which Claude Code may not find on its PATH`,
306
- fix: "npm run setup",
318
+ fix: repair.setup,
307
319
  });
308
320
  }
309
321
  else {
@@ -179,13 +179,13 @@ server.registerTool("scout_scan", {
179
179
  }
180
180
  }));
181
181
  server.registerTool("scout_attach", {
182
- description: "Launch a browser and attach to a running web app. Write policy is enforced at the NETWORK layer: mode='read-only' (default) blocks destructive-labeled elements AND all PUT/PATCH/DELETE + destructive POSTs; mode='safe-write' allows creating data and permits updates/deletes ONLY on resources this session created (use when the user wants create/edit flows tested); mode='destructive' allows everything — ONLY when the user explicitly confirmed a disposable/seeded environment. Pass a Playwright storage-state JSON to explore as an authenticated role. Pass `session` to keep MULTIPLE roles alive at once (one browser each, genuinely concurrent) for collaboration testing — target each directly with every tool's `session` param, or use scout_session to set which one is the default; coverage and findings merge into one project memory.",
182
+ description: "Launch a browser and attach to a running web app. Write policy is enforced at the NETWORK layer: mode='observe' blocks EVERY request that is not a GET (login and token refresh excepted) — choose it for a target that holds real data, where even an ordinary form submission would create a record; mode='read-only' (default) blocks destructive-labeled elements AND all PUT/PATCH/DELETE + destructive POSTs, but lets ordinary form POSTs through; mode='safe-write' allows creating data and permits updates/deletes ONLY on resources this session created (use when the user wants create/edit flows tested); mode='destructive' allows everything — ONLY when the user explicitly confirmed a disposable/seeded environment. Pass a Playwright storage-state JSON to explore as an authenticated role. Pass `session` to keep MULTIPLE roles alive at once (one browser each, genuinely concurrent) for collaboration testing — target each directly with every tool's `session` param, or use scout_session to set which one is the default; coverage and findings merge into one project memory.",
183
183
  inputSchema: {
184
184
  url: z.string().describe("Base URL of the running app, e.g. http://localhost:3000"),
185
185
  projectPath: z.string().describe("Absolute path to the project (memory + report live in .scenescout/ here)"),
186
186
  storageStatePath: z.string().optional().describe("Optional Playwright storage-state JSON path for authenticated exploration"),
187
187
  mode: z
188
- .enum(["read-only", "safe-write", "destructive"])
188
+ .enum(["observe", "read-only", "safe-write", "destructive"])
189
189
  .default("read-only")
190
190
  .describe("Write policy (see tool description). Never choose 'destructive' yourself — user opt-in only."),
191
191
  headed: z.boolean().default(false).describe("Show the browser window"),
@@ -563,7 +563,7 @@ server.registerTool("scout_note", {
563
563
  }
564
564
  }));
565
565
  server.registerTool("scout_screenshot", {
566
- description: "Take a JPEG screenshot of the current viewport. LAST RESORT: geometry issues are in scout_snapshot and style/contrast/spacing issues are in scout_design_audit — use a screenshot only for pixel-native content (broken images, canvas, visual gestalt) that computed data cannot capture.",
566
+ description: "Take a JPEG screenshot of the current viewport. LAST RESORT: geometry issues are in scout_snapshot and style/contrast/spacing issues are in scout_design_audit — images that failed to load are listed in scout_snapshot under BROKEN IMAGES — use a screenshot only for pixel-native content (a canvas, visual gestalt) that computed data cannot capture.",
567
567
  inputSchema: { session: sessionParam },
568
568
  }, serializedPerSession("scout_screenshot", async (_args, session) => {
569
569
  try {
@@ -702,9 +702,13 @@ server.registerTool("scout_report", {
702
702
  routesTotal: all.length,
703
703
  designAudits: eng.designAuditCount,
704
704
  unvisitedRoutes: unvisited,
705
+ mode: eng.mode,
705
706
  });
706
707
  if (lvl === "extensive" && gapList.length > 0) {
707
708
  gates.push(`Level 'extensive' claims completeness, so it refuses while the GAP LEDGER is non-empty:\n` +
709
+ (eng.mode === "observe"
710
+ ? `(observe mode blocks every form submission, so the unsubmitted-forms gap cannot be closed in this mode: report at level 'medium', which discloses it.)\n`
711
+ : "") +
708
712
  gapList.map((g) => ` ⚠ ${g}`).join("\n") +
709
713
  `\nClose the gaps (or report at level 'medium', which discloses them instead).`);
710
714
  }
@@ -718,6 +722,7 @@ server.registerTool("scout_report", {
718
722
  designAudits: eng.designAuditCount,
719
723
  createdResources: eng.createdResources,
720
724
  unvisitedRoutes: unvisited,
725
+ mode: eng.mode,
721
726
  policyAttributed: eng.oracleLog.policyAttributed,
722
727
  });
723
728
  void p;