scenescout 3.14.1 → 3.16.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +90 -18
  3. package/dist/browsers.js +28 -0
  4. package/dist/check-run.js +211 -24
  5. package/dist/ci-run.js +374 -62
  6. package/dist/cli.js +221 -47
  7. package/dist/commands.js +42 -4
  8. package/dist/engine/baseline.js +377 -0
  9. package/dist/engine/brief.js +16 -7
  10. package/dist/engine/browser.js +1660 -386
  11. package/dist/engine/calibration.js +61 -30
  12. package/dist/engine/capture.js +164 -0
  13. package/dist/engine/check.js +258 -68
  14. package/dist/engine/ci-lanes.js +215 -0
  15. package/dist/engine/ci.js +233 -18
  16. package/dist/engine/claims.js +234 -12
  17. package/dist/engine/collector.js +851 -78
  18. package/dist/engine/crawl.js +49 -0
  19. package/dist/engine/dedup.js +329 -9
  20. package/dist/engine/design.js +355 -43
  21. package/dist/engine/export.js +877 -0
  22. package/dist/engine/fingerprint.js +92 -4
  23. package/dist/engine/flow.js +18 -6
  24. package/dist/engine/forms.js +198 -18
  25. package/dist/engine/journey.js +29 -1
  26. package/dist/engine/lane.js +13 -3
  27. package/dist/engine/launch.js +45 -6
  28. package/dist/engine/limits.js +7 -0
  29. package/dist/engine/live-page.js +49 -2
  30. package/dist/engine/live.js +4 -1
  31. package/dist/engine/memory.js +668 -70
  32. package/dist/engine/open.js +118 -0
  33. package/dist/engine/oracles.js +167 -2
  34. package/dist/engine/plain.js +268 -0
  35. package/dist/engine/png.js +127 -0
  36. package/dist/engine/policy.js +379 -9
  37. package/dist/engine/probes.js +3 -2
  38. package/dist/engine/profiles.js +45 -9
  39. package/dist/engine/project-folder.js +191 -0
  40. package/dist/engine/provider.js +4 -3
  41. package/dist/engine/refresh.js +338 -25
  42. package/dist/engine/replay.js +63 -10
  43. package/dist/engine/report.js +248 -40
  44. package/dist/engine/request.js +317 -23
  45. package/dist/engine/sarif.js +120 -0
  46. package/dist/engine/scripted-login.js +342 -62
  47. package/dist/engine/settle.js +67 -0
  48. package/dist/engine/signed-in.js +256 -0
  49. package/dist/engine/tickets.js +671 -0
  50. package/dist/engine/unload.js +3 -2
  51. package/dist/export-run.js +633 -0
  52. package/dist/first-run.js +581 -0
  53. package/dist/installer.js +267 -3
  54. package/dist/intake.js +104 -0
  55. package/dist/login-run.js +386 -71
  56. package/dist/mcp-server.js +637 -68
  57. package/dist/playbook.js +5 -0
  58. package/package.json +6 -3
  59. package/skills/scenescout/SKILL.md +51 -18
@@ -1,38 +1,42 @@
1
1
  import { chromium, firefox, webkit, } from "playwright";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { elementKey, fingerprintState, isNonPageRoute, normalizePath } from "./fingerprint.js";
5
- import { AUTH_LOSS_PREFIX, JOURNEY_END, JOURNEY_START, MemoryStore, redactSecrets, TASK_SET } from "./memory.js";
4
+ import { elementKey, fingerprintState, isNonPageRoute, keyAliases, normalizePath, ordinalKeys, refsSurviveUrlChange, routeBase, } from "./fingerprint.js";
5
+ import { AUTH_LOSS_PREFIX, JOURNEY_END, JOURNEY_START, MemoryStore, reachedRoutes, redactSecrets, TASK_SET } from "./memory.js";
6
6
  import { normalizeTask } from "./task.js";
7
- import { CLAIM_SCAN_SCRIPT, findContradictions } from "./claims.js";
7
+ import { ACTED_CONTROL_SRC, CLAIM_SCAN_SCRIPT, findContradictions, INFRASTRUCTURE_WRITE_RE, isBackgroundRequest, OPEN_DIALOGS_SCRIPT, quietAnswer, saidSince, } from "./claims.js";
8
8
  import { POSTMESSAGE_BINDING, describeTokenPost, postMessageCaptureScript, tokenHits, tokenPostKey } from "./postmessage.js";
9
9
  import { describeInjection, newInjections, probeQueries, probeScript, probeShape, rememberProbe } from "./injection.js";
10
10
  import { AuthLossTracker } from "./authloss.js";
11
- import { captureClip } from "./capture.js";
12
- 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";
13
- import { OracleMonitor, formatViolations, httpErrorDetail } from "./oracles.js";
11
+ import { captureClip, cutByViewport } from "./capture.js";
12
+ import { STOP_ANIMATIONS_SCRIPT } from "./baseline.js";
13
+ import { COLLECT_INTERACTABLES_SCRIPT, COLLECTOR_CAP, NAME_SRC, POLICY_TEXT_SRC, VISIBLE_SRC, XPATH_OF_SRC, affordanceFlags, cutSummary, matchPrevious, geometryIssues, BROKEN_IMAGES_SCRIPT, brokenImageIssues, frameLines, hasVisibleFrame, frameElementKey, frameLabel, masksForeignName, MASKED_NAME, capForeignName, stripForeignHref, frameToPageRect, displayName, missingName, placeholderOnly, placeholderEvidence, describeControl, labelFlag, stateFlags, stateChange, trackedElements, inertKeys, MAIN_REGION_SCRIPT, mainRegionLine, mainRegionTag, mainState, describeCover, } from "./collector.js";
14
+ import { OracleMonitor, formatViolations, httpErrorDetail, planStopsAt, requestKey } from "./oracles.js";
14
15
  import { extractCreatedIds, isOwnedResource, normalizeId } from "./ownership.js";
15
- import { formatJourney, measureJourney } from "./journey.js";
16
+ import { formatJourney, journeyTime, measureJourney } from "./journey.js";
16
17
  import { describeStep, FLOW_AFTER_LAST_STEP_MS, isAction, matchRequest, parseTarget, splitRefusals, TARGET_HELP, urlMatches, } from "./flow.js";
17
18
  import { framePath, RECORD_MAX_FRAMES } from "./replay.js";
18
- import { keepWatchingUrl, normalizePace, SETTLE_TICK_MS, shouldKeepWaiting } from "./settle.js";
19
- import { buildRequestScript, formatReplay, replaySignature, requestHeaders, resolveMethod, resolveRequestUrl, toReplayResult } from "./request.js";
19
+ import { InFlightRequests, keepWatchingUrl, normalizePace, SETTLE_TICK_MS, shouldKeepWaiting } from "./settle.js";
20
+ import { crawledRoute, crawlLine, mainStateFlag } from "./crawl.js";
21
+ import { authToRemember, BODY_FETCH_MAX, buildRequestScript, formatPageRequests, formatReplay, PageRequests, replaySignature, requestHeaders, resolveMethod, resolveRequestUrl, resolveTarget, staleCredentialNote, toReplayResult, wantsView, } from "./request.js";
20
22
  import { defaultEngine, focusAdvanceKey, REMOVE_SHARED_WORKER_SCRIPT, screencastSupport, serviceWorkerPolicy, sharedWorkersAllowed, unloadWriteInterception, closeWaitsForLeavingWrites, } from "../browsers.js";
21
23
  import { revealedLines } from "./hover.js";
22
- import { FORMS_INVENTORY_SCRIPT, FORM_PROBE_BODY, formProbeExpression, formIdentity, formStatus, FORMS_READ_FAILED, FORMS_SUBMIT_UNMATCHED, isEmptySubmit, sameControl, isNavigationTeardown, submits, tracksForm, } from "./forms.js";
24
+ import { FORMS_INVENTORY_SCRIPT, FORM_PROBE_BODY, FORM_PROBE_OF_ACTIVE_ELEMENT, formIdentity, formStatus, FORMS_READ_FAILED, FORMS_SUBMIT_UNMATCHED, isEmptySubmit, isSubmitLike, sameControl, isNavigationTeardown, submits, tracksForm, APP_FILLED_TYPES, matchOption, normaliseDateValue, } from "./forms.js";
23
25
  import { explainLaunchFailure, isMissingBrowser } from "./launch.js";
24
- import { DEFAULT_TIME_LIMITS, explainTimeout, limitHint, resolveTimeLimits } from "./limits.js";
26
+ import { DEFAULT_TIME_LIMITS, explainTimeout, firstLineOf, isTimeoutMessage, limitHint, resolveTimeLimits } from "./limits.js";
25
27
  import { performScroll, probeFocusIndicators, probeOverlays, scrollContainer } from "./probes.js";
26
28
  import { boundedTeardown } from "./teardown.js";
27
29
  import { BROWSER_MARKER, reapOrphanBrowsers } from "./reaper.js";
28
30
  import { planUploadOptions, resolveDiskUpload } from "./uploads.js";
29
- import { answersWithRefusal, destructiveRefusal, isDestructive, isDestructiveWire, allowsWrite, policyRefusal, foreignFrameOrigin, foreignWrite, withForeignFrameSandbox, offAppPageWrite, embedOfRequest, hostileForEmbed, trustedEmbedOrigins, MAX_TRUSTED_EMBEDS, trustsForeignWrite, embedProbeRefusal, EmbedMoveTracker, sandboxedRedirectPage, allowsForeignWriteOnSignIn, isAuthExempt, WriteRule, } from "./policy.js";
31
+ import { answersWithRefusal, destructiveRefusal, destructiveLabelOf, pickIsDestructive, isDestructive, isDestructiveWire, blockSignature, BlockNotices, dialogNote, dialogResponse, ESCAPE_REFUSAL, allowsWrite, policyRefusal, foreignFrameOrigin, foreignWrite, withForeignFrameSandbox, offAppPageWrite, embedOfRequest, hostileForEmbed, trustedEmbedOrigins, MAX_TRUSTED_EMBEDS, MAX_READ_POSTS, READ_POSTS_ENV, readPostAllowed, matchReadPost, readPostEntries, readPostsSetting, trustsForeignWrite, embedProbeRefusal, EmbedMoveTracker, sandboxedRedirectPage, allowsForeignWriteOnSignIn, isAuthExempt, WriteRule, } from "./policy.js";
30
32
  import { scanProject } from "../scan.js";
31
33
  import { analyzeDesign, DESIGN_COLLECT_SCRIPT } from "./design.js";
32
34
  import { acceptMatches, generatedUpload } from "./fixtures.js";
33
35
  import { bodyDigest, headerOf, isReadMethod, judgeUnseenWrite, pausedRequestBytes, RoutedWrites, UnseenRefusals, unseenWriteSource, } from "./unload.js";
34
- import { loginCommand, permissionNote, resolveAttachAuth, roleLabel, sessionStorageInitScript, splitProfile, summarizeState, writeProfile, } from "./profiles.js";
35
- import { acquireLock, brokerEnabled, lockPathFor, planRefresh, presentedToken, profileAfterRotation, REFRESH_BROKER_ENV, refreshTokenSlots, rotatedFromResponse, rotationStored, swapProfileToken, swapRequest, } from "./refresh.js";
36
+ import { reloginCommand, permissionNote, resolveAttachAuth, roleLabel, sessionStorageInitScript, splitProfile, summarizeState, writeProfile, } from "./profiles.js";
37
+ import { acquireLock, brokerDecision, brokerEnabled, cookieMayCount, endpointKey, endpointsPathFor, headersForResend, isStaticAsset, learnableEndpoint, lockPathFor, planRefresh, profileAfterRotation, profileLoadWhileHeld, readLearnedEndpoints, REFRESH_BROKER_ENV, refreshNotice, refreshTokenSlots, rotatedCookies, rotatedFromResponse, rotationStored, swapProfileToken, swapRequest, swapToken, withLearnedEndpoint, writeBackStorageFrom, writeLearnedEndpoints, } from "./refresh.js";
38
+ /** How many routes' last snapshots are kept for diffs. */
39
+ const SNAPSHOTS_KEPT = 20;
36
40
  const SETTLE_MS = 400;
37
41
  /**
38
42
  * Request URL → pathname, falling back to the raw string for anything
@@ -59,8 +63,8 @@ const FORCED_CLICK_TIMEOUT_MS = 1500;
59
63
  const CHOOSER_GRACE_MS = 2000;
60
64
  /** How long a hover waits for delay-gated tooltips (component libraries warm up for as long as ~1500ms). */
61
65
  const HOVER_REVEAL_WINDOW_MS = 2500;
62
- /** Non-GET traffic that is auth/telemetry plumbing, not tester-caused state mutation. */
63
- const BENIGN_MUTATION_RE = /\/auth\/(refresh|token|session)|refresh[-_]?token|\/telemetry|\/analytics|\/heartbeat|\/sentry|\/collect\b|\/logs?\b|\/metrics\b/i;
66
+ /** Non-GET traffic that is auth/telemetry plumbing, not tester-caused state mutation. One list, shared with the false-success rule. */
67
+ const BENIGN_MUTATION_RE = INFRASTRUCTURE_WRITE_RE;
64
68
  /**
65
69
  * What the policy needs to know about where a request came from: the URLs of
66
70
  * the frame that sent it and of its parents, up to but not including the top
@@ -84,8 +88,6 @@ function requestSource(req) {
84
88
  }
85
89
  return { frameChain, frameUrl };
86
90
  }
87
- /** The `why` of a top-window navigation refused as a possible frame escape; the notice words it on its own. */
88
- const ESCAPE_REFUSAL = "a possible frame escape";
89
91
  /** The most frames a snapshot reads controls from. */
90
92
  const MAX_READ_FRAMES = 10;
91
93
  /** How long a snapshot waits for its frames' elements to answer. */
@@ -99,6 +101,15 @@ function submitControlIn(elements, probe) {
99
101
  const el = elements.find((e) => !e.frame && e.xpath === probe.submit);
100
102
  return el && sameControl(el, probe) ? el : null;
101
103
  }
104
+ /** Page-side, built from constants only: an element's LiveFacts. */
105
+ const LIVE_FACTS_BODY = `return { testid: node.getAttribute('data-testid'), ` +
106
+ `label: (node.getAttribute('aria-label') || node.innerText || node.textContent || node.getAttribute('placeholder') || '').trim().slice(0, 120), ` +
107
+ `name: (${NAME_SRC})(node).name, ...(${POLICY_TEXT_SRC})(node) };`;
108
+ const liveFactsOf = new Function("node", LIVE_FACTS_BODY);
109
+ /** Page-side: the one element carrying a test id, with its path and LiveFacts; null when none or several do. */
110
+ const uniqueByTestid = new Function("testid", `const all = document.querySelectorAll('[data-testid="' + CSS.escape(testid) + '"]'); ` +
111
+ `if (all.length !== 1) return null; const node = all[0]; ` +
112
+ `return { xpath: (${XPATH_OF_SRC})(node), live: (function (node) { ${LIVE_FACTS_BODY} })(node) };`);
102
113
  /** Page-side: the form an element belongs to, and what its fields hold now (forms.ts). */
103
114
  const probeFormOf = new Function("node", FORM_PROBE_BODY);
104
115
  /**
@@ -113,10 +124,12 @@ function xpathLookup(xpath) {
113
124
  }
114
125
  /**
115
126
  * Runs in the page against one dropdown, BEFORE a choice: its options' values
116
- * and labels. Read first because a dropdown may reset itself (a bulk-action or
117
- * "jump to" menu) or remove itself on change. Skips disabled and hidden
118
- * options, and a placeholder or "all" option with an empty value — the state
119
- * the page loads in, which is not an option anyone owes a choice.
127
+ * and labels, and which are selected. Read first because a dropdown may reset
128
+ * itself (a bulk-action or "jump to" menu) or remove itself on change. Skips
129
+ * disabled and hidden options, and a placeholder or "all" option with an empty
130
+ * value — the state the page loads in, which is not an option anyone owes a
131
+ * choice. The option selected before the choice is the value the page already
132
+ * asked the server for, which is not owed one either.
120
133
  */
121
134
  function describeSelect(node) {
122
135
  // A plan may target the dropdown by its <label>; selectOption follows a label to its control, so this does too.
@@ -126,7 +139,7 @@ function describeSelect(node) {
126
139
  return null;
127
140
  return Array.from(select.options)
128
141
  .filter((o) => !o.disabled && !o.hidden && o.value !== "")
129
- .map((o) => ({ value: o.value, label: (o.label || o.textContent || "").trim().slice(0, 80) }))
142
+ .map((o) => ({ value: o.value, label: (o.label || o.textContent || "").trim().slice(0, 80), selected: o.selected }))
130
143
  .filter((o) => o.label !== "");
131
144
  }
132
145
  /**
@@ -136,6 +149,68 @@ function describeSelect(node) {
136
149
  async function readSelectOptions(loc) {
137
150
  return loc.evaluate(describeSelect, undefined, { timeout: 1000 }).catch(() => null);
138
151
  }
152
+ /**
153
+ * Runs in the page against one dropdown: every option, disabled and
154
+ * placeholder ones included, in document order, so a requested value can be
155
+ * matched (forms.ts matchOption) before anything is picked. Null when the node
156
+ * is not a select (or a label for one).
157
+ */
158
+ function listSelectOptions(node) {
159
+ const target = node instanceof HTMLLabelElement ? (node.control ?? node.querySelector("select")) : node;
160
+ if (!(target instanceof HTMLSelectElement))
161
+ return null;
162
+ return Array.from(target.options).map((o) => ({
163
+ value: o.value,
164
+ // Whole, not cut short: an exact label has to match all of it.
165
+ label: (o.label || o.textContent || "").trim(),
166
+ disabled: o.disabled || (o.parentElement instanceof HTMLOptGroupElement && o.parentElement.disabled),
167
+ }));
168
+ }
169
+ /**
170
+ * Runs in the page against a click target, just before a forced click: what
171
+ * is on top of it at its centre, where the click aims. Null when nothing is
172
+ * (the target, something inside it, or one of its own labels is the top hit)
173
+ * or the centre is out of view. The hit is named by the nearest element at or
174
+ * above it with a role or a test id, never one that holds the target too.
175
+ */
176
+ function readCoverAt(node) {
177
+ const r = node.getBoundingClientRect();
178
+ const x = r.left + r.width / 2;
179
+ const y = r.top + r.height / 2;
180
+ if (x < 0 || y < 0 || x >= window.innerWidth || y >= window.innerHeight)
181
+ return null;
182
+ const hit = document.elementFromPoint(x, y);
183
+ // A hit that holds the target (its wrapper, under a target with pointer-events: none) is not on top of it.
184
+ if (!hit || node.contains(hit) || hit.contains(node))
185
+ return null;
186
+ const labels = node.labels;
187
+ if (labels && Array.from(labels).some((l) => l.contains(hit)))
188
+ return null;
189
+ let named = hit;
190
+ for (let n = hit; n && n !== document.body && !n.contains(node); n = n.parentElement) {
191
+ if (n.getAttribute("role") || n.getAttribute("data-testid")) {
192
+ named = n;
193
+ break;
194
+ }
195
+ }
196
+ const tag = named.tagName.toLowerCase();
197
+ const implied = {
198
+ button: "button",
199
+ a: "link",
200
+ dialog: "dialog",
201
+ img: "image",
202
+ nav: "navigation",
203
+ header: "banner",
204
+ footer: "contentinfo",
205
+ aside: "complementary",
206
+ };
207
+ return {
208
+ role: named.getAttribute("role") || implied[tag] || null,
209
+ tag,
210
+ text: (named.innerText || named.textContent || "").trim().slice(0, 120),
211
+ testid: named.getAttribute("data-testid"),
212
+ };
213
+ }
139
214
  /** Runs in the page against one file input (or the one a chooser belongs to). */
140
215
  function describeFileInput(node) {
141
216
  const input = node;
@@ -204,12 +279,25 @@ export class BrowserEngine {
204
279
  currentFingerprint = "";
205
280
  /** URL at the time of the last snapshot — refs are valid only while it matches. */
206
281
  snapshotUrl = "";
207
- /** Last snapshot's identity map (per route) — enables stable refs + diff snapshots. */
208
- lastSnap = null;
282
+ /** When the last snapshot was taken (0 before the first): what a forced click's covering element is compared against. */
283
+ snapshotAt = 0;
284
+ /**
285
+ * The last snapshot of each recent route, newest last (at most
286
+ * SNAPSHOTS_KEPT): a route snapshotted again keeps its refs and is shown as
287
+ * a diff, even after a visit elsewhere, and a tab of a screen diffs against
288
+ * the screen's last tab.
289
+ */
290
+ snaps = new Map();
291
+ /** The route of the latest snapshot, so a diff can say whether it is against that one. */
292
+ lastSnapRoute = "";
293
+ /** Why the refs of the latest snapshot were dropped since it was taken, or null when they were not. */
294
+ refsDropped = null;
209
295
  /** Non-parameterized routes discovered by the project scan — the objective completion contract. */
210
296
  knownRoutes = [];
211
297
  /** Real path of the attached project — the fence for scout_upload's filePath. */
212
298
  projectDir = "";
299
+ /** The folder a login command must name with --project (a folder SceneScout chose), else undefined. */
300
+ loginProject = { projectDir: "", projectChosen: false };
213
301
  /** Set when the project's real path could not be resolved — named in fence refusals, which it may then cause. */
214
302
  projectDirNote = "";
215
303
  memory = null;
@@ -223,6 +311,9 @@ export class BrowserEngine {
223
311
  /** Origins named as trusted embeds (policy.ts trustsEmbedWrite decides when that counts). */
224
312
  trustedEmbeds = new Set();
225
313
  trustNotice = "";
314
+ /** POST endpoints the user named as reads (policy.ts readPostAllowed decides when that counts). */
315
+ readPosts = [];
316
+ readPostNotice = "";
226
317
  /** Human label for the auth identity driving this session: the role, the storage-state file's name, or anonymous. Set by attach. */
227
318
  role = "anonymous";
228
319
  /** How the attached session signed in: a role profile, a storage-state file, or not at all. */
@@ -239,10 +330,14 @@ export class BrowserEngine {
239
330
  * profile holds no refresh token.
240
331
  */
241
332
  refresh = null;
333
+ /** Requests the broker took the lock for: their write-back is its own, so the rotation watcher leaves them alone. */
334
+ brokeredRequests = new WeakSet();
242
335
  /** Write-backs still waiting on a response, so close() lets them release their lock. */
243
336
  refreshTasks = new Set();
244
337
  /** What the broker did this session, for the lane report: counts only. */
245
- refreshCounts = { refreshed: 0, swapped: 0, failed: 0 };
338
+ refreshCounts = { refreshed: 0, swapped: 0, failed: 0, learned: 0 };
339
+ /** What the broker did since the last action result, reported (and cleared) by drainRefresh. */
340
+ refreshEvents = [];
246
341
  /** UI-label blocking applies only in read-only mode (safe-write enforces at the network layer instead). */
247
342
  get readOnly() {
248
343
  // observe is read-only and then some: every UI-level refusal applies to it too.
@@ -255,6 +350,14 @@ export class BrowserEngine {
255
350
  }
256
351
  /** Requests blocked by the write policy since the last action (timestamped for attribution). */
257
352
  blockedRequests = [];
353
+ /** What this session has been told about blocked writes, so each endpoint is explained once. */
354
+ blockNotices = new BlockNotices();
355
+ /** Native dialogs the page opened since the last action's result, and how each was answered. */
356
+ dialogsSeen = [];
357
+ /** The caller's answer to a leave confirmation for the action in progress (dialogResponse); undefined lets the mode decide. */
358
+ leaveChoice = undefined;
359
+ /** Resolves the navigation in progress when the page's leave confirmation is answered "stay". */
360
+ onLeaveRefused = null;
258
361
  /**
259
362
  * WebSockets this session's pages opened. The write policy works on HTTP
260
363
  * requests; frames sent over a socket are not inspected. In observe mode that
@@ -316,6 +419,33 @@ export class BrowserEngine {
316
419
  }
317
420
  /** Design audits run by this session. The report gate counts the whole run's, on the shared store (MemoryStore.auditsThisRun). */
318
421
  designAuditCount = 0;
422
+ /** The data requests the driven page made since its document loaded (scout_network). */
423
+ pageRequests = new PageRequests();
424
+ /** Add a fetch/XHR to the page's request list when the driven page (any of its frames) sent it. */
425
+ notePageRequest(req) {
426
+ let fromPage = false;
427
+ try {
428
+ fromPage = !!this.page && req.frame().page() === this.page;
429
+ }
430
+ catch {
431
+ /* a service worker's request has no frame: not the page's */
432
+ }
433
+ if (!fromPage)
434
+ return;
435
+ let route = "";
436
+ try {
437
+ route = new URL(this.page.url()).pathname;
438
+ }
439
+ catch {
440
+ /* about:blank and the like have no route */
441
+ }
442
+ this.pageRequests.started(req, { method: req.method(), url: req.url(), route, at: Date.now() });
443
+ }
444
+ /** The data requests the page made since it loaded, as scout_network lists them. */
445
+ listPageRequests(opts = {}) {
446
+ this.requirePage();
447
+ return formatPageRequests(this.pageRequests.snapshot, { now: Date.now(), ...opts });
448
+ }
319
449
  /** Active task-efficiency measurement (scout_journey), if any. */
320
450
  journey = null;
321
451
  /** The session's objective: the whole remit the agent was given at scout_attach. Empty when none was given. */
@@ -332,17 +462,40 @@ export class BrowserEngine {
332
462
  /** How many frames could not be written. The first one says so in the log; the rest are counted. */
333
463
  framesFailed = 0;
334
464
  /**
335
- * The Authorization header the app itself last sent, replayed by
336
- * scout_request so a call with the UI bypassed carries the same credential
337
- * as a click. Nothing here parses it: whatever scheme the app uses is
338
- * whatever gets replayed.
465
+ * The Authorization header the app itself last sent to its own origin, on
466
+ * any request (authToRemember says which), replayed by scout_request so a
467
+ * call with the UI bypassed carries the same credential as a click. Nothing
468
+ * here parses it: whatever scheme the app uses is whatever gets replayed.
339
469
  */
340
470
  lastAuthHeader = null;
341
- rememberAuthHeader(headers) {
342
- const value = headers["authorization"] ?? headers["Authorization"];
343
- if (value && value.trim())
471
+ /** The status of the page's latest authorised script request to its own origin, for staleCredentialNote. */
472
+ lastAuthAnswer = null;
473
+ rememberAuthHeader(req) {
474
+ const value = this.authOf(req);
475
+ if (value)
344
476
  this.lastAuthHeader = value;
345
477
  }
478
+ /** The Authorization header a request carries that counts as the app's own (authToRemember), or null. */
479
+ authOf(req) {
480
+ let frameUrl;
481
+ try {
482
+ frameUrl = req.frame().url();
483
+ }
484
+ catch {
485
+ /* a worker's request has no frame */
486
+ }
487
+ return authToRemember({ url: req.url(), baseUrl: this.baseUrl, headers: req.headers(), replay: this.replayRequests.has(req), frameUrl });
488
+ }
489
+ /** Note the status of the page's own authorised script request to its origin; a replay's is not the page's. */
490
+ noteAuthAnswer(res) {
491
+ const req = res.request();
492
+ const type = req.resourceType();
493
+ if (type !== "fetch" && type !== "xhr")
494
+ return;
495
+ if (this.authOf(req) === null)
496
+ return;
497
+ this.lastAuthAnswer = { status: res.status() };
498
+ }
346
499
  /**
347
500
  * Call the app's own API as this session, with the UI bypassed.
348
501
  *
@@ -360,16 +513,24 @@ export class BrowserEngine {
360
513
  const target = resolveRequestUrl(this.baseUrl, input.path);
361
514
  if ("problem" in target)
362
515
  return `REFUSED: ${target.problem}`;
516
+ const headers = requestHeaders({ given: input.headers, auth: this.lastAuthHeader, body: input.body });
517
+ // Whether the credential sent is the one replayed from the page, not one the caller chose.
518
+ const replayedAuth = this.lastAuthHeader !== null && headers["authorization"] === this.lastAuthHeader;
363
519
  const script = buildRequestScript({
364
520
  url: target.url,
365
521
  method: method.method,
366
522
  body: input.body,
367
- headers: requestHeaders({ given: input.headers, auth: this.lastAuthHeader, body: input.body }),
523
+ headers,
524
+ ...(wantsView(input.view) ? { keep: BODY_FETCH_MAX } : {}),
368
525
  });
369
526
  let raw;
527
+ // Claimed by the first matching request the page sends (the request
528
+ // listener), so the oracles and the contradiction ledger know it is ours.
529
+ this.pendingReplay = { method: method.method, key: requestKey(target.url) };
530
+ this.oracles.replayStarted(target.url);
370
531
  try {
371
532
  raw = (await page.evaluate(script));
372
- this.forgetReplay(method.method, target.url);
533
+ this.pageRequests.markReplay(method.method, target.url);
373
534
  }
374
535
  catch (err) {
375
536
  // The page could not run the fetch at all (a navigation mid-call, a
@@ -378,7 +539,13 @@ export class BrowserEngine {
378
539
  this.logAction({ action: "request", target: `${method.method} ${input.path}`, url: page.url(), result: `blocked: ${message.split("\n")[0]}` });
379
540
  return `${method.method} ${input.path} — the request did not complete: ${message.split("\n")[0]}`;
380
541
  }
381
- const result = toReplayResult(raw);
542
+ finally {
543
+ this.pendingReplay = null;
544
+ this.oracles.replayEnded(target.url);
545
+ for (const hop of this.replayHops.splice(0))
546
+ this.oracles.replayEnded(hop);
547
+ }
548
+ const result = toReplayResult(raw, input.view);
382
549
  this.logAction({
383
550
  action: "request",
384
551
  target: `${method.method} ${input.path}`,
@@ -386,23 +553,36 @@ export class BrowserEngine {
386
553
  // Not a status signature: the trail must not record the stand-in as the server's answer.
387
554
  result: result.refusedByPolicy ? `blocked: write policy (${result.refusedByPolicy})` : replaySignature(method.method, result.url, result.status),
388
555
  });
389
- return formatReplay(method.method, result);
556
+ return formatReplay(method.method, result) + (result.refusedByPolicy ? "" : staleCredentialNote(result.status, replayedAuth, this.lastAuthAnswer));
390
557
  }
558
+ /** The scout_request call in flight, until the page's request for it is seen. */
559
+ pendingReplay = null;
560
+ /** Addresses the call in flight was redirected to, ended with it. */
561
+ replayHops = [];
391
562
  /**
392
- * Take the replayed request back out of the contradiction ledger. It was the
393
- * agent's call, not the page's, so whatever the page says next is not its
394
- * answer — and left in, a replay the policy refused was blamed on the next
395
- * click as that click's false success, in place of the click's own request.
396
- * Only this request: anything else the page fetched meanwhile stays.
563
+ * The requests scout_request sent, by identity (redirect hops included). They
564
+ * are the agent's calls, not the page's: their failures are not the page's
565
+ * violations, and whatever the page says next is not their answer. Left in
566
+ * the contradiction ledger, a replay the policy refused was blamed on the
567
+ * next click as that click's false success.
397
568
  */
398
- forgetReplay(method, url) {
399
- for (let i = this.watchedResponses.length - 1; i >= 0; i -= 1) {
400
- const r = this.watchedResponses[i];
401
- if (r.method === method && r.url === url) {
402
- this.watchedResponses.splice(i, 1);
403
- return;
404
- }
569
+ replayRequests = new WeakSet();
570
+ /** When each request started, so the contradiction rules can tell the action's own writes from ones already in flight. */
571
+ requestStartedAt = new WeakMap();
572
+ /** Note whether a request the page is sending is this session's scout_request call. */
573
+ claimReplay(req) {
574
+ const from = req.redirectedFrom();
575
+ if (from && this.replayRequests.has(from)) {
576
+ this.replayRequests.add(req);
577
+ this.oracles.replayStarted(req.url());
578
+ this.replayHops.push(req.url());
579
+ return;
405
580
  }
581
+ const pending = this.pendingReplay;
582
+ if (!pending || req.method() !== pending.method || requestKey(req.url()) !== pending.key)
583
+ return;
584
+ this.replayRequests.add(req);
585
+ this.pendingReplay = null;
406
586
  }
407
587
  /**
408
588
  * The frame field for a log entry, or nothing. Spread into logAction so a
@@ -516,7 +696,7 @@ export class BrowserEngine {
516
696
  // role in a multi-role run, so a concurrent session navigating during this
517
697
  // journey would otherwise contaminate its path, screen count, and backtracks.
518
698
  const log = (this.memory?.actionLog ?? []).slice(j.fromLog).filter((e) => (e.session ?? this.sessionKey) === this.sessionKey);
519
- const seconds = Math.round((Date.now() - j.startedAt) / 1000);
699
+ const time = journeyTime(j.startedAt, log.map((e) => Date.parse(e.at)), Date.now());
520
700
  const measured = measureJourney(log, completed);
521
701
  try {
522
702
  // Only a COMPLETED journey is a measurement of task ease. An abandoned
@@ -528,7 +708,7 @@ export class BrowserEngine {
528
708
  /* fact recording is best-effort */
529
709
  }
530
710
  this.logAction({ action: JOURNEY_END, target: j.goal, url: page.url(), result: completed ? "completed" : "abandoned" });
531
- return formatJourney({ goal: j.goal, completed, seconds, note }, measured);
711
+ return formatJourney({ goal: j.goal, completed, time, note }, measured);
532
712
  }
533
713
  /** Whether the browser window is visible — headed hover results carry a physical-cursor caveat. */
534
714
  headed = false;
@@ -618,18 +798,101 @@ export class BrowserEngine {
618
798
  * element on the current page? Runs wherever violations are drained, so the
619
799
  * finding reaches the agent in the result of the action that revealed it.
620
800
  */
621
- /** Record one answered request for the contradiction rules. Ones the policy stopped are marked, never dropped: the rules need to know they were ours. */
801
+ /**
802
+ * Record one answered request for the contradiction rules. Ones the policy
803
+ * stopped are marked, never dropped: the rules need to know they were ours.
804
+ * The agent's own scout_request calls are left out: the page did not send them.
805
+ */
622
806
  watchResponse(req, status) {
807
+ if (this.replayRequests.has(req))
808
+ return;
623
809
  if (this.watchedResponses.length >= BrowserEngine.MAX_WATCHED_RESPONSES)
624
810
  return;
811
+ const started = this.requestStartedAt.get(req) ?? 0;
812
+ let isDocumentLoad = false;
813
+ try {
814
+ isDocumentLoad = req.isNavigationRequest() && this.page !== null && req.frame() === this.page.mainFrame();
815
+ }
816
+ catch {
817
+ /* a worker's request: no frame, so not the page's navigation */
818
+ }
625
819
  this.watchedResponses.push({
626
820
  method: req.method(),
627
821
  url: req.url(),
628
822
  status,
629
823
  resourceType: req.resourceType(),
630
824
  blockedByPolicy: this.refusedByAnyPolicy(req),
825
+ // The current action is a user input only when it is the one that set the input mark (claims.ts isBackgroundRequest).
826
+ background: isBackgroundRequest({
827
+ started,
828
+ inputSince: this.inputSince,
829
+ actionStartedAt: this.actionStartedAt,
830
+ documentLoadSince: this.documentLoadSince,
831
+ isDocumentLoad,
832
+ }),
631
833
  });
632
834
  }
835
+ /**
836
+ * When the current user input (a click, a keypress, typing, a choice) began,
837
+ * or null before the first. An action that is not input (a navigation, a
838
+ * scroll, a crawl) moves actionStartedAt past it, which is how a request is
839
+ * known to have no input behind it.
840
+ */
841
+ inputSince = null;
842
+ /** What the page said as the current input began, for the contradiction rules; null when it could not be read. */
843
+ claimBaseline = null;
844
+ /** When the current input started loading a new main-frame document, or null while it has loaded none (claims.ts isBackgroundRequest). */
845
+ documentLoadSince = null;
846
+ /** The control the current click acted on, as the click found it, for the kept-change rule (claims.ts keptChange). */
847
+ actedControl = null;
848
+ /** When the write policy last refused a request, for the snapshot's "after a write-policy block" tag. */
849
+ lastBlockAt = 0;
850
+ /**
851
+ * Mark the start of a user input: read what the page says now, so a claim
852
+ * already on screen (a status badge, a heading) is not taken for the
853
+ * page's answer to this input's write. Never fails the action: with no
854
+ * baseline every text counts, as before.
855
+ */
856
+ async beginInput() {
857
+ this.inputSince = this.actionStartedAt;
858
+ this.claimBaseline = null;
859
+ this.documentLoadSince = null;
860
+ this.actedControl = null;
861
+ const page = this.page;
862
+ if (!page || page.isClosed())
863
+ return;
864
+ try {
865
+ this.claimBaseline = (await page.evaluate(CLAIM_SCAN_SCRIPT));
866
+ }
867
+ catch {
868
+ // A page mid-navigation has nothing on screen to excuse.
869
+ }
870
+ }
871
+ /** The page's claims as they stand (CLAIM_SCAN_SCRIPT), or null on a page that cannot be read. */
872
+ async readClaims(page) {
873
+ try {
874
+ return (await page.evaluate(CLAIM_SCAN_SCRIPT));
875
+ }
876
+ catch {
877
+ // A page mid-navigation has nothing to read.
878
+ return null;
879
+ }
880
+ }
881
+ /**
882
+ * Read the control a click is about to act on, so a refused write it sends
883
+ * can be judged by whether the control then shows the change as kept
884
+ * (claims.ts keptChange). Never fails the click: unread, the rule is skipped.
885
+ */
886
+ async readActedControl(el) {
887
+ const scope = this.scopeOf(el);
888
+ try {
889
+ const before = (await scope.evaluate(`(${ACTED_CONTROL_SRC})(${xpathLookup(el.xpath)})`));
890
+ this.actedControl = before ? { scope, xpath: el.xpath, before } : null;
891
+ }
892
+ catch {
893
+ this.actedControl = null;
894
+ }
895
+ }
633
896
  /**
634
897
  * Did the page agree with what the network just did? Runs in the same slot
635
898
  * as the injection scan, so it sees the DOM the action settled on.
@@ -641,6 +904,8 @@ export class BrowserEngine {
641
904
  async scanForContradictions() {
642
905
  const requests = this.watchedResponses;
643
906
  this.watchedResponses = [];
907
+ const acted = this.actedControl;
908
+ this.actedControl = null;
644
909
  const page = this.page;
645
910
  if (!page || page.isClosed() || requests.length === 0)
646
911
  return;
@@ -655,7 +920,19 @@ export class BrowserEngine {
655
920
  // A page mid-navigation has no DOM to ask.
656
921
  return;
657
922
  }
658
- for (const found of findContradictions(requests, state)) {
923
+ // A baseline belongs to the input that took it, and to its page.
924
+ const before = this.inputSince !== null && this.inputSince >= this.actionStartedAt ? this.claimBaseline : null;
925
+ // The acted-on control as the click left it, read only when a refusal makes it matter.
926
+ let actedNow = null;
927
+ if (acted && before) {
928
+ try {
929
+ actedNow = (await acted.scope.evaluate(`(${ACTED_CONTROL_SRC})(${xpathLookup(acted.xpath)})`));
930
+ }
931
+ catch {
932
+ // The control's document went away: nothing left to show the change.
933
+ }
934
+ }
935
+ for (const found of findContradictions(requests, state, before, acted && actedNow ? { before: acted.before, after: actedNow } : null)) {
659
936
  if (this.contradictionsReported.has(found.evidence))
660
937
  continue;
661
938
  this.contradictionsReported.add(found.evidence);
@@ -722,6 +999,10 @@ export class BrowserEngine {
722
999
  /** Write-policy blocks drained by the last action — counted, so an action can know a request fired even when the policy stopped it. */
723
1000
  lastActionBlocked = 0;
724
1001
  baseUrl = "";
1002
+ /** The command that records `role` again so this session's next attach finds it (profiles.reloginCommand). */
1003
+ reloginCommand(role) {
1004
+ return reloginCommand({ role, url: this.baseUrl, ...this.loginProject });
1005
+ }
725
1006
  get attached() {
726
1007
  return this.page !== null;
727
1008
  }
@@ -729,7 +1010,13 @@ export class BrowserEngine {
729
1010
  // Resolved before anything is closed, so a bad role, a missing profile or
730
1011
  // a time limit out of bounds leaves a live session as it was.
731
1012
  const limits = resolveTimeLimits(opts, process.env);
732
- const auth = resolveAttachAuth({ projectDir: opts.projectDir, url: opts.url, role: opts.role, storageStatePath: opts.storageStatePath });
1013
+ const auth = resolveAttachAuth({
1014
+ projectDir: opts.projectDir,
1015
+ url: opts.url,
1016
+ role: opts.role,
1017
+ storageStatePath: opts.storageStatePath,
1018
+ projectChosen: opts.projectChosen,
1019
+ });
733
1020
  const brokered = brokerEnabled({ roleSession: auth.kind === "role", option: opts.refreshBroker, env: process.env[REFRESH_BROKER_ENV] });
734
1021
  const storageStatePath = auth.kind === "none" ? undefined : auth.storageStatePath;
735
1022
  if (storageStatePath && !fs.existsSync(storageStatePath)) {
@@ -754,7 +1041,12 @@ export class BrowserEngine {
754
1041
  this.authLoss.beginSession(auth.kind, this.role);
755
1042
  let profileNote = "";
756
1043
  this.refresh = null;
757
- this.refreshCounts = { refreshed: 0, swapped: 0, failed: 0 };
1044
+ this.refreshCounts = { refreshed: 0, swapped: 0, failed: 0, learned: 0 };
1045
+ this.refreshEvents = [];
1046
+ this.brokeredRequests = new WeakSet();
1047
+ // Another attach is another sign-in: the last one's credential is not this one's.
1048
+ this.lastAuthHeader = null;
1049
+ this.lastAuthAnswer = null;
758
1050
  if (auth.kind === "role") {
759
1051
  const note = permissionNote(auth.storageStatePath, fs.statSync(auth.storageStatePath).mode);
760
1052
  if (note)
@@ -762,8 +1054,19 @@ export class BrowserEngine {
762
1054
  if (brokered) {
763
1055
  const read = this.readRoleProfile();
764
1056
  const known = read.ok ? refreshTokenSlots(read.state) : [];
765
- if (known.length > 0)
766
- this.refresh = { projectDir: opts.projectDir, role: auth.role, file: auth.storageStatePath, known, spent: [] };
1057
+ if (known.length > 0) {
1058
+ this.refresh = {
1059
+ projectDir: opts.projectDir,
1060
+ role: auth.role,
1061
+ file: auth.storageStatePath,
1062
+ known,
1063
+ spent: [],
1064
+ learned: new Set(),
1065
+ learnedCheckedAt: 0,
1066
+ learnedMtimeMs: 0,
1067
+ };
1068
+ this.learnedEndpoints();
1069
+ }
767
1070
  }
768
1071
  }
769
1072
  this.currentMode = opts.mode ?? "read-only";
@@ -780,6 +1083,16 @@ export class BrowserEngine {
780
1083
  : "") +
781
1084
  (trust.rejected.length > 0 ? ` Not a plain http(s) origin, so not trusted: ${trust.rejected.join(", ")}.` : "") +
782
1085
  (trust.overflow.length > 0 ? ` More than ${MAX_TRUSTED_EMBEDS} trusted embeds; not trusted: ${trust.overflow.join(", ")}.` : "");
1086
+ const reads = readPostEntries(readPostsSetting(opts.readPosts, process.env[READ_POSTS_ENV]));
1087
+ this.readPosts = reads.entries;
1088
+ this.readPostNotice =
1089
+ (reads.entries.length > 0
1090
+ ? this.mode === "observe"
1091
+ ? ` Read POSTs: ${reads.entries.map((e) => e.entry).join(", ")} — named as reads, so observe lets them out unless the path or body looks destructive or the body is a GraphQL mutation; each one is logged.`
1092
+ : ` Read POSTs (${reads.entries.map((e) => e.entry).join(", ")}) apply in observe mode only; ${this.mode} judges POSTs by its own rule.`
1093
+ : "") +
1094
+ (reads.rejected.length > 0 ? ` Not a "POST /path" or "POST https://host/path" entry, so not a read: ${reads.rejected.join(", ")}.` : "") +
1095
+ (reads.overflow.length > 0 ? ` More than ${MAX_READ_POSTS} read POSTs; not reads: ${reads.overflow.join(", ")}.` : "");
783
1096
  this.sessionObjective = (opts.objective ?? "").trim().replace(/\s+/g, " ").slice(0, 300);
784
1097
  // An agent-supplied task counts as stated; the placeholder does not.
785
1098
  this.setTask(opts.task ?? "Attaching and taking stock", opts.task !== undefined);
@@ -791,6 +1104,8 @@ export class BrowserEngine {
791
1104
  this.framesFailed = 0;
792
1105
  this.headed = opts.headed ?? false;
793
1106
  this.blockedRequests = [];
1107
+ this.blockNotices.reset();
1108
+ this.dialogsSeen = [];
794
1109
  this.watchedResponses = [];
795
1110
  this.contradictionsReported = new Set();
796
1111
  this.tokenPostsReported = new Set();
@@ -802,6 +1117,14 @@ export class BrowserEngine {
802
1117
  // role must not discard what another role already created — otherwise
803
1118
  // every multi-role handoff would be blocked as "not yours".
804
1119
  this.memory = opts.memoryStore ?? new MemoryStore(opts.projectDir);
1120
+ // A page refused before an endpoint was named as a read is no longer a gap for it.
1121
+ if (this.readPosts.length > 0 && this.mode === "observe") {
1122
+ const appOrigin = URL.canParse(this.baseUrl) ? new URL(this.baseUrl).origin : "";
1123
+ this.memory.clearObserveRefusedPosts((endpoint) => {
1124
+ const target = endpoint.replace(/^POST /, "");
1125
+ return matchReadPost(this.readPosts, this.baseUrl, target.startsWith("/") ? appOrigin + target : target) !== null;
1126
+ });
1127
+ }
805
1128
  // The REAL path, not merely the resolved one: on macOS the temp tree is a
806
1129
  // symlink, and a fence comparing a real path against an unreal one would
807
1130
  // refuse every upload from inside the project.
@@ -816,8 +1139,10 @@ export class BrowserEngine {
816
1139
  this.projectDir = path.resolve(opts.projectDir);
817
1140
  this.projectDirNote = ` (its real path could not be resolved: ${err instanceof Error ? err.message : String(err)} — a symlinked project path may be wrongly refused)`;
818
1141
  }
1142
+ this.loginProject = { projectDir: opts.projectDir, projectChosen: Boolean(opts.projectChosen) };
819
1143
  this.oracles = new OracleMonitor();
820
1144
  this.oracles.setPolicyRefusalCheck((req) => this.refusedByAnyPolicy(req));
1145
+ this.oracles.setReplayCheck((req) => this.replayRequests.has(req));
821
1146
  // A request an embed sends to the app is the app's to answer: only one headed outside the app is the embed's.
822
1147
  this.oracles.setEmbedAttribution((req) => {
823
1148
  let site = null;
@@ -830,7 +1155,7 @@ export class BrowserEngine {
830
1155
  }
831
1156
  return embedOfRequest(this.baseUrl, req.url(), site);
832
1157
  });
833
- this.lastSnap = null;
1158
+ this.forgetSnapshots();
834
1159
  this.designAuditCount = 0;
835
1160
  // A new attach may be a restarted app: what failed to load before gets another try.
836
1161
  this.loadFailedRoutes = new Set();
@@ -851,6 +1176,7 @@ export class BrowserEngine {
851
1176
  this.context = await this.browser.newContext({
852
1177
  storageState: profile?.storageState,
853
1178
  viewport: opts.viewport ?? { width: 1280, height: 900 },
1179
+ ...(opts.deviceScaleFactor !== undefined ? { deviceScaleFactor: opts.deviceScaleFactor } : {}),
854
1180
  serviceWorkers: serviceWorkerPolicy(this.engineName),
855
1181
  });
856
1182
  const restoreSession = sessionStorageInitScript(profile?.sessionStorage ?? []);
@@ -874,40 +1200,75 @@ export class BrowserEngine {
874
1200
  this.openSockets.clear();
875
1201
  this.socketsWarned = false;
876
1202
  const watchSockets = (p) => void p.on("websocket", (ws) => this.openSockets.add(ws.url().slice(0, 120)));
1203
+ // A request whose document goes away mid-body gets no finished or failed
1204
+ // event, so a frame that commits a navigation, is removed, or closes with
1205
+ // its page drops what it left in flight (the rule is in settle.ts).
1206
+ const requests = (this.requests = new InFlightRequests());
1207
+ const watchFrames = (p) => {
1208
+ p.on("framenavigated", (frame) => requests.navigated(frame));
1209
+ p.on("framedetached", (detached) => requests.gone((frame) => frame === detached));
1210
+ p.on("close", () => requests.gone((frame) => frame.page() === p));
1211
+ };
1212
+ const watchPage = (p) => {
1213
+ watchSockets(p);
1214
+ watchFrames(p);
1215
+ };
877
1216
  // The first page already exists by now; later ones (popups) arrive as events.
878
1217
  if (this.page)
879
- watchSockets(this.page);
880
- this.context.on("page", watchSockets);
881
- this.context.on("requestfinished", () => {
882
- this.inFlight = Math.max(0, this.inFlight - 1);
883
- });
1218
+ watchPage(this.page);
1219
+ this.context.on("page", watchPage);
1220
+ this.context.on("requestfinished", (req) => requests.ended(req));
884
1221
  this.context.on("requestfailed", (req) => {
885
- this.inFlight = Math.max(0, this.inFlight - 1);
1222
+ requests.ended(req, true);
886
1223
  this.watchResponse(req, null);
1224
+ this.pageRequests.failed(req, req.failure()?.errorText ?? "", Date.now(), this.refusedByAnyPolicy(req));
887
1225
  });
888
1226
  this.context.on("response", (res) => {
1227
+ this.noteAuthAnswer(res);
889
1228
  this.watchResponse(res.request(), res.status());
1229
+ this.pageRequests.answered(res.request(), res.status(), Date.now(), this.refusedByAnyPolicy(res.request()));
890
1230
  });
891
1231
  this.context.on("request", (req) => {
892
1232
  // Who moved the driven page, decided on its navigation's first request
893
1233
  // (this event fires before the route handler judges that page's writes).
894
1234
  if (req.isNavigationRequest() && !req.redirectedFrom()) {
895
1235
  try {
896
- if (this.page && req.frame() === this.page.mainFrame())
1236
+ if (this.page && req.frame() === this.page.mainFrame()) {
897
1237
  this.embedMoves.navigationStarted(req.url(), req.headers()["referer"], this.embeddedSites());
1238
+ this.pageRequests.loaded(req.url(), Date.now());
1239
+ // The current input moved the page to a new document: what is sent from here on is not its own write.
1240
+ if (this.inputSince !== null && this.documentLoadSince === null && this.inputSince >= this.actionStartedAt)
1241
+ this.documentLoadSince = Date.now();
1242
+ }
898
1243
  }
899
1244
  catch {
900
1245
  /* no frame: not the driven page */
901
1246
  }
902
1247
  }
903
- this.inFlight += 1;
1248
+ let sender;
1249
+ try {
1250
+ sender = req.frame();
1251
+ }
1252
+ catch {
1253
+ /* a worker's request: no frame, so it ends only by finishing or failing */
1254
+ }
1255
+ requests.started(req, sender, req.isNavigationRequest());
904
1256
  this.lastRequestStart = Date.now();
1257
+ this.requestStartedAt.set(req, this.lastRequestStart);
1258
+ this.claimReplay(req);
1259
+ // After claimReplay, so a scout_request call's own header is not taken for the app's.
1260
+ this.rememberAuthHeader(req);
905
1261
  const type = req.resourceType();
906
- if (type === "xhr" || type === "fetch")
1262
+ if (type === "xhr" || type === "fetch") {
907
1263
  this.xhrCount += 1;
1264
+ this.notePageRequest(req);
1265
+ }
908
1266
  const method = req.method();
909
1267
  if (method === "GET" || method === "HEAD" || method === "OPTIONS")
910
1268
  return;
1269
+ // A POST the user named as a read is not state the tester mutated, nor a form exercised.
1270
+ if (this.readPostOf(this.writeRule.at(), method, req.url(), req.postData()))
1271
+ return;
911
1272
  // Infrastructure POSTs (token refresh, telemetry) are not state the
912
1273
  // tester mutated — reporting them trains the driver to ignore the notice.
913
1274
  if (BENIGN_MUTATION_RE.test(req.url()))
@@ -946,6 +1307,14 @@ export class BrowserEngine {
946
1307
  }
947
1308
  }
948
1309
  });
1310
+ // The refresh broker. Registered before the write policy, so it runs after
1311
+ // it: the policy judges each request as the page sent it and hands on
1312
+ // (route.fallback) only what it lets out, and the broker sees only those.
1313
+ // A refresh the policy refuses is never sent, so it needs no lock.
1314
+ if (this.refresh) {
1315
+ await this.context.route("**/*", (route) => this.brokerRefresh(route));
1316
+ this.context.on("response", (res) => this.watchRotation(res));
1317
+ }
949
1318
  // Write policy — enforced on the wire, where the truth lives.
950
1319
  if (this.mode !== "destructive") {
951
1320
  const judge = async (route) => {
@@ -975,8 +1344,7 @@ export class BrowserEngine {
975
1344
  if (method === "GET" && req.resourceType() === "document" && this.embedEscapeNavigation(req)) {
976
1345
  const why = ESCAPE_REFUSAL;
977
1346
  // Reported like any refusal, so a click whose navigation this stopped does not read as a click that did nothing.
978
- if (this.blockedRequests.length < 20)
979
- this.blockedRequests.push({ at: Date.now(), sig: `navigation to ${req.url().slice(0, 140)}`, answered: false, why, type: req.resourceType() });
1347
+ this.noteBlocked({ at: Date.now(), sig: `navigation to ${req.url().slice(0, 140)}`, answered: false, why, type: req.resourceType() });
980
1348
  this.logAction({ action: "write-policy:blocked", target: `navigation to ${req.url().slice(0, 140)} (${why})`, url: this.page?.url() ?? "" });
981
1349
  this.refusedByPolicy.add(req);
982
1350
  this.oracles.notePolicyBlock();
@@ -1026,17 +1394,20 @@ export class BrowserEngine {
1026
1394
  catch {
1027
1395
  /* could not ask: load it as it would have loaded */
1028
1396
  }
1029
- await route.continue().catch(() => { });
1397
+ await route.fallback().catch(() => { });
1030
1398
  return;
1031
1399
  }
1400
+ // What the policy lets out it hands on with route.fallback, never route.continue: the
1401
+ // refresh broker, registered before this handler, runs next. With no broker it goes out.
1032
1402
  if (method === "GET" || method === "HEAD" || method === "OPTIONS")
1033
- return route.continue();
1403
+ return route.fallback();
1034
1404
  const url = req.url();
1035
1405
  const pathname = pathnameOf(url);
1036
1406
  const refuse = (why) => {
1037
1407
  const answered = answersWithRefusal(req.resourceType());
1038
- if (this.blockedRequests.length < 20)
1039
- this.blockedRequests.push({ at: Date.now(), sig: `${method} ${url.slice(0, 140)}`, answered, why, type: req.resourceType() });
1408
+ if (rule === "observe" && method === "POST" && !why && answered && !BENIGN_MUTATION_RE.test(url))
1409
+ this.noteObserveRefusedPost(url, req.postData());
1410
+ this.noteBlocked({ at: Date.now(), sig: `${method} ${url.slice(0, 140)}`, answered, why, type: req.resourceType() });
1040
1411
  this.logAction({ action: "write-policy:blocked", target: `${method} ${pathname}${why ? ` (${why})` : ""}`, url: this.page?.url() ?? "" });
1041
1412
  this.refusedByPolicy.add(req);
1042
1413
  this.oracles.notePolicyBlock();
@@ -1052,7 +1423,6 @@ export class BrowserEngine {
1052
1423
  const foreign = this.foreignWriteOf(req);
1053
1424
  if (foreign)
1054
1425
  return refuse(`sent from a frame of ${foreign}`);
1055
- this.rememberAuthHeader(req.headers());
1056
1426
  const destructiveWire = isDestructiveWire(pathname, req.postData());
1057
1427
  // An embed moved the session's page off the app: its writes out are not the app's, a sign-in excepted.
1058
1428
  const offApp = offAppPageWrite(this.baseUrl, this.page?.url(), url, this.embedMoves.movedTo);
@@ -1062,7 +1432,19 @@ export class BrowserEngine {
1062
1432
  // one, and in observe only the requests a login itself needs.
1063
1433
  if (isAuthExempt(rule, method, pathname, destructiveWire)) {
1064
1434
  this.routedWrites.note(rule, method, url, bodyDigest(req.postDataBuffer()));
1065
- return route.continue();
1435
+ return route.fallback();
1436
+ }
1437
+ // A POST the user named as a read (observe only, never one that looks destructive): out, and logged.
1438
+ const readPost = this.readPostOf(rule, method, url, req.postData(), destructiveWire);
1439
+ if (readPost) {
1440
+ this.logAction({
1441
+ action: "write-policy:read-post",
1442
+ target: `${method} ${pathname} (named as a read: ${readPost.entry})`,
1443
+ url: this.page?.url() ?? "",
1444
+ });
1445
+ this.routedWrites.note(rule, method, url, bodyDigest(req.postDataBuffer()));
1446
+ this.clearRefusedPost(url);
1447
+ return route.fallback();
1066
1448
  }
1067
1449
  let owned = this.isOwnedResource(pathname);
1068
1450
  // A single UI action commonly fires create-then-immediately-save
@@ -1098,7 +1480,9 @@ export class BrowserEngine {
1098
1480
  this.pendingCreations.add(task);
1099
1481
  }
1100
1482
  this.routedWrites.note(rule, method, url, bodyDigest(req.postDataBuffer()));
1101
- return route.continue();
1483
+ if (method === "POST")
1484
+ this.clearRefusedPost(url);
1485
+ return route.fallback();
1102
1486
  }
1103
1487
  return refuse();
1104
1488
  };
@@ -1115,11 +1499,6 @@ export class BrowserEngine {
1115
1499
  }
1116
1500
  }
1117
1501
  }
1118
- // The refresh broker. Registered after the write policy so it runs first
1119
- // and hands every request on to it: a refresh it lets through is still
1120
- // judged by the policy like any other request.
1121
- if (this.refresh)
1122
- await this.context.route("**/*", (route) => this.brokerRefresh(route));
1123
1502
  // Popups / target=_blank: adopt same-origin pages as the active page (with
1124
1503
  // oracles attached); close foreign-origin popups so exploration cannot
1125
1504
  // silently escape the app under test.
@@ -1142,7 +1521,7 @@ export class BrowserEngine {
1142
1521
  this.page = newPage;
1143
1522
  this.refs.clear();
1144
1523
  this.snapshotUrl = "";
1145
- this.lastSnap = null;
1524
+ this.forgetSnapshots();
1146
1525
  }
1147
1526
  else {
1148
1527
  void this.leaveAndClose(newPage);
@@ -1182,7 +1561,7 @@ export class BrowserEngine {
1182
1561
  ? `\n⚠ AUTH FAILED — the storage state at ${storageStatePath} did not produce a signed-in session: ` +
1183
1562
  `attaching landed on ${landed}, a login page. ` +
1184
1563
  (auth.kind === "role"
1185
- ? `Record it again with \`${loginCommand(auth.role, this.baseUrl)}\` (its session has most likely expired) and re-attach. `
1564
+ ? `Record it again with \`${this.reloginCommand(auth.role)}\` (its session has most likely expired) and re-attach. `
1186
1565
  : `Regenerate it (its token has most likely expired) and re-attach. `) +
1187
1566
  `Continuing now tests a logged-out app.` +
1188
1567
  (recipe.length > 0
@@ -1196,7 +1575,7 @@ export class BrowserEngine {
1196
1575
  `Memory: ${this.memory.dir}.${this.memory.loadWarning ? ` WARNING: ${this.memory.loadWarning}` : ""}` +
1197
1576
  (this.memory.prunedStates > 0 ? ` Trimmed ${this.memory.prunedStates} old page state(s) from the history; coverage is unchanged.` : "") +
1198
1577
  `${this.memory.legacyDirNote ? ` ${this.memory.legacyDirNote}` : ""}` +
1199
- `${this.memory.gitIgnoreNote ? ` ${this.memory.gitIgnoreNote}` : ""}${this.trustNotice} Call scout_snapshot to see the current state.` +
1578
+ `${this.memory.gitIgnoreNote ? ` ${this.memory.gitIgnoreNote}` : ""}${this.trustNotice}${this.readPostNotice} Call scout_snapshot to see the current state.` +
1200
1579
  profileNote +
1201
1580
  authWarning);
1202
1581
  }
@@ -1242,18 +1621,50 @@ export class BrowserEngine {
1242
1621
  this.foreignFrames.set(frame, origin);
1243
1622
  });
1244
1623
  }
1245
- /** Dialogs (confirm/alert): dismiss in read-only mode, accept otherwise. Must be wired on every page we drive, including adopted popups. */
1624
+ /**
1625
+ * Native dialogs, answered by policy.ts dialogResponse: alert, confirm and
1626
+ * prompt dismissed in read-only and observe and accepted otherwise; a leave
1627
+ * confirmation (beforeunload) by the caller's `leave`, else by the mode. Each
1628
+ * is reported in the next action's result (dialogNote). Must be wired on
1629
+ * every page we drive, including adopted popups.
1630
+ */
1246
1631
  wireDialogHandler(page) {
1247
1632
  page.on("dialog", (dialog) => {
1248
- const action = this.readOnly ? "dismiss" : "accept";
1633
+ this.nativeDialogAt = Date.now();
1634
+ const type = dialog.type();
1635
+ if (type !== "alert")
1636
+ this.nativeQuestionAt = this.nativeDialogAt;
1637
+ const leave = type === "beforeunload" ? this.leaveChoice : undefined;
1638
+ const response = dialogResponse(type, this.readOnly, leave);
1249
1639
  this.logAction({
1250
- action: `dialog:${action}`,
1640
+ action: `dialog:${type === "beforeunload" ? "leave-" : ""}${response}`,
1251
1641
  target: dialog.message().slice(0, 120),
1252
1642
  url: this.page?.url() ?? "",
1253
1643
  });
1254
- void (this.readOnly ? dialog.dismiss() : dialog.accept()).catch(() => { });
1644
+ if (this.dialogsSeen.length < 10)
1645
+ this.dialogsSeen.push({ type, message: dialog.message(), response, ...(leave !== undefined ? { leave } : {}) });
1646
+ if (type === "beforeunload" && response === "dismiss" && page === this.page)
1647
+ this.onLeaveRefused?.();
1648
+ void (response === "dismiss" ? dialog.dismiss() : dialog.accept()).catch((err) => {
1649
+ // A dialog already closed by the page or by the browser going away has nothing left to answer.
1650
+ console.error(`[scenescout] could not answer a ${type} dialog: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`);
1651
+ });
1255
1652
  });
1256
1653
  }
1654
+ /** The lines for the native dialogs the page opened since the last result, cleared as they are read. */
1655
+ drainDialogs() {
1656
+ const notes = this.dialogsSeen.map((d) => dialogNote(d)).join("");
1657
+ this.dialogsSeen = [];
1658
+ return notes;
1659
+ }
1660
+ /** Whether the page asked to confirm leaving, since the `since`th dialog this action saw, and was answered "stay". */
1661
+ leaveRefused(since) {
1662
+ return this.dialogsSeen.slice(since).some((d) => d.type === "beforeunload" && d.response === "dismiss");
1663
+ }
1664
+ /** When the page last opened a native dialog (confirm, alert, prompt). */
1665
+ nativeDialogAt = 0;
1666
+ /** When a native dialog that asks something (confirm, prompt, a leave confirmation) last opened; an alert only tells. */
1667
+ nativeQuestionAt = 0;
1257
1668
  requirePage() {
1258
1669
  if (!this.page || !this.memory) {
1259
1670
  throw new Error("Not attached. Call scout_attach first with the app URL and project path.");
@@ -1320,8 +1731,12 @@ export class BrowserEngine {
1320
1731
  }
1321
1732
  /** Set at attach: this session was given credentials, so a bounce to a login page is a verdict worth waiting for. */
1322
1733
  watchesForBounce = false;
1323
- /** Requests started and not yet finished or failed, from the context's own events. */
1324
- inFlight = 0;
1734
+ /** Requests started and not yet finished, failed, or left behind by their frame. */
1735
+ requests = new InFlightRequests();
1736
+ /** How many are in flight. */
1737
+ get inFlight() {
1738
+ return this.requests.count;
1739
+ }
1325
1740
  /** When the most recent request started, so a page that fires one late is not read too early. */
1326
1741
  lastRequestStart = 0;
1327
1742
  /**
@@ -1338,6 +1753,39 @@ export class BrowserEngine {
1338
1753
  get pace() {
1339
1754
  return this.paceMs;
1340
1755
  }
1756
+ /** Forget every kept snapshot: the next one of any route is listed in full, with new refs. */
1757
+ forgetSnapshots() {
1758
+ this.snaps.clear();
1759
+ this.lastSnapRoute = "";
1760
+ this.refsDropped = null;
1761
+ }
1762
+ /** Drop the latest snapshot's refs, remembering why so the next diff does not call them stable. */
1763
+ dropRefs(reason) {
1764
+ this.refs.clear();
1765
+ this.refsDropped ??= reason;
1766
+ }
1767
+ /**
1768
+ * The kept snapshot a route's next snapshot is compared with: the route's
1769
+ * own, else the latest of another tab of the same screen (routeBase).
1770
+ */
1771
+ prevSnapFor(route) {
1772
+ const own = this.snaps.get(route);
1773
+ if (own)
1774
+ return { snap: own, how: route === this.lastSnapRoute ? "latest" : "revisited" };
1775
+ const base = routeBase(route);
1776
+ const tabs = [...this.snaps.values()].filter((s) => routeBase(s.route) === base);
1777
+ const latest = tabs[tabs.length - 1];
1778
+ return latest ? { snap: latest, how: "tab" } : null;
1779
+ }
1780
+ /** Keep a snapshot as its route's latest, dropping the least recent route past SNAPSHOTS_KEPT. */
1781
+ keepSnapshot(snap) {
1782
+ this.snaps.delete(snap.route);
1783
+ this.snaps.set(snap.route, snap);
1784
+ while (this.snaps.size > SNAPSHOTS_KEPT)
1785
+ this.snaps.delete(this.snaps.keys().next().value);
1786
+ this.lastSnapRoute = snap.route;
1787
+ this.refsDropped = null;
1788
+ }
1341
1789
  /** Collect the current page's interactables into SnapshotElements with stable refs. */
1342
1790
  async collect() {
1343
1791
  const page = this.requirePage();
@@ -1355,7 +1803,8 @@ export class BrowserEngine {
1355
1803
  if (stable)
1356
1804
  break;
1357
1805
  }
1358
- const mainCount = rawElements.length;
1806
+ const cut = rawElements.filter((r) => r.cut);
1807
+ rawElements = rawElements.filter((r) => !r.cut);
1359
1808
  // The page's forms, read in the same settled page as its elements. The
1360
1809
  // page's own document only: an xpath names a node in one document.
1361
1810
  const rawForms = ((await this.formRead("form inventory", page.evaluate(FORMS_INVENTORY_SCRIPT))) ?? []);
@@ -1367,33 +1816,48 @@ export class BrowserEngine {
1367
1816
  // (and previously issued refs) survive re-snapshots. An element inside a
1368
1817
  // frame carries the frame in its key (collector.ts frameElementKey).
1369
1818
  const route = normalizePath(page.url());
1370
- const prevByKey = this.lastSnap?.route === route ? this.lastSnap.byKey : null;
1819
+ const prev = this.prevSnapFor(route);
1371
1820
  this.refs.clear();
1372
1821
  this.refFrames.clear();
1373
- const keyCounts = new Map();
1374
1822
  const all = [
1375
1823
  ...rawElements.map((raw) => ({ raw })),
1376
1824
  ...framed.flatMap((g) => g.raws.map((raw) => ({ raw: raw, frame: g.frame, tag: g.tag }))),
1377
1825
  ];
1378
- const elements = all.map(({ raw: el, frame, tag }) => {
1379
- const baseKey = frameElementKey(elementKey(el), tag);
1380
- const count = keyCounts.get(baseKey) ?? 0;
1381
- keyCounts.set(baseKey, count + 1);
1382
- const key = count === 0 ? baseKey : `${baseKey}~${count}`;
1383
- const ref = prevByKey?.get(key)?.ref ?? `e${++this.refCounter}`;
1826
+ // A live region listed for what it says is known by its role, not its
1827
+ // text, so a new message reads as the same region saying something else.
1828
+ const baseKeyOf = (el, name, tag) => frameElementKey(el.liveOnly ? `live:${el.role}` : elementKey({ ...el, name }), tag);
1829
+ const bases = all.map(({ raw: el, tag }) => ({
1830
+ base: baseKeyOf(el, el.name, tag),
1831
+ prior: baseKeyOf(el, el.priorName ?? el.name, tag),
1832
+ tracked: !el.liveOnly,
1833
+ }));
1834
+ const keys = ordinalKeys(bases.map((b) => b.base));
1835
+ const aliases = keyAliases(bases);
1836
+ const built = all.map(({ raw: el, frame, tag }, i) => {
1837
+ const key = keys[i];
1838
+ const { ownText: _ownText, centre: _centre, cut: _cut, priorName: _priorName, ...listed } = el;
1384
1839
  const full = {
1385
- ...el,
1840
+ ...listed,
1386
1841
  ...(tag ? { frame: tag } : {}),
1387
1842
  // Judged on the real label, then masked: the policy must see what a click would press.
1388
- destructive: isDestructive(el.name, el.testid),
1843
+ // A message is not a control: "Could not delete" in an alert is not a Delete button.
1844
+ destructive: el.liveOnly ? false : destructiveLabelOf(el) !== null,
1389
1845
  ...(tag?.foreign ? { name: masksForeignName(el.tag, el.role) ? MASKED_NAME : capForeignName(el.name) } : {}),
1390
1846
  ...(tag?.foreign && el.href ? { href: stripForeignHref(el.href) } : {}),
1391
- ref,
1847
+ ref: "",
1392
1848
  key,
1393
1849
  };
1394
- this.refs.set(ref, full);
1850
+ return { full, frame };
1851
+ });
1852
+ // An element keeps the ref it had in the snapshot it is matched to, so
1853
+ // refs an agent holds survive a re-snapshot and the diff stays readable.
1854
+ const matched = matchPrevious(prev?.snap.byKey ?? new Map(), built.map(({ full }) => ({ key: full.key, name: full.name, href: full.href, byPosition: full.liveOnly })));
1855
+ const elements = built.map(({ full, frame }, i) => {
1856
+ const was = matched[i];
1857
+ full.ref = (was ? prev?.snap.byKey.get(was)?.ref : undefined) ?? `e${++this.refCounter}`;
1858
+ this.refs.set(full.ref, full);
1395
1859
  if (frame)
1396
- this.refFrames.set(ref, frame);
1860
+ this.refFrames.set(full.ref, frame);
1397
1861
  return full;
1398
1862
  });
1399
1863
  this.harvestRoutes(elements.filter((el) => !el.frame?.foreign));
@@ -1405,8 +1869,8 @@ export class BrowserEngine {
1405
1869
  const key = status === "untracked" ? null : (formIdentity(f.attrs) ?? keyAtXpath(elements, f.submit));
1406
1870
  return key ? [{ key, guarded: status === "guarded" }] : [];
1407
1871
  });
1408
- this.lastCollectTruncated = mainCount >= 150;
1409
- return { elements, truncated: mainCount >= 150, forms };
1872
+ this.lastCollectTruncated = cut.length > 0;
1873
+ return { elements, truncated: cut.length > 0, forms, cut, prev, matched, aliases };
1410
1874
  }
1411
1875
  /** Whether the latest collect stopped at the element cap, so some controls have no key at all. */
1412
1876
  lastCollectTruncated = false;
@@ -1435,7 +1899,9 @@ export class BrowserEngine {
1435
1899
  return null;
1436
1900
  const title = ((await el.getAttribute("title")) || (await el.getAttribute("name")) || "").trim();
1437
1901
  const got = (await frame.evaluate(`(() => ({ els: ${COLLECT_INTERACTABLES_SCRIPT}, sx: window.scrollX, sy: window.scrollY }))()`));
1438
- const raws = got.els.map((r) => ({ ...r, rect: frameToPageRect(r.rect, box, { x: got.sx, y: got.sy }, pageScroll) }));
1902
+ const raws = got.els
1903
+ .filter((r) => !r.cut)
1904
+ .map((r) => ({ ...r, rect: frameToPageRect(r.rect, box, { x: got.sx, y: got.sy }, pageScroll) }));
1439
1905
  let origin = "";
1440
1906
  try {
1441
1907
  const u = new URL(frame.url());
@@ -1528,12 +1994,12 @@ export class BrowserEngine {
1528
1994
  */
1529
1995
  async captureCoverageState() {
1530
1996
  const page = this.requirePage();
1531
- const { elements, forms } = await this.collect();
1997
+ const { elements, forms, aliases } = await this.collect();
1532
1998
  const url = page.url();
1533
- const fp = fingerprintState(url, elements);
1534
- this.memory?.visitState(fp, url, normalizePath(url), elements.map((el) => el.key));
1999
+ const fp = fingerprintState(url, trackedElements(elements));
2000
+ this.memory?.visitState(fp, url, normalizePath(url), trackedElements(elements).map((el) => el.key), inertKeys(elements), this.sessionKey, aliases);
1535
2001
  for (const f of forms)
1536
- this.memory?.recordForm(fp, f.key, f.guarded);
2002
+ this.memory?.recordForm(fp, f.key, f.guarded, this.sessionKey);
1537
2003
  return { fp, elements, url };
1538
2004
  }
1539
2005
  /**
@@ -1575,6 +2041,16 @@ export class BrowserEngine {
1575
2041
  if (found.length > 0)
1576
2042
  this.memory.addDiscoveredRoutes(found);
1577
2043
  }
2044
+ /** What the main region holds besides controls (MAIN_REGION_SCRIPT). A failed read is logged and gives no line, not "EMPTY". */
2045
+ async readMainRegion(page) {
2046
+ try {
2047
+ return (await page.evaluate(MAIN_REGION_SCRIPT));
2048
+ }
2049
+ catch (err) {
2050
+ console.error(`[scenescout] main-region read failed: ${err instanceof Error ? err.message : String(err)}`);
2051
+ return null;
2052
+ }
2053
+ }
1578
2054
  /** Every file input on the page. A failed probe is logged, not passed off as "none". */
1579
2055
  async listFileInputs(page) {
1580
2056
  try {
@@ -1605,15 +2081,16 @@ export class BrowserEngine {
1605
2081
  const page = this.requirePage();
1606
2082
  const memory = this.memory;
1607
2083
  await this.settle();
1608
- const { elements, truncated, forms } = await this.collect();
2084
+ const { elements, truncated, forms, cut, prev: prevSnap, matched, aliases } = await this.collect();
1609
2085
  const url = page.url();
1610
2086
  this.snapshotUrl = url;
2087
+ this.snapshotAt = Date.now();
1611
2088
  const route = normalizePath(url);
1612
- const fp = fingerprintState(url, elements);
2089
+ const fp = fingerprintState(url, trackedElements(elements));
1613
2090
  this.currentFingerprint = fp;
1614
- const isNew = memory.visitState(fp, url, route, elements.map((el) => el.key));
2091
+ const isNew = memory.visitState(fp, url, route, trackedElements(elements).map((el) => el.key), inertKeys(elements), this.sessionKey, aliases);
1615
2092
  for (const f of forms)
1616
- memory.recordForm(fp, f.key, f.guarded);
2093
+ memory.recordForm(fp, f.key, f.guarded, this.sessionKey);
1617
2094
  memory.recordRoleAccess(this.role, route, "reached");
1618
2095
  // A snapshot is what an agent takes when it wants to LOOK at something, so
1619
2096
  // it is the frame a reader most wants beside the step. Recording only the
@@ -1622,30 +2099,53 @@ export class BrowserEngine {
1622
2099
  this.logAction({ action: "snapshot", url, result: fp, ...(await this.frameFor("snapshot")) });
1623
2100
  await this.scanForInjections();
1624
2101
  await this.scanForContradictions();
2102
+ // An alert or live region that appeared after the write policy refused one of
2103
+ // the current input's requests may be the page answering the engine's
2104
+ // refusal, not a defect of its own: said beside it, so a reader of the
2105
+ // snapshot alone does not file it (claims.ts saidSince).
2106
+ const blockedThisInput = this.inputSince !== null && this.inputSince >= this.actionStartedAt && this.lastBlockAt >= this.inputSince;
2107
+ const afterBlock = (el) => blockedThisInput && (el.liveOnly || el.role === "alert" || el.role === "status") && saidSince(el.name, this.claimBaseline)
2108
+ ? " (after a write-policy block)"
2109
+ : "";
1625
2110
  const line = (el) => {
1626
2111
  const dup = el.key.match(/~(\d+)$/);
1627
2112
  const flags = [
2113
+ ...stateFlags(el.state),
1628
2114
  el.testid ? `testid=${el.testid}` : null,
1629
2115
  dup ? `copy#${Number(dup[1]) + 1}` : null,
1630
2116
  el.disabled ? "disabled" : null,
1631
2117
  el.destructive ? "DESTRUCTIVE" : null,
2118
+ ...affordanceFlags(el),
1632
2119
  labelFlag(el),
1633
- memory.wasExercised(fp, el.key) ? "done" : null,
2120
+ // "exercised", not "done": it says an earlier action or run acted on it, and "done" read as the control's own state.
2121
+ memory.wasExercised(fp, el.key) ? "exercised" : null,
1634
2122
  el.href ? `href=${el.href.slice(0, 60)}` : null,
1635
2123
  ].filter(Boolean);
1636
- return `${el.ref} ${el.role} "${displayName(el)}"${flags.length ? ` [${flags.join(", ")}]` : ""}${el.frame ? ` ⟨in ${frameLabel(el.frame)}⟩` : ""}`;
2124
+ return `${el.ref} ${el.role} "${displayName(el)}"${flags.length ? ` [${flags.join(", ")}]` : ""}${el.frame ? ` ⟨in ${frameLabel(el.frame)}⟩` : ""}${afterBlock(el)}`;
1637
2125
  };
1638
- // Diff mode: when re-snapshotting the same route, report only what
1639
- // changed — same idea as UI reconciliation, applied to agent context.
1640
- const prev = this.lastSnap?.route === route ? this.lastSnap : null;
1641
- this.lastSnap = { route, byKey: new Map(elements.map((el) => [el.key, { ref: el.ref, label: el.name, disabled: el.disabled }])) };
2126
+ // Diff mode: when re-snapshotting a route (or another tab of the same
2127
+ // screen), report only what changed — same idea as UI reconciliation,
2128
+ // applied to agent context. Elements are paired with the kept snapshot's
2129
+ // by matchPrevious, the same pairing that gave them their refs.
2130
+ const prev = prevSnap?.snap ?? null;
2131
+ const refsDropped = this.refsDropped;
2132
+ this.keepSnapshot({
2133
+ route,
2134
+ byKey: new Map(elements.map((el) => [el.key, { ref: el.ref, label: el.name, href: el.href, disabled: el.disabled, state: stateFlags(el.state) }])),
2135
+ });
2136
+ const cutLine = truncated ? `TRUNCATED at ${COLLECTOR_CAP}, dense page — cut: ${cutSummary(cut)}` : "";
1642
2137
  let body;
1643
- if (!full && prev) {
1644
- const currentKeys = new Set(elements.map((el) => el.key));
1645
- const added = elements.filter((el) => !prev.byKey.has(el.key));
1646
- const removed = [...prev.byKey.entries()].filter(([key]) => !currentKeys.has(key));
2138
+ if (!full && prev && prevSnap) {
2139
+ const was = new Map(elements.map((el, i) => [el.key, matched[i]]));
2140
+ const oldOf = (el) => {
2141
+ const key = was.get(el.key);
2142
+ return key ? prev.byKey.get(key) : undefined;
2143
+ };
2144
+ const pairedPrev = new Set(matched.filter((k) => k !== null));
2145
+ const added = elements.filter((el) => !oldOf(el));
2146
+ const removed = [...prev.byKey.entries()].filter(([key]) => !pairedPrev.has(key));
1647
2147
  const relabeled = elements.filter((el) => {
1648
- const old = prev.byKey.get(el.key);
2148
+ const old = oldOf(el);
1649
2149
  return old !== undefined && old.label !== el.name;
1650
2150
  });
1651
2151
  // An element can change WITHOUT being added, removed or relabeled: the
@@ -1657,29 +2157,52 @@ export class BrowserEngine {
1657
2157
  // to enabled "Save" changed BOTH ways, and reporting only the relabel
1658
2158
  // swallows the enable — the exact fact this line exists to surface.
1659
2159
  const retoggled = elements.filter((el) => {
1660
- const old = prev.byKey.get(el.key);
2160
+ const old = oldOf(el);
1661
2161
  return old !== undefined && old.disabled !== el.disabled;
1662
2162
  });
1663
- const changedKeys = new Set([...relabeled, ...retoggled].map((el) => el.key));
2163
+ // A filter pill that became the active one, a tab now selected, a
2164
+ // section now open: the same element, in a different state.
2165
+ const restated = elements.flatMap((el) => {
2166
+ const old = oldOf(el);
2167
+ const change = old ? stateChange(old.state, stateFlags(el.state)) : null;
2168
+ return change ? [{ el, change }] : [];
2169
+ });
2170
+ const changedKeys = new Set([...relabeled, ...retoggled, ...restated.map((r) => r.el)].map((el) => el.key));
1664
2171
  const unchanged = elements.length - added.length - changedKeys.size;
1665
- if (added.length === 0 && removed.length === 0 && relabeled.length === 0 && retoggled.length === 0) {
1666
- body = `No element changes since the last snapshot (${unchanged} interactables, refs unchanged).`;
2172
+ // What the diff is against, and whether refs held from the latest snapshot still work.
2173
+ const against = prevSnap.how === "latest"
2174
+ ? "the last snapshot"
2175
+ : prevSnap.how === "revisited"
2176
+ ? "this route's last snapshot"
2177
+ : `the last snapshot of ${prev.route} (tab changed)`;
2178
+ const refNote = prevSnap.how === "latest" && !refsDropped
2179
+ ? "refs stable"
2180
+ : `refs from ${prevSnap.how === "latest" ? "it were dropped when " + refsDropped : "that snapshot"} are re-issued: an unchanged element has its old ref again`;
2181
+ if (added.length === 0 && removed.length === 0 && relabeled.length === 0 && retoggled.length === 0 && restated.length === 0) {
2182
+ body = `No element changes since ${against} (${unchanged} interactables, ${refNote === "refs stable" ? "refs unchanged" : refNote}).`;
1667
2183
  }
1668
2184
  else {
1669
2185
  body =
1670
- `DIFF vs last snapshot (${unchanged} unchanged, refs stable):\n` +
2186
+ `DIFF vs ${against} (${unchanged} unchanged, ${refNote}):\n` +
1671
2187
  [
1672
2188
  ...added.map((el) => `+ ${line(el)}`),
1673
2189
  ...removed.map(([key, v]) => `- ${v.ref} "${v.label}" (gone: ${key})`),
1674
- ...relabeled.map((el) => `~ ${el.ref} relabeled → "${el.name}"`),
2190
+ // A live region saying something new is the message itself, so it is shown in full like a new element.
2191
+ ...relabeled.map((el) => el.liveOnly
2192
+ ? `~ ${el.ref} ${el.role} "${displayName(el)}" (was ${oldOf(el)?.label ? `"${oldOf(el)?.label}"` : "empty"})${afterBlock(el)}`
2193
+ : `~ ${el.ref} relabeled → "${el.name}"`),
1675
2194
  ...retoggled.map((el) => `~ ${el.ref} "${el.name}" is now ${el.disabled ? "DISABLED" : "ENABLED"}`),
2195
+ ...restated.map(({ el, change }) => `~ ${el.ref} "${displayName(el)}" ${change}`),
1676
2196
  ].join("\n");
1677
2197
  }
2198
+ if (cutLine)
2199
+ body += `\n${cutLine}`;
1678
2200
  }
1679
2201
  else {
1680
- const missingTestids = elements.filter((el) => !el.testid && !el.disabled).length;
2202
+ const missingTestids = trackedElements(elements).filter((el) => !el.testid && !el.disabled).length;
1681
2203
  body =
1682
- `Interactables (${elements.length}${truncated ? "+ — TRUNCATED at 150, dense page" : ""}${missingTestids ? `, ${missingTestids} missing data-testid` : ""}):\n` +
2204
+ `Interactables (${elements.length}${truncated ? "+" : ""}${missingTestids ? `, ${missingTestids} missing data-testid` : ""}):\n` +
2205
+ (cutLine ? `${cutLine}\n` : "") +
1683
2206
  elements.map(line).join("\n");
1684
2207
  }
1685
2208
  // Per document: the page's own controls together, and each frame's apart —
@@ -1690,10 +2213,12 @@ export class BrowserEngine {
1690
2213
  const cov = memory.coverage();
1691
2214
  const unvisited = this.unvisitedKnownRoutes();
1692
2215
  const title = await page.title();
2216
+ const main = await this.readMainRegion(page);
1693
2217
  return (`URL: ${url}\nTitle: ${title}\nState: ${fp} ${isNew ? "(NEW state)" : "(revisited)"}\n` +
1694
2218
  `Coverage: ${cov.states} states known · ${cov.elementsExercised}/${cov.elementsTotal} elements exercised` +
1695
2219
  (this.allKnownRoutes().length > 0 ? ` · routes ${this.allKnownRoutes().length - unvisited.length}/${this.allKnownRoutes().length} visited` : "") +
1696
2220
  `\n` +
2221
+ (main ? `${mainRegionLine(main)}\n` : "") +
1697
2222
  body +
1698
2223
  (geometry.length > 0 ? `\nGEOMETRY issues:\n` + geometry.map((g) => ` ⚠ ${g}`).join("\n") : "") +
1699
2224
  (brokenImages.length > 0 ? `\nBROKEN IMAGES:\n` + brokenImages.map((b) => ` ⚠ ${b}`).join("\n") : "") +
@@ -1712,7 +2237,7 @@ export class BrowserEngine {
1712
2237
  : "") +
1713
2238
  this.socketNotice() +
1714
2239
  formatViolations(this.oracles.drain()) +
1715
- (elements.length === 0
2240
+ (trackedElements(elements).length === 0
1716
2241
  ? hasVisibleFrame(frames)
1717
2242
  ? "\n⚠ No interactable elements in the page itself: what it shows is inside the frames listed above, which were not explored."
1718
2243
  : "\n⚠ DEAD END: no interactable elements found on this page."
@@ -1720,38 +2245,112 @@ export class BrowserEngine {
1720
2245
  }
1721
2246
  /**
1722
2247
  * Resolve a ref and re-verify the live element at action time. Refs are
1723
- * trusted only while the page URL exactly matches the snapshot's, and even
1724
- * then the located element's live identity is re-read so the destructive
1725
- * policy applies to what is actually acted on — SPA re-renders can put a
1726
- * different element under a previously-safe XPath.
2248
+ * trusted while the page is on the snapshot's URL, or one that differs from
2249
+ * it only in what refsSurviveUrlChange allows (a search or filter query),
2250
+ * and even then the located element's live identity is re-read so the
2251
+ * destructive policy applies to what is actually acted on — SPA re-renders
2252
+ * can put a different element under a previously-safe XPath. After a query
2253
+ * change, the element must still carry its name as well as its test id.
2254
+ *
2255
+ * When the XPath finds nothing because the page re-rendered around the
2256
+ * element (a footer that appears while typing shifts every path below it),
2257
+ * an element with a test id that is unique on the page is found again by it,
2258
+ * checked by name, and the ref is re-bound to it; the action's result says so (rebindNote).
1727
2259
  */
1728
2260
  async resolveForAction(ref) {
1729
2261
  this.actionStartedAt = Date.now();
2262
+ this.rebindNote = "";
1730
2263
  const page = this.requirePage();
1731
- const el = this.refs.get(ref);
2264
+ let el = this.refs.get(ref);
1732
2265
  if (!el) {
1733
- throw new Error(`Unknown ref "${ref}". Refs are only valid from the latest scout_snapshot — take a new snapshot.`);
2266
+ throw new Error(`Unknown ref "${ref}". Refs are only valid from the latest scout_snapshot${this.refsDropped ? ` (they were dropped when ${this.refsDropped})` : ""} — take a new snapshot.`);
1734
2267
  }
1735
- if (page.url() !== this.snapshotUrl) {
1736
- this.refs.clear();
2268
+ const urlMoved = page.url() !== this.snapshotUrl;
2269
+ if (urlMoved && !refsSurviveUrlChange(this.snapshotUrl, page.url())) {
2270
+ this.dropRefs(`the page moved to ${page.url()}`);
1737
2271
  throw new Error(`Page URL changed since the last snapshot (now ${page.url()}). Take a new scout_snapshot.`);
1738
2272
  }
1739
- // String EXPRESSION via page.evaluate (locator.evaluate treats a string as
1740
- // an expression, not a function — the element arg never binds).
1741
- const live = (await this.scopeOf(el)
1742
- .evaluate(`(() => { const node = ${xpathLookup(el.xpath)}; if (!node) return null; ` +
1743
- `return { testid: node.getAttribute('data-testid'), label: (node.getAttribute('aria-label') || node.innerText || node.textContent || node.getAttribute('placeholder') || '').trim().slice(0, 120) }; })()`)
1744
- .catch(() => null));
2273
+ // The element and the test id are passed as arguments: no value of the
2274
+ // page's is written into the code that runs there.
2275
+ const scope = this.scopeOf(el);
2276
+ const handle = await scope.$(`xpath=${el.xpath}`).catch(() => null);
2277
+ let live = handle ? await handle.evaluate(liveFactsOf).catch(() => null) : null;
2278
+ await handle?.dispose().catch(() => undefined);
2279
+ if (!live && el.testid) {
2280
+ // Found again only by a test id the page gives one element: two would leave it guessing.
2281
+ const found = await scope.evaluate(uniqueByTestid, el.testid).catch(() => null);
2282
+ if (found && found.live.name === el.name) {
2283
+ el = { ...el, xpath: found.xpath };
2284
+ this.refs.set(ref, el);
2285
+ live = found.live;
2286
+ this.rebindNote = `\nℹ ${ref} re-bound after a re-render: found again by its unique testid=${el.testid}.`;
2287
+ }
2288
+ }
1745
2289
  if (!live) {
1746
2290
  throw new Error(`Element ${ref} no longer exists in the DOM — take a new scout_snapshot.`);
1747
2291
  }
2292
+ await this.beginInput();
1748
2293
  if (el.testid && live.testid !== el.testid) {
1749
- this.refs.clear();
2294
+ this.dropRefs(`the element under ${ref} changed`);
1750
2295
  throw new Error(`Element under ${ref} changed (expected testid=${el.testid}, found ${live.testid ?? "none"}) — the DOM shifted; take a new scout_snapshot.`);
1751
2296
  }
1752
- return { el, liveLabel: live.label };
2297
+ // After a query change a list may hold other rows under the same paths and
2298
+ // the same shared test id, so the name must match too. Another site's frame
2299
+ // shows a masked name (masksForeignName), so there is nothing to compare there.
2300
+ if (urlMoved && !el.frame?.foreign && live.name !== el.name) {
2301
+ this.dropRefs(`the element under ${ref} changed`);
2302
+ throw new Error(`Element under ${ref} changed after the URL did (expected "${el.name}", found "${live.name}") — take a new scout_snapshot.`);
2303
+ }
2304
+ return { el, liveLabel: live.label, live: { ownText: live.ownText, centre: live.centre } };
2305
+ }
2306
+ /** Set when the action's ref had to be found again by its test id (resolveForAction); afterAction reports it once. */
2307
+ rebindNote = "";
2308
+ /** The read-POST entry that lets this request out under `rule`, or null (policy.ts readPostAllowed). */
2309
+ readPostOf(rule, method, url, body, destructiveWire) {
2310
+ if (this.readPosts.length === 0 || rule !== "observe" || method !== "POST")
2311
+ return null;
2312
+ const pathname = pathnameOf(url);
2313
+ return readPostAllowed({
2314
+ mode: rule,
2315
+ method,
2316
+ url,
2317
+ appUrl: this.baseUrl,
2318
+ body,
2319
+ destructiveWire: destructiveWire ?? isDestructiveWire(pathname, body),
2320
+ entries: this.readPosts,
2321
+ });
1753
2322
  }
1754
- actionPolicyCheck(el, liveLabel) {
2323
+ /** How the gap ledger names a POST's endpoint: "POST /path" on the app's origin, "POST https://host/path" elsewhere; null when unreadable. */
2324
+ refusedPostEndpoint(url) {
2325
+ try {
2326
+ const u = new URL(url);
2327
+ return `POST ${u.origin === new URL(this.baseUrl).origin ? "" : u.origin}${u.pathname}`;
2328
+ }
2329
+ catch {
2330
+ return null;
2331
+ }
2332
+ }
2333
+ /**
2334
+ * Remember in project memory, for the gap ledger, a script's POST that
2335
+ * observe refused on the page the session is on. Not one that looks
2336
+ * destructive, nor one to an endpoint already named as a read (its body was
2337
+ * a mutation): naming it would change nothing.
2338
+ */
2339
+ noteObserveRefusedPost(url, body) {
2340
+ const pageUrl = this.page?.url();
2341
+ if (!pageUrl || !this.memory || isDestructiveWire(pathnameOf(url), body) || matchReadPost(this.readPosts, this.baseUrl, url))
2342
+ return;
2343
+ const endpoint = this.refusedPostEndpoint(url);
2344
+ if (endpoint)
2345
+ this.memory.noteObserveRefusedPost(normalizePath(pageUrl), endpoint);
2346
+ }
2347
+ /** A POST to this endpoint went out, so the pages observe refused it on are no longer a gap for it. */
2348
+ clearRefusedPost(url) {
2349
+ const endpoint = this.refusedPostEndpoint(url);
2350
+ if (endpoint && this.memory)
2351
+ this.memory.clearObserveRefusedPosts((e) => e === endpoint);
2352
+ }
2353
+ actionPolicyCheck(el, liveLabel, live = {}) {
1755
2354
  if (!this.readOnly)
1756
2355
  return null;
1757
2356
  // Same-origin navigation links are exempt: navigation is non-destructive
@@ -1765,12 +2364,46 @@ export class BrowserEngine {
1765
2364
  // same holds for choosing a file: selection is not the send.
1766
2365
  if (el.role === "textbox" || el.role === "file")
1767
2366
  return null;
1768
- if (el.destructive || isDestructive(liveLabel)) {
1769
- return destructiveRefusal(liveLabel || el.name || el.testid || el.ref, this.mode);
2367
+ // Judged as the snapshot judged it, and again on what is there now (policy.ts destructiveLabelOf).
2368
+ const now = destructiveLabelOf({
2369
+ tag: el.tag,
2370
+ role: el.role,
2371
+ name: liveLabel,
2372
+ testid: el.testid,
2373
+ ownText: live.ownText,
2374
+ centre: live.centre,
2375
+ interactive: el.interactive,
2376
+ });
2377
+ if (el.destructive || now !== null) {
2378
+ return destructiveRefusal(now ?? (liveLabel || el.name || el.testid || el.ref), this.mode);
1770
2379
  }
1771
2380
  return null;
1772
2381
  }
1773
- async afterAction(action, target) {
2382
+ /**
2383
+ * Re-rank a page error this click raised when it reads as a router
2384
+ * cancelling a route change (oracles.ts isRouteCancellation). The dialog
2385
+ * count is read only when there is such an error to judge.
2386
+ */
2387
+ async rankRouteCancellations(click, url) {
2388
+ const since = this.actionStartedAt;
2389
+ if (!this.oracles.hasPageErrorSince(since))
2390
+ return;
2391
+ let dialogsNow = null;
2392
+ try {
2393
+ dialogsNow = (await this.requirePage().evaluate(OPEN_DIALOGS_SCRIPT));
2394
+ }
2395
+ catch {
2396
+ // A page mid-navigation: only a native dialog can count.
2397
+ }
2398
+ const ariaDialogOpened = dialogsNow !== null && click.dialogsBefore !== null && dialogsNow > click.dialogsBefore;
2399
+ this.oracles.downgradeRouteCancellations(since, {
2400
+ byClick: true,
2401
+ viaLink: click.viaLink,
2402
+ urlChanged: url !== click.urlBefore,
2403
+ dialogOpened: ariaDialogOpened || this.nativeDialogAt >= since,
2404
+ });
2405
+ }
2406
+ async afterAction(action, target, click) {
1774
2407
  const page = this.requirePage();
1775
2408
  await this.settle();
1776
2409
  let url = page.url();
@@ -1785,27 +2418,39 @@ export class BrowserEngine {
1785
2418
  this.logAction({ action: policyAbortedNavigation ? "write-policy:navigation-blocked" : "origin-fence:bounced", target: url.slice(0, 200), url });
1786
2419
  await page.goBack({ waitUntil: "domcontentloaded", timeout: this.limits.backNavMs }).catch(() => { });
1787
2420
  url = page.url();
1788
- this.refs.clear();
1789
- const blocked = this.drainBlocked();
2421
+ this.dropRefs("the page left the app's origin");
2422
+ const blocked = this.drainBlocked() + this.drainRefresh();
1790
2423
  return ((policyAbortedNavigation
1791
2424
  ? `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}.`
1792
2425
  : `OK: ${action} ${target}\nNavigated off-origin and was bounced back to ${url}. Exploration is fenced to ${this.baseUrl}.`) +
1793
2426
  blocked +
2427
+ this.drainDialogs() +
1794
2428
  formatViolations(this.oracles.drain()));
1795
2429
  }
1796
2430
  const frame = await this.recordFrame(action);
1797
2431
  this.logAction({ action, target, url, ...(frame ? { frame } : {}) });
1798
2432
  await this.scanForInjections();
1799
2433
  await this.scanForContradictions();
2434
+ if (click)
2435
+ await this.rankRouteCancellations(click, url);
1800
2436
  const violations = this.oracles.drain();
1801
- const mutations = this.drainMutations() + this.drainBlocked() + this.drainCreated();
1802
- const navigated = this.snapshotUrl !== "" && url !== this.snapshotUrl;
2437
+ const mutations = this.drainMutations() + this.drainBlocked() + this.drainRefresh() + this.drainCreated();
2438
+ const moved = this.snapshotUrl !== "" && url !== this.snapshotUrl;
2439
+ // A query that is not UI state (a search, a filter) leaves the same screen: its refs still work.
2440
+ const navigated = moved && !refsSurviveUrlChange(this.snapshotUrl, url);
1803
2441
  if (navigated) {
1804
2442
  // Refs point into the previous page's DOM; invalidate so a stale ref
1805
2443
  // errors ("take a new snapshot") instead of acting on the wrong element.
1806
- this.refs.clear();
2444
+ this.dropRefs(`the page moved to ${url}`);
1807
2445
  }
1808
- return `OK: ${action} ${target}\nURL now: ${url}` + (navigated ? " (page changed — take a new snapshot)" : "") + mutations + formatViolations(violations);
2446
+ const rebound = this.rebindNote;
2447
+ this.rebindNote = "";
2448
+ return (`OK: ${action} ${target}\nURL now: ${url}` +
2449
+ (navigated ? " (page changed — take a new snapshot)" : moved ? " (same screen, query changed — refs still apply)" : "") +
2450
+ rebound +
2451
+ this.drainDialogs() +
2452
+ mutations +
2453
+ formatViolations(violations));
1809
2454
  }
1810
2455
  /** Does this request path address a record this run created? Rules live in ownership.ts. */
1811
2456
  isOwnedResource(pathname) {
@@ -2005,6 +2650,7 @@ export class BrowserEngine {
2005
2650
  let pathname = url;
2006
2651
  try {
2007
2652
  pathname = pathnameOf(url);
2653
+ const readPost = this.readPostOf(rule, method, url, bytes?.toString("utf8"));
2008
2654
  const { foreign, offApp } = unseenWriteSource({
2009
2655
  appUrl: this.baseUrl,
2010
2656
  mode: rule,
@@ -2023,7 +2669,14 @@ export class BrowserEngine {
2023
2669
  foreign,
2024
2670
  offApp,
2025
2671
  owned: this.isOwnedResource(pathname),
2672
+ readPost: readPost !== null,
2026
2673
  });
2674
+ if (readPost && verdict.allow)
2675
+ this.logAction({
2676
+ action: "write-policy:read-post",
2677
+ target: `${method} ${pathname} (named as a read: ${readPost.entry})`,
2678
+ url: this.page?.url() ?? "",
2679
+ });
2027
2680
  }
2028
2681
  catch (err) {
2029
2682
  // Fail closed: a write that cannot be judged is refused, and reported as refused below.
@@ -2041,14 +2694,13 @@ export class BrowserEngine {
2041
2694
  return;
2042
2695
  }
2043
2696
  // Refused and reported as any refusal is. Dropped rather than answered: the page that sent it is going or gone.
2044
- if (this.blockedRequests.length < 20)
2045
- this.blockedRequests.push({
2046
- at: Date.now(),
2047
- sig: `${method} ${url.slice(0, 140)}`,
2048
- answered: false,
2049
- why: verdict.why,
2050
- type: (event.resourceType ?? "other").toLowerCase(),
2051
- });
2697
+ this.noteBlocked({
2698
+ at: Date.now(),
2699
+ sig: `${method} ${url.slice(0, 140)}`,
2700
+ answered: false,
2701
+ why: verdict.why,
2702
+ type: (event.resourceType ?? "other").toLowerCase(),
2703
+ });
2052
2704
  this.logAction({ action: "write-policy:blocked", target: `${method} ${pathname}${verdict.why ? ` (${verdict.why})` : ""}`, url: this.page?.url() ?? "" });
2053
2705
  this.oracles.notePolicyBlock();
2054
2706
  }
@@ -2066,13 +2718,12 @@ export class BrowserEngine {
2066
2718
  }
2067
2719
  await answer(false);
2068
2720
  try {
2069
- if (this.blockedRequests.length < 20)
2070
- this.blockedRequests.push({
2071
- at: Date.now(),
2072
- sig: `${method} ${url.slice(0, 140)}`,
2073
- answered: false,
2074
- type: (event.resourceType ?? "other").toLowerCase(),
2075
- });
2721
+ this.noteBlocked({
2722
+ at: Date.now(),
2723
+ sig: `${method} ${url.slice(0, 140)}`,
2724
+ answered: false,
2725
+ type: (event.resourceType ?? "other").toLowerCase(),
2726
+ });
2076
2727
  this.logAction({
2077
2728
  action: "write-policy:blocked",
2078
2729
  target: `${method} ${url.slice(0, 140)} (not judged: an error in the policy)`,
@@ -2086,39 +2737,26 @@ export class BrowserEngine {
2086
2737
  }
2087
2738
  }
2088
2739
  }
2089
- /** Report (and clear) write-policy blocks since the last action. */
2740
+ /**
2741
+ * Record a refused write for the next notice. Twenty are kept as they come;
2742
+ * past that only an endpoint not already kept, up to sixty, so a page that
2743
+ * beacons on every load cannot crowd out the block an action itself caused.
2744
+ */
2745
+ noteBlocked(entry) {
2746
+ this.lastBlockAt = Math.max(this.lastBlockAt, entry.at);
2747
+ const list = this.blockedRequests;
2748
+ if (list.length < 20 || (list.length < 60 && !list.some((e) => blockSignature(e.sig) === blockSignature(entry.sig))))
2749
+ list.push(entry);
2750
+ }
2751
+ /** Report (and clear) write-policy blocks since the last action: each endpoint in full once per session, then counted (BlockNotices). */
2090
2752
  drainBlocked() {
2091
2753
  this.lastActionBlocked = this.blockedRequests.length;
2092
2754
  if (this.blockedRequests.length === 0)
2093
2755
  return "";
2094
- const list = this.blockedRequests
2095
- .slice(0, 5)
2096
- .map((e) => this.lateMark(e))
2097
- .join("; ");
2098
- const extra = this.blockedRequests.length > 5 ? ` (+${this.blockedRequests.length - 5} more)` : "";
2099
- const answered = this.blockedRequests.some((e) => e.answered);
2100
- const reasons = new Set(this.blockedRequests.map((e) => e.why).filter((w) => !!w));
2101
- const escaped = reasons.delete(ESCAPE_REFUSAL);
2102
- const foreign = [...reasons];
2756
+ const blocked = this.blockedRequests.map((e) => ({ sig: e.sig, late: e.at < this.actionStartedAt, answered: e.answered, why: e.why }));
2103
2757
  this.blockedRequests = [];
2104
2758
  // The rule that judged them, which just after a flow hands back is still the flow's (WriteRule).
2105
- const rule = this.writeRule.at();
2106
- return (`\n🛡 WRITE-POLICY blocked (${rule}): ${list}${extra}. ` +
2107
- `This is the tester's safety policy, NOT an app bug — do not file a finding for the resulting error UI. ` +
2108
- (foreign.length > 0
2109
- ? `Refused because it was ${foreign.join("; ")}: it would reach a site embedded in the page rather than the app, which no mode but destructive allows. `
2110
- : "") +
2111
- (escaped
2112
- ? `A move of the whole page off the app, with no Referer, was refused: a frame that held another site now sits on a data: or blob: URL, where WebKit drops the frame's sandbox, so the move may be that frame's. No mode but destructive allows it. `
2113
- : "") +
2114
- (answered
2115
- ? `The page's own requests were answered with a 403 in the server's place, so the page's handling of a refusal is real: an error message is correct, and a success message is a false_success violation. `
2116
- : "") +
2117
- (rule === "observe"
2118
- ? `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.`
2119
- : rule === "read-only"
2120
- ? `Re-attach with mode="safe-write" to test create/edit flows, or "destructive" (user-approved disposable env only).`
2121
- : `In safe-write, updates/deletes are only allowed on resources this session created (${this.createdResources.length} so far).`));
2759
+ return this.blockNotices.notice(this.writeRule.at(), blocked, this.createdResources.length);
2122
2760
  }
2123
2761
  /** Once per session, in observe mode only: say that socket frames are outside the policy. */
2124
2762
  socketNotice() {
@@ -2198,7 +2836,7 @@ export class BrowserEngine {
2198
2836
  const clickCount = Math.max(1, Math.min(3, clicks));
2199
2837
  try {
2200
2838
  await locator.click({ timeout, clickCount });
2201
- return { forced: false };
2839
+ return { forced: false, cover: null, coverReadAt: 0 };
2202
2840
  }
2203
2841
  catch (err) {
2204
2842
  const msg = err instanceof Error ? err.message : String(err);
@@ -2207,12 +2845,27 @@ export class BrowserEngine {
2207
2845
  const diagnostic = actionabilityDiagnostic(msg);
2208
2846
  if (!diagnostic || !/intercepts pointer events|is not stable/i.test(diagnostic))
2209
2847
  throw err;
2848
+ // What is on top at the click point, read before the forced click can
2849
+ // change it (a toast the click dismisses). Only a name for the note: a
2850
+ // read that fails leaves the note unnamed, never the click undone.
2851
+ const cover = await locator.evaluate(readCoverAt, undefined, { timeout: FORCED_CLICK_TIMEOUT_MS }).catch(() => null);
2852
+ // A block the forced click itself causes comes after the cover was there, so it cannot explain it.
2853
+ const coverReadAt = Date.now();
2210
2854
  // A shorter budget here: the forced click skips the wait that
2211
2855
  // consumed the first `timeout`, so it needs very little of its own.
2212
2856
  await locator.click({ timeout: Math.min(timeout, FORCED_CLICK_TIMEOUT_MS), force: true, clickCount });
2213
- return { forced: true };
2857
+ return { forced: true, cover, coverReadAt };
2214
2858
  }
2215
2859
  }
2860
+ /**
2861
+ * Whether the write policy refused a request between `since` and `until`
2862
+ * (timestamps). Only the latest block is kept, so a later block outside the
2863
+ * window hides an earlier one inside it: the note then stays unsuffixed.
2864
+ */
2865
+ blockedBetween(since, until) {
2866
+ const at = this.lastBlockAt;
2867
+ return at > 0 && at >= since && at <= until;
2868
+ }
2216
2869
  /**
2217
2870
  * A form read (the inventory, or the probe before a submit), bounded by
2218
2871
  * FORM_READ_MS. This bookkeeping must never fail or stall the action, so a
@@ -2247,7 +2900,7 @@ export class BrowserEngine {
2247
2900
  }
2248
2901
  /** The form the focused element belongs to, for a key press that may submit through it. */
2249
2902
  probeFocusedForm() {
2250
- return this.formRead("form probe", this.requirePage().evaluate(formProbeExpression("document.activeElement")));
2903
+ return this.formRead("form probe", this.requirePage().evaluate(FORM_PROBE_OF_ACTIVE_ELEMENT));
2251
2904
  }
2252
2905
  /**
2253
2906
  * A plan's pre-action state, fit to record a submit of this form against.
@@ -2295,13 +2948,20 @@ export class BrowserEngine {
2295
2948
  url: this.page?.url() ?? "",
2296
2949
  });
2297
2950
  }
2298
- async click(ref, clicks = 1) {
2299
- return this.withinLimit("action", () => this.clickNow(ref, clicks));
2951
+ /** `leave` answers a leave confirmation the click raises (policy.ts dialogResponse); undefined lets the mode decide. */
2952
+ async click(ref, clicks = 1, leave) {
2953
+ this.leaveChoice = leave;
2954
+ try {
2955
+ return await this.withinLimit("action", () => this.clickNow(ref, clicks));
2956
+ }
2957
+ finally {
2958
+ this.leaveChoice = undefined;
2959
+ }
2300
2960
  }
2301
2961
  async clickNow(ref, clicks = 1) {
2302
2962
  const page = this.requirePage();
2303
- const { el, liveLabel } = await this.resolveForAction(ref);
2304
- const refusal = this.actionPolicyCheck(el, liveLabel);
2963
+ const { el, liveLabel, live } = await this.resolveForAction(ref);
2964
+ const refusal = this.actionPolicyCheck(el, liveLabel, live);
2305
2965
  if (refusal) {
2306
2966
  this.logAction({ action: "click:refused", target: liveLabel || el.name, url: page.url() });
2307
2967
  return refusal;
@@ -2314,16 +2974,24 @@ export class BrowserEngine {
2314
2974
  // Submit-shaped clicks that fire zero network requests are a smell
2315
2975
  // (silent no-op forms): capture the count before to compare after.
2316
2976
  const xhrBefore = this.xhrCount;
2317
- const submitLike = el.role === "button" && /submit|send|save|create|apply|subscribe|register|sign|post|add\b/i.test(el.name + " " + (el.testid ?? ""));
2977
+ const submitLike = isSubmitLike(el.role, el.name, el.testid);
2978
+ const clickContext = {
2979
+ viaLink: el.role === "link",
2980
+ urlBefore: page.url(),
2981
+ dialogsBefore: this.claimBaseline?.dialogs ?? null,
2982
+ claimsBefore: this.claimBaseline,
2983
+ };
2318
2984
  const clickTarget = this.scopeOf(el).locator(`xpath=${el.xpath}`);
2319
2985
  // Read before the click: what the form's fields hold when it goes. Only a
2320
2986
  // button or an input can submit a form; nothing else is asked.
2321
2987
  const form = !el.frame && (el.tag === "button" || el.tag === "input") ? await this.probeForm(clickTarget) : null;
2322
2988
  const formState = { fp: this.currentFingerprint, elements: [...this.refs.values()] };
2323
- const { forced } = await this.resilientClick(clickTarget, this.limits.actionMs, clicks);
2989
+ await this.readActedControl(el);
2990
+ const { forced, cover, coverReadAt } = await this.resilientClick(clickTarget, this.limits.actionMs, clicks);
2991
+ const coverNote = cover ? describeCover(cover, this.blockedBetween(this.snapshotAt, coverReadAt)) : null;
2324
2992
  this.memory.markExercised(this.currentFingerprint, el.key, clicks > 1 ? `click×${clicks}` : "click");
2325
2993
  this.noteFormSubmit(formState, "click", form);
2326
- const result = await this.afterAction(clicks > 1 ? `click×${clicks}` : "click", `${el.role} "${el.name}"`);
2994
+ const result = await this.afterAction(clicks > 1 ? `click×${clicks}` : "click", `${el.role} "${el.name}"`, clickContext);
2327
2995
  // Impatient-user probe: a rapid multi-click that fires the SAME
2328
2996
  // state-changing request more than once means the action is not guarded
2329
2997
  // against double submission (button not disabled during flight, endpoint
@@ -2341,12 +3009,30 @@ export class BrowserEngine {
2341
3009
  return result + `\nℹ ${clicks}× rapid click fired no duplicate state-changing requests — double-submit appears guarded on this control.`;
2342
3010
  }
2343
3011
  const forcedNote = forced
2344
- ? `\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.`
3012
+ ? `\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). ` +
3013
+ (coverNote
3014
+ ? `At its centre it is ${coverNote}; look at that element before treating this as a layout bug.`
3015
+ : `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.`)
2345
3016
  : "";
2346
3017
  // Not when the write policy blocked the submission: a native form POST or a
2347
3018
  // beacon is not counted as xhr/fetch, so an aborted one looks exactly like
2348
3019
  // "fired nothing" — and the note would blame the app for the tool's block.
2349
- if (submitLike && this.xhrCount === xhrBefore && this.lastActionBlocked === 0 && page.url() === this.snapshotUrl) {
3020
+ if (submitLike && this.xhrCount === xhrBefore && this.lastActionBlocked === 0 && page.url() === clickContext.urlBefore) {
3021
+ // A client-side router can move the page just after the request-based
3022
+ // settle, with no request for it to wait on: watch the URL before
3023
+ // calling the click silent, and look at the requests again after.
3024
+ const landed = await this.stableUrl();
3025
+ if (landed !== clickContext.urlBefore)
3026
+ return result + `\nℹ The page then moved client-side to ${landed} (take a new snapshot).` + forcedNote;
3027
+ if (this.xhrCount !== xhrBefore)
3028
+ return result + forcedNote;
3029
+ // The page may have answered without a request: a dialog, or validation saying what is missing (claims.ts quietAnswer).
3030
+ // A native alert is not a step: alert("Saved!") with nothing sent is the very case the note is for.
3031
+ const answer = this.nativeQuestionAt >= this.actionStartedAt ? "dialog" : quietAnswer(clickContext.claimsBefore, await this.readClaims(page));
3032
+ if (answer === "dialog")
3033
+ return result + `\nℹ The click opened a dialog (a step, not a submit), so no request was expected yet.` + forcedNote;
3034
+ if (answer === "validation")
3035
+ return result + `\nℹ Client-side validation answered (no request expected).` + forcedNote;
2350
3036
  return (result +
2351
3037
  `\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).` +
2352
3038
  forcedNote);
@@ -2369,20 +3055,33 @@ export class BrowserEngine {
2369
3055
  try {
2370
3056
  state = (await locator.evaluate((node) => {
2371
3057
  const n = node;
3058
+ const type = n.tagName === "INPUT" ? n.type.toLowerCase() : "";
2372
3059
  if (n.isContentEditable)
2373
- return { existing: (n.innerText || n.textContent || "").trim(), caretAppendable: true };
3060
+ return { existing: (n.innerText || n.textContent || "").trim(), caretAppendable: true, type };
2374
3061
  if (typeof n.value === "string") {
2375
3062
  // selectionStart is null on number/date/email-style inputs — caret
2376
3063
  // placement is unsupported there, so append must go via fill().
2377
- return { existing: n.value, caretAppendable: typeof n.selectionStart === "number" };
3064
+ return { existing: n.value, caretAppendable: typeof n.selectionStart === "number", type };
2378
3065
  }
2379
- return { existing: "", caretAppendable: false };
3066
+ return { existing: "", caretAppendable: false, type };
2380
3067
  }, undefined, { timeout: this.limits.actionMs }));
2381
3068
  }
2382
3069
  catch (err) {
2383
3070
  throw new Error(`Could not read the field's existing content before typing — aborting rather than risk overwriting it (${err instanceof Error ? err.message.split("\n")[0] : err}). Take a new scout_snapshot and retry.`);
2384
3071
  }
2385
- const { existing, caretAppendable } = state;
3072
+ const { existing, caretAppendable, type } = state;
3073
+ if (APP_FILLED_TYPES.has(type)) {
3074
+ // A date or time field holds one value in one format: anything else is
3075
+ // refused by the browser as a bare "Malformed value", and appending to
3076
+ // what it holds can only produce that. So the value is put in the
3077
+ // field's format, or refused naming it, and always replaces.
3078
+ const date = normaliseDateValue(type, text);
3079
+ if ("refused" in date)
3080
+ throw new Error(`Not typed: ${date.refused}`);
3081
+ await locator.fill(date.value, { timeout: this.limits.actionMs });
3082
+ return ((date.note ? ` (${date.note})` : "") +
3083
+ (existing && existing !== date.value ? ` (replaced existing content ${JSON.stringify(existing.slice(0, 60))}: a ${type} field holds one value)` : ""));
3084
+ }
2386
3085
  if (replace || !existing || text === "") {
2387
3086
  await locator.fill(text, { timeout: this.limits.actionMs });
2388
3087
  return existing && (replace || text === "") ? ` (replaced existing content ${JSON.stringify(existing.slice(0, 60))})` : "";
@@ -2424,14 +3123,14 @@ export class BrowserEngine {
2424
3123
  }
2425
3124
  async typeNow(ref, text, pressEnter = false, replace = false) {
2426
3125
  const page = this.requirePage();
2427
- const { el, liveLabel } = await this.resolveForAction(ref);
3126
+ const { el, liveLabel, live } = await this.resolveForAction(ref);
2428
3127
  if (el.role === "file") {
2429
3128
  // fill() refuses a file input — and it used to refuse with a stack
2430
3129
  // trace, leaving every upload form stranded. Point at the tool that can.
2431
3130
  return (`${ref} is a file input ("${el.name || el.testid || "unnamed"}") — text cannot be typed into it. ` +
2432
3131
  `Use scout_upload {ref:"${ref}"}: a small valid fixture is generated and matched to the input's accept attribute, or pass fixture / filePath.`);
2433
3132
  }
2434
- const refusal = this.actionPolicyCheck(el, liveLabel);
3133
+ const refusal = this.actionPolicyCheck(el, liveLabel, live);
2435
3134
  if (refusal)
2436
3135
  return refusal;
2437
3136
  const embed = this.foreignEmbedOf(el);
@@ -2494,7 +3193,7 @@ export class BrowserEngine {
2494
3193
  if (opts.ref) {
2495
3194
  const resolved = await this.resolveForAction(opts.ref);
2496
3195
  el = resolved.el;
2497
- const refusal = this.actionPolicyCheck(el, resolved.liveLabel);
3196
+ const refusal = this.actionPolicyCheck(el, resolved.liveLabel, resolved.live);
2498
3197
  if (refusal)
2499
3198
  return refusal;
2500
3199
  const embed = this.foreignEmbedOf(el);
@@ -2634,7 +3333,8 @@ export class BrowserEngine {
2634
3333
  async measureLayout(page, elements, url) {
2635
3334
  const viewport = page.viewportSize() ?? { width: 1280, height: 900 };
2636
3335
  const byDocument = new Map();
2637
- for (const el of elements) {
3336
+ // A live region is a message, not a control: its box overlapping a control is not a collision.
3337
+ for (const el of trackedElements(elements)) {
2638
3338
  const doc = el.frame ? el.key.slice(0, el.key.indexOf("|") + 1) : "";
2639
3339
  byDocument.set(doc, [...(byDocument.get(doc) ?? []), el]);
2640
3340
  }
@@ -2742,44 +3442,77 @@ export class BrowserEngine {
2742
3442
  ? `\nRevealed on hover:\n${notes.map((t) => ` · ${t}`).join("\n")}${caveat}`
2743
3443
  : `\n(no tooltip, overlay, or new page text appeared within ${HOVER_REVEAL_WINDOW_MS / 1000}s — this element reveals nothing on hover${churning ? "; page content was changing on its own, so the text-diff fallback was suppressed" : ""}${this.headed ? ". NOTE: in headed mode the PHYSICAL mouse cursor competes with the synthetic pointer — if it is resting over the browser window, hover warm-ups are cancelled; ask the user to move it off the window and retry" : ""})`));
2744
3444
  }
2745
- /** Record a dropdown's options and the ones picked, by the values selectOption reported. */
3445
+ /**
3446
+ * The label of the option a select step picks, matched as selectOption
3447
+ * matches it (value or label). Empty when none matches; null when it could
3448
+ * not be read, which the caller refuses, since the pick cannot be vetted.
3449
+ */
3450
+ static async chosenOptionLabel(loc, value) {
3451
+ if (value === undefined)
3452
+ return "";
3453
+ return loc
3454
+ .evaluate((node, v) => {
3455
+ const opts = Array.from(node.options ?? []);
3456
+ const o = opts.find((x) => x.value === v || x.label === v || (x.textContent || "").trim() === v);
3457
+ return o ? (o.label || o.textContent || "").trim().slice(0, 120) : "";
3458
+ }, value)
3459
+ .catch(() => null);
3460
+ }
3461
+ /**
3462
+ * What a select is asked to pick, settled against the dropdown's options
3463
+ * BEFORE the pick (forms.ts matchOption): a value naming no option, or more
3464
+ * than one, is refused at once instead of waiting out the action limit. The
3465
+ * pick goes by index, so the option chosen is the one matched. A control
3466
+ * whose options cannot be read (not a native select) keeps selectOption's
3467
+ * own matching, and its label as chosenOptionLabel reads it.
3468
+ */
3469
+ static async resolveSelectPick(loc, value) {
3470
+ const options = await loc.evaluate(listSelectOptions, undefined, { timeout: 1000 }).catch(() => null);
3471
+ if (!options)
3472
+ return { arg: value, label: await BrowserEngine.chosenOptionLabel(loc, value), option: null };
3473
+ const match = matchOption(options, value);
3474
+ if ("refused" in match)
3475
+ return match;
3476
+ return { arg: { index: match.index }, label: match.option.label, option: match.option };
3477
+ }
3478
+ /** Record a dropdown's options, the one it held before, and the ones picked, by the values selectOption reported. */
2746
3479
  recordSelectChoice(fingerprint, key, options, picked) {
2747
3480
  const labels = options.map((o) => o.label);
3481
+ const loaded = options.filter((o) => o.selected).map((o) => o.label);
2748
3482
  const chosen = picked.map((v) => options.find((o) => o.value === v)?.label).filter((l) => !!l);
2749
3483
  if (chosen.length === 0)
2750
- this.memory.recordSelectChoice(fingerprint, key, labels, "");
3484
+ this.memory.recordSelectChoice(fingerprint, key, labels, "", loaded);
2751
3485
  for (const label of chosen)
2752
- this.memory.recordSelectChoice(fingerprint, key, labels, label);
3486
+ this.memory.recordSelectChoice(fingerprint, key, labels, label, loaded);
2753
3487
  }
2754
3488
  async select(ref, value) {
2755
3489
  return this.withinLimit("action", () => this.selectNow(ref, value));
2756
3490
  }
2757
3491
  async selectNow(ref, value) {
2758
3492
  const page = this.requirePage();
2759
- const { el, liveLabel } = await this.resolveForAction(ref);
2760
- const refusal = this.actionPolicyCheck(el, liveLabel);
3493
+ const { el, liveLabel, live } = await this.resolveForAction(ref);
3494
+ const refusal = this.actionPolicyCheck(el, liveLabel, live);
2761
3495
  if (refusal)
2762
3496
  return refusal;
2763
- if (this.readOnly) {
2764
- // Bulk-action dropdowns fire on change — vet the chosen option itself.
2765
- const optionLabel = (await this.scopeOf(el)
2766
- .evaluate(`(() => { const node = ${xpathLookup(el.xpath)}; if (!node) return ''; const v = ${JSON.stringify(value)}; ` +
2767
- `const opts = Array.from(node.options || []); ` +
2768
- `const o = opts.find(o => o.value === v || o.label === v || (o.textContent || '').trim() === v); ` +
2769
- `return o ? (o.label || o.textContent || '').trim().slice(0, 120) : ''; })()`)
2770
- .catch(() => ""));
2771
- if (isDestructive(value) || isDestructive(optionLabel)) {
2772
- this.logAction({ action: "select:refused", target: optionLabel || value, url: page.url() });
2773
- return destructiveRefusal(optionLabel || value, this.mode);
2774
- }
2775
- }
2776
3497
  const loc = this.scopeOf(el).locator(`xpath=${el.xpath}`);
3498
+ const pick = await BrowserEngine.resolveSelectPick(loc, value);
3499
+ if ("refused" in pick) {
3500
+ this.logAction({ action: "select:no-match", target: `${el.name || el.testid || "dropdown"}: ${value}`.slice(0, 200), url: page.url() });
3501
+ return `NOT SELECTED: ${pick.refused} Nothing was changed.`;
3502
+ }
3503
+ // Bulk-action dropdowns fire on change — vet the chosen option itself. The
3504
+ // dropdown is not judged by its options (destructiveLabelOf), so the pick is the check: one that cannot be read is refused.
3505
+ if (this.readOnly && pickIsDestructive(value, el.testid, pick.label)) {
3506
+ this.logAction({ action: "select:refused", target: pick.label || value, url: page.url() });
3507
+ return destructiveRefusal(pick.label || value, this.mode);
3508
+ }
2777
3509
  const options = el.tag === "select" ? await readSelectOptions(loc) : null;
2778
- const picked = await loc.selectOption(value, { timeout: this.limits.actionMs });
3510
+ const picked = await loc.selectOption(pick.arg, { timeout: this.limits.actionMs });
2779
3511
  this.memory.markExercised(this.currentFingerprint, el.key, "select");
2780
3512
  if (options)
2781
3513
  this.recordSelectChoice(this.currentFingerprint, el.key, options, picked);
2782
- return this.afterAction("select", `${el.role} "${el.name}" = ${value}`);
3514
+ const matched = pick.option && pick.option.value !== value ? ` (matched option ${JSON.stringify(pick.option.value)}, labelled ${JSON.stringify(pick.option.label)})` : "";
3515
+ return this.afterAction("select", `${el.role} "${el.name}" = ${value}${matched}`);
2783
3516
  }
2784
3517
  /**
2785
3518
  * Enter/Space activate the focused element — apply the same destructive
@@ -2848,6 +3581,7 @@ export class BrowserEngine {
2848
3581
  async pressNow(key) {
2849
3582
  this.actionStartedAt = Date.now();
2850
3583
  const page = this.requirePage();
3584
+ await this.beginInput();
2851
3585
  const refusal = await this.vetFocusedActivation(key);
2852
3586
  if (refusal)
2853
3587
  return refusal;
@@ -2909,17 +3643,66 @@ export class BrowserEngine {
2909
3643
  return false;
2910
3644
  }
2911
3645
  }
2912
- async navigate(target) {
3646
+ /** `leave` answers a leave confirmation the page raises (policy.ts dialogResponse); undefined lets the mode decide. */
3647
+ async navigate(target, leave) {
3648
+ this.leaveChoice = leave;
3649
+ try {
3650
+ return await this.navigateNow(target);
3651
+ }
3652
+ finally {
3653
+ this.leaveChoice = undefined;
3654
+ this.onLeaveRefused = null;
3655
+ }
3656
+ }
3657
+ async navigateNow(target) {
2913
3658
  this.actionStartedAt = Date.now();
2914
3659
  const page = this.requirePage();
2915
- const url = target.startsWith("http") ? target : `${this.baseUrl}${target.startsWith("/") ? "" : "/"}${target}`;
2916
- if (!this.isSameOrigin(url)) {
2917
- return `REFUSED: ${url} is outside the attached origin (${this.baseUrl}). Exploration is fenced to the app under test.`;
2918
- }
3660
+ // A path resolves against the origin, not the page the session attached on (request.ts resolveTarget).
3661
+ const resolved = resolveTarget(this.baseUrl, target);
3662
+ if (!("url" in resolved)) {
3663
+ return resolved.offOrigin
3664
+ ? `REFUSED: ${target.trim()} is outside the attached origin (${new URL(this.baseUrl).origin}). Exploration is fenced to the app under test.`
3665
+ : `REFUSED: ${resolved.problem}`;
3666
+ }
3667
+ const url = resolved.url;
2919
3668
  // A notice describes ONE navigation. Clearing up front means a notice left
2920
3669
  // undelivered by a previous throw can never prepend itself to this result.
2921
3670
  this.authLoss.clear();
2922
- await this.withinLimit("nav", () => page.goto(url, { waitUntil: "domcontentloaded", timeout: this.limits.navMs }));
3671
+ // A page holding unsent input can ask to confirm leaving. Answered "stay",
3672
+ // the navigation is cancelled, which a browser reports as an aborted load
3673
+ // (or, in some engines, never reports at all), so the answer itself ends
3674
+ // the wait and the result says what happened instead of ERR_ABORTED.
3675
+ // Only a confirmation raised by this navigation explains its failure, not one left over from before it.
3676
+ const dialogsBefore = this.dialogsSeen.length;
3677
+ const stayed = new Promise((resolve) => {
3678
+ this.onLeaveRefused = () => resolve("stayed");
3679
+ });
3680
+ const going = this.withinLimit("nav", () => page.goto(url, { waitUntil: "domcontentloaded", timeout: this.limits.navMs }));
3681
+ let outcome;
3682
+ try {
3683
+ outcome = await Promise.race([going.then(() => "went"), stayed]);
3684
+ }
3685
+ catch (err) {
3686
+ if (!this.leaveRefused(dialogsBefore))
3687
+ throw err;
3688
+ outcome = "stayed";
3689
+ }
3690
+ finally {
3691
+ this.onLeaveRefused = null;
3692
+ }
3693
+ if (outcome === "stayed") {
3694
+ // The cancelled load settles on its own; its error is the cancellation this result reports.
3695
+ going.catch((err) => {
3696
+ this.logAction({
3697
+ action: "navigate:cancelled",
3698
+ target: err instanceof Error ? err.message.split("\n")[0].slice(0, 160) : String(err),
3699
+ url: page.url(),
3700
+ });
3701
+ });
3702
+ // Not a navigation outcome: the session stayed by its own answer, so nothing is recorded for the auth-loss tracker.
3703
+ const result = await this.afterAction("navigate", url);
3704
+ return result.replace(/^OK: navigate /, "NOT NAVIGATED: ");
3705
+ }
2923
3706
  // Settle BEFORE judging where we landed. A client-side auth guard redirects
2924
3707
  // after hydration, not during goto, so reading page.url() here showed the
2925
3708
  // requested path and the bounce went unnoticed — which is precisely how a
@@ -3028,8 +3811,9 @@ export class BrowserEngine {
3028
3811
  * Replace this context's cookies and storage with a profile, in place: same
3029
3812
  * pages, listeners and policy. Playwright is handed only what it restores;
3030
3813
  * the profile's sessionStorage list is not part of its storage state.
3814
+ * With `load` "cookies" only the cookie jar is replaced (profileLoadWhileHeld, refresh.ts).
3031
3815
  */
3032
- async applyState(state) {
3816
+ async applyState(state, load = "storage") {
3033
3817
  if (!this.context)
3034
3818
  return { ok: false, why: "no browser is open" };
3035
3819
  let storageState;
@@ -3039,8 +3823,15 @@ export class BrowserEngine {
3039
3823
  catch (err) {
3040
3824
  return { ok: false, why: `the saved profile could not be split (${err instanceof Error ? err.message : String(err)})` };
3041
3825
  }
3826
+ const playwrightState = storageState;
3042
3827
  try {
3043
- await this.context.setStorageState(storageState);
3828
+ if (load === "cookies") {
3829
+ await this.context.clearCookies();
3830
+ await this.context.addCookies(playwrightState.cookies ?? []);
3831
+ }
3832
+ else {
3833
+ await this.context.setStorageState(playwrightState);
3834
+ }
3044
3835
  }
3045
3836
  catch (err) {
3046
3837
  return { ok: false, why: `the browser refused its saved profile (${err instanceof Error ? err.message.split("\n")[0] : String(err)})` };
@@ -3051,113 +3842,352 @@ export class BrowserEngine {
3051
3842
  return { ok: true };
3052
3843
  }
3053
3844
  /**
3054
- * The refresh broker's route handler. A request carrying none of the role's
3055
- * refresh tokens is handed on untouched. One carrying one waits for the
3056
- * role's refresh lock; holding it, the session re-reads the profile, loads
3057
- * it and sends the current token in place of a spent one when another
3058
- * session rotated it meanwhile, and keeps the lock until the page has
3059
- * stored the rotation and it is written back. A lock that cannot be had is
3060
- * reported and the request dropped: sending a token another session may
3061
- * have spent is what revokes the family. Token values are never logged.
3845
+ * The refresh broker's route handler, run after the write policy has let a
3846
+ * request out. brokerDecision (refresh.ts) says which requests are refresh
3847
+ * calls; every other request goes on untouched, and a static asset is
3848
+ * decided before the first await so it is never held back. A refresh waits
3849
+ * for the role's lock; holding it, the session re-reads the profile, and
3850
+ * when another session has rotated the token meanwhile it loads that
3851
+ * profile and sends the current token in place of the spent one. The lock
3852
+ * is kept until the rotation is written back. Whatever goes wrong here, the
3853
+ * request goes on as the page sent it, with a line in the action log: an
3854
+ * app whose requests fail is worse than the rare double refresh. Token
3855
+ * values are never logged.
3062
3856
  */
3063
3857
  async brokerRefresh(route) {
3064
- const broker = this.refresh;
3065
3858
  const req = route.request();
3066
- if (!broker)
3067
- return route.fallback();
3068
- const headers = await req.allHeaders().catch(() => req.headers());
3069
- const wire = { url: req.url(), body: req.postData(), headers };
3070
- // Spent tokens too: a page still holding one would otherwise send it unbrokered.
3071
- const sent = presentedToken(wire, [...broker.known, ...broker.spent]);
3072
- if (!sent)
3073
- return route.fallback();
3074
- const where = `${req.method()} ${pathnameOf(req.url())}`;
3859
+ const broker = this.refresh;
3860
+ const method = req.method();
3861
+ if (!broker || isStaticAsset(method, req.resourceType()))
3862
+ return this.passOn(route);
3863
+ const where = `${method} ${pathnameOf(req.url())}`;
3864
+ let decision;
3865
+ try {
3866
+ const tokens = [...broker.known, ...broker.spent];
3867
+ const learned = this.learnedEndpoints();
3868
+ const base = { method, url: req.url(), resourceType: req.resourceType(), body: req.postData() };
3869
+ decision = brokerDecision({ ...base, headers: req.headers() }, tokens, learned);
3870
+ // The Cookie header is read only for a request it could make a refresh call:
3871
+ // a refresh cookie scoped to "/" rides on every request the page makes.
3872
+ if (decision.kind === "pass" && decision.why === "no known token" && cookieMayCount(method, base.url, learned)) {
3873
+ decision = brokerDecision({ ...base, headers: await this.withCookieHeader(req) }, tokens, learned);
3874
+ }
3875
+ }
3876
+ catch (err) {
3877
+ return this.unbrokered(route, where, err);
3878
+ }
3879
+ if (decision.kind === "pass")
3880
+ return this.passOn(route);
3075
3881
  let lock;
3076
3882
  try {
3077
3883
  lock = await acquireLock(lockPathFor(broker.file));
3078
3884
  }
3079
3885
  catch (err) {
3080
- this.refreshCounts.failed += 1;
3081
- this.logAction({
3082
- action: "refresh-broker:dropped",
3083
- target: `${where} (${err instanceof Error ? err.message : String(err)})`,
3084
- url: this.page?.url() ?? "",
3085
- });
3086
- await route.abort("blockedbyclient").catch(() => { });
3087
- return;
3886
+ return this.unbrokered(route, where, err);
3088
3887
  }
3888
+ this.brokeredRequests.add(req);
3889
+ const sent = decision.sent;
3089
3890
  let handedOn = false;
3090
3891
  try {
3091
3892
  const read = this.readRoleProfile();
3092
3893
  if (!read.ok)
3093
3894
  throw new Error(read.why);
3094
3895
  const plan = planRefresh(sent, refreshTokenSlots(read.state));
3095
- let presented = sent;
3096
- let overrides = {};
3097
- if (plan.kind === "swap") {
3098
- // Another session rotated the token while this one waited: load what it saved, and send the current token.
3099
- const applied = await this.applyState(read.state);
3100
- if (!applied.ok)
3101
- throw new Error(applied.why);
3102
- const swapped = swapRequest(wire, sent.value, plan.to.value);
3103
- overrides = {
3896
+ if (plan.kind === "unknown") {
3897
+ // The profile holds another sign-in now: send this one on, and leave the profile alone.
3898
+ this.logAction({ action: "refresh-broker", target: `${where} with ${sent.slot} (no longer in the profile; sent as is)`, url: this.page?.url() ?? "" });
3899
+ handedOn = true;
3900
+ lock.release();
3901
+ return this.passOn(route);
3902
+ }
3903
+ if (plan.kind === "send") {
3904
+ this.logAction({ action: "refresh-broker", target: `${where} with ${sent.slot}`, url: this.page?.url() ?? "" });
3905
+ handedOn = true;
3906
+ this.trackRefreshTask(this.writeBackAfter(this.pageAnswer(req), sent, lock, false));
3907
+ return this.passOn(route);
3908
+ }
3909
+ // Another session rotated the token while this one waited: load what it saved, and send the current token.
3910
+ const loaded = profileLoadWhileHeld(req.isNavigationRequest());
3911
+ const applied = await this.applyState(read.state, loaded);
3912
+ if (!applied.ok)
3913
+ throw new Error(applied.why);
3914
+ this.refreshCounts.swapped += 1;
3915
+ const all = await req.allHeaders();
3916
+ const cookieLine = Object.entries(all).find(([name]) => name.toLowerCase() === "cookie")?.[1] ?? "";
3917
+ const swapped = swapRequest({ url: req.url(), body: req.postData(), headers: all }, sent.value, plan.to.value);
3918
+ if (swapToken(cookieLine, sent.value, plan.to.value) === cookieLine) {
3919
+ // Not in the Cookie header the request shows: a route override carries a token
3920
+ // in its body, URL or headers, and a browser that adds cookies after this
3921
+ // handler (WebKit) takes them from the jar the profile was just loaded into.
3922
+ this.logAction({
3923
+ action: "refresh-broker",
3924
+ target: `${where} with ${plan.to.slot} (rotated by another session; loaded its profile)`,
3925
+ url: this.page?.url() ?? "",
3926
+ });
3927
+ handedOn = true;
3928
+ // Reported once the request goes out with the current token, so a swap that then fails is reported only as a failure.
3929
+ this.refreshEvents.push("swapped");
3930
+ this.trackRefreshTask(this.writeBackAfter(this.pageAnswer(req), plan.to, lock, true, writeBackStorageFrom(loaded, plan.to)));
3931
+ return this.passOn(route, {
3104
3932
  ...(swapped.url ? { url: swapped.url } : {}),
3105
3933
  ...(swapped.body !== undefined ? { postData: swapped.body } : {}),
3106
- ...(swapped.headers ? { headers: swapped.headers } : {}),
3107
- };
3108
- presented = plan.to;
3109
- this.refreshCounts.swapped += 1;
3934
+ ...(swapped.headers ? { headers: headersForResend(swapped.headers, null) } : {}),
3935
+ });
3936
+ }
3937
+ // In the Cookie header, which a browser keeps as it built it whatever a
3938
+ // route override says: the broker sends the request itself and answers
3939
+ // the page with the response. Only a script's request; anything else
3940
+ // goes on as the page sent it.
3941
+ handedOn = true;
3942
+ const type = req.resourceType();
3943
+ if (type !== "fetch" && type !== "xhr") {
3944
+ lock.release();
3945
+ return this.unbrokered(route, where, new Error(`the spent token is in its Cookie header, and a ${type} request is not one the broker answers itself`));
3946
+ }
3947
+ let response;
3948
+ try {
3949
+ response = await route.fetch({
3950
+ ...(swapped.url ? { url: swapped.url } : {}),
3951
+ ...(swapped.body !== undefined ? { postData: swapped.body } : {}),
3952
+ headers: headersForResend(swapped.headers ?? all, swapToken(cookieLine, sent.value, plan.to.value)),
3953
+ maxRedirects: 0,
3954
+ });
3955
+ }
3956
+ catch (err) {
3957
+ lock.release();
3958
+ return this.unbrokered(route, where, err);
3110
3959
  }
3111
3960
  this.logAction({
3112
3961
  action: "refresh-broker",
3113
- target: `${where} with ${presented.slot}${plan.kind === "swap" ? " (rotated by another session; loaded its profile)" : plan.kind === "unknown" ? " (no longer in the profile; sent as is)" : ""}`,
3962
+ target: `${where} with ${plan.to.slot} (rotated by another session; loaded its profile and sent the current cookie)`,
3114
3963
  url: this.page?.url() ?? "",
3115
3964
  });
3116
- handedOn = true;
3117
- if (plan.kind === "unknown") {
3118
- // The profile holds another sign-in now: send this one on, and leave the profile alone.
3119
- lock.release();
3120
- await route.fallback(overrides);
3965
+ this.refreshEvents.push("swapped");
3966
+ // The response's cookies are already in the browser's jar, so the write-back does not wait on the page.
3967
+ this.trackRefreshTask(this.writeBackAfter(Promise.resolve(BrowserEngine.fetchedAnswer(response)), plan.to, lock, true));
3968
+ await route.fulfill({ response }).catch(() => {
3969
+ /* the page went away before its answer: the rotation is still saved */
3970
+ });
3971
+ }
3972
+ catch (err) {
3973
+ if (handedOn) {
3974
+ this.logAction({
3975
+ action: "refresh-broker:error",
3976
+ target: `${where} (${err instanceof Error ? err.message.split("\n")[0] : String(err)})`,
3977
+ url: this.page?.url() ?? "",
3978
+ });
3121
3979
  return;
3122
3980
  }
3123
- const task = this.writeBackAfter(req, presented, lock).finally(() => this.refreshTasks.delete(task));
3124
- this.refreshTasks.add(task);
3125
- await route.fallback(overrides);
3981
+ lock.release();
3982
+ return this.unbrokered(route, where, err);
3983
+ }
3984
+ }
3985
+ /**
3986
+ * A request's headers with the Cookie header it will be sent with. Some
3987
+ * browsers (WebKit) add cookies only after the route handler has run, so
3988
+ * when the request shows none, the line is built from the browser's jar
3989
+ * for that URL, which is what will be sent.
3990
+ */
3991
+ async withCookieHeader(req) {
3992
+ const headers = await req.allHeaders();
3993
+ if (Object.keys(headers).some((name) => name.toLowerCase() === "cookie") || !this.context)
3994
+ return headers;
3995
+ const jar = await this.context.cookies(req.url());
3996
+ return jar.length > 0 ? { ...headers, cookie: jar.map((c) => `${c.name}=${c.value}`).join("; ") } : headers;
3997
+ }
3998
+ /** Hand a request on as it is, or with overrides. A request the page has already cancelled rejects this; nothing is left to send then. */
3999
+ async passOn(route, overrides) {
4000
+ await route.fallback(overrides).catch(() => { });
4001
+ }
4002
+ /** The broker could not do its job for this request: it goes on as the page sent it, and the action log says why. */
4003
+ async unbrokered(route, where, err) {
4004
+ this.refreshCounts.failed += 1;
4005
+ this.refreshEvents.push("failed");
4006
+ // Unbrokered after all: if its response rotates the cookie, the rotation watcher saves it.
4007
+ this.brokeredRequests.delete(route.request());
4008
+ this.logAction({
4009
+ action: "refresh-broker:unbrokered",
4010
+ target: `${where} sent as the page sent it (${err instanceof Error ? err.message.split("\n")[0] : String(err)})`,
4011
+ url: this.page?.url() ?? "",
4012
+ });
4013
+ return this.passOn(route);
4014
+ }
4015
+ /** Track a write-back so close() waits for it to release the role's lock. */
4016
+ trackRefreshTask(task) {
4017
+ const tracked = task.finally(() => this.refreshTasks.delete(tracked));
4018
+ this.refreshTasks.add(tracked);
4019
+ }
4020
+ /** The page's response to a request, or null if none came in 30 seconds. */
4021
+ pageAnswer(req) {
4022
+ let timer;
4023
+ const answered = req
4024
+ .response()
4025
+ .then((res) => res ? { ok: res.ok(), status: res.status(), text: () => res.text(), setCookies: () => res.headerValues("set-cookie") } : null)
4026
+ .finally(() => clearTimeout(timer));
4027
+ return Promise.race([answered, new Promise((resolve) => (timer = setTimeout(() => resolve(null), 30000)))]);
4028
+ }
4029
+ /** A response the broker fetched itself, read the way the write-back reads one. */
4030
+ static fetchedAnswer(res) {
4031
+ return {
4032
+ ok: res.ok(),
4033
+ status: res.status(),
4034
+ text: () => res.text(),
4035
+ setCookies: async () => res
4036
+ .headersArray()
4037
+ .filter((h) => h.name.toLowerCase() === "set-cookie")
4038
+ .map((h) => h.value),
4039
+ };
4040
+ }
4041
+ /**
4042
+ * The endpoints learned to rotate the role's token: this session's, and
4043
+ * those any session of the role saved beside the profile, re-read at most
4044
+ * twice a second and only when the file changed.
4045
+ */
4046
+ learnedEndpoints() {
4047
+ const broker = this.refresh;
4048
+ if (!broker)
4049
+ return new Set();
4050
+ const now = Date.now();
4051
+ if (now - broker.learnedCheckedAt < 500)
4052
+ return broker.learned;
4053
+ broker.learnedCheckedAt = now;
4054
+ const file = endpointsPathFor(broker.file);
4055
+ try {
4056
+ const mtimeMs = fs.statSync(file).mtimeMs;
4057
+ if (mtimeMs !== broker.learnedMtimeMs) {
4058
+ broker.learnedMtimeMs = mtimeMs;
4059
+ for (const key of readLearnedEndpoints(file))
4060
+ broker.learned.add(key);
4061
+ }
3126
4062
  }
3127
4063
  catch (err) {
3128
- if (!handedOn) {
3129
- lock.release();
3130
- this.refreshCounts.failed += 1;
4064
+ // No file yet means nothing learned yet. Any other error keeps what this session already knows.
4065
+ if (err.code !== "ENOENT")
3131
4066
  this.logAction({
3132
- action: "refresh-broker:dropped",
3133
- target: `${where} (${err instanceof Error ? err.message.split("\n")[0] : String(err)})`,
4067
+ action: "refresh-broker:error",
4068
+ target: `could not read the learned endpoints (${err instanceof Error ? err.message.split("\n")[0] : String(err)})`,
4069
+ url: "",
4070
+ });
4071
+ }
4072
+ return broker.learned;
4073
+ }
4074
+ /**
4075
+ * A response that replaced the role's refresh cookie with a new token on a
4076
+ * request the broker did not take the lock for: that endpoint rotates the
4077
+ * token. It is learned, for this session at once and for every session of
4078
+ * the role through the file beside the profile, so the next call to it is
4079
+ * brokered; and the rotation is written back, so the other sessions do not
4080
+ * go on presenting the token this one spent. Only a POST, PUT or PATCH is
4081
+ * learned, and never a sign-in, sign-up or sign-out (learnableEndpoint).
4082
+ */
4083
+ watchRotation(res) {
4084
+ const broker = this.refresh;
4085
+ if (!broker || !broker.known.some((t) => t.cookie !== undefined))
4086
+ return;
4087
+ const req = res.request();
4088
+ // A GET is never learned: a server that re-issues the cookie on its reads would otherwise put them all behind the lock.
4089
+ const method = req.method();
4090
+ if (method !== "POST" && method !== "PUT" && method !== "PATCH")
4091
+ return;
4092
+ if (this.brokeredRequests.has(req) || !res.ok() || !learnableEndpoint(req.url()))
4093
+ return;
4094
+ this.trackRefreshTask((async () => {
4095
+ const rotated = rotatedCookies(await res.headerValues("set-cookie"), broker.known);
4096
+ if (rotated.length > 0)
4097
+ await this.adoptRotation(endpointKey(req.method(), req.url()), rotated);
4098
+ })().catch((err) => {
4099
+ this.logAction({
4100
+ action: "refresh-broker:error",
4101
+ target: `${pathnameOf(req.url())}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
4102
+ url: this.page?.url() ?? "",
4103
+ });
4104
+ }));
4105
+ }
4106
+ /** Learn an endpoint seen to rotate the token, and write the rotation back if the profile still holds the token it replaced. */
4107
+ async adoptRotation(key, rotated) {
4108
+ const broker = this.refresh;
4109
+ if (!broker)
4110
+ return;
4111
+ const isNew = !broker.learned.has(key);
4112
+ broker.learned.add(key);
4113
+ if (isNew) {
4114
+ this.refreshCounts.learned += 1;
4115
+ this.refreshEvents.push("learned");
4116
+ }
4117
+ const names = rotated.map((r) => r.slot).join(", ");
4118
+ let lock;
4119
+ try {
4120
+ lock = await acquireLock(lockPathFor(broker.file));
4121
+ }
4122
+ catch (err) {
4123
+ this.logAction({
4124
+ action: "refresh-broker:learned",
4125
+ target: `${key} rotates ${names}; not saved (${err instanceof Error ? err.message.split("\n")[0] : String(err)})`,
4126
+ url: this.page?.url() ?? "",
4127
+ });
4128
+ return;
4129
+ }
4130
+ try {
4131
+ const file = endpointsPathFor(broker.file);
4132
+ const list = readLearnedEndpoints(file);
4133
+ if (!list.includes(key))
4134
+ writeLearnedEndpoints(file, withLearnedEndpoint(list, key));
4135
+ const read = this.readRoleProfile();
4136
+ if (!read.ok)
4137
+ throw new Error(read.why);
4138
+ const current = refreshTokenSlots(read.state);
4139
+ // Only over the token this session spent: a profile another session moved on since is left as it is.
4140
+ const stillSpent = rotated.every((r) => current.some((c) => c.slot === r.slot && c.value === r.value));
4141
+ let now = undefined;
4142
+ // The jar takes the Set-Cookie as the response arrives; a short wait covers a browser that is a moment behind.
4143
+ for (let waited = 0; stillSpent && waited <= 1000; waited += 50) {
4144
+ const state = await this.context?.storageState().catch(() => undefined);
4145
+ if (state && rotated.every((r) => rotationStored(state, r))) {
4146
+ now = await this.withPageIndexedDB(state, (full) => rotated.every((r) => rotationStored(full, r)));
4147
+ break;
4148
+ }
4149
+ await new Promise((r) => setTimeout(r, 50));
4150
+ }
4151
+ if (now) {
4152
+ const state = profileAfterRotation(now, read.state);
4153
+ writeProfile(broker.projectDir, broker.role, state);
4154
+ this.rememberTokens(refreshTokenSlots(state));
4155
+ this.logAction({
4156
+ action: "refresh-broker:learned",
4157
+ target: `${key} rotates ${names}; saved to the profile, and brokered from now on`,
4158
+ url: this.page?.url() ?? "",
4159
+ });
4160
+ }
4161
+ else {
4162
+ this.logAction({
4163
+ action: "refresh-broker:learned",
4164
+ target: `${key} rotates ${names}; brokered from now on (${stillSpent ? "the browser had not stored the new token" : "the profile already holds another token"}, so the profile was left as it is)`,
3134
4165
  url: this.page?.url() ?? "",
3135
4166
  });
3136
- await route.abort("blockedbyclient").catch(() => { });
3137
4167
  }
3138
4168
  }
4169
+ finally {
4170
+ lock.release();
4171
+ }
3139
4172
  }
3140
4173
  /**
3141
4174
  * Once the refresh is answered, write the rotation back over the profile
3142
4175
  * and release the lock. The page's own state is saved once it holds the
3143
4176
  * rotated token, so the next session gets the access token with it; a page
3144
4177
  * that has not stored it in time has the token read from the response
3145
- * instead. A refused or failed refresh changes nothing on disk.
4178
+ * instead. A refused or failed refresh changes nothing on disk, and nor does
4179
+ * one whose response left a refresh cookie as it was (a server that does
4180
+ * not rotate refresh tokens). `storageFrom` says whether the origins'
4181
+ * storage written back is the page's or stays the profile's (writeBackStorageFrom, refresh.ts).
3146
4182
  */
3147
- async writeBackAfter(req, presented, lock) {
4183
+ async writeBackAfter(answer, presented, lock, swapped, storageFrom = "page") {
3148
4184
  const broker = this.refresh;
3149
4185
  try {
3150
- let timer;
3151
- const res = await Promise.race([
3152
- req.response().finally(() => clearTimeout(timer)),
3153
- new Promise((resolve) => {
3154
- timer = setTimeout(() => resolve(null), 30000);
3155
- }),
3156
- ]);
4186
+ const res = await answer;
3157
4187
  if (!broker)
3158
4188
  return;
3159
- if (!res || !res.ok()) {
3160
- const why = res ? `answered ${res.status()}` : "had no response";
4189
+ if (!res || !res.ok) {
4190
+ const why = res ? `answered ${res.status}` : "had no response";
3161
4191
  this.logAction({
3162
4192
  action: "refresh-broker:not-saved",
3163
4193
  target: `${presented.slot}: the refresh ${why}; the profile was left as it was`,
@@ -3166,24 +4196,36 @@ export class BrowserEngine {
3166
4196
  return;
3167
4197
  }
3168
4198
  let state = null;
3169
- for (let waited = 0; waited <= 5000; waited += 50) {
4199
+ // A cookie is in the browser's jar by the time the response is; a token the page stores itself can take longer.
4200
+ const patienceMs = presented.cookie !== undefined ? 1000 : 5000;
4201
+ for (let waited = 0; waited <= patienceMs; waited += 50) {
3170
4202
  const now = await this.context?.storageState().catch(() => null);
3171
4203
  if (now && rotationStored(now, presented)) {
4204
+ const full = await this.withPageIndexedDB(now, (read) => rotationStored(read, presented));
3172
4205
  const onDisk = this.readRoleProfile();
3173
- state = profileAfterRotation(now, onDisk.ok ? onDisk.state : null);
4206
+ state = profileAfterRotation(full, onDisk.ok ? onDisk.state : null, storageFrom);
3174
4207
  break;
3175
4208
  }
3176
4209
  await new Promise((r) => setTimeout(r, 50));
3177
4210
  }
3178
4211
  if (!state) {
3179
4212
  const body = await res.text().catch(() => "");
3180
- const rotated = rotatedFromResponse(body, await res.headerValues("set-cookie").catch(() => []));
4213
+ const rotated = rotatedFromResponse(body, await res.setCookies().catch(() => []));
3181
4214
  const read = this.readRoleProfile();
3182
4215
  if (rotated && read.ok)
3183
4216
  state = swapProfileToken(read.state, presented.value, rotated);
3184
4217
  }
4218
+ if (!state && presented.cookie !== undefined) {
4219
+ this.logAction({
4220
+ action: "refresh-broker:unrotated",
4221
+ target: `${presented.slot}: the response kept it as it was; nothing to save`,
4222
+ url: this.page?.url() ?? "",
4223
+ });
4224
+ return;
4225
+ }
3185
4226
  if (!state) {
3186
4227
  this.refreshCounts.failed += 1;
4228
+ this.refreshEvents.push("failed");
3187
4229
  this.logAction({
3188
4230
  action: "refresh-broker:not-saved",
3189
4231
  target: `${presented.slot}: the page stored no rotated token and the response named none`,
@@ -3194,9 +4236,13 @@ export class BrowserEngine {
3194
4236
  writeProfile(broker.projectDir, broker.role, state);
3195
4237
  this.rememberTokens(refreshTokenSlots(state));
3196
4238
  this.refreshCounts.refreshed += 1;
4239
+ // A swap was reported as it happened; this is the session's own refresh.
4240
+ if (!swapped)
4241
+ this.refreshEvents.push("refreshed");
3197
4242
  }
3198
4243
  catch (err) {
3199
4244
  this.refreshCounts.failed += 1;
4245
+ this.refreshEvents.push("failed");
3200
4246
  this.logAction({
3201
4247
  action: "refresh-broker:not-saved",
3202
4248
  target: `${presented.slot}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
@@ -3207,6 +4253,24 @@ export class BrowserEngine {
3207
4253
  lock.release();
3208
4254
  }
3209
4255
  }
4256
+ /**
4257
+ * The page's storage state read again with its IndexedDB, which a plain read
4258
+ * leaves out and a saved profile keeps. The plain state when that read fails
4259
+ * or no longer shows the rotation; profileAfterRotation then keeps the
4260
+ * profile's own IndexedDB, so nothing is dropped either way.
4261
+ */
4262
+ async withPageIndexedDB(plain, holds) {
4263
+ const full = await this.context?.storageState({ indexedDB: true }).catch(() => null);
4264
+ return full && holds(full) ? full : plain;
4265
+ }
4266
+ /** Report (and clear) what the refresh broker did since the last action: counts only (refreshNotice). */
4267
+ drainRefresh() {
4268
+ if (this.refreshEvents.length === 0)
4269
+ return "";
4270
+ const events = this.refreshEvents;
4271
+ this.refreshEvents = [];
4272
+ return refreshNotice(events);
4273
+ }
3210
4274
  /** Take `now` as the role's current tokens; the ones it replaces are kept as spent, so a page that still sends one is caught. */
3211
4275
  rememberTokens(now) {
3212
4276
  if (!this.refresh)
@@ -3217,12 +4281,14 @@ export class BrowserEngine {
3217
4281
  }
3218
4282
  /** What the refresh broker did this session, or "" when it did nothing. Counts only. */
3219
4283
  refreshSummary() {
3220
- const { refreshed, swapped, failed } = this.refreshCounts;
3221
- if (refreshed + swapped + failed === 0)
4284
+ const { refreshed, swapped, failed, learned } = this.refreshCounts;
4285
+ if (refreshed + swapped + failed + learned === 0)
3222
4286
  return "";
3223
4287
  const parts = [`refreshed role '${this.role}''s token ${refreshed} time${refreshed === 1 ? "" : "s"} under the role's lock`];
3224
4288
  if (swapped > 0)
3225
4289
  parts.push(`${swapped} of them after another session had rotated it`);
4290
+ if (learned > 0)
4291
+ parts.push(`learned ${learned} endpoint${learned === 1 ? "" : "s"} that rotate${learned === 1 ? "s" : ""} it`);
3226
4292
  if (failed > 0)
3227
4293
  parts.push(`${failed} refresh${failed === 1 ? "" : "es"} could not be brokered (see the action log)`);
3228
4294
  return parts.join("; ");
@@ -3243,11 +4309,25 @@ export class BrowserEngine {
3243
4309
  }
3244
4310
  this.authLoss.record({ requestedRoute, landedRoute, bounced, role: this.role, target: requestedUrl });
3245
4311
  }
3246
- async goBack() {
4312
+ /** `leave` answers a leave confirmation the page raises (policy.ts dialogResponse); undefined lets the mode decide. */
4313
+ async goBack(leave) {
3247
4314
  this.actionStartedAt = Date.now();
3248
4315
  const page = this.requirePage();
3249
- await page.goBack({ waitUntil: "domcontentloaded", timeout: this.limits.backNavMs }).catch(() => { });
3250
- return this.afterAction("back", "");
4316
+ this.leaveChoice = leave;
4317
+ try {
4318
+ // A back that goes nowhere (no history, a cancelled leave) is reported by the URL the result shows.
4319
+ await page.goBack({ waitUntil: "domcontentloaded", timeout: this.limits.backNavMs }).catch((err) => {
4320
+ this.logAction({
4321
+ action: "back:no-navigation",
4322
+ target: err instanceof Error ? err.message.split("\n")[0].slice(0, 160) : String(err),
4323
+ url: page.url(),
4324
+ });
4325
+ });
4326
+ return await this.afterAction("back", "");
4327
+ }
4328
+ finally {
4329
+ this.leaveChoice = undefined;
4330
+ }
3251
4331
  }
3252
4332
  /** The full route contract: scanned filesystem routes ∪ link-discovered route classes. */
3253
4333
  allKnownRoutes() {
@@ -3268,7 +4348,10 @@ export class BrowserEngine {
3268
4348
  *
3269
4349
  * Visited/attempted keys are stored NORMALIZED, so the normalized form of
3270
4350
  * each known route is compared too — normalizePath is idempotent, so this
3271
- * only adds matches for routes that genuinely were reached.
4351
+ * only adds matches for routes that genuinely were reached. Reached is
4352
+ * memory's reachedRoutes, the gap ledger's own rule: stored routes are read
4353
+ * through today's route identity, and a base path is reached by one of its
4354
+ * tabs or sections.
3272
4355
  */
3273
4356
  unvisitedKnownRoutes() {
3274
4357
  if (!this.memory)
@@ -3276,11 +4359,11 @@ export class BrowserEngine {
3276
4359
  const all = this.allKnownRoutes();
3277
4360
  if (all.length === 0)
3278
4361
  return [];
3279
- const visited = new Set(Object.values(this.memory.states).map((s) => s.route));
4362
+ const reached = reachedRoutes(Object.values(this.memory.states).map((s) => s.route));
3280
4363
  const attempted = this.memory.attemptedByRole(this.role);
3281
4364
  return all.filter((r) => {
3282
4365
  const n = normalizePath(r);
3283
- return !visited.has(r) && !(r in attempted) && !visited.has(n) && !(n in attempted);
4366
+ return !reached(r) && !(r in attempted) && !(n in attempted);
3284
4367
  });
3285
4368
  }
3286
4369
  /** Map a route class to something goto-able (discovered classes carry a concrete example). */
@@ -3326,12 +4409,16 @@ export class BrowserEngine {
3326
4409
  * harvested from their links, none is marked visited or attempted, and a
3327
4410
  * failed load is not remembered, so the pages and what they link to stay on
3328
4411
  * the unvisited list exactly as before. A check's re-test loads use it.
4412
+ *
4413
+ * `deadline` (epoch ms): no route is started once it has passed; those left
4414
+ * stay unvisited. The first run's time budget is the only caller.
3329
4415
  */
3330
4416
  async crawl(paths, opts = {}) {
3331
4417
  const page = this.requirePage();
3332
4418
  const memory = this.memory;
3333
4419
  this.crawlHealth = [];
3334
- const targets = (paths && paths.length > 0 ? paths : this.crawlableRoutes().map((r) => this.navigablePath(r))).slice(0, Math.min(150, opts.limit ?? 150));
4420
+ const explicit = !!paths && paths.length > 0;
4421
+ const targets = (explicit ? paths : this.crawlableRoutes().map((r) => this.navigablePath(r))).slice(0, Math.min(150, opts.limit ?? 150));
3335
4422
  if (targets.length === 0) {
3336
4423
  const failed = this.unvisitedKnownRoutes().filter((r) => this.loadFailedRoutes.has(normalizePath(r)));
3337
4424
  if (failed.length > 0) {
@@ -3350,11 +4437,16 @@ export class BrowserEngine {
3350
4437
  const queue = [...targets];
3351
4438
  for (let i = 0; i < queue.length; i++) {
3352
4439
  const path = queue[i];
3353
- const url = `${this.baseUrl}${path.startsWith("/") ? "" : "/"}${path}`;
3354
- if (!this.isSameOrigin(url)) {
3355
- summary.push(`${path} — SKIPPED (off-origin)`);
4440
+ if (opts.deadline !== undefined && Date.now() >= opts.deadline) {
4441
+ summary.push(`… stopped at the time limit: ${queue.length - i} route(s) not started`);
4442
+ break;
4443
+ }
4444
+ const resolved = resolveTarget(this.baseUrl, path);
4445
+ if (!("url" in resolved)) {
4446
+ summary.push(`${path} — SKIPPED (${resolved.offOrigin ? "off-origin" : resolved.problem})`);
3356
4447
  continue;
3357
4448
  }
4449
+ const url = resolved.url;
3358
4450
  this.actionStartedAt = Date.now();
3359
4451
  this.oracles.drain(false); // discard pre-route leftovers WITHOUT marking their signatures as reported
3360
4452
  let status = "ERR";
@@ -3370,11 +4462,7 @@ export class BrowserEngine {
3370
4462
  // must not count a route as covered in every later run's gap ledger.
3371
4463
  if (!opts.measureOnly)
3372
4464
  this.loadFailedRoutes.add(normalizePath(url));
3373
- // The browser commits its own error page for this failure tens of
3374
- // milliseconds after goto has thrown, and that commit interrupts the next
3375
- // navigation, charging this route's failure to the next one. Wait for it;
3376
- // a browser that shows no error page simply lets the wait time out.
3377
- await page.waitForEvent("framenavigated", { predicate: (f) => f === page.mainFrame(), timeout: 1500 }).catch(() => undefined);
4465
+ await this.errorPageCommitted(page);
3378
4466
  summary.push(`${path} — LOAD FAILED`);
3379
4467
  // The page a re-attach went back to never loaded, so nothing says whether it worked.
3380
4468
  if (this.authLoss.reattaching)
@@ -3398,14 +4486,14 @@ export class BrowserEngine {
3398
4486
  }
3399
4487
  await this.settle();
3400
4488
  this.harvestPaused = opts.measureOnly === true;
3401
- const { elements, forms } = await this.collect().finally(() => (this.harvestPaused = false));
4489
+ const { elements, forms, aliases } = await this.collect().finally(() => (this.harvestPaused = false));
3402
4490
  const finalUrl = page.url();
3403
4491
  const route = normalizePath(finalUrl);
3404
- const fp = fingerprintState(finalUrl, elements);
4492
+ const fp = fingerprintState(finalUrl, trackedElements(elements));
3405
4493
  if (!opts.measureOnly) {
3406
- memory.visitState(fp, finalUrl, route, elements.map((el) => el.key));
4494
+ memory.visitState(fp, finalUrl, route, trackedElements(elements).map((el) => el.key), inertKeys(elements), this.sessionKey, aliases);
3407
4495
  for (const f of forms)
3408
- memory.recordForm(fp, f.key, f.guarded);
4496
+ memory.recordForm(fp, f.key, f.guarded, this.sessionKey);
3409
4497
  memory.recordRoleAccess(this.role, route, "reached");
3410
4498
  }
3411
4499
  // If we landed somewhere else (auth wall, canonical redirect), the
@@ -3440,7 +4528,7 @@ export class BrowserEngine {
3440
4528
  url: finalUrl,
3441
4529
  status: typeof status === "number" ? status : null,
3442
4530
  loginRedirect,
3443
- elements: elements.length,
4531
+ elements: trackedElements(elements).length,
3444
4532
  unnamed: own.filter(missingName).map(describeControl),
3445
4533
  placeholderOnly: own.filter(placeholderOnly).map(placeholderEvidence),
3446
4534
  violations: violations.map(({ kind, severity, detail, url, embed }) => ({ kind, severity, detail, url, ...(embed ? { embed } : {}) })),
@@ -3449,22 +4537,40 @@ export class BrowserEngine {
3449
4537
  design: inspected?.design ?? [],
3450
4538
  ...(inspected?.auditError ? { auditError: inspected.auditError } : {}),
3451
4539
  });
3452
- const deadEnd = elements.length === 0;
4540
+ const deadEnd = trackedElements(elements).length === 0;
3453
4541
  // A field labelled only by its placeholder counts here too: to someone reading the crawl, it has no label.
3454
4542
  // Counted over the page's own controls, as the check's lists are: another site's frame is not this app's to fix.
3455
4543
  const unnamed = own.filter((el) => missingName(el) || placeholderOnly(el)).length;
3456
- const missingTestid = elements.filter((el) => !el.testid && !el.disabled).length;
3457
- const flags = [loginRedirect ? "AUTH-REDIRECT" : null, deadEnd ? "DEAD-END" : null, violations.length > 0 ? `${violations.length}⚠` : null].filter(Boolean);
3458
- summary.push(`${path} — ${status} · ${elements.length} el` +
3459
- (missingTestid ? ` · ${missingTestid} no-testid` : "") +
3460
- (unnamed ? ` · ${unnamed} unnamed` : "") +
3461
- (flags.length ? ` · ${flags.join(" ")}` : ""));
3462
- if (violations.length > 0 || deadEnd || loginRedirect || (typeof status === "number" && status >= 400)) {
4544
+ const missingTestid = trackedElements(elements).filter((el) => !el.testid && !el.disabled).length;
4545
+ // What the page's main area holds besides controls: "41 el" alone cannot tell a page
4546
+ // of text from a main area that rendered nothing.
4547
+ const main = await this.readMainRegion(page);
4548
+ // A main area holding only an alert or a loading placeholder is the usual look of a broken route that answered 200.
4549
+ const shows = main && !deadEnd ? mainState(main) : null;
4550
+ const stateFlag = mainStateFlag(shows);
4551
+ const flags = [
4552
+ loginRedirect ? "AUTH-REDIRECT" : null,
4553
+ deadEnd ? "DEAD-END" : null,
4554
+ stateFlag,
4555
+ violations.length > 0 ? `${violations.length}⚠` : null,
4556
+ ].filter((f) => f !== null);
4557
+ const outcome = { path, status, requestedRoute, landedRoute: route, loginRedirect, deadEnd, mainState: shows };
4558
+ summary.push(crawlLine(outcome, { elements: trackedElements(elements).length, missingTestid, unnamed, main: main ? mainRegionTag(main) : null }, flags));
4559
+ // A path asked for by name joins the route contract once it answered as a page (crawl.ts crawledRoute).
4560
+ const joined = explicit && !opts.measureOnly ? crawledRoute(outcome) : null;
4561
+ if (joined)
4562
+ memory.addDiscoveredRoutes([{ route: joined, example: path }]);
4563
+ if (violations.length > 0 || deadEnd || stateFlag || loginRedirect || (typeof status === "number" && status >= 400)) {
3463
4564
  const detail = violations
3464
4565
  .slice(0, 3)
3465
4566
  .map((v) => ` ${v.kind}: ${v.detail.slice(0, 160)}`)
3466
4567
  .join("\n");
3467
- problems.push(`${path}${loginRedirect ? " → redirected to login (auth missing/expired?)" : ""}${deadEnd ? " → dead end" : ""}${detail ? `\n${detail}` : ""}`);
4568
+ const showing = shows === "error"
4569
+ ? ` → main area shows only an error view${main?.text ? ` ("${main.text.slice(0, 80)}")` : ""}`
4570
+ : shows === "loading"
4571
+ ? ` → main area still shows only a loading placeholder after settling${main?.text ? ` ("${main.text.slice(0, 80)}")` : ""}`
4572
+ : "";
4573
+ problems.push(`${path}${loginRedirect ? " → redirected to login (auth missing/expired?)" : ""}${deadEnd ? " → dead end" : ""}${showing}${detail ? `\n${detail}` : ""}`);
3468
4574
  }
3469
4575
  // A role session that lost its sign-in on this route re-attaches once
3470
4576
  // and visits the routes the loss bounced again, next. Their bounced
@@ -3490,7 +4596,7 @@ export class BrowserEngine {
3490
4596
  }
3491
4597
  // Crawl leaves the page wherever it ended — refs from before are gone.
3492
4598
  this.refs.clear();
3493
- this.lastSnap = null;
4599
+ this.forgetSnapshots();
3494
4600
  this.snapshotUrl = "";
3495
4601
  const unvisited = this.unvisitedKnownRoutes();
3496
4602
  return (
@@ -3512,11 +4618,18 @@ export class BrowserEngine {
3512
4618
  * time by semantic locator (testid= / text= / label=), never by snapshot
3513
4619
  * ref, so the plan is immune to DOM drift. Aborts on the first oracle
3514
4620
  * violation or policy refusal so the driver re-enters at the interesting moment.
4621
+ * With onViolation "continue", a new error status (or its console echo) is
4622
+ * listed on its step's line and the plan goes on (oracles.ts planStopsAt):
4623
+ * for a sweep of independent steps, where a later step does not depend on
4624
+ * an earlier one. A failed step or a policy refusal still stops it.
3515
4625
  */
3516
- async runPlan(steps) {
4626
+ async runPlan(steps, onViolation = "stop") {
3517
4627
  const page = this.requirePage();
3518
4628
  const transcript = [];
3519
- const resolveTarget = (target) => {
4629
+ const planStartedAt = Date.now();
4630
+ /** Steps the plan went on past a new violation at (onViolation "continue"). */
4631
+ const continuedPast = [];
4632
+ const locatePlanTarget = (target) => {
3520
4633
  const parsed = parseTarget(target);
3521
4634
  if (!parsed)
3522
4635
  throw new Error(`Plan targets must be ${TARGET_HELP} (got: ${target})`);
@@ -3534,12 +4647,17 @@ export class BrowserEngine {
3534
4647
  };
3535
4648
  for (const [i, step] of steps.slice(0, 20).entries()) {
3536
4649
  this.actionStartedAt = Date.now();
4650
+ // A navigation or a scroll is not input: navigate() moves the action mark itself, and what a scroll loads is the page's own doing.
4651
+ if (step.action !== "navigate" && step.action !== "scroll")
4652
+ await this.beginInput();
3537
4653
  const desc = `${i + 1}. ${step.action} ${step.target ?? step.value ?? ""}`;
3538
4654
  // Identity captured BEFORE the action — buttons that relabel themselves
3539
4655
  // (Add to Cart → View Cart) are unmatchable in the post-action DOM.
3540
4656
  let preTestid = null;
3541
4657
  let preLabel = "";
3542
4658
  let forcedClick = false;
4659
+ let cover = null;
4660
+ let coverReadAt = 0;
3543
4661
  // What a type step has to say about the field it typed into; it goes on
3544
4662
  // the step's own line, so it cannot read as the previous step's.
3545
4663
  let note = "";
@@ -3591,7 +4709,7 @@ export class BrowserEngine {
3591
4709
  else {
3592
4710
  if (!step.target)
3593
4711
  throw new Error(`${step.action} needs a target`);
3594
- const loc = resolveTarget(step.target);
4712
+ const loc = locatePlanTarget(step.target);
3595
4713
  // Coverage is recorded against the state the element LIVED IN, so it
3596
4714
  // has to be captured before the action changes the page. Marking it
3597
4715
  // afterwards (as this did) recorded against the state the click
@@ -3610,7 +4728,17 @@ export class BrowserEngine {
3610
4728
  preTestid = await loc.getAttribute("data-testid").catch(() => null);
3611
4729
  const label = await liveLabel(loc);
3612
4730
  preLabel = label;
3613
- if (this.readOnly && (step.action === "click" || step.action === "select" || step.action === "upload") && isDestructive(label, step.value)) {
4731
+ // A dropdown is named by all its options; a select step is judged by the option it picks (destructiveLabelOf).
4732
+ const isSelect = step.action === "select" && (await loc.evaluate((n) => n.tagName.toLowerCase() === "select").catch(() => false));
4733
+ // The option a select step means, settled before anything is picked: one naming none fails the step at once.
4734
+ const pick = step.action === "select" ? await BrowserEngine.resolveSelectPick(loc, step.value ?? "") : null;
4735
+ if (pick && "refused" in pick) {
4736
+ transcript.push(`${desc} → FAILED: ${pick.refused}`);
4737
+ break;
4738
+ }
4739
+ if (this.readOnly &&
4740
+ (step.action === "click" || step.action === "select" || step.action === "upload") &&
4741
+ (isSelect ? pickIsDestructive(step.value, preTestid, pick ? pick.label : null) : isDestructive(label, step.value))) {
3614
4742
  transcript.push(`${desc} → ${destructiveRefusal(label || step.target, this.mode)}`);
3615
4743
  break;
3616
4744
  }
@@ -3620,7 +4748,7 @@ export class BrowserEngine {
3620
4748
  preState = await this.stateHolding(probe, preState, preState !== null && preState === lastCapture);
3621
4749
  form = { kind: "click", probe };
3622
4750
  }
3623
- forcedClick = (await this.resilientClick(loc, this.limits.actionMs)).forced;
4751
+ ({ forced: forcedClick, cover, coverReadAt } = await this.resilientClick(loc, this.limits.actionMs));
3624
4752
  }
3625
4753
  else if (step.action === "hover") {
3626
4754
  const { before, bodyBefore, churning } = await this.hoverBaselines();
@@ -3653,7 +4781,7 @@ export class BrowserEngine {
3653
4781
  }
3654
4782
  else if (step.action === "select") {
3655
4783
  const options = await readSelectOptions(loc);
3656
- const picked = await loc.selectOption(step.value ?? "", { timeout: this.limits.actionMs });
4784
+ const picked = await loc.selectOption(pick && !("refused" in pick) ? pick.arg : (step.value ?? ""), { timeout: this.limits.actionMs });
3657
4785
  if (options)
3658
4786
  chose = { options, picked };
3659
4787
  }
@@ -3683,12 +4811,12 @@ export class BrowserEngine {
3683
4811
  try {
3684
4812
  // Record the state the action LANDED on (it may be a new screen
3685
4813
  // this plan just reached, and it deserves coverage of its own)…
3686
- const { elements, forms } = await this.collect();
4814
+ const { elements, forms, aliases } = await this.collect();
3687
4815
  const url = page.url();
3688
- const fp = fingerprintState(url, elements);
3689
- this.memory.visitState(fp, url, normalizePath(url), elements.map((el) => el.key));
4816
+ const fp = fingerprintState(url, trackedElements(elements));
4817
+ this.memory.visitState(fp, url, normalizePath(url), trackedElements(elements).map((el) => el.key), inertKeys(elements), this.sessionKey, aliases);
3690
4818
  for (const f of forms)
3691
- this.memory.recordForm(fp, f.key, f.guarded);
4819
+ this.memory.recordForm(fp, f.key, f.guarded, this.sessionKey);
3692
4820
  this.memory.recordRoleAccess(this.role, normalizePath(url), "reached");
3693
4821
  lastCapture = { fp, elements, url };
3694
4822
  // …but mark the acted-on element in the state it came FROM, using
@@ -3723,17 +4851,24 @@ export class BrowserEngine {
3723
4851
  await this.scanForInjections();
3724
4852
  await this.scanForContradictions();
3725
4853
  const violations = this.oracles.drain();
3726
- const mutations = this.drainMutations() + this.drainBlocked() + this.drainCreated();
4854
+ const mutations = this.drainDialogs() + this.drainMutations() + this.drainBlocked() + this.drainRefresh() + this.drainCreated();
4855
+ const forcedNote = !forcedClick
4856
+ ? ""
4857
+ : cover
4858
+ ? ` (forced — the strict click timed out because at its centre it is ${describeCover(cover, this.blockedBetween(Math.max(planStartedAt, this.snapshotAt), coverReadAt))}; a forced click still landed)`
4859
+ : " (forced — the strict click timed out on this element's hit-test/stability check but a forced click still landed; something may render on top of it or delegate via a label, cross-check GEOMETRY overlaps before calling it a bug)";
3727
4860
  // Abort only on NEW violations: a known-failing endpoint repeating on
3728
4861
  // every navigation must not make every plan abort at step 1.
3729
- if (violations.some((v) => !v.repeat)) {
4862
+ if (planStopsAt(violations, onViolation)) {
3730
4863
  transcript.push(`${desc} → OK${note}, but oracle fired:${formatViolations(violations)}${mutations}`);
3731
4864
  transcript.push(`PLAN ABORTED at step ${i + 1} — investigate before continuing.`);
3732
4865
  break;
3733
4866
  }
3734
- const forcedNote = forcedClick
3735
- ? " (forced — the strict click timed out on this element's hit-test/stability check but a forced click still landed; something may render on top of it or delegate via a label, cross-check GEOMETRY overlaps before calling it a bug)"
3736
- : "";
4867
+ if (violations.some((v) => !v.repeat)) {
4868
+ continuedPast.push(i + 1);
4869
+ transcript.push(`${desc} → OK (${page.url()})${note}${forcedNote}, oracle fired (continuing: onViolation "continue"):${formatViolations(violations)}${mutations}`);
4870
+ continue;
4871
+ }
3737
4872
  transcript.push(`${desc} → OK (${page.url()})${note}${mutations}${forcedNote}`);
3738
4873
  }
3739
4874
  catch (err) {
@@ -3756,12 +4891,15 @@ export class BrowserEngine {
3756
4891
  }
3757
4892
  }
3758
4893
  this.refs.clear();
3759
- this.lastSnap = null;
4894
+ this.forgetSnapshots();
3760
4895
  this.snapshotUrl = "";
3761
4896
  // Count step lines, not transcript lines — hover reveals and scroll
3762
4897
  // positions push informational entries that are not steps.
3763
4898
  const ran = transcript.filter((l) => /^\d+\. /.test(l)).length;
3764
- return `PLAN (${ran}/${Math.min(steps.length, 20)} steps ran):\n${transcript.join("\n")}\nTake scout_snapshot to see the resulting state.`;
4899
+ const continued = continuedPast.length > 0
4900
+ ? `\nCONTINUED PAST new oracle violations at step${continuedPast.length > 1 ? "s" : ""} ${continuedPast.join(", ")} (onViolation "continue"); each is listed on its step's line and kept for the report.`
4901
+ : "";
4902
+ return `PLAN (${ran}/${Math.min(steps.length, 20)} steps ran):\n${transcript.join("\n")}${continued}\nTake scout_snapshot to see the resulting state.`;
3765
4903
  }
3766
4904
  /** A plan's or a flow's target as a Playwright locator (every match; callers pick). */
3767
4905
  static locatorFor(page, target) {
@@ -3841,10 +4979,6 @@ export class BrowserEngine {
3841
4979
  await (this.page ?? page).waitForTimeout(100).catch(() => { });
3842
4980
  }
3843
4981
  };
3844
- const firstLine = (err) => (err instanceof Error ? err.message : String(err))
3845
- .split("\n")[0]
3846
- .replace(/^[a-z]+\.[a-zA-Z]+: /, "")
3847
- .trim();
3848
4982
  try {
3849
4983
  for (const [i, step] of steps.entries()) {
3850
4984
  const n = i + 1;
@@ -3854,10 +4988,15 @@ export class BrowserEngine {
3854
4988
  let failure = null;
3855
4989
  let refusal = null;
3856
4990
  this.actionStartedAt = Date.now();
4991
+ if (step.action === "click" || step.action === "type" || step.action === "select" || step.action === "press")
4992
+ await this.beginInput();
3857
4993
  try {
3858
4994
  const current = this.requirePage();
3859
4995
  if (step.action === "navigate") {
3860
- const url = `${this.baseUrl}${step.target}`;
4996
+ const resolved = resolveTarget(this.baseUrl, step.target);
4997
+ if (!("url" in resolved))
4998
+ throw new Error(resolved.problem);
4999
+ const url = resolved.url;
3861
5000
  const resp = await current
3862
5001
  .goto(url, { waitUntil: "domcontentloaded", timeout: this.limits.crawlNavMs })
3863
5002
  .catch((err) => Promise.reject(explainTimeout(err, "nav", this.limits.crawlNavMs)));
@@ -3890,7 +5029,14 @@ export class BrowserEngine {
3890
5029
  const label = ((await loc.getAttribute("aria-label").catch(() => null)) ??
3891
5030
  (await loc.textContent({ timeout: 1000 }).catch(() => null)) ??
3892
5031
  "").trim();
3893
- if (this.readOnly && step.action !== "type" && isDestructive(label, step.action === "select" ? step.value : undefined)) {
5032
+ // A dropdown is named by all its options; a select step is judged by the option it picks (destructiveLabelOf).
5033
+ const isSelect = step.action === "select" && (await loc.evaluate((n) => n.tagName.toLowerCase() === "select").catch(() => false));
5034
+ const testid = await loc.getAttribute("data-testid").catch(() => null);
5035
+ if (this.readOnly &&
5036
+ step.action !== "type" &&
5037
+ (isSelect
5038
+ ? pickIsDestructive(step.value, testid, await BrowserEngine.chosenOptionLabel(loc, step.value))
5039
+ : isDestructive(label, step.action === "select" ? step.value : undefined))) {
3894
5040
  refusal = destructiveRefusal(label || step.target, this.mode);
3895
5041
  }
3896
5042
  else if (step.action === "click") {
@@ -3937,7 +5083,7 @@ export class BrowserEngine {
3937
5083
  }
3938
5084
  }
3939
5085
  catch (err) {
3940
- failure = firstLine(explainTimeout(err, "action", this.limits.actionMs));
5086
+ failure = firstLineOf(explainTimeout(err, "action", this.limits.actionMs));
3941
5087
  }
3942
5088
  await this.scanForInjections().catch(() => { });
3943
5089
  await this.scanForContradictions().catch(() => { });
@@ -3988,7 +5134,7 @@ export class BrowserEngine {
3988
5134
  page.off("websocket", onSocket);
3989
5135
  context.off("response", onResponse);
3990
5136
  this.refs.clear();
3991
- this.lastSnap = null;
5137
+ this.forgetSnapshots();
3992
5138
  this.snapshotUrl = "";
3993
5139
  }
3994
5140
  }
@@ -4011,10 +5157,9 @@ export class BrowserEngine {
4011
5157
  this.designAuditCount += 1;
4012
5158
  if (this.memory)
4013
5159
  this.memory.auditsThisRun += 1;
4014
- // The census is built from previous audits, so the first few pages of a run
4015
- // score with chrome included and later ones don't. That is the same warm-up
4016
- // the coverage census has: nothing is knowable as "shared" until it has been
4017
- // seen on several routes.
5160
+ // The census is built from previous audits and knows nothing until it has
5161
+ // seen several routes; the shell's landmarks (design.ts) cover those first
5162
+ // pages, so the shell is out of the score from the first audit.
4018
5163
  const { report, score, signatures, defects } = analyzeDesign(payload, page.viewportSize() ?? { width: 1280, height: 900 }, this.memory?.designChromeKeys() ?? new Set());
4019
5164
  const route = normalizePath(page.url());
4020
5165
  this.memory?.recordDesignElements(route, signatures);
@@ -4061,15 +5206,15 @@ export class BrowserEngine {
4061
5206
  * clicked, and the write policy is not involved. The rectangle's rules are
4062
5207
  * capture.ts.
4063
5208
  */
4064
- async captureElement(target, margin) {
5209
+ async captureElement(target, margin, log = true) {
4065
5210
  const page = this.requirePage();
4066
5211
  let el;
4067
5212
  if (target.ref) {
4068
5213
  el = this.refs.get(target.ref);
4069
5214
  if (!el)
4070
5215
  throw new Error(`Unknown ref "${target.ref}". Refs are only valid from the latest scout_snapshot — take a new snapshot.`);
4071
- if (page.url() !== this.snapshotUrl) {
4072
- this.refs.clear();
5216
+ if (!refsSurviveUrlChange(this.snapshotUrl, page.url())) {
5217
+ this.dropRefs(`the page moved to ${page.url()}`);
4073
5218
  throw new Error(`Page URL changed since the last snapshot (now ${page.url()}). Take a new scout_snapshot.`);
4074
5219
  }
4075
5220
  }
@@ -4094,12 +5239,141 @@ export class BrowserEngine {
4094
5239
  if (!clip)
4095
5240
  throw new Error(`${el.name || el.key} is outside the viewport, so it has no picture.`);
4096
5241
  const png = await page.screenshot({ type: "png", clip, animations: "disabled" });
4097
- this.logAction({ action: "capture", target: el.key.slice(0, 200), url: page.url() });
5242
+ if (log)
5243
+ this.logAction({ action: "capture", target: el.key.slice(0, 200), url: page.url() });
4098
5244
  // Found by key, the refs were rebuilt without a snapshot: none of them may be acted on.
4099
5245
  if (target.key)
4100
- this.refs.clear();
5246
+ this.dropRefs("an element was captured by its key");
4101
5247
  return { png, key: el.key, label: el.name, url: page.url() };
4102
5248
  }
5249
+ /**
5250
+ * A finding's picture, as a PNG: the element `ref` names plus
5251
+ * `margin` (captureElement), or the viewport. An element that cannot be
5252
+ * pictured (a stale ref, one not displayed) leaves the viewport instead, and
5253
+ * `note` says why, so a finding is never filed without the evidence that was
5254
+ * there to take. What is taken and how it is bounded is capture.ts and png.ts.
5255
+ */
5256
+ async captureEvidence(ref, margin) {
5257
+ let note;
5258
+ if (ref) {
5259
+ try {
5260
+ // Not logged: the log is what a later finding's repro trace is built from, and a picture is no step to repeat.
5261
+ const shot = await this.captureElement({ ref }, margin, false);
5262
+ return { png: shot.png, frame: "element", label: shot.label };
5263
+ }
5264
+ catch (err) {
5265
+ note = `${ref} could not be pictured (${err instanceof Error ? err.message : String(err)}), so the viewport was`;
5266
+ }
5267
+ }
5268
+ const page = this.requirePage();
5269
+ const png = await page.screenshot({ type: "png", animations: "disabled", scale: "css", timeout: this.limits.actionMs });
5270
+ return { png, frame: "viewport", label: "", ...(note ? { note } : {}) };
5271
+ }
5272
+ /**
5273
+ * After a navigation threw. The browser commits its own error page for the
5274
+ * failure tens of milliseconds after goto has thrown, and that commit
5275
+ * interrupts the next navigation, charging this failure to the next page.
5276
+ * Wait for it; a browser that shows no error page simply lets the wait time
5277
+ * out, which is not an error.
5278
+ */
5279
+ async errorPageCommitted(page) {
5280
+ await page.waitForEvent("framenavigated", { predicate: (f) => f === page.mainFrame(), timeout: 1500 }).catch(() => undefined);
5281
+ }
5282
+ /** The browser this session drives. */
5283
+ get browserEngine() {
5284
+ return this.engineName;
5285
+ }
5286
+ /** Whether the page and its browser are still there: a failure after either is gone is the engine's, not the page's. */
5287
+ get alive() {
5288
+ return !!this.page && !this.page.isClosed() && !!this.browser?.isConnected();
5289
+ }
5290
+ /** True when `p` settles in time, false when it times out. Any other failure is thrown, so a closed page never reads as a slow one. */
5291
+ static async inTime(p) {
5292
+ try {
5293
+ await p;
5294
+ return true;
5295
+ }
5296
+ catch (err) {
5297
+ if (err instanceof Error && isTimeoutMessage(err.message))
5298
+ return false;
5299
+ throw err;
5300
+ }
5301
+ }
5302
+ /**
5303
+ * A picture for `scenescout check --baseline`: the page at `pathOnApp`
5304
+ * loaded afresh (from a blank page, so a target that differs from the last
5305
+ * only by its #fragment is still a new load), told the motion preference
5306
+ * `how` names, read once its fonts have loaded and its animations are
5307
+ * stopped, then either the viewport from the top (no target) or one element,
5308
+ * found the way a saved flow finds its target, with `how.margin` around it.
5309
+ * The screenshot is taken with `how`'s animation and caret settings, at one
5310
+ * picture pixel per CSS pixel. Reads only. Throws a sentence when it cannot
5311
+ * take the picture. `cut` says when an element reaches outside the window.
5312
+ * What is compared and what it means is baseline.ts, which also holds the
5313
+ * settings; the rectangle is capture.ts. Call endBaselineCaptures when done.
5314
+ */
5315
+ async captureForBaseline(pathOnApp, target, how) {
5316
+ const page = this.requirePage();
5317
+ const action = (p) => p.catch((err) => Promise.reject(explainTimeout(err, "action", this.limits.actionMs)));
5318
+ const within = `within ${this.limits.actionMs / 1000}s — ${limitHint("action", this.limits.actionMs)}`;
5319
+ // Before the load, so a page that reads the preference once, as it starts, reads it too.
5320
+ await page.emulateMedia({ reducedMotion: how.reducedMotion });
5321
+ let resp;
5322
+ try {
5323
+ await page.goto("about:blank", { timeout: this.limits.crawlNavMs });
5324
+ resp = await page.goto(`${this.baseUrl}${pathOnApp}`, { waitUntil: "domcontentloaded", timeout: this.limits.crawlNavMs });
5325
+ }
5326
+ catch (err) {
5327
+ await this.errorPageCommitted(page);
5328
+ throw explainTimeout(err, "nav", this.limits.crawlNavMs);
5329
+ }
5330
+ const status = resp?.status() ?? null;
5331
+ if (status !== null && status >= 400)
5332
+ throw new Error(`the page answered HTTP ${status}`);
5333
+ await this.settle();
5334
+ // A sign-in page pictured as the page's baseline would be written by an update and compared from then on.
5335
+ const landed = await this.landedUrl(page);
5336
+ if (this.authLoss.isLoginRedirect(pathOnApp, landed, this.baseUrl)) {
5337
+ throw new Error(`the page sent the browser to sign-in (${new URL(landed).pathname}): the session is missing or expired`);
5338
+ }
5339
+ // Text drawn in a fallback font, then again when the web font arrives, would differ from one run to the next.
5340
+ const fonts = page.waitForFunction("!document.fonts || document.fonts.status === 'loaded'", null, { timeout: this.limits.actionMs });
5341
+ if (!(await BrowserEngine.inTime(fonts)))
5342
+ throw new Error(`its fonts were still loading, not done ${within}`);
5343
+ // Stopped before anything is measured, not only for the screenshot: a target that itself moves would be cropped mid-movement.
5344
+ if (how.animations === "disabled")
5345
+ await page.evaluate(STOP_ANIMATIONS_SCRIPT);
5346
+ const viewport = page.viewportSize() ?? (await page.evaluate("({ width: innerWidth, height: innerHeight })"));
5347
+ const deviceScaleFactor = Number(await page.evaluate("window.devicePixelRatio"));
5348
+ const settings = { type: "png", animations: how.animations, caret: how.caret, scale: "css", timeout: this.limits.actionMs };
5349
+ let png;
5350
+ let cut = null;
5351
+ if (!target) {
5352
+ await page.evaluate("window.scrollTo(0, 0)");
5353
+ png = await action(page.screenshot(settings));
5354
+ }
5355
+ else {
5356
+ const locator = BrowserEngine.locatorFor(page, target).first();
5357
+ if (!(await BrowserEngine.inTime(locator.waitFor({ state: "visible", timeout: this.limits.actionMs })))) {
5358
+ throw new Error(`nothing visible matches it ${within}`);
5359
+ }
5360
+ await action(locator.scrollIntoViewIfNeeded({ timeout: this.limits.actionMs }));
5361
+ const box = await action(locator.boundingBox({ timeout: this.limits.actionMs }));
5362
+ if (!box)
5363
+ throw new Error("it is not displayed");
5364
+ const clip = captureClip(box, how.margin, viewport);
5365
+ if (!clip)
5366
+ throw new Error("it is outside the viewport");
5367
+ cut = cutByViewport(box, viewport);
5368
+ png = await action(page.screenshot({ ...settings, clip }));
5369
+ }
5370
+ this.logAction({ action: "capture", target: target ? `baseline ${pathOnApp}` : `baseline ${pathOnApp} (page)`, url: page.url() });
5371
+ return { png, viewport, deviceScaleFactor, cut };
5372
+ }
5373
+ /** Stop telling the page to reduce motion, so what runs after the baselines (saved flows) sees the page as a user does. */
5374
+ async endBaselineCaptures() {
5375
+ await this.requirePage().emulateMedia({ reducedMotion: null });
5376
+ }
4103
5377
  /**
4104
5378
  * What the live view shows next to a session's name. The task is what the
4105
5379
  * agent said the session is for; the objective is the goal of the journey it
@@ -4383,6 +5657,6 @@ export class BrowserEngine {
4383
5657
  this.refs.clear();
4384
5658
  this.snapshotUrl = "";
4385
5659
  this.currentFingerprint = "";
4386
- this.lastSnap = null;
5660
+ this.forgetSnapshots();
4387
5661
  }
4388
5662
  }