scenescout 3.15.0 → 3.17.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 +87 -0
  2. package/README.md +70 -18
  3. package/dist/browsers.js +28 -0
  4. package/dist/check-run.js +191 -14
  5. package/dist/ci-run.js +268 -52
  6. package/dist/cli.js +107 -47
  7. package/dist/commands.js +3 -2
  8. package/dist/engine/baseline.js +377 -0
  9. package/dist/engine/brief.js +16 -7
  10. package/dist/engine/browser.js +1147 -286
  11. package/dist/engine/calibration.js +61 -30
  12. package/dist/engine/capture.js +164 -0
  13. package/dist/engine/check.js +244 -42
  14. package/dist/engine/ci-lanes.js +215 -0
  15. package/dist/engine/ci.js +136 -18
  16. package/dist/engine/claims.js +159 -3
  17. package/dist/engine/collector.js +561 -30
  18. package/dist/engine/crawl.js +49 -0
  19. package/dist/engine/design.js +281 -38
  20. package/dist/engine/export.js +877 -0
  21. package/dist/engine/fingerprint.js +92 -4
  22. package/dist/engine/flow.js +18 -6
  23. package/dist/engine/forms.js +181 -18
  24. package/dist/engine/journey.js +29 -1
  25. package/dist/engine/lane.js +13 -3
  26. package/dist/engine/launch.js +45 -6
  27. package/dist/engine/limits.js +7 -0
  28. package/dist/engine/live-page.js +49 -2
  29. package/dist/engine/live.js +4 -1
  30. package/dist/engine/memory.js +501 -47
  31. package/dist/engine/open.js +118 -0
  32. package/dist/engine/oracles.js +41 -1
  33. package/dist/engine/plain.js +268 -0
  34. package/dist/engine/png.js +127 -0
  35. package/dist/engine/policy.js +379 -9
  36. package/dist/engine/probes.js +3 -2
  37. package/dist/engine/profiles.js +45 -9
  38. package/dist/engine/project-folder.js +191 -0
  39. package/dist/engine/refresh.js +68 -3
  40. package/dist/engine/replay.js +63 -10
  41. package/dist/engine/report.js +241 -40
  42. package/dist/engine/request.js +317 -23
  43. package/dist/engine/sarif.js +120 -0
  44. package/dist/engine/settle.js +67 -0
  45. package/dist/engine/signed-in.js +256 -0
  46. package/dist/engine/status-pane-page.js +441 -0
  47. package/dist/engine/status-pane.js +128 -0
  48. package/dist/engine/tickets.js +671 -0
  49. package/dist/engine/unload.js +3 -2
  50. package/dist/export-run.js +633 -0
  51. package/dist/first-run.js +5 -0
  52. package/dist/installer.js +378 -8
  53. package/dist/intake.js +104 -0
  54. package/dist/login-run.js +250 -36
  55. package/dist/mcp-server.js +660 -65
  56. package/dist/playbook.js +5 -0
  57. package/dist/prompts.js +106 -0
  58. package/package.json +8 -5
  59. package/skills/scenescout/SKILL.md +49 -16
@@ -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, INFRASTRUCTURE_WRITE_RE, OPEN_DIALOGS_SCRIPT } 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, stateFlags, stateChange, trackedElements, inertKeys, MAIN_REGION_SCRIPT, mainRegionLine, mainRegionTag, } from "./collector.js";
13
- import { OracleMonitor, formatViolations, httpErrorDetail, requestKey } 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, isSubmitLike, 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, brokerDecision, brokerEnabled, cookieMayCount, endpointKey, endpointsPathFor, headersForResend, isStaticAsset, learnableEndpoint, lockPathFor, planRefresh, profileAfterRotation, readLearnedEndpoints, REFRESH_BROKER_ENV, refreshTokenSlots, rotatedCookies, rotatedFromResponse, rotationStored, swapProfileToken, swapRequest, swapToken, withLearnedEndpoint, writeLearnedEndpoints, } 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
@@ -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. */
@@ -245,6 +336,8 @@ export class BrowserEngine {
245
336
  refreshTasks = new Set();
246
337
  /** What the broker did this session, for the lane report: counts only. */
247
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 = [];
248
341
  /** UI-label blocking applies only in read-only mode (safe-write enforces at the network layer instead). */
249
342
  get readOnly() {
250
343
  // observe is read-only and then some: every UI-level refusal applies to it too.
@@ -257,6 +350,14 @@ export class BrowserEngine {
257
350
  }
258
351
  /** Requests blocked by the write policy since the last action (timestamped for attribution). */
259
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;
260
361
  /**
261
362
  * WebSockets this session's pages opened. The write policy works on HTTP
262
363
  * requests; frames sent over a socket are not inspected. In observe mode that
@@ -318,6 +419,33 @@ export class BrowserEngine {
318
419
  }
319
420
  /** Design audits run by this session. The report gate counts the whole run's, on the shared store (MemoryStore.auditsThisRun). */
320
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
+ }
321
449
  /** Active task-efficiency measurement (scout_journey), if any. */
322
450
  journey = null;
323
451
  /** The session's objective: the whole remit the agent was given at scout_attach. Empty when none was given. */
@@ -334,17 +462,40 @@ export class BrowserEngine {
334
462
  /** How many frames could not be written. The first one says so in the log; the rest are counted. */
335
463
  framesFailed = 0;
336
464
  /**
337
- * The Authorization header the app itself last sent, replayed by
338
- * scout_request so a call with the UI bypassed carries the same credential
339
- * as a click. Nothing here parses it: whatever scheme the app uses is
340
- * 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.
341
469
  */
342
470
  lastAuthHeader = null;
343
- rememberAuthHeader(headers) {
344
- const value = headers["authorization"] ?? headers["Authorization"];
345
- 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)
346
476
  this.lastAuthHeader = value;
347
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
+ }
348
499
  /**
349
500
  * Call the app's own API as this session, with the UI bypassed.
350
501
  *
@@ -362,11 +513,15 @@ export class BrowserEngine {
362
513
  const target = resolveRequestUrl(this.baseUrl, input.path);
363
514
  if ("problem" in target)
364
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;
365
519
  const script = buildRequestScript({
366
520
  url: target.url,
367
521
  method: method.method,
368
522
  body: input.body,
369
- headers: requestHeaders({ given: input.headers, auth: this.lastAuthHeader, body: input.body }),
523
+ headers,
524
+ ...(wantsView(input.view) ? { keep: BODY_FETCH_MAX } : {}),
370
525
  });
371
526
  let raw;
372
527
  // Claimed by the first matching request the page sends (the request
@@ -375,6 +530,7 @@ export class BrowserEngine {
375
530
  this.oracles.replayStarted(target.url);
376
531
  try {
377
532
  raw = (await page.evaluate(script));
533
+ this.pageRequests.markReplay(method.method, target.url);
378
534
  }
379
535
  catch (err) {
380
536
  // The page could not run the fetch at all (a navigation mid-call, a
@@ -389,7 +545,7 @@ export class BrowserEngine {
389
545
  for (const hop of this.replayHops.splice(0))
390
546
  this.oracles.replayEnded(hop);
391
547
  }
392
- const result = toReplayResult(raw);
548
+ const result = toReplayResult(raw, input.view);
393
549
  this.logAction({
394
550
  action: "request",
395
551
  target: `${method.method} ${input.path}`,
@@ -397,7 +553,7 @@ export class BrowserEngine {
397
553
  // Not a status signature: the trail must not record the stand-in as the server's answer.
398
554
  result: result.refusedByPolicy ? `blocked: write policy (${result.refusedByPolicy})` : replaySignature(method.method, result.url, result.status),
399
555
  });
400
- return formatReplay(method.method, result);
556
+ return formatReplay(method.method, result) + (result.refusedByPolicy ? "" : staleCredentialNote(result.status, replayedAuth, this.lastAuthAnswer));
401
557
  }
402
558
  /** The scout_request call in flight, until the page's request for it is seen. */
403
559
  pendingReplay = null;
@@ -540,7 +696,7 @@ export class BrowserEngine {
540
696
  // role in a multi-role run, so a concurrent session navigating during this
541
697
  // journey would otherwise contaminate its path, screen count, and backtracks.
542
698
  const log = (this.memory?.actionLog ?? []).slice(j.fromLog).filter((e) => (e.session ?? this.sessionKey) === this.sessionKey);
543
- const seconds = Math.round((Date.now() - j.startedAt) / 1000);
699
+ const time = journeyTime(j.startedAt, log.map((e) => Date.parse(e.at)), Date.now());
544
700
  const measured = measureJourney(log, completed);
545
701
  try {
546
702
  // Only a COMPLETED journey is a measurement of task ease. An abandoned
@@ -552,7 +708,7 @@ export class BrowserEngine {
552
708
  /* fact recording is best-effort */
553
709
  }
554
710
  this.logAction({ action: JOURNEY_END, target: j.goal, url: page.url(), result: completed ? "completed" : "abandoned" });
555
- return formatJourney({ goal: j.goal, completed, seconds, note }, measured);
711
+ return formatJourney({ goal: j.goal, completed, time, note }, measured);
556
712
  }
557
713
  /** Whether the browser window is visible — headed hover results carry a physical-cursor caveat. */
558
714
  headed = false;
@@ -653,14 +809,27 @@ export class BrowserEngine {
653
809
  if (this.watchedResponses.length >= BrowserEngine.MAX_WATCHED_RESPONSES)
654
810
  return;
655
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
+ }
656
819
  this.watchedResponses.push({
657
820
  method: req.method(),
658
821
  url: req.url(),
659
822
  status,
660
823
  resourceType: req.resourceType(),
661
824
  blockedByPolicy: this.refusedByAnyPolicy(req),
662
- // The current action is a user input only when it is the one that set the input mark.
663
- background: this.inputSince === null || this.inputSince < this.actionStartedAt || started < this.inputSince,
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
+ }),
664
833
  });
665
834
  }
666
835
  /**
@@ -672,6 +841,12 @@ export class BrowserEngine {
672
841
  inputSince = null;
673
842
  /** What the page said as the current input began, for the contradiction rules; null when it could not be read. */
674
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;
675
850
  /**
676
851
  * Mark the start of a user input: read what the page says now, so a claim
677
852
  * already on screen (a status badge, a heading) is not taken for the
@@ -681,6 +856,8 @@ export class BrowserEngine {
681
856
  async beginInput() {
682
857
  this.inputSince = this.actionStartedAt;
683
858
  this.claimBaseline = null;
859
+ this.documentLoadSince = null;
860
+ this.actedControl = null;
684
861
  const page = this.page;
685
862
  if (!page || page.isClosed())
686
863
  return;
@@ -691,6 +868,31 @@ export class BrowserEngine {
691
868
  // A page mid-navigation has nothing on screen to excuse.
692
869
  }
693
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
+ }
694
896
  /**
695
897
  * Did the page agree with what the network just did? Runs in the same slot
696
898
  * as the injection scan, so it sees the DOM the action settled on.
@@ -702,6 +904,8 @@ export class BrowserEngine {
702
904
  async scanForContradictions() {
703
905
  const requests = this.watchedResponses;
704
906
  this.watchedResponses = [];
907
+ const acted = this.actedControl;
908
+ this.actedControl = null;
705
909
  const page = this.page;
706
910
  if (!page || page.isClosed() || requests.length === 0)
707
911
  return;
@@ -718,7 +922,17 @@ export class BrowserEngine {
718
922
  }
719
923
  // A baseline belongs to the input that took it, and to its page.
720
924
  const before = this.inputSince !== null && this.inputSince >= this.actionStartedAt ? this.claimBaseline : null;
721
- for (const found of findContradictions(requests, state, before)) {
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)) {
722
936
  if (this.contradictionsReported.has(found.evidence))
723
937
  continue;
724
938
  this.contradictionsReported.add(found.evidence);
@@ -785,6 +999,10 @@ export class BrowserEngine {
785
999
  /** Write-policy blocks drained by the last action — counted, so an action can know a request fired even when the policy stopped it. */
786
1000
  lastActionBlocked = 0;
787
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
+ }
788
1006
  get attached() {
789
1007
  return this.page !== null;
790
1008
  }
@@ -792,7 +1010,13 @@ export class BrowserEngine {
792
1010
  // Resolved before anything is closed, so a bad role, a missing profile or
793
1011
  // a time limit out of bounds leaves a live session as it was.
794
1012
  const limits = resolveTimeLimits(opts, process.env);
795
- 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
+ });
796
1020
  const brokered = brokerEnabled({ roleSession: auth.kind === "role", option: opts.refreshBroker, env: process.env[REFRESH_BROKER_ENV] });
797
1021
  const storageStatePath = auth.kind === "none" ? undefined : auth.storageStatePath;
798
1022
  if (storageStatePath && !fs.existsSync(storageStatePath)) {
@@ -818,7 +1042,11 @@ export class BrowserEngine {
818
1042
  let profileNote = "";
819
1043
  this.refresh = null;
820
1044
  this.refreshCounts = { refreshed: 0, swapped: 0, failed: 0, learned: 0 };
1045
+ this.refreshEvents = [];
821
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;
822
1050
  if (auth.kind === "role") {
823
1051
  const note = permissionNote(auth.storageStatePath, fs.statSync(auth.storageStatePath).mode);
824
1052
  if (note)
@@ -855,6 +1083,16 @@ export class BrowserEngine {
855
1083
  : "") +
856
1084
  (trust.rejected.length > 0 ? ` Not a plain http(s) origin, so not trusted: ${trust.rejected.join(", ")}.` : "") +
857
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(", ")}.` : "");
858
1096
  this.sessionObjective = (opts.objective ?? "").trim().replace(/\s+/g, " ").slice(0, 300);
859
1097
  // An agent-supplied task counts as stated; the placeholder does not.
860
1098
  this.setTask(opts.task ?? "Attaching and taking stock", opts.task !== undefined);
@@ -866,6 +1104,8 @@ export class BrowserEngine {
866
1104
  this.framesFailed = 0;
867
1105
  this.headed = opts.headed ?? false;
868
1106
  this.blockedRequests = [];
1107
+ this.blockNotices.reset();
1108
+ this.dialogsSeen = [];
869
1109
  this.watchedResponses = [];
870
1110
  this.contradictionsReported = new Set();
871
1111
  this.tokenPostsReported = new Set();
@@ -877,6 +1117,14 @@ export class BrowserEngine {
877
1117
  // role must not discard what another role already created — otherwise
878
1118
  // every multi-role handoff would be blocked as "not yours".
879
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
+ }
880
1128
  // The REAL path, not merely the resolved one: on macOS the temp tree is a
881
1129
  // symlink, and a fence comparing a real path against an unreal one would
882
1130
  // refuse every upload from inside the project.
@@ -891,6 +1139,7 @@ export class BrowserEngine {
891
1139
  this.projectDir = path.resolve(opts.projectDir);
892
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)`;
893
1141
  }
1142
+ this.loginProject = { projectDir: opts.projectDir, projectChosen: Boolean(opts.projectChosen) };
894
1143
  this.oracles = new OracleMonitor();
895
1144
  this.oracles.setPolicyRefusalCheck((req) => this.refusedByAnyPolicy(req));
896
1145
  this.oracles.setReplayCheck((req) => this.replayRequests.has(req));
@@ -906,7 +1155,7 @@ export class BrowserEngine {
906
1155
  }
907
1156
  return embedOfRequest(this.baseUrl, req.url(), site);
908
1157
  });
909
- this.lastSnap = null;
1158
+ this.forgetSnapshots();
910
1159
  this.designAuditCount = 0;
911
1160
  // A new attach may be a restarted app: what failed to load before gets another try.
912
1161
  this.loadFailedRoutes = new Set();
@@ -927,6 +1176,7 @@ export class BrowserEngine {
927
1176
  this.context = await this.browser.newContext({
928
1177
  storageState: profile?.storageState,
929
1178
  viewport: opts.viewport ?? { width: 1280, height: 900 },
1179
+ ...(opts.deviceScaleFactor !== undefined ? { deviceScaleFactor: opts.deviceScaleFactor } : {}),
930
1180
  serviceWorkers: serviceWorkerPolicy(this.engineName),
931
1181
  });
932
1182
  const restoreSession = sessionStorageInitScript(profile?.sessionStorage ?? []);
@@ -950,42 +1200,75 @@ export class BrowserEngine {
950
1200
  this.openSockets.clear();
951
1201
  this.socketsWarned = false;
952
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
+ };
953
1216
  // The first page already exists by now; later ones (popups) arrive as events.
954
1217
  if (this.page)
955
- watchSockets(this.page);
956
- this.context.on("page", watchSockets);
957
- this.context.on("requestfinished", () => {
958
- this.inFlight = Math.max(0, this.inFlight - 1);
959
- });
1218
+ watchPage(this.page);
1219
+ this.context.on("page", watchPage);
1220
+ this.context.on("requestfinished", (req) => requests.ended(req));
960
1221
  this.context.on("requestfailed", (req) => {
961
- this.inFlight = Math.max(0, this.inFlight - 1);
1222
+ requests.ended(req, true);
962
1223
  this.watchResponse(req, null);
1224
+ this.pageRequests.failed(req, req.failure()?.errorText ?? "", Date.now(), this.refusedByAnyPolicy(req));
963
1225
  });
964
1226
  this.context.on("response", (res) => {
1227
+ this.noteAuthAnswer(res);
965
1228
  this.watchResponse(res.request(), res.status());
1229
+ this.pageRequests.answered(res.request(), res.status(), Date.now(), this.refusedByAnyPolicy(res.request()));
966
1230
  });
967
1231
  this.context.on("request", (req) => {
968
1232
  // Who moved the driven page, decided on its navigation's first request
969
1233
  // (this event fires before the route handler judges that page's writes).
970
1234
  if (req.isNavigationRequest() && !req.redirectedFrom()) {
971
1235
  try {
972
- if (this.page && req.frame() === this.page.mainFrame())
1236
+ if (this.page && req.frame() === this.page.mainFrame()) {
973
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
+ }
974
1243
  }
975
1244
  catch {
976
1245
  /* no frame: not the driven page */
977
1246
  }
978
1247
  }
979
- 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());
980
1256
  this.lastRequestStart = Date.now();
981
1257
  this.requestStartedAt.set(req, this.lastRequestStart);
982
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);
983
1261
  const type = req.resourceType();
984
- if (type === "xhr" || type === "fetch")
1262
+ if (type === "xhr" || type === "fetch") {
985
1263
  this.xhrCount += 1;
1264
+ this.notePageRequest(req);
1265
+ }
986
1266
  const method = req.method();
987
1267
  if (method === "GET" || method === "HEAD" || method === "OPTIONS")
988
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;
989
1272
  // Infrastructure POSTs (token refresh, telemetry) are not state the
990
1273
  // tester mutated — reporting them trains the driver to ignore the notice.
991
1274
  if (BENIGN_MUTATION_RE.test(req.url()))
@@ -1061,8 +1344,7 @@ export class BrowserEngine {
1061
1344
  if (method === "GET" && req.resourceType() === "document" && this.embedEscapeNavigation(req)) {
1062
1345
  const why = ESCAPE_REFUSAL;
1063
1346
  // Reported like any refusal, so a click whose navigation this stopped does not read as a click that did nothing.
1064
- if (this.blockedRequests.length < 20)
1065
- 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() });
1066
1348
  this.logAction({ action: "write-policy:blocked", target: `navigation to ${req.url().slice(0, 140)} (${why})`, url: this.page?.url() ?? "" });
1067
1349
  this.refusedByPolicy.add(req);
1068
1350
  this.oracles.notePolicyBlock();
@@ -1123,8 +1405,9 @@ export class BrowserEngine {
1123
1405
  const pathname = pathnameOf(url);
1124
1406
  const refuse = (why) => {
1125
1407
  const answered = answersWithRefusal(req.resourceType());
1126
- if (this.blockedRequests.length < 20)
1127
- 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() });
1128
1411
  this.logAction({ action: "write-policy:blocked", target: `${method} ${pathname}${why ? ` (${why})` : ""}`, url: this.page?.url() ?? "" });
1129
1412
  this.refusedByPolicy.add(req);
1130
1413
  this.oracles.notePolicyBlock();
@@ -1140,7 +1423,6 @@ export class BrowserEngine {
1140
1423
  const foreign = this.foreignWriteOf(req);
1141
1424
  if (foreign)
1142
1425
  return refuse(`sent from a frame of ${foreign}`);
1143
- this.rememberAuthHeader(req.headers());
1144
1426
  const destructiveWire = isDestructiveWire(pathname, req.postData());
1145
1427
  // An embed moved the session's page off the app: its writes out are not the app's, a sign-in excepted.
1146
1428
  const offApp = offAppPageWrite(this.baseUrl, this.page?.url(), url, this.embedMoves.movedTo);
@@ -1152,6 +1434,18 @@ export class BrowserEngine {
1152
1434
  this.routedWrites.note(rule, method, url, bodyDigest(req.postDataBuffer()));
1153
1435
  return route.fallback();
1154
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();
1448
+ }
1155
1449
  let owned = this.isOwnedResource(pathname);
1156
1450
  // A single UI action commonly fires create-then-immediately-save
1157
1451
  // (POST gets an id, PUT saves content under it) faster than the
@@ -1186,6 +1480,8 @@ export class BrowserEngine {
1186
1480
  this.pendingCreations.add(task);
1187
1481
  }
1188
1482
  this.routedWrites.note(rule, method, url, bodyDigest(req.postDataBuffer()));
1483
+ if (method === "POST")
1484
+ this.clearRefusedPost(url);
1189
1485
  return route.fallback();
1190
1486
  }
1191
1487
  return refuse();
@@ -1225,7 +1521,7 @@ export class BrowserEngine {
1225
1521
  this.page = newPage;
1226
1522
  this.refs.clear();
1227
1523
  this.snapshotUrl = "";
1228
- this.lastSnap = null;
1524
+ this.forgetSnapshots();
1229
1525
  }
1230
1526
  else {
1231
1527
  void this.leaveAndClose(newPage);
@@ -1265,7 +1561,7 @@ export class BrowserEngine {
1265
1561
  ? `\n⚠ AUTH FAILED — the storage state at ${storageStatePath} did not produce a signed-in session: ` +
1266
1562
  `attaching landed on ${landed}, a login page. ` +
1267
1563
  (auth.kind === "role"
1268
- ? `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. `
1269
1565
  : `Regenerate it (its token has most likely expired) and re-attach. `) +
1270
1566
  `Continuing now tests a logged-out app.` +
1271
1567
  (recipe.length > 0
@@ -1279,7 +1575,7 @@ export class BrowserEngine {
1279
1575
  `Memory: ${this.memory.dir}.${this.memory.loadWarning ? ` WARNING: ${this.memory.loadWarning}` : ""}` +
1280
1576
  (this.memory.prunedStates > 0 ? ` Trimmed ${this.memory.prunedStates} old page state(s) from the history; coverage is unchanged.` : "") +
1281
1577
  `${this.memory.legacyDirNote ? ` ${this.memory.legacyDirNote}` : ""}` +
1282
- `${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.` +
1283
1579
  profileNote +
1284
1580
  authWarning);
1285
1581
  }
@@ -1325,21 +1621,50 @@ export class BrowserEngine {
1325
1621
  this.foreignFrames.set(frame, origin);
1326
1622
  });
1327
1623
  }
1328
- /** 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
+ */
1329
1631
  wireDialogHandler(page) {
1330
1632
  page.on("dialog", (dialog) => {
1331
1633
  this.nativeDialogAt = Date.now();
1332
- const action = this.readOnly ? "dismiss" : "accept";
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);
1333
1639
  this.logAction({
1334
- action: `dialog:${action}`,
1640
+ action: `dialog:${type === "beforeunload" ? "leave-" : ""}${response}`,
1335
1641
  target: dialog.message().slice(0, 120),
1336
1642
  url: this.page?.url() ?? "",
1337
1643
  });
1338
- 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
+ });
1339
1652
  });
1340
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
+ }
1341
1664
  /** When the page last opened a native dialog (confirm, alert, prompt). */
1342
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;
1343
1668
  requirePage() {
1344
1669
  if (!this.page || !this.memory) {
1345
1670
  throw new Error("Not attached. Call scout_attach first with the app URL and project path.");
@@ -1406,8 +1731,12 @@ export class BrowserEngine {
1406
1731
  }
1407
1732
  /** Set at attach: this session was given credentials, so a bounce to a login page is a verdict worth waiting for. */
1408
1733
  watchesForBounce = false;
1409
- /** Requests started and not yet finished or failed, from the context's own events. */
1410
- 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
+ }
1411
1740
  /** When the most recent request started, so a page that fires one late is not read too early. */
1412
1741
  lastRequestStart = 0;
1413
1742
  /**
@@ -1424,6 +1753,39 @@ export class BrowserEngine {
1424
1753
  get pace() {
1425
1754
  return this.paceMs;
1426
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
+ }
1427
1789
  /** Collect the current page's interactables into SnapshotElements with stable refs. */
1428
1790
  async collect() {
1429
1791
  const page = this.requirePage();
@@ -1441,7 +1803,8 @@ export class BrowserEngine {
1441
1803
  if (stable)
1442
1804
  break;
1443
1805
  }
1444
- const mainCount = rawElements.length;
1806
+ const cut = rawElements.filter((r) => r.cut);
1807
+ rawElements = rawElements.filter((r) => !r.cut);
1445
1808
  // The page's forms, read in the same settled page as its elements. The
1446
1809
  // page's own document only: an xpath names a node in one document.
1447
1810
  const rawForms = ((await this.formRead("form inventory", page.evaluate(FORMS_INVENTORY_SCRIPT))) ?? []);
@@ -1453,36 +1816,48 @@ export class BrowserEngine {
1453
1816
  // (and previously issued refs) survive re-snapshots. An element inside a
1454
1817
  // frame carries the frame in its key (collector.ts frameElementKey).
1455
1818
  const route = normalizePath(page.url());
1456
- const prevByKey = this.lastSnap?.route === route ? this.lastSnap.byKey : null;
1819
+ const prev = this.prevSnapFor(route);
1457
1820
  this.refs.clear();
1458
1821
  this.refFrames.clear();
1459
- const keyCounts = new Map();
1460
1822
  const all = [
1461
1823
  ...rawElements.map((raw) => ({ raw })),
1462
1824
  ...framed.flatMap((g) => g.raws.map((raw) => ({ raw: raw, frame: g.frame, tag: g.tag }))),
1463
1825
  ];
1464
- const elements = all.map(({ raw: el, frame, tag }) => {
1465
- // A live region listed for what it says is known by its role, not its
1466
- // text, so a new message reads as the same region saying something else.
1467
- const baseKey = frameElementKey(el.liveOnly ? `live:${el.role}` : elementKey(el), tag);
1468
- const count = keyCounts.get(baseKey) ?? 0;
1469
- keyCounts.set(baseKey, count + 1);
1470
- const key = count === 0 ? baseKey : `${baseKey}~${count}`;
1471
- 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;
1472
1839
  const full = {
1473
- ...el,
1840
+ ...listed,
1474
1841
  ...(tag ? { frame: tag } : {}),
1475
1842
  // Judged on the real label, then masked: the policy must see what a click would press.
1476
1843
  // A message is not a control: "Could not delete" in an alert is not a Delete button.
1477
- destructive: el.liveOnly ? false : isDestructive(el.name, el.testid),
1844
+ destructive: el.liveOnly ? false : destructiveLabelOf(el) !== null,
1478
1845
  ...(tag?.foreign ? { name: masksForeignName(el.tag, el.role) ? MASKED_NAME : capForeignName(el.name) } : {}),
1479
1846
  ...(tag?.foreign && el.href ? { href: stripForeignHref(el.href) } : {}),
1480
- ref,
1847
+ ref: "",
1481
1848
  key,
1482
1849
  };
1483
- 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);
1484
1859
  if (frame)
1485
- this.refFrames.set(ref, frame);
1860
+ this.refFrames.set(full.ref, frame);
1486
1861
  return full;
1487
1862
  });
1488
1863
  this.harvestRoutes(elements.filter((el) => !el.frame?.foreign));
@@ -1494,8 +1869,8 @@ export class BrowserEngine {
1494
1869
  const key = status === "untracked" ? null : (formIdentity(f.attrs) ?? keyAtXpath(elements, f.submit));
1495
1870
  return key ? [{ key, guarded: status === "guarded" }] : [];
1496
1871
  });
1497
- this.lastCollectTruncated = mainCount >= 150;
1498
- return { elements, truncated: mainCount >= 150, forms };
1872
+ this.lastCollectTruncated = cut.length > 0;
1873
+ return { elements, truncated: cut.length > 0, forms, cut, prev, matched, aliases };
1499
1874
  }
1500
1875
  /** Whether the latest collect stopped at the element cap, so some controls have no key at all. */
1501
1876
  lastCollectTruncated = false;
@@ -1524,7 +1899,9 @@ export class BrowserEngine {
1524
1899
  return null;
1525
1900
  const title = ((await el.getAttribute("title")) || (await el.getAttribute("name")) || "").trim();
1526
1901
  const got = (await frame.evaluate(`(() => ({ els: ${COLLECT_INTERACTABLES_SCRIPT}, sx: window.scrollX, sy: window.scrollY }))()`));
1527
- 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) }));
1528
1905
  let origin = "";
1529
1906
  try {
1530
1907
  const u = new URL(frame.url());
@@ -1617,12 +1994,12 @@ export class BrowserEngine {
1617
1994
  */
1618
1995
  async captureCoverageState() {
1619
1996
  const page = this.requirePage();
1620
- const { elements, forms } = await this.collect();
1997
+ const { elements, forms, aliases } = await this.collect();
1621
1998
  const url = page.url();
1622
1999
  const fp = fingerprintState(url, trackedElements(elements));
1623
- this.memory?.visitState(fp, url, normalizePath(url), trackedElements(elements).map((el) => el.key), inertKeys(elements));
2000
+ this.memory?.visitState(fp, url, normalizePath(url), trackedElements(elements).map((el) => el.key), inertKeys(elements), this.sessionKey, aliases);
1624
2001
  for (const f of forms)
1625
- this.memory?.recordForm(fp, f.key, f.guarded);
2002
+ this.memory?.recordForm(fp, f.key, f.guarded, this.sessionKey);
1626
2003
  return { fp, elements, url };
1627
2004
  }
1628
2005
  /**
@@ -1704,15 +2081,16 @@ export class BrowserEngine {
1704
2081
  const page = this.requirePage();
1705
2082
  const memory = this.memory;
1706
2083
  await this.settle();
1707
- const { elements, truncated, forms } = await this.collect();
2084
+ const { elements, truncated, forms, cut, prev: prevSnap, matched, aliases } = await this.collect();
1708
2085
  const url = page.url();
1709
2086
  this.snapshotUrl = url;
2087
+ this.snapshotAt = Date.now();
1710
2088
  const route = normalizePath(url);
1711
2089
  const fp = fingerprintState(url, trackedElements(elements));
1712
2090
  this.currentFingerprint = fp;
1713
- const isNew = memory.visitState(fp, url, route, trackedElements(elements).map((el) => el.key), inertKeys(elements));
2091
+ const isNew = memory.visitState(fp, url, route, trackedElements(elements).map((el) => el.key), inertKeys(elements), this.sessionKey, aliases);
1714
2092
  for (const f of forms)
1715
- memory.recordForm(fp, f.key, f.guarded);
2093
+ memory.recordForm(fp, f.key, f.guarded, this.sessionKey);
1716
2094
  memory.recordRoleAccess(this.role, route, "reached");
1717
2095
  // A snapshot is what an agent takes when it wants to LOOK at something, so
1718
2096
  // it is the frame a reader most wants beside the step. Recording only the
@@ -1721,6 +2099,14 @@ export class BrowserEngine {
1721
2099
  this.logAction({ action: "snapshot", url, result: fp, ...(await this.frameFor("snapshot")) });
1722
2100
  await this.scanForInjections();
1723
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
+ : "";
1724
2110
  const line = (el) => {
1725
2111
  const dup = el.key.match(/~(\d+)$/);
1726
2112
  const flags = [
@@ -1729,27 +2115,37 @@ export class BrowserEngine {
1729
2115
  dup ? `copy#${Number(dup[1]) + 1}` : null,
1730
2116
  el.disabled ? "disabled" : null,
1731
2117
  el.destructive ? "DESTRUCTIVE" : null,
2118
+ ...affordanceFlags(el),
1732
2119
  labelFlag(el),
1733
2120
  // "exercised", not "done": it says an earlier action or run acted on it, and "done" read as the control's own state.
1734
2121
  memory.wasExercised(fp, el.key) ? "exercised" : null,
1735
2122
  el.href ? `href=${el.href.slice(0, 60)}` : null,
1736
2123
  ].filter(Boolean);
1737
- 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)}`;
1738
2125
  };
1739
- // Diff mode: when re-snapshotting the same route, report only what
1740
- // changed — same idea as UI reconciliation, applied to agent context.
1741
- const prev = this.lastSnap?.route === route ? this.lastSnap : null;
1742
- this.lastSnap = {
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({
1743
2133
  route,
1744
- byKey: new Map(elements.map((el) => [el.key, { ref: el.ref, label: el.name, disabled: el.disabled, state: stateFlags(el.state) }])),
1745
- };
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)}` : "";
1746
2137
  let body;
1747
- if (!full && prev) {
1748
- const currentKeys = new Set(elements.map((el) => el.key));
1749
- const added = elements.filter((el) => !prev.byKey.has(el.key));
1750
- 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));
1751
2147
  const relabeled = elements.filter((el) => {
1752
- const old = prev.byKey.get(el.key);
2148
+ const old = oldOf(el);
1753
2149
  return old !== undefined && old.label !== el.name;
1754
2150
  });
1755
2151
  // An element can change WITHOUT being added, removed or relabeled: the
@@ -1761,40 +2157,52 @@ export class BrowserEngine {
1761
2157
  // to enabled "Save" changed BOTH ways, and reporting only the relabel
1762
2158
  // swallows the enable — the exact fact this line exists to surface.
1763
2159
  const retoggled = elements.filter((el) => {
1764
- const old = prev.byKey.get(el.key);
2160
+ const old = oldOf(el);
1765
2161
  return old !== undefined && old.disabled !== el.disabled;
1766
2162
  });
1767
2163
  // A filter pill that became the active one, a tab now selected, a
1768
2164
  // section now open: the same element, in a different state.
1769
2165
  const restated = elements.flatMap((el) => {
1770
- const old = prev.byKey.get(el.key);
2166
+ const old = oldOf(el);
1771
2167
  const change = old ? stateChange(old.state, stateFlags(el.state)) : null;
1772
2168
  return change ? [{ el, change }] : [];
1773
2169
  });
1774
2170
  const changedKeys = new Set([...relabeled, ...retoggled, ...restated.map((r) => r.el)].map((el) => el.key));
1775
2171
  const unchanged = elements.length - added.length - changedKeys.size;
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`;
1776
2181
  if (added.length === 0 && removed.length === 0 && relabeled.length === 0 && retoggled.length === 0 && restated.length === 0) {
1777
- body = `No element changes since the last snapshot (${unchanged} interactables, refs unchanged).`;
2182
+ body = `No element changes since ${against} (${unchanged} interactables, ${refNote === "refs stable" ? "refs unchanged" : refNote}).`;
1778
2183
  }
1779
2184
  else {
1780
2185
  body =
1781
- `DIFF vs last snapshot (${unchanged} unchanged, refs stable):\n` +
2186
+ `DIFF vs ${against} (${unchanged} unchanged, ${refNote}):\n` +
1782
2187
  [
1783
2188
  ...added.map((el) => `+ ${line(el)}`),
1784
2189
  ...removed.map(([key, v]) => `- ${v.ref} "${v.label}" (gone: ${key})`),
1785
2190
  // A live region saying something new is the message itself, so it is shown in full like a new element.
1786
2191
  ...relabeled.map((el) => el.liveOnly
1787
- ? `~ ${el.ref} ${el.role} "${displayName(el)}" (was ${prev.byKey.get(el.key)?.label ? `"${prev.byKey.get(el.key)?.label}"` : "empty"})`
2192
+ ? `~ ${el.ref} ${el.role} "${displayName(el)}" (was ${oldOf(el)?.label ? `"${oldOf(el)?.label}"` : "empty"})${afterBlock(el)}`
1788
2193
  : `~ ${el.ref} relabeled → "${el.name}"`),
1789
2194
  ...retoggled.map((el) => `~ ${el.ref} "${el.name}" is now ${el.disabled ? "DISABLED" : "ENABLED"}`),
1790
2195
  ...restated.map(({ el, change }) => `~ ${el.ref} "${displayName(el)}" ${change}`),
1791
2196
  ].join("\n");
1792
2197
  }
2198
+ if (cutLine)
2199
+ body += `\n${cutLine}`;
1793
2200
  }
1794
2201
  else {
1795
2202
  const missingTestids = trackedElements(elements).filter((el) => !el.testid && !el.disabled).length;
1796
2203
  body =
1797
- `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` : "") +
1798
2206
  elements.map(line).join("\n");
1799
2207
  }
1800
2208
  // Per document: the page's own controls together, and each frame's apart —
@@ -1837,39 +2245,112 @@ export class BrowserEngine {
1837
2245
  }
1838
2246
  /**
1839
2247
  * Resolve a ref and re-verify the live element at action time. Refs are
1840
- * trusted only while the page URL exactly matches the snapshot's, and even
1841
- * then the located element's live identity is re-read so the destructive
1842
- * policy applies to what is actually acted on — SPA re-renders can put a
1843
- * 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).
1844
2259
  */
1845
2260
  async resolveForAction(ref) {
1846
2261
  this.actionStartedAt = Date.now();
2262
+ this.rebindNote = "";
1847
2263
  const page = this.requirePage();
1848
- const el = this.refs.get(ref);
2264
+ let el = this.refs.get(ref);
1849
2265
  if (!el) {
1850
- 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.`);
1851
2267
  }
1852
- if (page.url() !== this.snapshotUrl) {
1853
- 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()}`);
1854
2271
  throw new Error(`Page URL changed since the last snapshot (now ${page.url()}). Take a new scout_snapshot.`);
1855
2272
  }
1856
- // String EXPRESSION via page.evaluate (locator.evaluate treats a string as
1857
- // an expression, not a function — the element arg never binds).
1858
- const live = (await this.scopeOf(el)
1859
- .evaluate(`(() => { const node = ${xpathLookup(el.xpath)}; if (!node) return null; ` +
1860
- `return { testid: node.getAttribute('data-testid'), label: (node.getAttribute('aria-label') || node.innerText || node.textContent || node.getAttribute('placeholder') || '').trim().slice(0, 120) }; })()`)
1861
- .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
+ }
1862
2289
  if (!live) {
1863
2290
  throw new Error(`Element ${ref} no longer exists in the DOM — take a new scout_snapshot.`);
1864
2291
  }
1865
2292
  await this.beginInput();
1866
2293
  if (el.testid && live.testid !== el.testid) {
1867
- this.refs.clear();
2294
+ this.dropRefs(`the element under ${ref} changed`);
1868
2295
  throw new Error(`Element under ${ref} changed (expected testid=${el.testid}, found ${live.testid ?? "none"}) — the DOM shifted; take a new scout_snapshot.`);
1869
2296
  }
1870
- 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
+ });
2322
+ }
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);
1871
2352
  }
1872
- actionPolicyCheck(el, liveLabel) {
2353
+ actionPolicyCheck(el, liveLabel, live = {}) {
1873
2354
  if (!this.readOnly)
1874
2355
  return null;
1875
2356
  // Same-origin navigation links are exempt: navigation is non-destructive
@@ -1883,8 +2364,18 @@ export class BrowserEngine {
1883
2364
  // same holds for choosing a file: selection is not the send.
1884
2365
  if (el.role === "textbox" || el.role === "file")
1885
2366
  return null;
1886
- if (el.destructive || isDestructive(liveLabel)) {
1887
- 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);
1888
2379
  }
1889
2380
  return null;
1890
2381
  }
@@ -1927,12 +2418,13 @@ export class BrowserEngine {
1927
2418
  this.logAction({ action: policyAbortedNavigation ? "write-policy:navigation-blocked" : "origin-fence:bounced", target: url.slice(0, 200), url });
1928
2419
  await page.goBack({ waitUntil: "domcontentloaded", timeout: this.limits.backNavMs }).catch(() => { });
1929
2420
  url = page.url();
1930
- this.refs.clear();
1931
- const blocked = this.drainBlocked();
2421
+ this.dropRefs("the page left the app's origin");
2422
+ const blocked = this.drainBlocked() + this.drainRefresh();
1932
2423
  return ((policyAbortedNavigation
1933
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}.`
1934
2425
  : `OK: ${action} ${target}\nNavigated off-origin and was bounced back to ${url}. Exploration is fenced to ${this.baseUrl}.`) +
1935
2426
  blocked +
2427
+ this.drainDialogs() +
1936
2428
  formatViolations(this.oracles.drain()));
1937
2429
  }
1938
2430
  const frame = await this.recordFrame(action);
@@ -1942,14 +2434,23 @@ export class BrowserEngine {
1942
2434
  if (click)
1943
2435
  await this.rankRouteCancellations(click, url);
1944
2436
  const violations = this.oracles.drain();
1945
- const mutations = this.drainMutations() + this.drainBlocked() + this.drainCreated();
1946
- 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);
1947
2441
  if (navigated) {
1948
2442
  // Refs point into the previous page's DOM; invalidate so a stale ref
1949
2443
  // errors ("take a new snapshot") instead of acting on the wrong element.
1950
- this.refs.clear();
2444
+ this.dropRefs(`the page moved to ${url}`);
1951
2445
  }
1952
- 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));
1953
2454
  }
1954
2455
  /** Does this request path address a record this run created? Rules live in ownership.ts. */
1955
2456
  isOwnedResource(pathname) {
@@ -2149,6 +2650,7 @@ export class BrowserEngine {
2149
2650
  let pathname = url;
2150
2651
  try {
2151
2652
  pathname = pathnameOf(url);
2653
+ const readPost = this.readPostOf(rule, method, url, bytes?.toString("utf8"));
2152
2654
  const { foreign, offApp } = unseenWriteSource({
2153
2655
  appUrl: this.baseUrl,
2154
2656
  mode: rule,
@@ -2167,7 +2669,14 @@ export class BrowserEngine {
2167
2669
  foreign,
2168
2670
  offApp,
2169
2671
  owned: this.isOwnedResource(pathname),
2672
+ readPost: readPost !== null,
2170
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
+ });
2171
2680
  }
2172
2681
  catch (err) {
2173
2682
  // Fail closed: a write that cannot be judged is refused, and reported as refused below.
@@ -2185,14 +2694,13 @@ export class BrowserEngine {
2185
2694
  return;
2186
2695
  }
2187
2696
  // Refused and reported as any refusal is. Dropped rather than answered: the page that sent it is going or gone.
2188
- if (this.blockedRequests.length < 20)
2189
- this.blockedRequests.push({
2190
- at: Date.now(),
2191
- sig: `${method} ${url.slice(0, 140)}`,
2192
- answered: false,
2193
- why: verdict.why,
2194
- type: (event.resourceType ?? "other").toLowerCase(),
2195
- });
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
+ });
2196
2704
  this.logAction({ action: "write-policy:blocked", target: `${method} ${pathname}${verdict.why ? ` (${verdict.why})` : ""}`, url: this.page?.url() ?? "" });
2197
2705
  this.oracles.notePolicyBlock();
2198
2706
  }
@@ -2210,13 +2718,12 @@ export class BrowserEngine {
2210
2718
  }
2211
2719
  await answer(false);
2212
2720
  try {
2213
- if (this.blockedRequests.length < 20)
2214
- this.blockedRequests.push({
2215
- at: Date.now(),
2216
- sig: `${method} ${url.slice(0, 140)}`,
2217
- answered: false,
2218
- type: (event.resourceType ?? "other").toLowerCase(),
2219
- });
2721
+ this.noteBlocked({
2722
+ at: Date.now(),
2723
+ sig: `${method} ${url.slice(0, 140)}`,
2724
+ answered: false,
2725
+ type: (event.resourceType ?? "other").toLowerCase(),
2726
+ });
2220
2727
  this.logAction({
2221
2728
  action: "write-policy:blocked",
2222
2729
  target: `${method} ${url.slice(0, 140)} (not judged: an error in the policy)`,
@@ -2230,39 +2737,26 @@ export class BrowserEngine {
2230
2737
  }
2231
2738
  }
2232
2739
  }
2233
- /** 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). */
2234
2752
  drainBlocked() {
2235
2753
  this.lastActionBlocked = this.blockedRequests.length;
2236
2754
  if (this.blockedRequests.length === 0)
2237
2755
  return "";
2238
- const list = this.blockedRequests
2239
- .slice(0, 5)
2240
- .map((e) => this.lateMark(e))
2241
- .join("; ");
2242
- const extra = this.blockedRequests.length > 5 ? ` (+${this.blockedRequests.length - 5} more)` : "";
2243
- const answered = this.blockedRequests.some((e) => e.answered);
2244
- const reasons = new Set(this.blockedRequests.map((e) => e.why).filter((w) => !!w));
2245
- const escaped = reasons.delete(ESCAPE_REFUSAL);
2246
- const foreign = [...reasons];
2756
+ const blocked = this.blockedRequests.map((e) => ({ sig: e.sig, late: e.at < this.actionStartedAt, answered: e.answered, why: e.why }));
2247
2757
  this.blockedRequests = [];
2248
2758
  // The rule that judged them, which just after a flow hands back is still the flow's (WriteRule).
2249
- const rule = this.writeRule.at();
2250
- return (`\n🛡 WRITE-POLICY blocked (${rule}): ${list}${extra}. ` +
2251
- `This is the tester's safety policy, NOT an app bug — do not file a finding for the resulting error UI. ` +
2252
- (foreign.length > 0
2253
- ? `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. `
2254
- : "") +
2255
- (escaped
2256
- ? `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. `
2257
- : "") +
2258
- (answered
2259
- ? `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. `
2260
- : "") +
2261
- (rule === "observe"
2262
- ? `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.`
2263
- : rule === "read-only"
2264
- ? `Re-attach with mode="safe-write" to test create/edit flows, or "destructive" (user-approved disposable env only).`
2265
- : `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);
2266
2760
  }
2267
2761
  /** Once per session, in observe mode only: say that socket frames are outside the policy. */
2268
2762
  socketNotice() {
@@ -2342,7 +2836,7 @@ export class BrowserEngine {
2342
2836
  const clickCount = Math.max(1, Math.min(3, clicks));
2343
2837
  try {
2344
2838
  await locator.click({ timeout, clickCount });
2345
- return { forced: false };
2839
+ return { forced: false, cover: null, coverReadAt: 0 };
2346
2840
  }
2347
2841
  catch (err) {
2348
2842
  const msg = err instanceof Error ? err.message : String(err);
@@ -2351,12 +2845,27 @@ export class BrowserEngine {
2351
2845
  const diagnostic = actionabilityDiagnostic(msg);
2352
2846
  if (!diagnostic || !/intercepts pointer events|is not stable/i.test(diagnostic))
2353
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();
2354
2854
  // A shorter budget here: the forced click skips the wait that
2355
2855
  // consumed the first `timeout`, so it needs very little of its own.
2356
2856
  await locator.click({ timeout: Math.min(timeout, FORCED_CLICK_TIMEOUT_MS), force: true, clickCount });
2357
- return { forced: true };
2857
+ return { forced: true, cover, coverReadAt };
2358
2858
  }
2359
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
+ }
2360
2869
  /**
2361
2870
  * A form read (the inventory, or the probe before a submit), bounded by
2362
2871
  * FORM_READ_MS. This bookkeeping must never fail or stall the action, so a
@@ -2391,7 +2900,7 @@ export class BrowserEngine {
2391
2900
  }
2392
2901
  /** The form the focused element belongs to, for a key press that may submit through it. */
2393
2902
  probeFocusedForm() {
2394
- 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));
2395
2904
  }
2396
2905
  /**
2397
2906
  * A plan's pre-action state, fit to record a submit of this form against.
@@ -2439,13 +2948,20 @@ export class BrowserEngine {
2439
2948
  url: this.page?.url() ?? "",
2440
2949
  });
2441
2950
  }
2442
- async click(ref, clicks = 1) {
2443
- 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
+ }
2444
2960
  }
2445
2961
  async clickNow(ref, clicks = 1) {
2446
2962
  const page = this.requirePage();
2447
- const { el, liveLabel } = await this.resolveForAction(ref);
2448
- const refusal = this.actionPolicyCheck(el, liveLabel);
2963
+ const { el, liveLabel, live } = await this.resolveForAction(ref);
2964
+ const refusal = this.actionPolicyCheck(el, liveLabel, live);
2449
2965
  if (refusal) {
2450
2966
  this.logAction({ action: "click:refused", target: liveLabel || el.name, url: page.url() });
2451
2967
  return refusal;
@@ -2459,13 +2975,20 @@ export class BrowserEngine {
2459
2975
  // (silent no-op forms): capture the count before to compare after.
2460
2976
  const xhrBefore = this.xhrCount;
2461
2977
  const submitLike = isSubmitLike(el.role, el.name, el.testid);
2462
- const clickContext = { viaLink: el.role === "link", urlBefore: page.url(), dialogsBefore: this.claimBaseline?.dialogs ?? null };
2978
+ const clickContext = {
2979
+ viaLink: el.role === "link",
2980
+ urlBefore: page.url(),
2981
+ dialogsBefore: this.claimBaseline?.dialogs ?? null,
2982
+ claimsBefore: this.claimBaseline,
2983
+ };
2463
2984
  const clickTarget = this.scopeOf(el).locator(`xpath=${el.xpath}`);
2464
2985
  // Read before the click: what the form's fields hold when it goes. Only a
2465
2986
  // button or an input can submit a form; nothing else is asked.
2466
2987
  const form = !el.frame && (el.tag === "button" || el.tag === "input") ? await this.probeForm(clickTarget) : null;
2467
2988
  const formState = { fp: this.currentFingerprint, elements: [...this.refs.values()] };
2468
- 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;
2469
2992
  this.memory.markExercised(this.currentFingerprint, el.key, clicks > 1 ? `click×${clicks}` : "click");
2470
2993
  this.noteFormSubmit(formState, "click", form);
2471
2994
  const result = await this.afterAction(clicks > 1 ? `click×${clicks}` : "click", `${el.role} "${el.name}"`, clickContext);
@@ -2486,20 +3009,30 @@ export class BrowserEngine {
2486
3009
  return result + `\nℹ ${clicks}× rapid click fired no duplicate state-changing requests — double-submit appears guarded on this control.`;
2487
3010
  }
2488
3011
  const forcedNote = forced
2489
- ? `\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.`)
2490
3016
  : "";
2491
3017
  // Not when the write policy blocked the submission: a native form POST or a
2492
3018
  // beacon is not counted as xhr/fetch, so an aborted one looks exactly like
2493
3019
  // "fired nothing" — and the note would blame the app for the tool's block.
2494
- 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) {
2495
3021
  // A client-side router can move the page just after the request-based
2496
3022
  // settle, with no request for it to wait on: watch the URL before
2497
3023
  // calling the click silent, and look at the requests again after.
2498
3024
  const landed = await this.stableUrl();
2499
- if (landed !== this.snapshotUrl)
3025
+ if (landed !== clickContext.urlBefore)
2500
3026
  return result + `\nℹ The page then moved client-side to ${landed} (take a new snapshot).` + forcedNote;
2501
3027
  if (this.xhrCount !== xhrBefore)
2502
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;
2503
3036
  return (result +
2504
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).` +
2505
3038
  forcedNote);
@@ -2522,20 +3055,33 @@ export class BrowserEngine {
2522
3055
  try {
2523
3056
  state = (await locator.evaluate((node) => {
2524
3057
  const n = node;
3058
+ const type = n.tagName === "INPUT" ? n.type.toLowerCase() : "";
2525
3059
  if (n.isContentEditable)
2526
- return { existing: (n.innerText || n.textContent || "").trim(), caretAppendable: true };
3060
+ return { existing: (n.innerText || n.textContent || "").trim(), caretAppendable: true, type };
2527
3061
  if (typeof n.value === "string") {
2528
3062
  // selectionStart is null on number/date/email-style inputs — caret
2529
3063
  // placement is unsupported there, so append must go via fill().
2530
- return { existing: n.value, caretAppendable: typeof n.selectionStart === "number" };
3064
+ return { existing: n.value, caretAppendable: typeof n.selectionStart === "number", type };
2531
3065
  }
2532
- return { existing: "", caretAppendable: false };
3066
+ return { existing: "", caretAppendable: false, type };
2533
3067
  }, undefined, { timeout: this.limits.actionMs }));
2534
3068
  }
2535
3069
  catch (err) {
2536
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.`);
2537
3071
  }
2538
- 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
+ }
2539
3085
  if (replace || !existing || text === "") {
2540
3086
  await locator.fill(text, { timeout: this.limits.actionMs });
2541
3087
  return existing && (replace || text === "") ? ` (replaced existing content ${JSON.stringify(existing.slice(0, 60))})` : "";
@@ -2577,14 +3123,14 @@ export class BrowserEngine {
2577
3123
  }
2578
3124
  async typeNow(ref, text, pressEnter = false, replace = false) {
2579
3125
  const page = this.requirePage();
2580
- const { el, liveLabel } = await this.resolveForAction(ref);
3126
+ const { el, liveLabel, live } = await this.resolveForAction(ref);
2581
3127
  if (el.role === "file") {
2582
3128
  // fill() refuses a file input — and it used to refuse with a stack
2583
3129
  // trace, leaving every upload form stranded. Point at the tool that can.
2584
3130
  return (`${ref} is a file input ("${el.name || el.testid || "unnamed"}") — text cannot be typed into it. ` +
2585
3131
  `Use scout_upload {ref:"${ref}"}: a small valid fixture is generated and matched to the input's accept attribute, or pass fixture / filePath.`);
2586
3132
  }
2587
- const refusal = this.actionPolicyCheck(el, liveLabel);
3133
+ const refusal = this.actionPolicyCheck(el, liveLabel, live);
2588
3134
  if (refusal)
2589
3135
  return refusal;
2590
3136
  const embed = this.foreignEmbedOf(el);
@@ -2647,7 +3193,7 @@ export class BrowserEngine {
2647
3193
  if (opts.ref) {
2648
3194
  const resolved = await this.resolveForAction(opts.ref);
2649
3195
  el = resolved.el;
2650
- const refusal = this.actionPolicyCheck(el, resolved.liveLabel);
3196
+ const refusal = this.actionPolicyCheck(el, resolved.liveLabel, resolved.live);
2651
3197
  if (refusal)
2652
3198
  return refusal;
2653
3199
  const embed = this.foreignEmbedOf(el);
@@ -2896,44 +3442,77 @@ export class BrowserEngine {
2896
3442
  ? `\nRevealed on hover:\n${notes.map((t) => ` · ${t}`).join("\n")}${caveat}`
2897
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" : ""})`));
2898
3444
  }
2899
- /** 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. */
2900
3479
  recordSelectChoice(fingerprint, key, options, picked) {
2901
3480
  const labels = options.map((o) => o.label);
3481
+ const loaded = options.filter((o) => o.selected).map((o) => o.label);
2902
3482
  const chosen = picked.map((v) => options.find((o) => o.value === v)?.label).filter((l) => !!l);
2903
3483
  if (chosen.length === 0)
2904
- this.memory.recordSelectChoice(fingerprint, key, labels, "");
3484
+ this.memory.recordSelectChoice(fingerprint, key, labels, "", loaded);
2905
3485
  for (const label of chosen)
2906
- this.memory.recordSelectChoice(fingerprint, key, labels, label);
3486
+ this.memory.recordSelectChoice(fingerprint, key, labels, label, loaded);
2907
3487
  }
2908
3488
  async select(ref, value) {
2909
3489
  return this.withinLimit("action", () => this.selectNow(ref, value));
2910
3490
  }
2911
3491
  async selectNow(ref, value) {
2912
3492
  const page = this.requirePage();
2913
- const { el, liveLabel } = await this.resolveForAction(ref);
2914
- const refusal = this.actionPolicyCheck(el, liveLabel);
3493
+ const { el, liveLabel, live } = await this.resolveForAction(ref);
3494
+ const refusal = this.actionPolicyCheck(el, liveLabel, live);
2915
3495
  if (refusal)
2916
3496
  return refusal;
2917
- if (this.readOnly) {
2918
- // Bulk-action dropdowns fire on change — vet the chosen option itself.
2919
- const optionLabel = (await this.scopeOf(el)
2920
- .evaluate(`(() => { const node = ${xpathLookup(el.xpath)}; if (!node) return ''; const v = ${JSON.stringify(value)}; ` +
2921
- `const opts = Array.from(node.options || []); ` +
2922
- `const o = opts.find(o => o.value === v || o.label === v || (o.textContent || '').trim() === v); ` +
2923
- `return o ? (o.label || o.textContent || '').trim().slice(0, 120) : ''; })()`)
2924
- .catch(() => ""));
2925
- if (isDestructive(value) || isDestructive(optionLabel)) {
2926
- this.logAction({ action: "select:refused", target: optionLabel || value, url: page.url() });
2927
- return destructiveRefusal(optionLabel || value, this.mode);
2928
- }
2929
- }
2930
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
+ }
2931
3509
  const options = el.tag === "select" ? await readSelectOptions(loc) : null;
2932
- const picked = await loc.selectOption(value, { timeout: this.limits.actionMs });
3510
+ const picked = await loc.selectOption(pick.arg, { timeout: this.limits.actionMs });
2933
3511
  this.memory.markExercised(this.currentFingerprint, el.key, "select");
2934
3512
  if (options)
2935
3513
  this.recordSelectChoice(this.currentFingerprint, el.key, options, picked);
2936
- 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}`);
2937
3516
  }
2938
3517
  /**
2939
3518
  * Enter/Space activate the focused element — apply the same destructive
@@ -3064,17 +3643,66 @@ export class BrowserEngine {
3064
3643
  return false;
3065
3644
  }
3066
3645
  }
3067
- 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) {
3068
3658
  this.actionStartedAt = Date.now();
3069
3659
  const page = this.requirePage();
3070
- const url = target.startsWith("http") ? target : `${this.baseUrl}${target.startsWith("/") ? "" : "/"}${target}`;
3071
- if (!this.isSameOrigin(url)) {
3072
- return `REFUSED: ${url} is outside the attached origin (${this.baseUrl}). Exploration is fenced to the app under test.`;
3073
- }
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;
3074
3668
  // A notice describes ONE navigation. Clearing up front means a notice left
3075
3669
  // undelivered by a previous throw can never prepend itself to this result.
3076
3670
  this.authLoss.clear();
3077
- 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
+ }
3078
3706
  // Settle BEFORE judging where we landed. A client-side auth guard redirects
3079
3707
  // after hydration, not during goto, so reading page.url() here showed the
3080
3708
  // requested path and the bounce went unnoticed — which is precisely how a
@@ -3183,8 +3811,9 @@ export class BrowserEngine {
3183
3811
  * Replace this context's cookies and storage with a profile, in place: same
3184
3812
  * pages, listeners and policy. Playwright is handed only what it restores;
3185
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).
3186
3815
  */
3187
- async applyState(state) {
3816
+ async applyState(state, load = "storage") {
3188
3817
  if (!this.context)
3189
3818
  return { ok: false, why: "no browser is open" };
3190
3819
  let storageState;
@@ -3194,8 +3823,15 @@ export class BrowserEngine {
3194
3823
  catch (err) {
3195
3824
  return { ok: false, why: `the saved profile could not be split (${err instanceof Error ? err.message : String(err)})` };
3196
3825
  }
3826
+ const playwrightState = storageState;
3197
3827
  try {
3198
- 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
+ }
3199
3835
  }
3200
3836
  catch (err) {
3201
3837
  return { ok: false, why: `the browser refused its saved profile (${err instanceof Error ? err.message.split("\n")[0] : String(err)})` };
@@ -3267,11 +3903,12 @@ export class BrowserEngine {
3267
3903
  if (plan.kind === "send") {
3268
3904
  this.logAction({ action: "refresh-broker", target: `${where} with ${sent.slot}`, url: this.page?.url() ?? "" });
3269
3905
  handedOn = true;
3270
- this.trackRefreshTask(this.writeBackAfter(this.pageAnswer(req), sent, lock));
3906
+ this.trackRefreshTask(this.writeBackAfter(this.pageAnswer(req), sent, lock, false));
3271
3907
  return this.passOn(route);
3272
3908
  }
3273
3909
  // Another session rotated the token while this one waited: load what it saved, and send the current token.
3274
- const applied = await this.applyState(read.state);
3910
+ const loaded = profileLoadWhileHeld(req.isNavigationRequest());
3911
+ const applied = await this.applyState(read.state, loaded);
3275
3912
  if (!applied.ok)
3276
3913
  throw new Error(applied.why);
3277
3914
  this.refreshCounts.swapped += 1;
@@ -3288,7 +3925,9 @@ export class BrowserEngine {
3288
3925
  url: this.page?.url() ?? "",
3289
3926
  });
3290
3927
  handedOn = true;
3291
- this.trackRefreshTask(this.writeBackAfter(this.pageAnswer(req), plan.to, lock));
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)));
3292
3931
  return this.passOn(route, {
3293
3932
  ...(swapped.url ? { url: swapped.url } : {}),
3294
3933
  ...(swapped.body !== undefined ? { postData: swapped.body } : {}),
@@ -3323,8 +3962,9 @@ export class BrowserEngine {
3323
3962
  target: `${where} with ${plan.to.slot} (rotated by another session; loaded its profile and sent the current cookie)`,
3324
3963
  url: this.page?.url() ?? "",
3325
3964
  });
3965
+ this.refreshEvents.push("swapped");
3326
3966
  // The response's cookies are already in the browser's jar, so the write-back does not wait on the page.
3327
- this.trackRefreshTask(this.writeBackAfter(Promise.resolve(BrowserEngine.fetchedAnswer(response)), plan.to, lock));
3967
+ this.trackRefreshTask(this.writeBackAfter(Promise.resolve(BrowserEngine.fetchedAnswer(response)), plan.to, lock, true));
3328
3968
  await route.fulfill({ response }).catch(() => {
3329
3969
  /* the page went away before its answer: the rotation is still saved */
3330
3970
  });
@@ -3362,6 +4002,7 @@ export class BrowserEngine {
3362
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. */
3363
4003
  async unbrokered(route, where, err) {
3364
4004
  this.refreshCounts.failed += 1;
4005
+ this.refreshEvents.push("failed");
3365
4006
  // Unbrokered after all: if its response rotates the cookie, the rotation watcher saves it.
3366
4007
  this.brokeredRequests.delete(route.request());
3367
4008
  this.logAction({
@@ -3469,8 +4110,10 @@ export class BrowserEngine {
3469
4110
  return;
3470
4111
  const isNew = !broker.learned.has(key);
3471
4112
  broker.learned.add(key);
3472
- if (isNew)
4113
+ if (isNew) {
3473
4114
  this.refreshCounts.learned += 1;
4115
+ this.refreshEvents.push("learned");
4116
+ }
3474
4117
  const names = rotated.map((r) => r.slot).join(", ");
3475
4118
  let lock;
3476
4119
  try {
@@ -3500,7 +4143,7 @@ export class BrowserEngine {
3500
4143
  for (let waited = 0; stillSpent && waited <= 1000; waited += 50) {
3501
4144
  const state = await this.context?.storageState().catch(() => undefined);
3502
4145
  if (state && rotated.every((r) => rotationStored(state, r))) {
3503
- now = state;
4146
+ now = await this.withPageIndexedDB(state, (full) => rotated.every((r) => rotationStored(full, r)));
3504
4147
  break;
3505
4148
  }
3506
4149
  await new Promise((r) => setTimeout(r, 50));
@@ -3534,9 +4177,10 @@ export class BrowserEngine {
3534
4177
  * that has not stored it in time has the token read from the response
3535
4178
  * instead. A refused or failed refresh changes nothing on disk, and nor does
3536
4179
  * one whose response left a refresh cookie as it was (a server that does
3537
- * not rotate refresh tokens).
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).
3538
4182
  */
3539
- async writeBackAfter(answer, presented, lock) {
4183
+ async writeBackAfter(answer, presented, lock, swapped, storageFrom = "page") {
3540
4184
  const broker = this.refresh;
3541
4185
  try {
3542
4186
  const res = await answer;
@@ -3557,8 +4201,9 @@ export class BrowserEngine {
3557
4201
  for (let waited = 0; waited <= patienceMs; waited += 50) {
3558
4202
  const now = await this.context?.storageState().catch(() => null);
3559
4203
  if (now && rotationStored(now, presented)) {
4204
+ const full = await this.withPageIndexedDB(now, (read) => rotationStored(read, presented));
3560
4205
  const onDisk = this.readRoleProfile();
3561
- state = profileAfterRotation(now, onDisk.ok ? onDisk.state : null);
4206
+ state = profileAfterRotation(full, onDisk.ok ? onDisk.state : null, storageFrom);
3562
4207
  break;
3563
4208
  }
3564
4209
  await new Promise((r) => setTimeout(r, 50));
@@ -3580,6 +4225,7 @@ export class BrowserEngine {
3580
4225
  }
3581
4226
  if (!state) {
3582
4227
  this.refreshCounts.failed += 1;
4228
+ this.refreshEvents.push("failed");
3583
4229
  this.logAction({
3584
4230
  action: "refresh-broker:not-saved",
3585
4231
  target: `${presented.slot}: the page stored no rotated token and the response named none`,
@@ -3590,9 +4236,13 @@ export class BrowserEngine {
3590
4236
  writeProfile(broker.projectDir, broker.role, state);
3591
4237
  this.rememberTokens(refreshTokenSlots(state));
3592
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");
3593
4242
  }
3594
4243
  catch (err) {
3595
4244
  this.refreshCounts.failed += 1;
4245
+ this.refreshEvents.push("failed");
3596
4246
  this.logAction({
3597
4247
  action: "refresh-broker:not-saved",
3598
4248
  target: `${presented.slot}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
@@ -3603,6 +4253,24 @@ export class BrowserEngine {
3603
4253
  lock.release();
3604
4254
  }
3605
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
+ }
3606
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. */
3607
4275
  rememberTokens(now) {
3608
4276
  if (!this.refresh)
@@ -3641,11 +4309,25 @@ export class BrowserEngine {
3641
4309
  }
3642
4310
  this.authLoss.record({ requestedRoute, landedRoute, bounced, role: this.role, target: requestedUrl });
3643
4311
  }
3644
- async goBack() {
4312
+ /** `leave` answers a leave confirmation the page raises (policy.ts dialogResponse); undefined lets the mode decide. */
4313
+ async goBack(leave) {
3645
4314
  this.actionStartedAt = Date.now();
3646
4315
  const page = this.requirePage();
3647
- await page.goBack({ waitUntil: "domcontentloaded", timeout: this.limits.backNavMs }).catch(() => { });
3648
- 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
+ }
3649
4331
  }
3650
4332
  /** The full route contract: scanned filesystem routes ∪ link-discovered route classes. */
3651
4333
  allKnownRoutes() {
@@ -3666,7 +4348,10 @@ export class BrowserEngine {
3666
4348
  *
3667
4349
  * Visited/attempted keys are stored NORMALIZED, so the normalized form of
3668
4350
  * each known route is compared too — normalizePath is idempotent, so this
3669
- * 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.
3670
4355
  */
3671
4356
  unvisitedKnownRoutes() {
3672
4357
  if (!this.memory)
@@ -3674,11 +4359,11 @@ export class BrowserEngine {
3674
4359
  const all = this.allKnownRoutes();
3675
4360
  if (all.length === 0)
3676
4361
  return [];
3677
- 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));
3678
4363
  const attempted = this.memory.attemptedByRole(this.role);
3679
4364
  return all.filter((r) => {
3680
4365
  const n = normalizePath(r);
3681
- return !visited.has(r) && !(r in attempted) && !visited.has(n) && !(n in attempted);
4366
+ return !reached(r) && !(r in attempted) && !(n in attempted);
3682
4367
  });
3683
4368
  }
3684
4369
  /** Map a route class to something goto-able (discovered classes carry a concrete example). */
@@ -3732,7 +4417,8 @@ export class BrowserEngine {
3732
4417
  const page = this.requirePage();
3733
4418
  const memory = this.memory;
3734
4419
  this.crawlHealth = [];
3735
- 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));
3736
4422
  if (targets.length === 0) {
3737
4423
  const failed = this.unvisitedKnownRoutes().filter((r) => this.loadFailedRoutes.has(normalizePath(r)));
3738
4424
  if (failed.length > 0) {
@@ -3755,11 +4441,12 @@ export class BrowserEngine {
3755
4441
  summary.push(`… stopped at the time limit: ${queue.length - i} route(s) not started`);
3756
4442
  break;
3757
4443
  }
3758
- const url = `${this.baseUrl}${path.startsWith("/") ? "" : "/"}${path}`;
3759
- if (!this.isSameOrigin(url)) {
3760
- summary.push(`${path} — SKIPPED (off-origin)`);
4444
+ const resolved = resolveTarget(this.baseUrl, path);
4445
+ if (!("url" in resolved)) {
4446
+ summary.push(`${path} — SKIPPED (${resolved.offOrigin ? "off-origin" : resolved.problem})`);
3761
4447
  continue;
3762
4448
  }
4449
+ const url = resolved.url;
3763
4450
  this.actionStartedAt = Date.now();
3764
4451
  this.oracles.drain(false); // discard pre-route leftovers WITHOUT marking their signatures as reported
3765
4452
  let status = "ERR";
@@ -3775,11 +4462,7 @@ export class BrowserEngine {
3775
4462
  // must not count a route as covered in every later run's gap ledger.
3776
4463
  if (!opts.measureOnly)
3777
4464
  this.loadFailedRoutes.add(normalizePath(url));
3778
- // The browser commits its own error page for this failure tens of
3779
- // milliseconds after goto has thrown, and that commit interrupts the next
3780
- // navigation, charging this route's failure to the next one. Wait for it;
3781
- // a browser that shows no error page simply lets the wait time out.
3782
- await page.waitForEvent("framenavigated", { predicate: (f) => f === page.mainFrame(), timeout: 1500 }).catch(() => undefined);
4465
+ await this.errorPageCommitted(page);
3783
4466
  summary.push(`${path} — LOAD FAILED`);
3784
4467
  // The page a re-attach went back to never loaded, so nothing says whether it worked.
3785
4468
  if (this.authLoss.reattaching)
@@ -3803,14 +4486,14 @@ export class BrowserEngine {
3803
4486
  }
3804
4487
  await this.settle();
3805
4488
  this.harvestPaused = opts.measureOnly === true;
3806
- const { elements, forms } = await this.collect().finally(() => (this.harvestPaused = false));
4489
+ const { elements, forms, aliases } = await this.collect().finally(() => (this.harvestPaused = false));
3807
4490
  const finalUrl = page.url();
3808
4491
  const route = normalizePath(finalUrl);
3809
4492
  const fp = fingerprintState(finalUrl, trackedElements(elements));
3810
4493
  if (!opts.measureOnly) {
3811
- memory.visitState(fp, finalUrl, route, trackedElements(elements).map((el) => el.key), inertKeys(elements));
4494
+ memory.visitState(fp, finalUrl, route, trackedElements(elements).map((el) => el.key), inertKeys(elements), this.sessionKey, aliases);
3812
4495
  for (const f of forms)
3813
- memory.recordForm(fp, f.key, f.guarded);
4496
+ memory.recordForm(fp, f.key, f.guarded, this.sessionKey);
3814
4497
  memory.recordRoleAccess(this.role, route, "reached");
3815
4498
  }
3816
4499
  // If we landed somewhere else (auth wall, canonical redirect), the
@@ -3862,18 +4545,32 @@ export class BrowserEngine {
3862
4545
  // What the page's main area holds besides controls: "41 el" alone cannot tell a page
3863
4546
  // of text from a main area that rendered nothing.
3864
4547
  const main = await this.readMainRegion(page);
3865
- const flags = [loginRedirect ? "AUTH-REDIRECT" : null, deadEnd ? "DEAD-END" : null, violations.length > 0 ? `${violations.length}⚠` : null].filter(Boolean);
3866
- summary.push(`${path} — ${status} · ${trackedElements(elements).length} el` +
3867
- (main ? ` · ${mainRegionTag(main)}` : "") +
3868
- (missingTestid ? ` · ${missingTestid} no-testid` : "") +
3869
- (unnamed ? ` · ${unnamed} unnamed` : "") +
3870
- (flags.length ? ` · ${flags.join(" ")}` : ""));
3871
- if (violations.length > 0 || deadEnd || loginRedirect || (typeof status === "number" && status >= 400)) {
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)) {
3872
4564
  const detail = violations
3873
4565
  .slice(0, 3)
3874
4566
  .map((v) => ` ${v.kind}: ${v.detail.slice(0, 160)}`)
3875
4567
  .join("\n");
3876
- 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}` : ""}`);
3877
4574
  }
3878
4575
  // A role session that lost its sign-in on this route re-attaches once
3879
4576
  // and visits the routes the loss bounced again, next. Their bounced
@@ -3899,7 +4596,7 @@ export class BrowserEngine {
3899
4596
  }
3900
4597
  // Crawl leaves the page wherever it ended — refs from before are gone.
3901
4598
  this.refs.clear();
3902
- this.lastSnap = null;
4599
+ this.forgetSnapshots();
3903
4600
  this.snapshotUrl = "";
3904
4601
  const unvisited = this.unvisitedKnownRoutes();
3905
4602
  return (
@@ -3921,11 +4618,18 @@ export class BrowserEngine {
3921
4618
  * time by semantic locator (testid= / text= / label=), never by snapshot
3922
4619
  * ref, so the plan is immune to DOM drift. Aborts on the first oracle
3923
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.
3924
4625
  */
3925
- async runPlan(steps) {
4626
+ async runPlan(steps, onViolation = "stop") {
3926
4627
  const page = this.requirePage();
3927
4628
  const transcript = [];
3928
- 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) => {
3929
4633
  const parsed = parseTarget(target);
3930
4634
  if (!parsed)
3931
4635
  throw new Error(`Plan targets must be ${TARGET_HELP} (got: ${target})`);
@@ -3952,6 +4656,8 @@ export class BrowserEngine {
3952
4656
  let preTestid = null;
3953
4657
  let preLabel = "";
3954
4658
  let forcedClick = false;
4659
+ let cover = null;
4660
+ let coverReadAt = 0;
3955
4661
  // What a type step has to say about the field it typed into; it goes on
3956
4662
  // the step's own line, so it cannot read as the previous step's.
3957
4663
  let note = "";
@@ -4003,7 +4709,7 @@ export class BrowserEngine {
4003
4709
  else {
4004
4710
  if (!step.target)
4005
4711
  throw new Error(`${step.action} needs a target`);
4006
- const loc = resolveTarget(step.target);
4712
+ const loc = locatePlanTarget(step.target);
4007
4713
  // Coverage is recorded against the state the element LIVED IN, so it
4008
4714
  // has to be captured before the action changes the page. Marking it
4009
4715
  // afterwards (as this did) recorded against the state the click
@@ -4022,7 +4728,17 @@ export class BrowserEngine {
4022
4728
  preTestid = await loc.getAttribute("data-testid").catch(() => null);
4023
4729
  const label = await liveLabel(loc);
4024
4730
  preLabel = label;
4025
- 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))) {
4026
4742
  transcript.push(`${desc} → ${destructiveRefusal(label || step.target, this.mode)}`);
4027
4743
  break;
4028
4744
  }
@@ -4032,7 +4748,7 @@ export class BrowserEngine {
4032
4748
  preState = await this.stateHolding(probe, preState, preState !== null && preState === lastCapture);
4033
4749
  form = { kind: "click", probe };
4034
4750
  }
4035
- forcedClick = (await this.resilientClick(loc, this.limits.actionMs)).forced;
4751
+ ({ forced: forcedClick, cover, coverReadAt } = await this.resilientClick(loc, this.limits.actionMs));
4036
4752
  }
4037
4753
  else if (step.action === "hover") {
4038
4754
  const { before, bodyBefore, churning } = await this.hoverBaselines();
@@ -4065,7 +4781,7 @@ export class BrowserEngine {
4065
4781
  }
4066
4782
  else if (step.action === "select") {
4067
4783
  const options = await readSelectOptions(loc);
4068
- 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 });
4069
4785
  if (options)
4070
4786
  chose = { options, picked };
4071
4787
  }
@@ -4095,12 +4811,12 @@ export class BrowserEngine {
4095
4811
  try {
4096
4812
  // Record the state the action LANDED on (it may be a new screen
4097
4813
  // this plan just reached, and it deserves coverage of its own)…
4098
- const { elements, forms } = await this.collect();
4814
+ const { elements, forms, aliases } = await this.collect();
4099
4815
  const url = page.url();
4100
4816
  const fp = fingerprintState(url, trackedElements(elements));
4101
- this.memory.visitState(fp, url, normalizePath(url), trackedElements(elements).map((el) => el.key), inertKeys(elements));
4817
+ this.memory.visitState(fp, url, normalizePath(url), trackedElements(elements).map((el) => el.key), inertKeys(elements), this.sessionKey, aliases);
4102
4818
  for (const f of forms)
4103
- this.memory.recordForm(fp, f.key, f.guarded);
4819
+ this.memory.recordForm(fp, f.key, f.guarded, this.sessionKey);
4104
4820
  this.memory.recordRoleAccess(this.role, normalizePath(url), "reached");
4105
4821
  lastCapture = { fp, elements, url };
4106
4822
  // …but mark the acted-on element in the state it came FROM, using
@@ -4135,17 +4851,24 @@ export class BrowserEngine {
4135
4851
  await this.scanForInjections();
4136
4852
  await this.scanForContradictions();
4137
4853
  const violations = this.oracles.drain();
4138
- 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)";
4139
4860
  // Abort only on NEW violations: a known-failing endpoint repeating on
4140
4861
  // every navigation must not make every plan abort at step 1.
4141
- if (violations.some((v) => !v.repeat)) {
4862
+ if (planStopsAt(violations, onViolation)) {
4142
4863
  transcript.push(`${desc} → OK${note}, but oracle fired:${formatViolations(violations)}${mutations}`);
4143
4864
  transcript.push(`PLAN ABORTED at step ${i + 1} — investigate before continuing.`);
4144
4865
  break;
4145
4866
  }
4146
- const forcedNote = forcedClick
4147
- ? " (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)"
4148
- : "";
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
+ }
4149
4872
  transcript.push(`${desc} → OK (${page.url()})${note}${mutations}${forcedNote}`);
4150
4873
  }
4151
4874
  catch (err) {
@@ -4168,12 +4891,15 @@ export class BrowserEngine {
4168
4891
  }
4169
4892
  }
4170
4893
  this.refs.clear();
4171
- this.lastSnap = null;
4894
+ this.forgetSnapshots();
4172
4895
  this.snapshotUrl = "";
4173
4896
  // Count step lines, not transcript lines — hover reveals and scroll
4174
4897
  // positions push informational entries that are not steps.
4175
4898
  const ran = transcript.filter((l) => /^\d+\. /.test(l)).length;
4176
- 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.`;
4177
4903
  }
4178
4904
  /** A plan's or a flow's target as a Playwright locator (every match; callers pick). */
4179
4905
  static locatorFor(page, target) {
@@ -4253,10 +4979,6 @@ export class BrowserEngine {
4253
4979
  await (this.page ?? page).waitForTimeout(100).catch(() => { });
4254
4980
  }
4255
4981
  };
4256
- const firstLine = (err) => (err instanceof Error ? err.message : String(err))
4257
- .split("\n")[0]
4258
- .replace(/^[a-z]+\.[a-zA-Z]+: /, "")
4259
- .trim();
4260
4982
  try {
4261
4983
  for (const [i, step] of steps.entries()) {
4262
4984
  const n = i + 1;
@@ -4271,7 +4993,10 @@ export class BrowserEngine {
4271
4993
  try {
4272
4994
  const current = this.requirePage();
4273
4995
  if (step.action === "navigate") {
4274
- 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;
4275
5000
  const resp = await current
4276
5001
  .goto(url, { waitUntil: "domcontentloaded", timeout: this.limits.crawlNavMs })
4277
5002
  .catch((err) => Promise.reject(explainTimeout(err, "nav", this.limits.crawlNavMs)));
@@ -4304,7 +5029,14 @@ export class BrowserEngine {
4304
5029
  const label = ((await loc.getAttribute("aria-label").catch(() => null)) ??
4305
5030
  (await loc.textContent({ timeout: 1000 }).catch(() => null)) ??
4306
5031
  "").trim();
4307
- 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))) {
4308
5040
  refusal = destructiveRefusal(label || step.target, this.mode);
4309
5041
  }
4310
5042
  else if (step.action === "click") {
@@ -4351,7 +5083,7 @@ export class BrowserEngine {
4351
5083
  }
4352
5084
  }
4353
5085
  catch (err) {
4354
- failure = firstLine(explainTimeout(err, "action", this.limits.actionMs));
5086
+ failure = firstLineOf(explainTimeout(err, "action", this.limits.actionMs));
4355
5087
  }
4356
5088
  await this.scanForInjections().catch(() => { });
4357
5089
  await this.scanForContradictions().catch(() => { });
@@ -4402,7 +5134,7 @@ export class BrowserEngine {
4402
5134
  page.off("websocket", onSocket);
4403
5135
  context.off("response", onResponse);
4404
5136
  this.refs.clear();
4405
- this.lastSnap = null;
5137
+ this.forgetSnapshots();
4406
5138
  this.snapshotUrl = "";
4407
5139
  }
4408
5140
  }
@@ -4474,15 +5206,15 @@ export class BrowserEngine {
4474
5206
  * clicked, and the write policy is not involved. The rectangle's rules are
4475
5207
  * capture.ts.
4476
5208
  */
4477
- async captureElement(target, margin) {
5209
+ async captureElement(target, margin, log = true) {
4478
5210
  const page = this.requirePage();
4479
5211
  let el;
4480
5212
  if (target.ref) {
4481
5213
  el = this.refs.get(target.ref);
4482
5214
  if (!el)
4483
5215
  throw new Error(`Unknown ref "${target.ref}". Refs are only valid from the latest scout_snapshot — take a new snapshot.`);
4484
- if (page.url() !== this.snapshotUrl) {
4485
- this.refs.clear();
5216
+ if (!refsSurviveUrlChange(this.snapshotUrl, page.url())) {
5217
+ this.dropRefs(`the page moved to ${page.url()}`);
4486
5218
  throw new Error(`Page URL changed since the last snapshot (now ${page.url()}). Take a new scout_snapshot.`);
4487
5219
  }
4488
5220
  }
@@ -4507,12 +5239,141 @@ export class BrowserEngine {
4507
5239
  if (!clip)
4508
5240
  throw new Error(`${el.name || el.key} is outside the viewport, so it has no picture.`);
4509
5241
  const png = await page.screenshot({ type: "png", clip, animations: "disabled" });
4510
- 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() });
4511
5244
  // Found by key, the refs were rebuilt without a snapshot: none of them may be acted on.
4512
5245
  if (target.key)
4513
- this.refs.clear();
5246
+ this.dropRefs("an element was captured by its key");
4514
5247
  return { png, key: el.key, label: el.name, url: page.url() };
4515
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
+ }
4516
5377
  /**
4517
5378
  * What the live view shows next to a session's name. The task is what the
4518
5379
  * agent said the session is for; the objective is the goal of the journey it
@@ -4796,6 +5657,6 @@ export class BrowserEngine {
4796
5657
  this.refs.clear();
4797
5658
  this.snapshotUrl = "";
4798
5659
  this.currentFingerprint = "";
4799
- this.lastSnap = null;
5660
+ this.forgetSnapshots();
4800
5661
  }
4801
5662
  }