scenescout 3.17.0 → 3.19.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.
@@ -7,9 +7,11 @@ import { addReading, addVerdict, MAX_TICKETS_KEPT, mergeTicketData } from "./tic
7
7
  /**
8
8
  * The element-name rule current keys are made under. 2: a link or button with
9
9
  * no text is named by its image content (an <img>'s alt text, an <svg>'s
10
- * aria-label or <title>) before its title attribute.
10
+ * aria-label or <title>) before its title attribute. 3: that content also
11
+ * counts a descendant's aria-label and an <img>'s title when its alt is empty
12
+ * (collector.ts CONTENT_SAYS_SRC).
11
13
  */
12
- export const NAME_RULE = 2;
14
+ export const NAME_RULE = 3;
13
15
  /**
14
16
  * The kinds a finding can be. One list: scout_finding's input schema, the lane
15
17
  * report a parallel agent hands back, and the skill text all read it from
@@ -278,6 +280,20 @@ export function rekeyed(elements, aliases) {
278
280
  }
279
281
  return out;
280
282
  }
283
+ /** A route's aliases for the step that leads to `rule`: key under rule - 1 → key under rule. */
284
+ function stepAliases(data, rule, route) {
285
+ return rule === 2 ? data.keyAliases?.[route] : data.ruleKeyAliases?.[String(rule)]?.[route];
286
+ }
287
+ /**
288
+ * A state's elements moved from the keys of name rule `from` to those of rule
289
+ * `to`, one step at a time, each through the route's aliases for that step.
290
+ */
291
+ function keysUnderRule(elements, from, to, data, route) {
292
+ let out = elements;
293
+ for (let rule = from + 1; rule <= to; rule++)
294
+ out = rekeyed(out, stepAliases(data, rule, route));
295
+ return out;
296
+ }
281
297
  /**
282
298
  * Fold another process's memory into ours, losing nothing from either side.
283
299
  *
@@ -300,6 +316,15 @@ export function mergeMemory(mine, theirs) {
300
316
  }
301
317
  if (Object.keys(out.keyAliases).length === 0)
302
318
  delete out.keyAliases;
319
+ out.ruleKeyAliases = {};
320
+ for (const rule of new Set([...Object.keys(theirs.ruleKeyAliases ?? {}), ...Object.keys(mine.ruleKeyAliases ?? {})])) {
321
+ const step = { ...(theirs.ruleKeyAliases?.[rule] ?? {}) };
322
+ for (const [route, aliases] of Object.entries(mine.ruleKeyAliases?.[rule] ?? {}))
323
+ step[route] = { ...aliases, ...(step[route] ?? {}) };
324
+ out.ruleKeyAliases[rule] = step;
325
+ }
326
+ if (Object.keys(out.ruleKeyAliases).length === 0)
327
+ delete out.ruleKeyAliases;
303
328
  out.states = { ...theirs.states };
304
329
  for (const [fp, ours] of Object.entries(mine.states)) {
305
330
  const other = theirs.states[fp];
@@ -308,10 +333,10 @@ export function mergeMemory(mine, theirs) {
308
333
  continue;
309
334
  }
310
335
  const nameRule = Math.max(ours.nameRule ?? 0, other.nameRule ?? 0);
311
- // When one side is under the current name rule and the other is not, the
312
- // other's keys are moved to current ones before the union, as a revisit
313
- // moves them (MemoryStore visitState).
314
- const current = (side) => nameRule >= NAME_RULE && (side.nameRule ?? 1) < NAME_RULE ? rekeyed(side.elements, out.keyAliases?.[routeIdentity(side.route)]) : side.elements;
336
+ // When one side is under a later name rule than the other, the other's
337
+ // keys are moved to that rule's before the union, as a revisit moves them
338
+ // (MemoryStore visitState).
339
+ const current = (side) => keysUnderRule(side.elements, side.nameRule ?? 1, Math.max(nameRule, 1), out, routeIdentity(side.route));
315
340
  const elements = { ...current(other) };
316
341
  for (const [key, el] of Object.entries(current(ours))) {
317
342
  const prev = elements[key];
@@ -384,6 +409,7 @@ export function mergeMemory(mine, theirs) {
384
409
  const unionRecord = (a, b) => (a || b ? { ...(b ?? {}), ...(a ?? {}) } : undefined);
385
410
  out.discoveredRoutes = unionRecord(mine.discoveredRoutes, theirs.discoveredRoutes);
386
411
  out.attemptedRoutes = unionRecord(mine.attemptedRoutes, theirs.attemptedRoutes);
412
+ out.resourceRoutes = unionRecord(mine.resourceRoutes, theirs.resourceRoutes);
387
413
  out.designElements = unionRecord(mine.designElements, theirs.designElements);
388
414
  out.pageScores = { ...(theirs.pageScores ?? {}) };
389
415
  for (const [route, score] of Object.entries(mine.pageScores ?? {})) {
@@ -1289,6 +1315,31 @@ export class MemoryStore {
1289
1315
  get discoveredRoutes() {
1290
1316
  return this.data.discoveredRoutes ?? {};
1291
1317
  }
1318
+ /**
1319
+ * Record that a route answered with a feed, a file or data (crawl.ts
1320
+ * isNonPageResource). A route harvested from a link joins the contract before
1321
+ * anything knows what it serves; this takes it back out, for every role and
1322
+ * every later run, so a feed linked from every page is not a gap forever.
1323
+ */
1324
+ markResource(route, mediaType) {
1325
+ const map = this.data.resourceRoutes ?? {};
1326
+ if (map[route] !== mediaType) {
1327
+ map[route] = mediaType;
1328
+ this.data.resourceRoutes = map;
1329
+ this.save();
1330
+ }
1331
+ }
1332
+ /** A route that has since answered as a page is one again: back into the contract. */
1333
+ clearResource(route) {
1334
+ const map = this.data.resourceRoutes;
1335
+ if (map && route in map) {
1336
+ delete map[route];
1337
+ this.save();
1338
+ }
1339
+ }
1340
+ get resourceRoutes() {
1341
+ return this.data.resourceRoutes ?? {};
1342
+ }
1292
1343
  /**
1293
1344
  * Record that a route was attempted but landed elsewhere (e.g. auth-redirect).
1294
1345
  *
@@ -1827,9 +1878,10 @@ export class MemoryStore {
1827
1878
  * listed keys a user cannot act on (collector inertKeys): they stay known,
1828
1879
  * so a click on one still registers, but coverage does not count them.
1829
1880
  * `session` names who visited, for this run's per-session coverage.
1830
- * `aliases` maps a listed key to the key the same control had under the
1831
- * earlier name rule (fingerprint.ts keyAliases); the route keeps them so
1832
- * states written before the rule changed are read under current keys.
1881
+ * `aliases` maps, for each step between name rules, a listed key to the
1882
+ * key the same control had under the rule before (KeyAliasSteps); the route
1883
+ * keeps them so states written before a rule changed are read under
1884
+ * current keys.
1833
1885
  */
1834
1886
  visitState(fingerprint, url, route, elementKeys, inertKeys = [], session, aliases = {}) {
1835
1887
  this.runStates.add(fingerprint);
@@ -1856,7 +1908,7 @@ export class MemoryStore {
1856
1908
  // named like a text button beside it) leaves it unchanged. Its keys are
1857
1909
  // moved to the current ones before it is marked current.
1858
1910
  if ((rec.nameRule ?? 1) < NAME_RULE)
1859
- rec.elements = rekeyed(rec.elements, this.data.keyAliases?.[routeIdentity(route)]);
1911
+ rec.elements = keysUnderRule(rec.elements, rec.nameRule ?? 1, NAME_RULE, this.data, routeIdentity(route));
1860
1912
  rec.nameRule = NAME_RULE;
1861
1913
  const present = new Set(elementKeys);
1862
1914
  const inert = new Set(inertKeys);
@@ -1888,16 +1940,18 @@ export class MemoryStore {
1888
1940
  * hand out ordinals differently, and the states written under the earlier
1889
1941
  * rule do not change, so neither should what their keys are read as.
1890
1942
  */
1891
- learnAliases(route, aliases) {
1892
- const pairs = Object.entries(aliases);
1893
- if (pairs.length === 0)
1894
- return;
1895
- const byRoute = (this.data.keyAliases ??= {});
1943
+ learnAliases(route, steps) {
1896
1944
  const id = routeIdentity(route);
1897
- const known = (byRoute[id] ??= {});
1898
- for (const [key, prior] of pairs)
1899
- if (!Object.hasOwn(known, prior))
1900
- known[prior] = key;
1945
+ for (const [rule, aliases] of Object.entries(steps)) {
1946
+ const pairs = Object.entries(aliases);
1947
+ if (pairs.length === 0)
1948
+ continue;
1949
+ const byRoute = rule === "2" ? (this.data.keyAliases ??= {}) : ((this.data.ruleKeyAliases ??= {})[rule] ??= {});
1950
+ const known = (byRoute[id] ??= {});
1951
+ for (const [key, prior] of pairs)
1952
+ if (!Object.hasOwn(known, prior))
1953
+ known[prior] = key;
1954
+ }
1901
1955
  }
1902
1956
  markExercised(fingerprint, key, action) {
1903
1957
  const rec = this.data.states[fingerprint];
@@ -2265,7 +2319,7 @@ export class MemoryStore {
2265
2319
  const route = routeIdentity(rec.route);
2266
2320
  const keys = only && !only.has(fp) ? null : bucket(listed, route);
2267
2321
  // A state written under an earlier name rule is read under current keys.
2268
- const elements = (rec.nameRule ?? 1) < NAME_RULE ? rekeyed(rec.elements, this.data.keyAliases?.[route]) : rec.elements;
2322
+ const elements = keysUnderRule(rec.elements, rec.nameRule ?? 1, NAME_RULE, this.data, route);
2269
2323
  for (const [key, v] of Object.entries(elements)) {
2270
2324
  if (v.inert)
2271
2325
  bucket(inert, route).add(key);
@@ -887,6 +887,37 @@ export function matchReadPost(entries, appUrl, url) {
887
887
  const segments = target.pathname.split("/").filter(Boolean);
888
888
  return (entries.find((e) => (e.origin ?? app) === target.origin && e.segments.length === segments.length && e.segments.every((seg, i) => seg === "*" || seg === segments[i])) ?? null);
889
889
  }
890
+ /**
891
+ * The page a script's POST was sent from, for the gap ledger's list of pages
892
+ * observe refused a POST on. The Referer of a request the top document sent,
893
+ * when it is a page of the app; otherwise the page the session drives when the
894
+ * request is seen. A frame's Referer is its own document, which the session
895
+ * never navigates to, so the caller passes none for a frame's request.
896
+ *
897
+ * The Referer comes first because the session's page URL can lag: a page's
898
+ * script can send its first request before the browser has told Playwright
899
+ * that the page committed, so the session still reports the page it came
900
+ * from. A Referer that is the app's bare origin is set aside for the session's
901
+ * page: an app whose referrer policy sends only the origin would otherwise
902
+ * charge every page's POST to "/". The price is that the app's root page,
903
+ * whose own Referer is that bare origin, still relies on the session's page
904
+ * URL, lag and all.
905
+ */
906
+ export function refusedPostPage(appUrl, referer, pageUrl) {
907
+ if (!referer)
908
+ return pageUrl;
909
+ let from;
910
+ try {
911
+ from = new URL(referer);
912
+ if (from.origin !== new URL(appUrl).origin)
913
+ return pageUrl;
914
+ }
915
+ catch {
916
+ return pageUrl;
917
+ }
918
+ const bareOrigin = from.pathname === "/" && !from.search;
919
+ return bareOrigin && pageUrl ? pageUrl : referer;
920
+ }
890
921
  /** The longest body read for a GraphQL operation; a longer one cannot be vetted and is refused. */
891
922
  export const MAX_READ_POST_BODY = 100_000;
892
923
  /** A GraphQL operation that writes: `mutation` or `subscription`, then an optional name, then its variables, directives or selection. */
@@ -1,5 +1,6 @@
1
1
  import { ACCESSIBLE_NAME_SRC, DIALOG_LIKE_SEL } from "./collector.js";
2
2
  import { focusAdvanceKey, isBrowserEngine } from "../browsers.js";
3
+ import { focusedControl } from "./design.js";
3
4
  /**
4
5
  * Scroll ONE named region rather than the page. The page-level heuristic
5
6
  * picks the largest scrollable pane, so a smaller independently-scrolling
@@ -158,6 +159,12 @@ export async function probeOverlays(page) {
158
159
  const s = getComputedStyle(el);
159
160
  return r.width > 0 && r.height > 0 && s.visibility !== "hidden" && s.display !== "none";
160
161
  };
162
+ // A translucent background or a blur: what dims the page behind a modal.
163
+ const darkens = (s) => {
164
+ const m = (s.backgroundColor || "").match(/rgba?\\((\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)(?:\\s*,\\s*([0-9.]+))?/);
165
+ const alpha = m ? (m[4] === undefined ? 1 : Number(m[4])) : 0;
166
+ return (alpha > 0.05 && alpha < 0.98) || ((s.backdropFilter || "") + "").includes("blur");
167
+ };
161
168
  // Backdrops: fixed, near-full-viewport, visually darkening/blurring.
162
169
  const backdrops = [];
163
170
  for (const el of document.querySelectorAll("body *")) {
@@ -166,10 +173,7 @@ export async function probeOverlays(page) {
166
173
  if (s.position !== "fixed") continue;
167
174
  const r = el.getBoundingClientRect();
168
175
  if (r.width < vw * 0.9 || r.height < vh * 0.9) continue;
169
- const m = (s.backgroundColor || "").match(/rgba?\\((\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)(?:\\s*,\\s*([0-9.]+))?/);
170
- const alpha = m ? (m[4] === undefined ? 1 : Number(m[4])) : 0;
171
- const darkens = (alpha > 0.05 && alpha < 0.98) || ((s.backdropFilter || "") + "").includes("blur");
172
- if (darkens) backdrops.push(el);
176
+ if (darkens(s)) backdrops.push(el);
173
177
  }
174
178
  const hasContent = (el) => {
175
179
  const t = (el.textContent || "").trim();
@@ -183,12 +187,47 @@ export async function probeOverlays(page) {
183
187
  const dialogsAll = [...document.querySelectorAll('${DIALOG_LIKE_SEL}')].filter((d) => visible(d));
184
188
  // Leaked modal scroll-lock: content extends past the fold, the page
185
189
  // itself cannot scroll (overflow hidden on body/html), and NO overlay
186
- // of any kind — dialog-like panel OR backdrop — is up to justify the
190
+ // of any kind — dialog-like panel, backdrop or framed modal — is up to justify the
187
191
  // lock; everything below the fold is unreachable and the page looks
188
192
  // perfectly healthy otherwise.
193
+ // A modal can also live inside a frame: the top document then holds only
194
+ // a pinned wrapper around a full-viewport iframe, and the dialog and its
195
+ // backdrop are in the frame's document, out of reach of the selectors
196
+ // above. Such an iframe justifies the lock when it is visible, covers the
197
+ // viewport, is pinned (itself or through a fixed ancestor) and is not
198
+ // faded out or click-through, and, when its document can be read (same
199
+ // origin), something in it is open: a dialog-like panel or a dimming
200
+ // backdrop. A frame left mounted after its modal closed holds neither.
201
+ // Another site's frame cannot be read, so its geometry has to do.
202
+ const frameOverlay = (f) => {
203
+ if (!visible(f)) return false;
204
+ const r = f.getBoundingClientRect();
205
+ if (r.width < vw * 0.9 || r.height < vh * 0.9) return false;
206
+ let pinned = false;
207
+ for (let e = f; e && e !== document.body; e = e.parentElement) {
208
+ const s = getComputedStyle(e);
209
+ if (Number(s.opacity) === 0 || s.pointerEvents === "none") return false;
210
+ if (s.position === "fixed") pinned = true;
211
+ }
212
+ if (!pinned) return false;
213
+ // contentDocument is null for another site's frame.
214
+ const doc = f.contentDocument;
215
+ const win = doc && doc.defaultView;
216
+ if (!win) return true;
217
+ const fw = win.innerWidth, fh = win.innerHeight;
218
+ const shown = (el) => { const b = el.getBoundingClientRect(); const s = win.getComputedStyle(el); return b.width > 0 && b.height > 0 && s.visibility !== "hidden" && s.display !== "none"; };
219
+ if ([...doc.querySelectorAll('${DIALOG_LIKE_SEL}')].some(shown)) return true;
220
+ return [...doc.querySelectorAll("body *")].some((el) => {
221
+ const s = win.getComputedStyle(el);
222
+ if (s.position !== "fixed" || !shown(el)) return false;
223
+ const b = el.getBoundingClientRect();
224
+ return b.width >= fw * 0.9 && b.height >= fh * 0.9 && darkens(s);
225
+ });
226
+ };
227
+ const framedOverlay = [...document.querySelectorAll("iframe")].some(frameOverlay);
189
228
  const docH = Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0);
190
229
  const ovLock = [getComputedStyle(document.documentElement).overflowY, document.body ? getComputedStyle(document.body).overflowY : ""].some((o) => o === "hidden" || o === "clip");
191
- if (ovLock && docH > vh + 50 && dialogsAll.length === 0 && backdrops.length === 0) {
230
+ if (ovLock && docH > vh + 50 && dialogsAll.length === 0 && backdrops.length === 0 && !framedOverlay) {
192
231
  issues.push("OVERLAY: page scrolling is DISABLED (overflow hidden on body/html) with " + Math.round(docH - vh) + "px of content below the fold and NO open dialog to justify it — likely a leaked modal scroll-lock; users cannot reach the rest of the page");
193
232
  }
194
233
  if (backdrops.length === 0) return issues;
@@ -249,8 +288,11 @@ export async function probeOverlays(page) {
249
288
  * focusless. As Tab advances, the previous stop is naturally blurred, so
250
289
  * each stop's focused style (captured at visit time) can be diffed against
251
290
  * its blurred style (captured in one pass at the end) without fighting the
252
- * tab order. Best-effort: any failure returns an empty sample set rather
253
- * than failing the audit.
291
+ * tab order. A Tab onto a frame is followed into a same-origin frame's
292
+ * document and the control focused there is sampled; a press that leaves
293
+ * focus inside a frame it cannot read is skipped (design.ts focusedControl).
294
+ * Best-effort: any failure returns an empty sample set rather than failing
295
+ * the audit.
254
296
  */
255
297
  export async function probeFocusIndicators(page) {
256
298
  const name = page.context().browser()?.browserType().name();
@@ -261,11 +303,11 @@ export async function probeFocusIndicators(page) {
261
303
  for (let i = 0; i < 15; i++) {
262
304
  await page.keyboard.press(advanceKey);
263
305
  const info = (await page.evaluate(`(() => {
264
- const el = document.activeElement;
265
- if (!el || el === document.body || el === document.documentElement) return null;
306
+ const el = (${focusedControl.toString()})(document);
307
+ if (el === null || el === "frame") return el;
266
308
  if (el.hasAttribute("data-scout-focus-probe")) return "wrapped";
267
309
  el.setAttribute("data-scout-focus-probe", "${i}");
268
- const s = getComputedStyle(el);
310
+ const s = el.ownerDocument.defaultView.getComputedStyle(el);
269
311
  const tid = el.getAttribute("data-testid");
270
312
  // Named as the snapshot names it, so an icon button reads by its aria-label here too.
271
313
  const name = (${ACCESSIBLE_NAME_SRC})(el).slice(0, 30);
@@ -273,15 +315,32 @@ export async function probeFocusIndicators(page) {
273
315
  })()`));
274
316
  if (info === null || info === "wrapped")
275
317
  break;
318
+ if (info === "frame")
319
+ continue;
276
320
  stops.push({ i, ...info });
277
321
  }
278
- await page.evaluate("document.activeElement && document.activeElement.blur && document.activeElement.blur()");
322
+ // Blur from the innermost focused element out, so a control focused inside a frame loses focus too.
323
+ await page.evaluate(`(() => {
324
+ const chain = [];
325
+ for (let d = document; d; ) {
326
+ const a = d.activeElement;
327
+ if (!a || a === d.body || a === d.documentElement) break;
328
+ chain.push(a);
329
+ d = /^i?frame$/i.test(a.tagName) ? a.contentDocument : null;
330
+ }
331
+ for (const a of chain.reverse()) if (a.blur) a.blur();
332
+ })()`);
333
+ // The marked stops live in the page and in every same-origin frame the walk followed focus into.
279
334
  const blurred = (await page.evaluate(`(() => {
280
335
  const out = {};
281
- for (const el of document.querySelectorAll("[data-scout-focus-probe]")) {
282
- const s = getComputedStyle(el);
283
- out[el.getAttribute("data-scout-focus-probe")] = ${styleSig};
284
- el.removeAttribute("data-scout-focus-probe");
336
+ const docs = [document];
337
+ for (let k = 0; k < docs.length; k++) {
338
+ for (const f of docs[k].querySelectorAll("iframe, frame")) if (f.contentDocument) docs.push(f.contentDocument);
339
+ for (const el of docs[k].querySelectorAll("[data-scout-focus-probe]")) {
340
+ const s = el.ownerDocument.defaultView.getComputedStyle(el);
341
+ out[el.getAttribute("data-scout-focus-probe")] = ${styleSig};
342
+ el.removeAttribute("data-scout-focus-probe");
343
+ }
285
344
  }
286
345
  return out;
287
346
  })()`));