scenescout 3.9.0 → 3.10.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,7 +7,7 @@ import { normalizeTask } from "./task.js";
7
7
  import { CLAIM_SCAN_SCRIPT, findContradictions } from "./claims.js";
8
8
  import { describeInjection, newInjections, probeQueries, probeScript, probeShape, rememberProbe } from "./injection.js";
9
9
  import { AuthLossTracker } from "./authloss.js";
10
- import { COLLECT_INTERACTABLES_SCRIPT, VISIBLE_SRC, geometryIssues, BROKEN_IMAGES_SCRIPT, brokenImageIssues, frameLines, hasVisibleFrame, frameElementKey, frameLabel, masksForeignName, MASKED_NAME, capForeignName, stripForeignHref, frameToPageRect, displayName, missingName, } from "./collector.js";
10
+ import { COLLECT_INTERACTABLES_SCRIPT, VISIBLE_SRC, geometryIssues, BROKEN_IMAGES_SCRIPT, brokenImageIssues, frameLines, hasVisibleFrame, frameElementKey, frameLabel, masksForeignName, MASKED_NAME, capForeignName, stripForeignHref, frameToPageRect, displayName, missingName, placeholderOnly, placeholderEvidence, describeControl, labelFlag, } from "./collector.js";
11
11
  import { OracleMonitor, formatViolations } from "./oracles.js";
12
12
  import { extractCreatedIds, isOwnedResource, normalizeId } from "./ownership.js";
13
13
  import { formatJourney, measureJourney } from "./journey.js";
@@ -16,6 +16,7 @@ import { keepWatchingUrl, normalizePace, SETTLE_TICK_MS, shouldKeepWaiting } fro
16
16
  import { buildRequestScript, formatReplay, replaySignature, requestHeaders, resolveMethod, resolveRequestUrl, toReplayResult } from "./request.js";
17
17
  import { defaultEngine, focusAdvanceKey, REMOVE_SHARED_WORKER_SCRIPT, screencastSupport, serviceWorkerPolicy, sharedWorkersAllowed, } from "../browsers.js";
18
18
  import { revealedLines } from "./hover.js";
19
+ import { FORMS_INVENTORY_SCRIPT, FORM_PROBE_BODY, formProbeExpression, formIdentity, formStatus, FORMS_READ_FAILED, FORMS_SUBMIT_UNMATCHED, isEmptySubmit, sameControl, isNavigationTeardown, submits, tracksForm, } from "./forms.js";
19
20
  import { explainLaunchFailure, isMissingBrowser } from "./launch.js";
20
21
  import { ACTION_TIMEOUT_MS, performScroll, probeFocusIndicators, probeOverlays, scrollContainer } from "./probes.js";
21
22
  import { BROWSER_MARKER, reapOrphanBrowsers } from "./reaper.js";
@@ -81,6 +82,23 @@ const ESCAPE_REFUSAL = "a possible frame escape";
81
82
  const MAX_READ_FRAMES = 10;
82
83
  /** How long a snapshot waits for its frames' elements to answer. */
83
84
  const FRAME_READ_MS = 1500;
85
+ /** The coverage key of the page's own element at this xpath (never one in a frame: its xpath names a node in another document). */
86
+ function keyAtXpath(elements, xpath) {
87
+ return elements.find((el) => !el.frame && el.xpath === xpath)?.key ?? null;
88
+ }
89
+ /** The page's own element at the probe's submit xpath, when it really is that control (forms.ts sameControl). */
90
+ function submitControlIn(elements, probe) {
91
+ const el = elements.find((e) => !e.frame && e.xpath === probe.submit);
92
+ return el && sameControl(el, probe) ? el : null;
93
+ }
94
+ /** Page-side: the form an element belongs to, and what its fields hold now (forms.ts). */
95
+ const probeFormOf = new Function("node", FORM_PROBE_BODY);
96
+ /**
97
+ * How long a form read may take: the inventory on every collect, the probe
98
+ * before a submit-capable action. One pass over document.forms takes a few
99
+ * milliseconds; this only stops a wedged page stalling the action.
100
+ */
101
+ const FORM_READ_MS = 1000;
84
102
  /** In-page XPath lookup fragment for string-expression evaluates. */
85
103
  function xpathLookup(xpath) {
86
104
  return `document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue`;
@@ -696,6 +714,8 @@ export class BrowserEngine {
696
714
  });
697
715
  this.lastSnap = null;
698
716
  this.designAuditCount = 0;
717
+ // A new attach may be a restarted app: what failed to load before gets another try.
718
+ this.loadFailedRoutes = new Set();
699
719
  // The engine learns the route list itself so completion is an objective,
700
720
  // enforceable contract (scout_report refuses while known routes are unvisited)
701
721
  // rather than a prompt suggestion the driver may ignore.
@@ -1184,6 +1204,9 @@ export class BrowserEngine {
1184
1204
  break;
1185
1205
  }
1186
1206
  const mainCount = rawElements.length;
1207
+ // The page's forms, read in the same settled page as its elements. The
1208
+ // page's own document only: an xpath names a node in one document.
1209
+ const rawForms = ((await this.formRead("form inventory", page.evaluate(FORMS_INVENTORY_SCRIPT))) ?? []);
1187
1210
  // Then each frame's own controls, read once, each within its own limit.
1188
1211
  const framed = await this.collectFrames(page);
1189
1212
  // Element identity is the coverage key (testid or role+name, ordinal-
@@ -1223,8 +1246,18 @@ export class BrowserEngine {
1223
1246
  });
1224
1247
  this.harvestRoutes(elements.filter((el) => !el.frame?.foreign));
1225
1248
  this.framesRead = new Set(framed.map((g) => g.frame));
1226
- return { elements, truncated: mainCount >= 150 };
1249
+ // A form is known by its own attributes, else by its submit control's coverage
1250
+ // key (forms.ts formIdentity); a form with neither is not tracked.
1251
+ const forms = rawForms.flatMap((f) => {
1252
+ const status = formStatus(f);
1253
+ const key = status === "untracked" ? null : (formIdentity(f.attrs) ?? keyAtXpath(elements, f.submit));
1254
+ return key ? [{ key, guarded: status === "guarded" }] : [];
1255
+ });
1256
+ this.lastCollectTruncated = mainCount >= 150;
1257
+ return { elements, truncated: mainCount >= 150, forms };
1227
1258
  }
1259
+ /** Whether the latest collect stopped at the element cap, so some controls have no key at all. */
1260
+ lastCollectTruncated = false;
1228
1261
  /**
1229
1262
  * The controls inside the page's frames, up to MAX_READ_FRAMES visible ones,
1230
1263
  * each read within FRAME_READ_MS so one busy frame cannot stall a snapshot.
@@ -1343,10 +1376,12 @@ export class BrowserEngine {
1343
1376
  */
1344
1377
  async captureCoverageState() {
1345
1378
  const page = this.requirePage();
1346
- const { elements } = await this.collect();
1379
+ const { elements, forms } = await this.collect();
1347
1380
  const url = page.url();
1348
1381
  const fp = fingerprintState(url, elements);
1349
1382
  this.memory?.visitState(fp, url, normalizePath(url), elements.map((el) => el.key));
1383
+ for (const f of forms)
1384
+ this.memory?.recordForm(fp, f.key, f.guarded);
1350
1385
  return { fp, elements, url };
1351
1386
  }
1352
1387
  /**
@@ -1414,13 +1449,15 @@ export class BrowserEngine {
1414
1449
  const page = this.requirePage();
1415
1450
  const memory = this.memory;
1416
1451
  await this.settle();
1417
- const { elements, truncated } = await this.collect();
1452
+ const { elements, truncated, forms } = await this.collect();
1418
1453
  const url = page.url();
1419
1454
  this.snapshotUrl = url;
1420
1455
  const route = normalizePath(url);
1421
1456
  const fp = fingerprintState(url, elements);
1422
1457
  this.currentFingerprint = fp;
1423
1458
  const isNew = memory.visitState(fp, url, route, elements.map((el) => el.key));
1459
+ for (const f of forms)
1460
+ memory.recordForm(fp, f.key, f.guarded);
1424
1461
  memory.recordRoleAccess(this.role, route, "reached");
1425
1462
  // A snapshot is what an agent takes when it wants to LOOK at something, so
1426
1463
  // it is the frame a reader most wants beside the step. Recording only the
@@ -1436,6 +1473,7 @@ export class BrowserEngine {
1436
1473
  dup ? `copy#${Number(dup[1]) + 1}` : null,
1437
1474
  el.disabled ? "disabled" : null,
1438
1475
  el.destructive ? "DESTRUCTIVE" : null,
1476
+ labelFlag(el),
1439
1477
  memory.wasExercised(fp, el.key) ? "done" : null,
1440
1478
  el.href ? `href=${el.href.slice(0, 60)}` : null,
1441
1479
  ].filter(Boolean);
@@ -1490,16 +1528,8 @@ export class BrowserEngine {
1490
1528
  }
1491
1529
  // Per document: the page's own controls together, and each frame's apart —
1492
1530
  // an embed's controls are not laid out against the page's.
1493
- const viewport = page.viewportSize() ?? { width: 1280, height: 900 };
1494
- const byDocument = new Map();
1495
- for (const el of elements) {
1496
- const doc = el.frame ? el.key.slice(0, el.key.indexOf("|") + 1) : "";
1497
- byDocument.set(doc, [...(byDocument.get(doc) ?? []), el]);
1498
- }
1499
- const geometry = [...byDocument.values()].flatMap((group) => geometryIssues(group, viewport));
1500
- geometry.push(...(await probeOverlays(page)));
1531
+ const { geometry, brokenImages } = await this.measureLayout(page, elements, url);
1501
1532
  const hiddenFileInputs = await this.hiddenFileInputs(page);
1502
- const brokenImages = brokenImageIssues((await page.evaluate(BROKEN_IMAGES_SCRIPT).catch(() => null)) ?? { images: [], total: 0 }, url);
1503
1533
  const { frames, nested: nestedFrames } = await this.frameInventory(page);
1504
1534
  const cov = memory.coverage();
1505
1535
  const unvisited = this.unvisitedKnownRoutes();
@@ -1899,6 +1929,88 @@ export class BrowserEngine {
1899
1929
  return { forced: true };
1900
1930
  }
1901
1931
  }
1932
+ /**
1933
+ * A form read (the inventory, or the probe before a submit), bounded by
1934
+ * FORM_READ_MS. This bookkeeping must never fail or stall the action, so a
1935
+ * failure yields null — but only a page navigating away under the read is
1936
+ * expected; a timeout or any other error goes in the action log.
1937
+ */
1938
+ async formRead(what, work) {
1939
+ let timer;
1940
+ const limit = new Promise((resolve) => {
1941
+ timer = setTimeout(() => resolve("timeout"), FORM_READ_MS);
1942
+ });
1943
+ const outcome = await Promise.race([
1944
+ work.then((value) => ({ value }), (err) => ({ err })),
1945
+ limit,
1946
+ ]).finally(() => clearTimeout(timer));
1947
+ const url = this.page?.url() ?? "";
1948
+ if (outcome === "timeout") {
1949
+ this.logAction({ action: FORMS_READ_FAILED, target: `${what} took over ${FORM_READ_MS} ms`, url });
1950
+ return null;
1951
+ }
1952
+ if ("err" in outcome) {
1953
+ const message = outcome.err instanceof Error ? outcome.err.message.split("\n")[0] : String(outcome.err);
1954
+ if (!isNavigationTeardown(message))
1955
+ this.logAction({ action: FORMS_READ_FAILED, target: `${what}: ${message}`.slice(0, 300), url });
1956
+ return null;
1957
+ }
1958
+ return outcome.value;
1959
+ }
1960
+ /** The form a located element belongs to, read just before an action that may submit it (forms.ts). */
1961
+ probeForm(target) {
1962
+ return this.formRead("form probe", target.evaluate(probeFormOf, undefined, { timeout: FORM_READ_MS }));
1963
+ }
1964
+ /** The form the focused element belongs to, for a key press that may submit through it. */
1965
+ probeFocusedForm() {
1966
+ return this.formRead("form probe", this.requirePage().evaluate(formProbeExpression("document.activeElement")));
1967
+ }
1968
+ /**
1969
+ * A plan's pre-action state, fit to record a submit of this form against.
1970
+ * A plan reuses its previous capture while the URL has not changed, but the
1971
+ * page can shift under the same URL (a control inserted above the form
1972
+ * moves every xpath after it). Only a REUSED capture is suspect: when the
1973
+ * form has no identity of its own and the reused state does not hold its
1974
+ * submit control, the state is captured afresh. A capture this step just
1975
+ * took is never taken twice, and a step with no state gets one.
1976
+ */
1977
+ async stateHolding(probe, state, reused) {
1978
+ if (state && (!reused || formIdentity(probe.attrs) || submitControlIn(state.elements, probe)))
1979
+ return state;
1980
+ return (await this.captureCoverageState().catch(() => null)) ?? state;
1981
+ }
1982
+ /** Whether this probe describes a submit of a tracked form, so the submit is worth recording. */
1983
+ static isTrackedSubmit(kind, probe) {
1984
+ return probe !== null && tracksForm(probe.fields) && submits(kind, probe);
1985
+ }
1986
+ /**
1987
+ * Mark a submit of a listed form against the state it was made in. The
1988
+ * form is known by its own attributes when it has any; otherwise by the
1989
+ * snapshot element at its submit control's xpath, accepted only when that
1990
+ * element's testid and name agree with the control the probe found (for a
1991
+ * click on that control, the element clicked). A submit that matches no
1992
+ * listed form is logged, never dropped silently and never added to the
1993
+ * list. Whether it was a submit, and an empty one, is forms.ts's rule.
1994
+ */
1995
+ noteFormSubmit(state, kind, probe) {
1996
+ if (!this.memory || !BrowserEngine.isTrackedSubmit(kind, probe))
1997
+ return;
1998
+ const empty = isEmptySubmit(kind, probe);
1999
+ const key = formIdentity(probe.attrs) ?? (state ? (submitControlIn(state.elements, probe)?.key ?? null) : null);
2000
+ if (state?.fp && key && this.memory.recordFormSubmit(state.fp, key, empty))
2001
+ return;
2002
+ // A page past the snapshot's element cap may never list this control, so
2003
+ // a fresh snapshot would not help: say it is untracked, not what to retry.
2004
+ const beyondCap = !key && this.lastCollectTruncated && !state?.elements.some((e) => !e.frame && e.xpath === probe.submit);
2005
+ const what = `${empty ? "empty " : ""}${kind} submit of a form (submit control ${JSON.stringify(probe.submitName)} at ${probe.submit})`;
2006
+ this.logAction({
2007
+ action: FORMS_SUBMIT_UNMATCHED,
2008
+ target: beyondCap
2009
+ ? `${what} is untracked: the page has more controls than a snapshot lists, so this form, which has no id, name or action, cannot be keyed`
2010
+ : `${what} matches no form listed for this page, so it is not counted — take a scout_snapshot and submit again`,
2011
+ url: this.page?.url() ?? "",
2012
+ });
2013
+ }
1902
2014
  async click(ref, clicks = 1) {
1903
2015
  const page = this.requirePage();
1904
2016
  const { el, liveLabel } = await this.resolveForAction(ref);
@@ -1916,8 +2028,14 @@ export class BrowserEngine {
1916
2028
  // (silent no-op forms): capture the count before to compare after.
1917
2029
  const xhrBefore = this.xhrCount;
1918
2030
  const submitLike = el.role === "button" && /submit|send|save|create|apply|subscribe|register|sign|post|add\b/i.test(el.name + " " + (el.testid ?? ""));
1919
- const { forced } = await this.resilientClick(this.scopeOf(el).locator(`xpath=${el.xpath}`), ACTION_TIMEOUT_MS, clicks);
2031
+ const clickTarget = this.scopeOf(el).locator(`xpath=${el.xpath}`);
2032
+ // Read before the click: what the form's fields hold when it goes. Only a
2033
+ // button or an input can submit a form; nothing else is asked.
2034
+ const form = !el.frame && (el.tag === "button" || el.tag === "input") ? await this.probeForm(clickTarget) : null;
2035
+ const formState = { fp: this.currentFingerprint, elements: [...this.refs.values()] };
2036
+ const { forced } = await this.resilientClick(clickTarget, ACTION_TIMEOUT_MS, clicks);
1920
2037
  this.memory.markExercised(this.currentFingerprint, el.key, clicks > 1 ? `click×${clicks}` : "click");
2038
+ this.noteFormSubmit(formState, "click", form);
1921
2039
  const result = await this.afterAction(clicks > 1 ? `click×${clicks}` : "click", `${el.role} "${el.name}"`);
1922
2040
  // Impatient-user probe: a rapid multi-click that fires the SAME
1923
2041
  // state-changing request more than once means the action is not guarded
@@ -2058,7 +2176,11 @@ export class BrowserEngine {
2058
2176
  return `Filled ${el.role} "${el.name}" but did NOT press Enter. ` + destructiveRefusal(submitLabel, this.mode);
2059
2177
  }
2060
2178
  }
2179
+ // After the fill, before the key: the fields as they are when it submits.
2180
+ const form = !el.frame && el.tag === "input" ? await this.probeForm(locator) : null;
2181
+ const formState = { fp: this.currentFingerprint, elements: [...this.refs.values()] };
2061
2182
  await locator.press("Enter", { timeout: ACTION_TIMEOUT_MS });
2183
+ this.noteFormSubmit(formState, "enter", form);
2062
2184
  }
2063
2185
  this.memory.markExercised(this.currentFingerprint, el.key, "type");
2064
2186
  return this.afterAction("type", `${el.role} "${el.name}" ← ${JSON.stringify(text.slice(0, 60))}${pressEnter ? " + Enter" : ""}${fillNote}`);
@@ -2211,6 +2333,23 @@ export class BrowserEngine {
2211
2333
  static OVERLAY_SELECTOR = '[role="tooltip"], [data-tippy-root], [data-radix-popper-content-wrapper], [data-floating-ui-portal], ' +
2212
2334
  '[data-slot*="tooltip" i], [data-slot*="popover" i], ' +
2213
2335
  '[class*="tooltip" i], [class*="hovercard" i], [class*="popover" i]';
2336
+ /**
2337
+ * The layout checks a snapshot prints: geometry per document (the page's own
2338
+ * controls together, each frame's apart, since an embed's controls are not
2339
+ * laid out against the page's), the overlay probe, and images that failed.
2340
+ */
2341
+ async measureLayout(page, elements, url) {
2342
+ const viewport = page.viewportSize() ?? { width: 1280, height: 900 };
2343
+ const byDocument = new Map();
2344
+ for (const el of elements) {
2345
+ const doc = el.frame ? el.key.slice(0, el.key.indexOf("|") + 1) : "";
2346
+ byDocument.set(doc, [...(byDocument.get(doc) ?? []), el]);
2347
+ }
2348
+ const geometry = [...byDocument.values()].flatMap((group) => geometryIssues(group, viewport));
2349
+ geometry.push(...(await probeOverlays(page)));
2350
+ const brokenImages = brokenImageIssues((await page.evaluate(BROKEN_IMAGES_SCRIPT).catch(() => null)) ?? { images: [], total: 0 }, url);
2351
+ return { geometry, brokenImages };
2352
+ }
2214
2353
  /**
2215
2354
  * Visible overlay texts right now — diffed before/after a hover to isolate
2216
2355
  * what the hover revealed. Returns null when the read itself failed, so a
@@ -2413,7 +2552,13 @@ export class BrowserEngine {
2413
2552
  // Identify the focused control BEFORE the key lands — activating it may
2414
2553
  // navigate, close a dialog, or otherwise destroy the element.
2415
2554
  const focused = BrowserEngine.ACTIVATION_KEY_RE.test(key) ? await this.focusedInteractable() : null;
2555
+ // Enter submits from a field or a submit control; Space only activates a control.
2556
+ const formKind = BrowserEngine.ACTIVATION_KEY_RE.test(key) ? (/enter/i.test(key) ? "enter" : "click") : null;
2557
+ const form = formKind ? await this.probeFocusedForm() : null;
2558
+ const formState = { fp: this.currentFingerprint, elements: [...this.refs.values()] };
2416
2559
  await page.keyboard.press(key);
2560
+ if (formKind)
2561
+ this.noteFormSubmit(formState, formKind, form);
2417
2562
  if (focused && this.memory && this.currentFingerprint) {
2418
2563
  this.memory.markExercised(this.currentFingerprint, focused, `press:${key}`);
2419
2564
  }
@@ -2554,17 +2699,51 @@ export class BrowserEngine {
2554
2699
  navigablePath(routeClass) {
2555
2700
  return this.memory?.discoveredRoutes[routeClass] ?? routeClass;
2556
2701
  }
2702
+ /** Routes whose page failed to load in this process: still unvisited, but not tried again by a crawl that picks its own targets. */
2703
+ loadFailedRoutes = new Set();
2704
+ /** Unvisited known routes a crawl would still try: those that already failed to load in this process are left out. */
2705
+ crawlableRoutes() {
2706
+ return this.unvisitedKnownRoutes().filter((r) => !this.loadFailedRoutes.has(normalizePath(r)));
2707
+ }
2708
+ /** What the last crawl measured on each route, as data: `scenescout check` builds its verdict from this. */
2709
+ crawlHealth = [];
2710
+ /** The per-route results of the most recent crawl. */
2711
+ get lastCrawlHealth() {
2712
+ return this.crawlHealth;
2713
+ }
2714
+ /** A check's extra measurements on a crawled page: the snapshot's layout checks and the design audit's defects. */
2715
+ async inspectRoute(page, elements, url) {
2716
+ const { geometry, brokenImages } = await this.measureLayout(page, elements, url);
2717
+ try {
2718
+ const { defects, sampled } = await this.auditPage();
2719
+ // Nothing styled to read is not a clean page: contrast, focus and target size went unmeasured.
2720
+ return sampled > 0
2721
+ ? { geometry, brokenImages, design: defects }
2722
+ : { geometry, brokenImages, design: [], auditError: "no visible styled elements to measure" };
2723
+ }
2724
+ catch (err) {
2725
+ // A page that navigated away mid-audit has nothing to measure. The
2726
+ // route's other results still stand, and the report says the audit is missing.
2727
+ return { geometry, brokenImages, design: [], auditError: err instanceof Error ? err.message.split("\n")[0] : String(err) };
2728
+ }
2729
+ }
2557
2730
  /**
2558
2731
  * Engine-side route sweep: visit each path, record the state in memory, and
2559
2732
  * collect per-route health — one tool call instead of one LLM turn per route.
2560
2733
  * Output is anomaly-oriented: a summary line per route, details only where
2561
2734
  * something is wrong. Navigation-only, so it is safe in read-only mode.
2562
2735
  */
2563
- async crawl(paths) {
2736
+ async crawl(paths, opts = {}) {
2564
2737
  const page = this.requirePage();
2565
2738
  const memory = this.memory;
2566
- const targets = (paths && paths.length > 0 ? paths : this.unvisitedKnownRoutes().map((r) => this.navigablePath(r))).slice(0, 150);
2739
+ this.crawlHealth = [];
2740
+ const targets = (paths && paths.length > 0 ? paths : this.crawlableRoutes().map((r) => this.navigablePath(r))).slice(0, Math.min(150, opts.limit ?? 150));
2567
2741
  if (targets.length === 0) {
2742
+ const failed = this.unvisitedKnownRoutes().filter((r) => this.loadFailedRoutes.has(normalizePath(r)));
2743
+ if (failed.length > 0) {
2744
+ return (`Nothing new to crawl. ${failed.length} route(s) failed to load earlier in this session and are still unvisited: ${failed.slice(0, 20).join(", ")}${failed.length > 20 ? " …" : ""}. ` +
2745
+ `A crawl without paths does not retry them; pass them as paths once the app is reachable, e.g. scout_crawl {paths:${JSON.stringify(failed.slice(0, 3).map((r) => this.navigablePath(r)))}}.`);
2746
+ }
2568
2747
  return this.allKnownRoutes().length > 0
2569
2748
  ? "Nothing to crawl: every known route has been visited. Use scout_coverage for remaining unexercised elements."
2570
2749
  : "No routes to crawl yet: no scanned or link-discovered routes. Take a snapshot first (links harvest routes) or pass explicit paths.";
@@ -2585,16 +2764,42 @@ export class BrowserEngine {
2585
2764
  status = resp?.status() ?? "no-response";
2586
2765
  }
2587
2766
  catch (err) {
2767
+ const reason = err instanceof Error ? err.message.split("\n")[0] : String(err);
2768
+ // Not retried by later crawls in this process (a check would otherwise try it
2769
+ // on every discovery round). Deliberately not written to memory: one outage
2770
+ // must not count a route as covered in every later run's gap ledger.
2771
+ this.loadFailedRoutes.add(normalizePath(url));
2772
+ // The browser commits its own error page for this failure tens of
2773
+ // milliseconds after goto has thrown, and that commit interrupts the next
2774
+ // navigation, charging this route's failure to the next one. Wait for it;
2775
+ // a browser that shows no error page simply lets the wait time out.
2776
+ await page.waitForEvent("framenavigated", { predicate: (f) => f === page.mainFrame(), timeout: 1500 }).catch(() => undefined);
2588
2777
  summary.push(`${path} — LOAD FAILED`);
2589
- problems.push(`${path}: ${err instanceof Error ? err.message.split("\n")[0] : err}`);
2778
+ problems.push(`${path}: ${reason}`);
2779
+ this.crawlHealth.push({
2780
+ path,
2781
+ url,
2782
+ status: null,
2783
+ loadError: reason,
2784
+ loginRedirect: false,
2785
+ elements: 0,
2786
+ unnamed: [],
2787
+ placeholderOnly: [],
2788
+ violations: [],
2789
+ geometry: [],
2790
+ brokenImages: [],
2791
+ design: [],
2792
+ });
2590
2793
  continue;
2591
2794
  }
2592
2795
  await this.settle();
2593
- const { elements } = await this.collect();
2796
+ const { elements, forms } = await this.collect();
2594
2797
  const finalUrl = page.url();
2595
2798
  const route = normalizePath(finalUrl);
2596
2799
  const fp = fingerprintState(finalUrl, elements);
2597
2800
  memory.visitState(fp, finalUrl, route, elements.map((el) => el.key));
2801
+ for (const f of forms)
2802
+ memory.recordForm(fp, f.key, f.guarded);
2598
2803
  memory.recordRoleAccess(this.role, route, "reached");
2599
2804
  // If we landed somewhere else (auth wall, canonical redirect), the
2600
2805
  // REQUESTED route still counts as covered for THIS role — a role that
@@ -2619,9 +2824,28 @@ export class BrowserEngine {
2619
2824
  this.logAction({ action: "crawl", target: path, url: finalUrl, ...(await this.frameFor("crawl")) });
2620
2825
  await this.scanForInjections();
2621
2826
  await this.scanForContradictions();
2827
+ // Measured before the drain: the audit's Tab presses can load things too.
2828
+ const inspected = opts.inspect ? await this.inspectRoute(page, elements, finalUrl) : null;
2622
2829
  const violations = this.oracles.drain();
2830
+ const own = elements.filter((el) => !el.frame?.foreign);
2831
+ this.crawlHealth.push({
2832
+ path,
2833
+ url: finalUrl,
2834
+ status: typeof status === "number" ? status : null,
2835
+ loginRedirect,
2836
+ elements: elements.length,
2837
+ unnamed: own.filter(missingName).map(describeControl),
2838
+ placeholderOnly: own.filter(placeholderOnly).map(placeholderEvidence),
2839
+ violations: violations.map(({ kind, severity, detail, url, embed }) => ({ kind, severity, detail, url, ...(embed ? { embed } : {}) })),
2840
+ geometry: inspected?.geometry ?? [],
2841
+ brokenImages: inspected?.brokenImages ?? [],
2842
+ design: inspected?.design ?? [],
2843
+ ...(inspected?.auditError ? { auditError: inspected.auditError } : {}),
2844
+ });
2623
2845
  const deadEnd = elements.length === 0;
2624
- const unnamed = elements.filter(missingName).length;
2846
+ // A field labelled only by its placeholder counts here too: to someone reading the crawl, it has no label.
2847
+ // Counted over the page's own controls, as the check's lists are: another site's frame is not this app's to fix.
2848
+ const unnamed = own.filter((el) => missingName(el) || placeholderOnly(el)).length;
2625
2849
  const missingTestid = elements.filter((el) => !el.testid && !el.disabled).length;
2626
2850
  const flags = [loginRedirect ? "AUTH-REDIRECT" : null, deadEnd ? "DEAD-END" : null, violations.length > 0 ? `${violations.length}⚠` : null].filter(Boolean);
2627
2851
  summary.push(`${path} — ${status} · ${elements.length} el` +
@@ -2697,6 +2921,8 @@ export class BrowserEngine {
2697
2921
  // A select step's options and choice, recorded against the dropdown the bookkeeping below finds.
2698
2922
  let chose = null;
2699
2923
  let preState = null;
2924
+ // The form this step may submit, read before it went (forms.ts).
2925
+ let form = null;
2700
2926
  try {
2701
2927
  if (step.action === "navigate") {
2702
2928
  const result = await this.navigate(step.target ?? step.value ?? "/");
@@ -2726,6 +2952,15 @@ export class BrowserEngine {
2726
2952
  transcript.push(`${desc} → ${refusal}`);
2727
2953
  break;
2728
2954
  }
2955
+ if (BrowserEngine.ACTIVATION_KEY_RE.test(key)) {
2956
+ const kind = /enter/i.test(key) ? "enter" : "click";
2957
+ const probe = await this.probeFocusedForm();
2958
+ if (BrowserEngine.isTrackedSubmit(kind, probe)) {
2959
+ const reusable = lastCapture?.url === page.url() ? lastCapture : null;
2960
+ preState = await this.stateHolding(probe, reusable, reusable !== null);
2961
+ form = { kind, probe };
2962
+ }
2963
+ }
2729
2964
  await page.keyboard.press(key);
2730
2965
  }
2731
2966
  else {
@@ -2754,8 +2989,14 @@ export class BrowserEngine {
2754
2989
  transcript.push(`${desc} → ${destructiveRefusal(label || step.target, this.mode)}`);
2755
2990
  break;
2756
2991
  }
2757
- if (step.action === "click")
2992
+ if (step.action === "click") {
2993
+ const probe = await this.probeForm(loc);
2994
+ if (BrowserEngine.isTrackedSubmit("click", probe)) {
2995
+ preState = await this.stateHolding(probe, preState, preState !== null && preState === lastCapture);
2996
+ form = { kind: "click", probe };
2997
+ }
2758
2998
  forcedClick = (await this.resilientClick(loc, ACTION_TIMEOUT_MS)).forced;
2999
+ }
2759
3000
  else if (step.action === "hover") {
2760
3001
  const { before, bodyBefore, churning } = await this.hoverBaselines();
2761
3002
  await loc.hover({ timeout: ACTION_TIMEOUT_MS });
@@ -2777,6 +3018,11 @@ export class BrowserEngine {
2777
3018
  break;
2778
3019
  }
2779
3020
  }
3021
+ const probe = await this.probeForm(loc);
3022
+ if (BrowserEngine.isTrackedSubmit("enter", probe)) {
3023
+ preState = await this.stateHolding(probe, preState, preState !== null && preState === lastCapture);
3024
+ form = { kind: "enter", probe };
3025
+ }
2780
3026
  await loc.press("Enter", { timeout: ACTION_TIMEOUT_MS });
2781
3027
  }
2782
3028
  }
@@ -2796,6 +3042,8 @@ export class BrowserEngine {
2796
3042
  }
2797
3043
  }
2798
3044
  await this.settle();
3045
+ if (form)
3046
+ this.noteFormSubmit(preState, form.kind, form.probe);
2799
3047
  // A plan is the RECOMMENDED way to run a mechanical sequence, so its
2800
3048
  // steps are where most of a recorded run actually happens. Leaving
2801
3049
  // them unframed reproduced, inside run_plan, the same hole that crawl
@@ -2810,10 +3058,12 @@ export class BrowserEngine {
2810
3058
  try {
2811
3059
  // Record the state the action LANDED on (it may be a new screen
2812
3060
  // this plan just reached, and it deserves coverage of its own)…
2813
- const { elements } = await this.collect();
3061
+ const { elements, forms } = await this.collect();
2814
3062
  const url = page.url();
2815
3063
  const fp = fingerprintState(url, elements);
2816
3064
  this.memory.visitState(fp, url, normalizePath(url), elements.map((el) => el.key));
3065
+ for (const f of forms)
3066
+ this.memory.recordForm(fp, f.key, f.guarded);
2817
3067
  this.memory.recordRoleAccess(this.role, normalizePath(url), "reached");
2818
3068
  lastCapture = { fp, elements, url };
2819
3069
  // …but mark the acted-on element in the state it came FROM, using
@@ -2890,6 +3140,11 @@ export class BrowserEngine {
2890
3140
  }
2891
3141
  /** Computed-style design audit of the current page — visual judgment material without pixels. */
2892
3142
  async designAudit() {
3143
+ const { url, report } = await this.auditPage();
3144
+ return `URL: ${url}\n` + report;
3145
+ }
3146
+ /** The design audit's measurements, as data as well as prose; scout_design_audit and a check's crawl share it. */
3147
+ async auditPage() {
2893
3148
  const page = this.requirePage();
2894
3149
  await this.settle();
2895
3150
  const payload = (await page.evaluate(DESIGN_COLLECT_SCRIPT));
@@ -2901,7 +3156,7 @@ export class BrowserEngine {
2901
3156
  // score with chrome included and later ones don't. That is the same warm-up
2902
3157
  // the coverage census has: nothing is knowable as "shared" until it has been
2903
3158
  // seen on several routes.
2904
- const { report, score, signatures } = analyzeDesign(payload, page.viewportSize() ?? { width: 1280, height: 900 }, this.memory?.designChromeKeys() ?? new Set());
3159
+ const { report, score, signatures, defects } = analyzeDesign(payload, page.viewportSize() ?? { width: 1280, height: 900 }, this.memory?.designChromeKeys() ?? new Set());
2905
3160
  const route = normalizePath(page.url());
2906
3161
  this.memory?.recordDesignElements(route, signatures);
2907
3162
  // Only a page that actually produced a score counts as audited. An empty or
@@ -2913,7 +3168,7 @@ export class BrowserEngine {
2913
3168
  this.memory?.markRouteFact(route, { audited: true });
2914
3169
  }
2915
3170
  this.logAction({ action: "design-audit", url: page.url(), result: score ? `score:${score.overall}` : undefined, ...(await this.frameFor("design-audit")) });
2916
- return `URL: ${page.url()}\n` + report;
3171
+ return { url: page.url(), report, defects, sampled: payload.records.length };
2917
3172
  }
2918
3173
  /**
2919
3174
  * Scroll like a user — and notice when the page won't let you. Reports the