scenescout 3.13.0 → 3.14.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.
@@ -5,21 +5,25 @@ import { elementKey, fingerprintState, isNonPageRoute, normalizePath } from "./f
5
5
  import { AUTH_LOSS_PREFIX, JOURNEY_END, JOURNEY_START, MemoryStore, redactSecrets, TASK_SET } from "./memory.js";
6
6
  import { normalizeTask } from "./task.js";
7
7
  import { CLAIM_SCAN_SCRIPT, findContradictions } from "./claims.js";
8
+ import { POSTMESSAGE_BINDING, describeTokenPost, postMessageCaptureScript, tokenHits, tokenPostKey } from "./postmessage.js";
8
9
  import { describeInjection, newInjections, probeQueries, probeScript, probeShape, rememberProbe } from "./injection.js";
9
10
  import { AuthLossTracker } from "./authloss.js";
11
+ import { captureClip } from "./capture.js";
10
12
  import { COLLECT_INTERACTABLES_SCRIPT, VISIBLE_SRC, geometryIssues, BROKEN_IMAGES_SCRIPT, brokenImageIssues, frameLines, hasVisibleFrame, frameElementKey, frameLabel, masksForeignName, MASKED_NAME, capForeignName, stripForeignHref, frameToPageRect, displayName, missingName, placeholderOnly, placeholderEvidence, describeControl, labelFlag, } from "./collector.js";
11
13
  import { OracleMonitor, formatViolations, httpErrorDetail } from "./oracles.js";
12
14
  import { extractCreatedIds, isOwnedResource, normalizeId } from "./ownership.js";
13
15
  import { formatJourney, measureJourney } from "./journey.js";
14
- import { describeStep, FLOW_AFTER_LAST_STEP_MS, FLOW_STEP_TIMEOUT_MS, isAction, matchRequest, parseTarget, splitRefusals, TARGET_HELP, urlMatches, } from "./flow.js";
16
+ import { describeStep, FLOW_AFTER_LAST_STEP_MS, isAction, matchRequest, parseTarget, splitRefusals, TARGET_HELP, urlMatches, } from "./flow.js";
15
17
  import { framePath, RECORD_MAX_FRAMES } from "./replay.js";
16
18
  import { keepWatchingUrl, normalizePace, SETTLE_TICK_MS, shouldKeepWaiting } from "./settle.js";
17
19
  import { buildRequestScript, formatReplay, replaySignature, requestHeaders, resolveMethod, resolveRequestUrl, toReplayResult } from "./request.js";
18
- import { defaultEngine, focusAdvanceKey, REMOVE_SHARED_WORKER_SCRIPT, screencastSupport, serviceWorkerPolicy, sharedWorkersAllowed, unloadWriteInterception, } from "../browsers.js";
20
+ import { defaultEngine, focusAdvanceKey, REMOVE_SHARED_WORKER_SCRIPT, screencastSupport, serviceWorkerPolicy, sharedWorkersAllowed, unloadWriteInterception, closeWaitsForLeavingWrites, } from "../browsers.js";
19
21
  import { revealedLines } from "./hover.js";
20
22
  import { FORMS_INVENTORY_SCRIPT, FORM_PROBE_BODY, formProbeExpression, formIdentity, formStatus, FORMS_READ_FAILED, FORMS_SUBMIT_UNMATCHED, isEmptySubmit, sameControl, isNavigationTeardown, submits, tracksForm, } from "./forms.js";
21
23
  import { explainLaunchFailure, isMissingBrowser } from "./launch.js";
22
- import { ACTION_TIMEOUT_MS, performScroll, probeFocusIndicators, probeOverlays, scrollContainer } from "./probes.js";
24
+ import { DEFAULT_TIME_LIMITS, explainTimeout, limitHint, resolveTimeLimits } from "./limits.js";
25
+ import { performScroll, probeFocusIndicators, probeOverlays, scrollContainer } from "./probes.js";
26
+ import { boundedTeardown } from "./teardown.js";
23
27
  import { BROWSER_MARKER, reapOrphanBrowsers } from "./reaper.js";
24
28
  import { planUploadOptions, resolveDiskUpload } from "./uploads.js";
25
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";
@@ -27,6 +31,8 @@ import { scanProject } from "../scan.js";
27
31
  import { analyzeDesign, DESIGN_COLLECT_SCRIPT } from "./design.js";
28
32
  import { acceptMatches, generatedUpload } from "./fixtures.js";
29
33
  import { bodyDigest, headerOf, isReadMethod, judgeUnseenWrite, pausedRequestBytes, RoutedWrites, UnseenRefusals, unseenWriteSource, } from "./unload.js";
34
+ import { loginCommand, permissionNote, resolveAttachAuth, roleLabel, sessionStorageInitScript, splitProfile, summarizeState, writeProfile, } from "./profiles.js";
35
+ import { acquireLock, brokerEnabled, lockPathFor, planRefresh, presentedToken, profileAfterRotation, REFRESH_BROKER_ENV, refreshTokenSlots, rotatedFromResponse, rotationStored, swapProfileToken, swapRequest, } from "./refresh.js";
30
36
  const SETTLE_MS = 400;
31
37
  /**
32
38
  * Request URL → pathname, falling back to the raw string for anything
@@ -184,6 +190,12 @@ const SCREENCAST_PAGELESS_TICKS = 20;
184
190
  const RECORD_SHOT_TIMEOUT_MS = 2500;
185
191
  export class BrowserEngine {
186
192
  browser = null;
193
+ /** How long an action and a page load may take; set at attach (limits.ts). */
194
+ limits = DEFAULT_TIME_LIMITS;
195
+ /** The limits this session runs with, so the server can size its watchdogs to them. */
196
+ get timeLimits() {
197
+ return this.limits;
198
+ }
187
199
  context = null;
188
200
  page = null;
189
201
  oracles = new OracleMonitor();
@@ -211,13 +223,26 @@ export class BrowserEngine {
211
223
  /** Origins named as trusted embeds (policy.ts trustsEmbedWrite decides when that counts). */
212
224
  trustedEmbeds = new Set();
213
225
  trustNotice = "";
214
- /** Human label for the auth identity driving this session (the server sets it from the storage-state filename). */
226
+ /** Human label for the auth identity driving this session: the role, the storage-state file's name, or anonymous. Set by attach. */
215
227
  role = "anonymous";
228
+ /** How the attached session signed in: a role profile, a storage-state file, or not at all. */
229
+ auth = { kind: "none" };
216
230
  /** Named-session id (the server sets it; every engine shares one MemoryStore, so
217
231
  * per-session reads of the interleaved action log must filter by this). */
218
232
  sessionKey = "default";
219
233
  /** Login-bounce streak, the SESSION AUTH LOST verdict, and the per-call notice. */
220
234
  authLoss = new AuthLossTracker();
235
+ /**
236
+ * The refresh broker for a role session: the role's profile, and the
237
+ * refresh tokens this session last loaded from it (never printed). Null
238
+ * when the session is not attached by role, the broker is off, or the
239
+ * profile holds no refresh token.
240
+ */
241
+ refresh = null;
242
+ /** Write-backs still waiting on a response, so close() lets them release their lock. */
243
+ refreshTasks = new Set();
244
+ /** What the broker did this session, for the lane report: counts only. */
245
+ refreshCounts = { refreshed: 0, swapped: 0, failed: 0 };
221
246
  /** UI-label blocking applies only in read-only mode (safe-write enforces at the network layer instead). */
222
247
  get readOnly() {
223
248
  // observe is read-only and then some: every UI-level refusal applies to it too.
@@ -518,6 +543,8 @@ export class BrowserEngine {
518
543
  * as long as the browser, so a page closed at the end still meets it.
519
544
  */
520
545
  routedWrites = new RoutedWrites();
546
+ /** Requests the route handler is still judging, by the page that sent them: a page is closed only once its own are answered. */
547
+ routesInFlight = new Map();
521
548
  /** Writes refused at the browser level, so the driver's own report of them (a redirect hop failing) is known as the policy's doing. */
522
549
  unseenRefusals = new UnseenRefusals();
523
550
  /** Whether the write policy stopped this request, at the route handler or at the browser level. */
@@ -635,6 +662,38 @@ export class BrowserEngine {
635
662
  this.oracles.noteContradiction(found, url);
636
663
  }
637
664
  }
665
+ /** postMessage-token findings already reported this session, by route, receiving frame, path and shape. */
666
+ tokenPostsReported = new Set();
667
+ /**
668
+ * What the capture script handed back from a postMessage to "*": classified
669
+ * here (postmessage.ts) and dropped, so no value outlives this call. Only the
670
+ * path, shape and masked preview reach the oracle log.
671
+ */
672
+ noteTokenPosts(frame, entries) {
673
+ const hits = tokenHits(entries);
674
+ if (hits.length === 0)
675
+ return;
676
+ let pageUrl;
677
+ let frameUrl;
678
+ let embed;
679
+ try {
680
+ const page = frame.page();
681
+ pageUrl = page.url();
682
+ frameUrl = frame.url();
683
+ embed = frame === page.mainFrame() ? null : this.foreignOriginOf(frame);
684
+ }
685
+ catch {
686
+ // The frame went away between the call and its report: nothing left to attribute it to.
687
+ return;
688
+ }
689
+ for (const hit of hits) {
690
+ const key = tokenPostKey(hit, pageUrl, frameUrl);
691
+ if (this.tokenPostsReported.has(key))
692
+ continue;
693
+ this.tokenPostsReported.add(key);
694
+ this.oracles.noteTokenPost(describeTokenPost(hit, frameUrl), pageUrl, embed);
695
+ }
696
+ }
638
697
  /** Contradiction signatures already reported this session — the same refused endpoint on every page must not flood the run. */
639
698
  contradictionsReported = new Set();
640
699
  async scanForInjections() {
@@ -667,9 +726,45 @@ export class BrowserEngine {
667
726
  return this.page !== null;
668
727
  }
669
728
  async attach(opts) {
729
+ // Resolved before anything is closed, so a bad role, a missing profile or
730
+ // a time limit out of bounds leaves a live session as it was.
731
+ const limits = resolveTimeLimits(opts, process.env);
732
+ const auth = resolveAttachAuth({ projectDir: opts.projectDir, url: opts.url, role: opts.role, storageStatePath: opts.storageStatePath });
733
+ const brokered = brokerEnabled({ roleSession: auth.kind === "role", option: opts.refreshBroker, env: process.env[REFRESH_BROKER_ENV] });
734
+ const storageStatePath = auth.kind === "none" ? undefined : auth.storageStatePath;
735
+ if (storageStatePath && !fs.existsSync(storageStatePath)) {
736
+ throw new Error(`storageStatePath does not exist: ${storageStatePath}`);
737
+ }
738
+ // Read and checked here, also before anything is closed. Playwright restores
739
+ // the storage state; sessionStorage, which it has no field for, is put back
740
+ // by an init script before the app's own code runs.
741
+ let profile;
742
+ if (storageStatePath) {
743
+ try {
744
+ profile = splitProfile(JSON.parse(fs.readFileSync(storageStatePath, "utf8")));
745
+ }
746
+ catch (err) {
747
+ throw new Error(`could not load the storage state at ${storageStatePath}: ${err instanceof Error ? err.message : String(err)}`);
748
+ }
749
+ }
670
750
  await this.close();
671
- if (opts.storageStatePath && !fs.existsSync(opts.storageStatePath)) {
672
- throw new Error(`storageStatePath does not exist: ${opts.storageStatePath}`);
751
+ this.limits = limits;
752
+ this.auth = auth;
753
+ this.role = roleLabel(auth);
754
+ this.authLoss.beginSession(auth.kind, this.role);
755
+ let profileNote = "";
756
+ this.refresh = null;
757
+ this.refreshCounts = { refreshed: 0, swapped: 0, failed: 0 };
758
+ if (auth.kind === "role") {
759
+ const note = permissionNote(auth.storageStatePath, fs.statSync(auth.storageStatePath).mode);
760
+ if (note)
761
+ profileNote = `\n⚠ ${note}`;
762
+ if (brokered) {
763
+ const read = this.readRoleProfile();
764
+ const known = read.ok ? refreshTokenSlots(read.state) : [];
765
+ if (known.length > 0)
766
+ this.refresh = { projectDir: opts.projectDir, role: auth.role, file: auth.storageStatePath, known, spent: [] };
767
+ }
673
768
  }
674
769
  this.currentMode = opts.mode ?? "read-only";
675
770
  this.writeRule = new WriteRule(this.currentMode);
@@ -698,6 +793,7 @@ export class BrowserEngine {
698
793
  this.blockedRequests = [];
699
794
  this.watchedResponses = [];
700
795
  this.contradictionsReported = new Set();
796
+ this.tokenPostsReported = new Set();
701
797
  this.pendingCreations = new Set();
702
798
  this.baseUrl = opts.url.replace(/\/$/, "");
703
799
  this.embedMoves = new EmbedMoveTracker(this.baseUrl);
@@ -753,12 +849,18 @@ export class BrowserEngine {
753
849
  this.engineName = opts.browser ?? defaultEngine(process.env);
754
850
  this.browser = await this.launchWithRecovery(this.engineName, opts.headed ?? false);
755
851
  this.context = await this.browser.newContext({
756
- storageState: opts.storageStatePath,
852
+ storageState: profile?.storageState,
757
853
  viewport: opts.viewport ?? { width: 1280, height: 900 },
758
854
  serviceWorkers: serviceWorkerPolicy(this.engineName),
759
855
  });
856
+ const restoreSession = sessionStorageInitScript(profile?.sessionStorage ?? []);
857
+ if (restoreSession)
858
+ await this.context.addInitScript(restoreSession);
760
859
  if (!sharedWorkersAllowed(this.mode))
761
860
  await this.context.addInitScript(REMOVE_SHARED_WORKER_SCRIPT);
861
+ // The postMessage-token oracle: every frame's postMessage is wrapped, and a "*" call hands its longer strings here.
862
+ await this.context.exposeBinding(POSTMESSAGE_BINDING, (source, entries) => this.noteTokenPosts(source.frame, entries));
863
+ await this.context.addInitScript(postMessageCaptureScript());
762
864
  this.page = await this.context.newPage();
763
865
  }
764
866
  catch (err) {
@@ -846,7 +948,7 @@ export class BrowserEngine {
846
948
  });
847
949
  // Write policy — enforced on the wire, where the truth lives.
848
950
  if (this.mode !== "destructive") {
849
- await this.context.route("**/*", async (route) => {
951
+ const judge = async (route) => {
850
952
  const req = route.request();
851
953
  const method = req.method();
852
954
  // Read once, as the request arrives: the rule the page sent it under (policy.ts WriteRule), not whatever holds after an await below.
@@ -999,7 +1101,8 @@ export class BrowserEngine {
999
1101
  return route.continue();
1000
1102
  }
1001
1103
  return refuse();
1002
- });
1104
+ };
1105
+ await this.context.route("**/*", (route) => this.judgeTracked(route, judge));
1003
1106
  // Chromium never routes a write a page sends as it is being left; it is judged at the browser level instead.
1004
1107
  if (unloadWriteInterception(this.engineName) === "browser-fetch" && this.browser) {
1005
1108
  try {
@@ -1012,12 +1115,17 @@ export class BrowserEngine {
1012
1115
  }
1013
1116
  }
1014
1117
  }
1118
+ // The refresh broker. Registered after the write policy so it runs first
1119
+ // and hands every request on to it: a refresh it lets through is still
1120
+ // judged by the policy like any other request.
1121
+ if (this.refresh)
1122
+ await this.context.route("**/*", (route) => this.brokerRefresh(route));
1015
1123
  // Popups / target=_blank: adopt same-origin pages as the active page (with
1016
1124
  // oracles attached); close foreign-origin popups so exploration cannot
1017
1125
  // silently escape the app under test.
1018
1126
  this.context.on("page", (newPage) => {
1019
1127
  newPage
1020
- .waitForLoadState("domcontentloaded", { timeout: 10000 })
1128
+ .waitForLoadState("domcontentloaded", { timeout: this.limits.backNavMs })
1021
1129
  .then(() => {
1022
1130
  if (newPage === this.page || newPage.isClosed())
1023
1131
  return;
@@ -1037,7 +1145,7 @@ export class BrowserEngine {
1037
1145
  this.lastSnap = null;
1038
1146
  }
1039
1147
  else {
1040
- void BrowserEngine.leaveAndClose(newPage);
1148
+ void this.leaveAndClose(newPage);
1041
1149
  }
1042
1150
  })
1043
1151
  .catch(() => { });
@@ -1045,12 +1153,13 @@ export class BrowserEngine {
1045
1153
  this.wireDialogHandler(this.page);
1046
1154
  this.wireEmbedMoves(this.page);
1047
1155
  try {
1048
- await this.page.goto(opts.url, { waitUntil: "domcontentloaded", timeout: 20000 });
1156
+ await this.page.goto(opts.url, { waitUntil: "domcontentloaded", timeout: this.limits.navMs });
1049
1157
  }
1050
1158
  catch (err) {
1051
1159
  // Don't leave a half-attached engine (leaked browser, snapshots of about:blank).
1052
1160
  await this.close();
1053
- throw new Error(`Could not load ${opts.url} — is the app running? (${err instanceof Error ? err.message.split("\n")[0] : err})`);
1161
+ const explained = explainTimeout(err, "nav", this.limits.navMs);
1162
+ throw new Error(`Could not load ${opts.url} — is the app running? (${explained instanceof Error ? explained.message.split("\n")[0] : explained})`);
1054
1163
  }
1055
1164
  await this.settle();
1056
1165
  this.logAction({ action: "attach", url: this.page.url() });
@@ -1062,16 +1171,19 @@ export class BrowserEngine {
1062
1171
  // Whether a bounce verdict can matter later: only a session carrying
1063
1172
  // credentials has any to lose, and only it pays for watching the URL on
1064
1173
  // every navigation. An anonymous crawl keeps its full speed.
1065
- this.watchesForBounce = Boolean(opts.storageStatePath);
1174
+ this.watchesForBounce = Boolean(storageStatePath);
1066
1175
  const landed = await this.stableUrl();
1067
- const authFailed = Boolean(opts.storageStatePath) && this.authLoss.isLoginRedirect(normalizePath(opts.url), landed, this.baseUrl);
1176
+ const authFailed = Boolean(storageStatePath) && this.authLoss.isLoginRedirect(normalizePath(opts.url), landed, this.baseUrl);
1068
1177
  // An earlier run may have written down how this app's login state is
1069
1178
  // regenerated. "Regenerate it" is advice the reader already had; the
1070
1179
  // command that worked last time is the part worth keeping.
1071
1180
  const recipe = authFailed ? this.memory.setupRecipe() : [];
1072
1181
  const authWarning = authFailed
1073
- ? `\n⚠ AUTH FAILED — the storage state at ${opts.storageStatePath} did not produce a signed-in session: ` +
1074
- `attaching landed on ${landed}, a login page. Regenerate it (its token has most likely expired) and re-attach. ` +
1182
+ ? `\n⚠ AUTH FAILED — the storage state at ${storageStatePath} did not produce a signed-in session: ` +
1183
+ `attaching landed on ${landed}, a login page. ` +
1184
+ (auth.kind === "role"
1185
+ ? `Record it again with \`${loginCommand(auth.role, this.baseUrl)}\` (its session has most likely expired) and re-attach. `
1186
+ : `Regenerate it (its token has most likely expired) and re-attach. `) +
1075
1187
  `Continuing now tests a logged-out app.` +
1076
1188
  (recipe.length > 0
1077
1189
  ? `\n Recorded by an earlier run under setup:\n${recipe.map((line) => ` · ${line}`).join("\n")}`
@@ -1080,11 +1192,12 @@ export class BrowserEngine {
1080
1192
  return (`Attached to ${this.page.url()} (mode=${this.mode}` +
1081
1193
  `${this.engineName === "chromium" ? "" : `, browser=${this.engineName}, service workers blocked because their requests cannot be intercepted here`}` +
1082
1194
  `${focusAdvanceKey(this.engineName, process.platform) === "Tab" ? "" : `, keyboard: Tab stops only at text fields in this browser — press Alt+Tab to reach buttons and links`}` +
1083
- `${opts.storageStatePath ? `, auth=${opts.storageStatePath}` : ""}). ` +
1195
+ `${auth.kind === "role" ? `, role=${auth.role}` : auth.kind === "file" ? `, auth=${auth.storageStatePath}` : ""}). ` +
1084
1196
  `Memory: ${this.memory.dir}.${this.memory.loadWarning ? ` WARNING: ${this.memory.loadWarning}` : ""}` +
1085
1197
  (this.memory.prunedStates > 0 ? ` Trimmed ${this.memory.prunedStates} old page state(s) from the history; coverage is unchanged.` : "") +
1086
1198
  `${this.memory.legacyDirNote ? ` ${this.memory.legacyDirNote}` : ""}` +
1087
1199
  `${this.memory.gitIgnoreNote ? ` ${this.memory.gitIgnoreNote}` : ""}${this.trustNotice} Call scout_snapshot to see the current state.` +
1200
+ profileNote +
1088
1201
  authWarning);
1089
1202
  }
1090
1203
  /** Whether the page was moved off the app by one of its embeds (policy.ts EmbedMoveTracker). */
@@ -1670,7 +1783,7 @@ export class BrowserEngine {
1670
1783
  const policyAbortedNavigation = url.startsWith("chrome-error://") && this.blockedRequests.length > 0;
1671
1784
  this.logAction({ action, target, url });
1672
1785
  this.logAction({ action: policyAbortedNavigation ? "write-policy:navigation-blocked" : "origin-fence:bounced", target: url.slice(0, 200), url });
1673
- await page.goBack({ waitUntil: "domcontentloaded", timeout: 10000 }).catch(() => { });
1786
+ await page.goBack({ waitUntil: "domcontentloaded", timeout: this.limits.backNavMs }).catch(() => { });
1674
1787
  url = page.url();
1675
1788
  this.refs.clear();
1676
1789
  const blocked = this.drainBlocked();
@@ -2045,6 +2158,15 @@ export class BrowserEngine {
2045
2158
  ? `\n⚠ READ-ONLY notice: this action fired state-changing requests — server state may have mutated despite ${this.mode} mode: ${list}${extra}. Consider whether this flow should be avoided or the environment confirmed disposable.`
2046
2159
  : `\n(state-changing requests: ${list}${extra})`;
2047
2160
  }
2161
+ /** Run one action or page load; a timeout it throws names the limit that ran out and how to raise it. */
2162
+ async withinLimit(kind, work) {
2163
+ try {
2164
+ return await work();
2165
+ }
2166
+ catch (err) {
2167
+ throw explainTimeout(err, kind, kind === "action" ? this.limits.actionMs : this.limits.navMs);
2168
+ }
2169
+ }
2048
2170
  /**
2049
2171
  * Click a locator the way a real user's click resolves, not the way
2050
2172
  * Playwright's strict actionability protocol insists on. `.click()` polls
@@ -2174,6 +2296,9 @@ export class BrowserEngine {
2174
2296
  });
2175
2297
  }
2176
2298
  async click(ref, clicks = 1) {
2299
+ return this.withinLimit("action", () => this.clickNow(ref, clicks));
2300
+ }
2301
+ async clickNow(ref, clicks = 1) {
2177
2302
  const page = this.requirePage();
2178
2303
  const { el, liveLabel } = await this.resolveForAction(ref);
2179
2304
  const refusal = this.actionPolicyCheck(el, liveLabel);
@@ -2195,7 +2320,7 @@ export class BrowserEngine {
2195
2320
  // button or an input can submit a form; nothing else is asked.
2196
2321
  const form = !el.frame && (el.tag === "button" || el.tag === "input") ? await this.probeForm(clickTarget) : null;
2197
2322
  const formState = { fp: this.currentFingerprint, elements: [...this.refs.values()] };
2198
- const { forced } = await this.resilientClick(clickTarget, ACTION_TIMEOUT_MS, clicks);
2323
+ const { forced } = await this.resilientClick(clickTarget, this.limits.actionMs, clicks);
2199
2324
  this.memory.markExercised(this.currentFingerprint, el.key, clicks > 1 ? `click×${clicks}` : "click");
2200
2325
  this.noteFormSubmit(formState, "click", form);
2201
2326
  const result = await this.afterAction(clicks > 1 ? `click×${clicks}` : "click", `${el.role} "${el.name}"`);
@@ -2252,21 +2377,21 @@ export class BrowserEngine {
2252
2377
  return { existing: n.value, caretAppendable: typeof n.selectionStart === "number" };
2253
2378
  }
2254
2379
  return { existing: "", caretAppendable: false };
2255
- }, undefined, { timeout: ACTION_TIMEOUT_MS }));
2380
+ }, undefined, { timeout: this.limits.actionMs }));
2256
2381
  }
2257
2382
  catch (err) {
2258
2383
  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.`);
2259
2384
  }
2260
2385
  const { existing, caretAppendable } = state;
2261
2386
  if (replace || !existing || text === "") {
2262
- await locator.fill(text, { timeout: ACTION_TIMEOUT_MS });
2387
+ await locator.fill(text, { timeout: this.limits.actionMs });
2263
2388
  return existing && (replace || text === "") ? ` (replaced existing content ${JSON.stringify(existing.slice(0, 60))})` : "";
2264
2389
  }
2265
2390
  const appendNote = ` (APPENDED after existing content ${JSON.stringify(existing.slice(0, 60))} — pass replace=true to overwrite instead)`;
2266
2391
  if (!caretAppendable) {
2267
2392
  // Data-typed inputs (number, email, date): concatenate without a
2268
2393
  // separator — an injected space would invalidate the value.
2269
- await locator.fill(existing + text, { timeout: ACTION_TIMEOUT_MS });
2394
+ await locator.fill(existing + text, { timeout: this.limits.actionMs });
2270
2395
  return appendNote;
2271
2396
  }
2272
2397
  // Native append: focus, move the caret to the end, insert via the
@@ -2287,7 +2412,7 @@ export class BrowserEngine {
2287
2412
  sel?.removeAllRanges();
2288
2413
  sel?.addRange(range);
2289
2414
  }
2290
- }, undefined, { timeout: ACTION_TIMEOUT_MS });
2415
+ }, undefined, { timeout: this.limits.actionMs });
2291
2416
  // Space only at a word-to-word boundary: "@mention " + "hi" needs none,
2292
2417
  // "user@" + "x" must not become "user@ x".
2293
2418
  const separator = /\w$/.test(existing) && /^\w/.test(text) ? " " : "";
@@ -2295,6 +2420,9 @@ export class BrowserEngine {
2295
2420
  return appendNote;
2296
2421
  }
2297
2422
  async type(ref, text, pressEnter = false, replace = false) {
2423
+ return this.withinLimit("action", () => this.typeNow(ref, text, pressEnter, replace));
2424
+ }
2425
+ async typeNow(ref, text, pressEnter = false, replace = false) {
2298
2426
  const page = this.requirePage();
2299
2427
  const { el, liveLabel } = await this.resolveForAction(ref);
2300
2428
  if (el.role === "file") {
@@ -2341,7 +2469,7 @@ export class BrowserEngine {
2341
2469
  // After the fill, before the key: the fields as they are when it submits.
2342
2470
  const form = !el.frame && el.tag === "input" ? await this.probeForm(locator) : null;
2343
2471
  const formState = { fp: this.currentFingerprint, elements: [...this.refs.values()] };
2344
- await locator.press("Enter", { timeout: ACTION_TIMEOUT_MS });
2472
+ await locator.press("Enter", { timeout: this.limits.actionMs });
2345
2473
  this.noteFormSubmit(formState, "enter", form);
2346
2474
  }
2347
2475
  this.memory.markExercised(this.currentFingerprint, el.key, "type");
@@ -2356,6 +2484,9 @@ export class BrowserEngine {
2356
2484
  * fixture unless `filePath` names one inside the attached project.
2357
2485
  */
2358
2486
  async upload(opts) {
2487
+ return this.withinLimit("action", () => this.uploadNow(opts));
2488
+ }
2489
+ async uploadNow(opts) {
2359
2490
  this.actionStartedAt = Date.now();
2360
2491
  const page = this.requirePage();
2361
2492
  let el = null;
@@ -2411,7 +2542,7 @@ export class BrowserEngine {
2411
2542
  if ("refused" in target)
2412
2543
  return refuse(target.refused);
2413
2544
  const { input, chooser, how } = target;
2414
- const meta = await (input ? input.evaluate(describeFileInput, undefined, { timeout: ACTION_TIMEOUT_MS }) : chooser.element().evaluate(describeFileInput)).catch(() => ({ accept: null, multiple: false, label: "", disabled: false, probed: false }));
2545
+ const meta = await (input ? input.evaluate(describeFileInput, undefined, { timeout: this.limits.actionMs }) : chooser.element().evaluate(describeFileInput)).catch(() => ({ accept: null, multiple: false, label: "", disabled: false, probed: false }));
2415
2546
  // setInputFiles never checks `disabled` — it would report success on a
2416
2547
  // control no user can operate.
2417
2548
  if (meta.disabled) {
@@ -2421,14 +2552,14 @@ export class BrowserEngine {
2421
2552
  const mutationsBefore = this.mutationRequests.length;
2422
2553
  const blockedBefore = this.blockedRequests.length;
2423
2554
  if (chooser)
2424
- await chooser.setFiles(file.payload, { timeout: ACTION_TIMEOUT_MS });
2555
+ await chooser.setFiles(file.payload, { timeout: this.limits.actionMs });
2425
2556
  else
2426
- await input.setInputFiles(file.payload, { timeout: ACTION_TIMEOUT_MS });
2557
+ await input.setInputFiles(file.payload, { timeout: this.limits.actionMs });
2427
2558
  // The set call succeeding means the browser took the payload — not that
2428
2559
  // the app kept it. Read the input back: an app that rejects client-side
2429
2560
  // clears it, and then nothing would ever be sent on submit.
2430
2561
  const kept = await (input
2431
- ? input.evaluate((node) => node.files?.length ?? 0, undefined, { timeout: ACTION_TIMEOUT_MS })
2562
+ ? input.evaluate((node) => node.files?.length ?? 0, undefined, { timeout: this.limits.actionMs })
2432
2563
  : chooser.element().evaluate((node) => node.files?.length ?? 0)).catch(() => -1);
2433
2564
  const inputDesc = [
2434
2565
  meta.label ? `"${meta.label}"` : null,
@@ -2464,7 +2595,7 @@ export class BrowserEngine {
2464
2595
  return { input: fileInputs.first(), chooser: null, how: "set on the page's only file input" };
2465
2596
  }
2466
2597
  const isFileInput = await locator
2467
- .evaluate((node) => node instanceof HTMLInputElement && node.type === "file", undefined, { timeout: ACTION_TIMEOUT_MS })
2598
+ .evaluate((node) => node instanceof HTMLInputElement && node.type === "file", undefined, { timeout: this.limits.actionMs })
2468
2599
  .catch(() => false);
2469
2600
  if (isFileInput)
2470
2601
  return { input: locator, chooser: null, how: "set directly on the file input" };
@@ -2473,8 +2604,8 @@ export class BrowserEngine {
2473
2604
  // outlasts the click's own — forced retry included — plus a grace period
2474
2605
  // after it, for apps that fetch an upload URL before opening the picker. A
2475
2606
  // wait shorter than the click reported such controls as "uploads nothing".
2476
- const opened = page.waitForEvent("filechooser", { timeout: ACTION_TIMEOUT_MS + FORCED_CLICK_TIMEOUT_MS + CHOOSER_GRACE_MS }).catch(() => null);
2477
- await this.resilientClick(locator, ACTION_TIMEOUT_MS);
2607
+ const opened = page.waitForEvent("filechooser", { timeout: this.limits.actionMs + FORCED_CLICK_TIMEOUT_MS + CHOOSER_GRACE_MS }).catch(() => null);
2608
+ await this.resilientClick(locator, this.limits.actionMs);
2478
2609
  const chooser = await Promise.race([opened, page.waitForTimeout(CHOOSER_GRACE_MS).then(() => null)]);
2479
2610
  if (chooser)
2480
2611
  return { input: null, chooser, how: "via the file chooser the click opened" };
@@ -2580,11 +2711,14 @@ export class BrowserEngine {
2580
2711
  * look, not an interaction, and the element still deserves a click.
2581
2712
  */
2582
2713
  async hover(ref) {
2714
+ return this.withinLimit("action", () => this.hoverNow(ref));
2715
+ }
2716
+ async hoverNow(ref) {
2583
2717
  const page = this.requirePage();
2584
2718
  const { el } = await this.resolveForAction(ref);
2585
2719
  const { before, bodyBefore, churning } = await this.hoverBaselines();
2586
2720
  const locator = this.scopeOf(el).locator(`xpath=${el.xpath}`);
2587
- await locator.hover({ timeout: ACTION_TIMEOUT_MS });
2721
+ await locator.hover({ timeout: this.limits.actionMs });
2588
2722
  // Wiggle inside the element: pointer-tracking libraries distinguish real
2589
2723
  // movement from a single synthetic hover event.
2590
2724
  const box = await locator.boundingBox().catch(() => null);
@@ -2618,6 +2752,9 @@ export class BrowserEngine {
2618
2752
  this.memory.recordSelectChoice(fingerprint, key, labels, label);
2619
2753
  }
2620
2754
  async select(ref, value) {
2755
+ return this.withinLimit("action", () => this.selectNow(ref, value));
2756
+ }
2757
+ async selectNow(ref, value) {
2621
2758
  const page = this.requirePage();
2622
2759
  const { el, liveLabel } = await this.resolveForAction(ref);
2623
2760
  const refusal = this.actionPolicyCheck(el, liveLabel);
@@ -2638,7 +2775,7 @@ export class BrowserEngine {
2638
2775
  }
2639
2776
  const loc = this.scopeOf(el).locator(`xpath=${el.xpath}`);
2640
2777
  const options = el.tag === "select" ? await readSelectOptions(loc) : null;
2641
- const picked = await loc.selectOption(value, { timeout: ACTION_TIMEOUT_MS });
2778
+ const picked = await loc.selectOption(value, { timeout: this.limits.actionMs });
2642
2779
  this.memory.markExercised(this.currentFingerprint, el.key, "select");
2643
2780
  if (options)
2644
2781
  this.recordSelectChoice(this.currentFingerprint, el.key, options, picked);
@@ -2706,6 +2843,9 @@ export class BrowserEngine {
2706
2843
  */
2707
2844
  static ACTIVATION_KEY_RE = /^(Enter|NumpadEnter|Space| )$/i;
2708
2845
  async press(key) {
2846
+ return this.withinLimit("action", () => this.pressNow(key));
2847
+ }
2848
+ async pressNow(key) {
2709
2849
  this.actionStartedAt = Date.now();
2710
2850
  const page = this.requirePage();
2711
2851
  const refusal = await this.vetFocusedActivation(key);
@@ -2779,7 +2919,7 @@ export class BrowserEngine {
2779
2919
  // A notice describes ONE navigation. Clearing up front means a notice left
2780
2920
  // undelivered by a previous throw can never prepend itself to this result.
2781
2921
  this.authLoss.clear();
2782
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: 20000 });
2922
+ await this.withinLimit("nav", () => page.goto(url, { waitUntil: "domcontentloaded", timeout: this.limits.navMs }));
2783
2923
  // Settle BEFORE judging where we landed. A client-side auth guard redirects
2784
2924
  // after hydration, not during goto, so reading page.url() here showed the
2785
2925
  // requested path and the bounce went unnoticed — which is precisely how a
@@ -2803,7 +2943,293 @@ export class BrowserEngine {
2803
2943
  throw new Error(`${notice}${msg}`);
2804
2944
  }
2805
2945
  this.recordNavigationOutcome(url, await this.landedUrl(page));
2806
- return this.authLoss.take() + settled;
2946
+ // Only after a navigation that finished: one that threw leaves its verdict standing.
2947
+ const plan = this.authLoss.takeReattach();
2948
+ if (!plan)
2949
+ return this.authLoss.take() + settled;
2950
+ // The loss this navigation declared is answered once, from the role's latest
2951
+ // saved profile, and the session goes back to the page it asked for.
2952
+ const applied = await this.applyLatestProfile();
2953
+ if (!applied.ok) {
2954
+ this.authLoss.abortReattach(applied.why);
2955
+ return this.authLoss.take() + settled;
2956
+ }
2957
+ const returnUrl = plan.returnTo;
2958
+ let recovered;
2959
+ try {
2960
+ await this.withinLimit("nav", () => page.goto(returnUrl, { waitUntil: "domcontentloaded", timeout: this.limits.navMs }));
2961
+ recovered = await this.afterAction("navigate", returnUrl);
2962
+ }
2963
+ catch (err) {
2964
+ this.recordNavigationOutcome(returnUrl, await this.landedUrl(page));
2965
+ const msg = err instanceof Error ? err.message : String(err);
2966
+ throw new Error(`${this.authLoss.take()}${msg}`);
2967
+ }
2968
+ this.recordNavigationOutcome(returnUrl, await this.landedUrl(page));
2969
+ return this.authLoss.take() + recovered;
2970
+ }
2971
+ /**
2972
+ * Replace this session's cookies and storage with its role's saved profile,
2973
+ * read from disk now: another process may have recorded it again since this
2974
+ * session attached. Same context, same page, same listeners and write policy;
2975
+ * only the sign-in changes. Never prints the profile's contents.
2976
+ */
2977
+ async applyLatestProfile() {
2978
+ if (this.auth.kind !== "role" || !this.context)
2979
+ return { ok: false, why: "this session was not attached by role" };
2980
+ const read = this.readRoleProfile();
2981
+ if (!read.ok)
2982
+ return read;
2983
+ const applied = await this.applyState(read.state);
2984
+ if (!applied.ok)
2985
+ return applied;
2986
+ // setStorageState has no sessionStorage: the latest profile's is seeded once more in each tab, as the page loads.
2987
+ const reseed = sessionStorageInitScript(splitProfile(read.state).sessionStorage, { generation: `reattach-${Date.now()}` });
2988
+ if (reseed) {
2989
+ try {
2990
+ await this.context.addInitScript(reseed);
2991
+ }
2992
+ catch (err) {
2993
+ return {
2994
+ ok: false,
2995
+ why: `the browser took its saved cookies and storage, but its session storage could not be put back (${err instanceof Error ? err.message.split("\n")[0] : String(err)})`,
2996
+ };
2997
+ }
2998
+ }
2999
+ this.logAction({ action: "reattach", target: `role ${this.auth.role}`, url: this.page?.url() ?? "" });
3000
+ return { ok: true };
3001
+ }
3002
+ /** The role's saved profile as it is on disk now, checked to be a storage state. Never printed. */
3003
+ readRoleProfile() {
3004
+ if (this.auth.kind !== "role")
3005
+ return { ok: false, why: "this session was not attached by role" };
3006
+ const file = this.auth.storageStatePath;
3007
+ let state;
3008
+ try {
3009
+ state = JSON.parse(fs.readFileSync(file, "utf8"));
3010
+ }
3011
+ catch (err) {
3012
+ const reason = err instanceof SyntaxError ? "it is not valid JSON" : err instanceof Error ? err.message.split("\n")[0] : String(err);
3013
+ return { ok: false, why: `its saved profile at ${file} could not be read (${reason})` };
3014
+ }
3015
+ const checked = summarizeState(state);
3016
+ if (!checked.ok)
3017
+ return { ok: false, why: `its saved profile at ${file} is not a storage state (${checked.error})` };
3018
+ // A malformed sessionStorage list is refused here, before anything is applied.
3019
+ try {
3020
+ splitProfile(state);
3021
+ }
3022
+ catch (err) {
3023
+ return { ok: false, why: `its saved profile at ${file} could not be split (${err instanceof Error ? err.message : String(err)})` };
3024
+ }
3025
+ return { ok: true, state };
3026
+ }
3027
+ /**
3028
+ * Replace this context's cookies and storage with a profile, in place: same
3029
+ * pages, listeners and policy. Playwright is handed only what it restores;
3030
+ * the profile's sessionStorage list is not part of its storage state.
3031
+ */
3032
+ async applyState(state) {
3033
+ if (!this.context)
3034
+ return { ok: false, why: "no browser is open" };
3035
+ let storageState;
3036
+ try {
3037
+ storageState = splitProfile(state).storageState;
3038
+ }
3039
+ catch (err) {
3040
+ return { ok: false, why: `the saved profile could not be split (${err instanceof Error ? err.message : String(err)})` };
3041
+ }
3042
+ try {
3043
+ await this.context.setStorageState(storageState);
3044
+ }
3045
+ catch (err) {
3046
+ return { ok: false, why: `the browser refused its saved profile (${err instanceof Error ? err.message.split("\n")[0] : String(err)})` };
3047
+ }
3048
+ // What the session now holds is what it knows as the role's tokens.
3049
+ if (this.refresh)
3050
+ this.rememberTokens(refreshTokenSlots(state));
3051
+ return { ok: true };
3052
+ }
3053
+ /**
3054
+ * The refresh broker's route handler. A request carrying none of the role's
3055
+ * refresh tokens is handed on untouched. One carrying one waits for the
3056
+ * role's refresh lock; holding it, the session re-reads the profile, loads
3057
+ * it and sends the current token in place of a spent one when another
3058
+ * session rotated it meanwhile, and keeps the lock until the page has
3059
+ * stored the rotation and it is written back. A lock that cannot be had is
3060
+ * reported and the request dropped: sending a token another session may
3061
+ * have spent is what revokes the family. Token values are never logged.
3062
+ */
3063
+ async brokerRefresh(route) {
3064
+ const broker = this.refresh;
3065
+ const req = route.request();
3066
+ if (!broker)
3067
+ return route.fallback();
3068
+ const headers = await req.allHeaders().catch(() => req.headers());
3069
+ const wire = { url: req.url(), body: req.postData(), headers };
3070
+ // Spent tokens too: a page still holding one would otherwise send it unbrokered.
3071
+ const sent = presentedToken(wire, [...broker.known, ...broker.spent]);
3072
+ if (!sent)
3073
+ return route.fallback();
3074
+ const where = `${req.method()} ${pathnameOf(req.url())}`;
3075
+ let lock;
3076
+ try {
3077
+ lock = await acquireLock(lockPathFor(broker.file));
3078
+ }
3079
+ catch (err) {
3080
+ this.refreshCounts.failed += 1;
3081
+ this.logAction({
3082
+ action: "refresh-broker:dropped",
3083
+ target: `${where} (${err instanceof Error ? err.message : String(err)})`,
3084
+ url: this.page?.url() ?? "",
3085
+ });
3086
+ await route.abort("blockedbyclient").catch(() => { });
3087
+ return;
3088
+ }
3089
+ let handedOn = false;
3090
+ try {
3091
+ const read = this.readRoleProfile();
3092
+ if (!read.ok)
3093
+ throw new Error(read.why);
3094
+ const plan = planRefresh(sent, refreshTokenSlots(read.state));
3095
+ let presented = sent;
3096
+ let overrides = {};
3097
+ if (plan.kind === "swap") {
3098
+ // Another session rotated the token while this one waited: load what it saved, and send the current token.
3099
+ const applied = await this.applyState(read.state);
3100
+ if (!applied.ok)
3101
+ throw new Error(applied.why);
3102
+ const swapped = swapRequest(wire, sent.value, plan.to.value);
3103
+ overrides = {
3104
+ ...(swapped.url ? { url: swapped.url } : {}),
3105
+ ...(swapped.body !== undefined ? { postData: swapped.body } : {}),
3106
+ ...(swapped.headers ? { headers: swapped.headers } : {}),
3107
+ };
3108
+ presented = plan.to;
3109
+ this.refreshCounts.swapped += 1;
3110
+ }
3111
+ this.logAction({
3112
+ action: "refresh-broker",
3113
+ target: `${where} with ${presented.slot}${plan.kind === "swap" ? " (rotated by another session; loaded its profile)" : plan.kind === "unknown" ? " (no longer in the profile; sent as is)" : ""}`,
3114
+ url: this.page?.url() ?? "",
3115
+ });
3116
+ handedOn = true;
3117
+ if (plan.kind === "unknown") {
3118
+ // The profile holds another sign-in now: send this one on, and leave the profile alone.
3119
+ lock.release();
3120
+ await route.fallback(overrides);
3121
+ return;
3122
+ }
3123
+ const task = this.writeBackAfter(req, presented, lock).finally(() => this.refreshTasks.delete(task));
3124
+ this.refreshTasks.add(task);
3125
+ await route.fallback(overrides);
3126
+ }
3127
+ catch (err) {
3128
+ if (!handedOn) {
3129
+ lock.release();
3130
+ this.refreshCounts.failed += 1;
3131
+ this.logAction({
3132
+ action: "refresh-broker:dropped",
3133
+ target: `${where} (${err instanceof Error ? err.message.split("\n")[0] : String(err)})`,
3134
+ url: this.page?.url() ?? "",
3135
+ });
3136
+ await route.abort("blockedbyclient").catch(() => { });
3137
+ }
3138
+ }
3139
+ }
3140
+ /**
3141
+ * Once the refresh is answered, write the rotation back over the profile
3142
+ * and release the lock. The page's own state is saved once it holds the
3143
+ * rotated token, so the next session gets the access token with it; a page
3144
+ * that has not stored it in time has the token read from the response
3145
+ * instead. A refused or failed refresh changes nothing on disk.
3146
+ */
3147
+ async writeBackAfter(req, presented, lock) {
3148
+ const broker = this.refresh;
3149
+ try {
3150
+ let timer;
3151
+ const res = await Promise.race([
3152
+ req.response().finally(() => clearTimeout(timer)),
3153
+ new Promise((resolve) => {
3154
+ timer = setTimeout(() => resolve(null), 30000);
3155
+ }),
3156
+ ]);
3157
+ if (!broker)
3158
+ return;
3159
+ if (!res || !res.ok()) {
3160
+ const why = res ? `answered ${res.status()}` : "had no response";
3161
+ this.logAction({
3162
+ action: "refresh-broker:not-saved",
3163
+ target: `${presented.slot}: the refresh ${why}; the profile was left as it was`,
3164
+ url: this.page?.url() ?? "",
3165
+ });
3166
+ return;
3167
+ }
3168
+ let state = null;
3169
+ for (let waited = 0; waited <= 5000; waited += 50) {
3170
+ const now = await this.context?.storageState().catch(() => null);
3171
+ if (now && rotationStored(now, presented)) {
3172
+ const onDisk = this.readRoleProfile();
3173
+ state = profileAfterRotation(now, onDisk.ok ? onDisk.state : null);
3174
+ break;
3175
+ }
3176
+ await new Promise((r) => setTimeout(r, 50));
3177
+ }
3178
+ if (!state) {
3179
+ const body = await res.text().catch(() => "");
3180
+ const rotated = rotatedFromResponse(body, await res.headerValues("set-cookie").catch(() => []));
3181
+ const read = this.readRoleProfile();
3182
+ if (rotated && read.ok)
3183
+ state = swapProfileToken(read.state, presented.value, rotated);
3184
+ }
3185
+ if (!state) {
3186
+ this.refreshCounts.failed += 1;
3187
+ this.logAction({
3188
+ action: "refresh-broker:not-saved",
3189
+ target: `${presented.slot}: the page stored no rotated token and the response named none`,
3190
+ url: this.page?.url() ?? "",
3191
+ });
3192
+ return;
3193
+ }
3194
+ writeProfile(broker.projectDir, broker.role, state);
3195
+ this.rememberTokens(refreshTokenSlots(state));
3196
+ this.refreshCounts.refreshed += 1;
3197
+ }
3198
+ catch (err) {
3199
+ this.refreshCounts.failed += 1;
3200
+ this.logAction({
3201
+ action: "refresh-broker:not-saved",
3202
+ target: `${presented.slot}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
3203
+ url: this.page?.url() ?? "",
3204
+ });
3205
+ }
3206
+ finally {
3207
+ lock.release();
3208
+ }
3209
+ }
3210
+ /** Take `now` as the role's current tokens; the ones it replaces are kept as spent, so a page that still sends one is caught. */
3211
+ rememberTokens(now) {
3212
+ if (!this.refresh)
3213
+ return;
3214
+ const replaced = this.refresh.known.filter((k) => !now.some((n) => n.value === k.value));
3215
+ this.refresh.spent = [...replaced, ...this.refresh.spent].slice(0, 20);
3216
+ this.refresh.known = now;
3217
+ }
3218
+ /** What the refresh broker did this session, or "" when it did nothing. Counts only. */
3219
+ refreshSummary() {
3220
+ const { refreshed, swapped, failed } = this.refreshCounts;
3221
+ if (refreshed + swapped + failed === 0)
3222
+ return "";
3223
+ const parts = [`refreshed role '${this.role}''s token ${refreshed} time${refreshed === 1 ? "" : "s"} under the role's lock`];
3224
+ if (swapped > 0)
3225
+ parts.push(`${swapped} of them after another session had rotated it`);
3226
+ if (failed > 0)
3227
+ parts.push(`${failed} refresh${failed === 1 ? "" : "es"} could not be brokered (see the action log)`);
3228
+ return parts.join("; ");
3229
+ }
3230
+ /** What this session's one automatic re-attach did, or "" when it never re-attached. */
3231
+ reattachSummary() {
3232
+ return this.authLoss.reattachSummary();
2807
3233
  }
2808
3234
  /** Where did we ASK to go, where did we END UP, and does that count as coverage? */
2809
3235
  recordNavigationOutcome(requestedUrl, landedUrl) {
@@ -2815,12 +3241,12 @@ export class BrowserEngine {
2815
3241
  this.memory?.markAttempted(requestedRoute, outcome, this.role);
2816
3242
  this.memory?.recordRoleAccess(this.role, requestedRoute, outcome);
2817
3243
  }
2818
- this.authLoss.record({ requestedRoute, landedRoute, bounced, role: this.role });
3244
+ this.authLoss.record({ requestedRoute, landedRoute, bounced, role: this.role, target: requestedUrl });
2819
3245
  }
2820
3246
  async goBack() {
2821
3247
  this.actionStartedAt = Date.now();
2822
3248
  const page = this.requirePage();
2823
- await page.goBack({ waitUntil: "domcontentloaded", timeout: 10000 }).catch(() => { });
3249
+ await page.goBack({ waitUntil: "domcontentloaded", timeout: this.limits.backNavMs }).catch(() => { });
2824
3250
  return this.afterAction("back", "");
2825
3251
  }
2826
3252
  /** The full route contract: scanned filesystem routes ∪ link-discovered route classes. */
@@ -2918,7 +3344,12 @@ export class BrowserEngine {
2918
3344
  }
2919
3345
  const summary = [];
2920
3346
  const problems = [];
2921
- for (const path of targets) {
3347
+ this.authLoss.beginBatch();
3348
+ // A queue rather than the list itself: a session that re-attaches mid-sweep
3349
+ // visits the routes its loss bounced again, straight after.
3350
+ const queue = [...targets];
3351
+ for (let i = 0; i < queue.length; i++) {
3352
+ const path = queue[i];
2922
3353
  const url = `${this.baseUrl}${path.startsWith("/") ? "" : "/"}${path}`;
2923
3354
  if (!this.isSameOrigin(url)) {
2924
3355
  summary.push(`${path} — SKIPPED (off-origin)`);
@@ -2928,11 +3359,12 @@ export class BrowserEngine {
2928
3359
  this.oracles.drain(false); // discard pre-route leftovers WITHOUT marking their signatures as reported
2929
3360
  let status = "ERR";
2930
3361
  try {
2931
- const resp = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 });
3362
+ const resp = await page.goto(url, { waitUntil: "domcontentloaded", timeout: this.limits.crawlNavMs });
2932
3363
  status = resp?.status() ?? "no-response";
2933
3364
  }
2934
3365
  catch (err) {
2935
- const reason = err instanceof Error ? err.message.split("\n")[0] : String(err);
3366
+ const explained = explainTimeout(err, "nav", this.limits.crawlNavMs);
3367
+ const reason = explained instanceof Error ? explained.message.split("\n")[0] : String(explained);
2936
3368
  // Not retried by later crawls in this process (a check would otherwise try it
2937
3369
  // on every discovery round). Deliberately not written to memory: one outage
2938
3370
  // must not count a route as covered in every later run's gap ledger.
@@ -2944,6 +3376,9 @@ export class BrowserEngine {
2944
3376
  // a browser that shows no error page simply lets the wait time out.
2945
3377
  await page.waitForEvent("framenavigated", { predicate: (f) => f === page.mainFrame(), timeout: 1500 }).catch(() => undefined);
2946
3378
  summary.push(`${path} — LOAD FAILED`);
3379
+ // The page a re-attach went back to never loaded, so nothing says whether it worked.
3380
+ if (this.authLoss.reattaching)
3381
+ this.authLoss.abortReattach(`the page it went back to, ${path}, did not load (${reason})`);
2947
3382
  problems.push(`${path}: ${reason}`);
2948
3383
  this.crawlHealth.push({
2949
3384
  path,
@@ -2987,7 +3422,7 @@ export class BrowserEngine {
2987
3422
  // Feeds the streak. The per-route notice is discarded — crawl already
2988
3423
  // flags AUTH-REDIRECT per route in its own summary, and the verdict for
2989
3424
  // the sweep as a whole is emitted once at the end via batchVerdict().
2990
- this.authLoss.record({ requestedRoute, landedRoute: route, bounced: loginRedirect, role: this.role });
3425
+ this.authLoss.record({ requestedRoute, landedRoute: route, bounced: loginRedirect, role: this.role, target: path });
2991
3426
  this.authLoss.clear();
2992
3427
  // Error-status routes render but would otherwise be re-crawled forever —
2993
3428
  // an attempt with the status satisfies the contract.
@@ -3031,6 +3466,27 @@ export class BrowserEngine {
3031
3466
  .join("\n");
3032
3467
  problems.push(`${path}${loginRedirect ? " → redirected to login (auth missing/expired?)" : ""}${deadEnd ? " → dead end" : ""}${detail ? `\n${detail}` : ""}`);
3033
3468
  }
3469
+ // A role session that lost its sign-in on this route re-attaches once
3470
+ // and visits the routes the loss bounced again, next. Their bounced
3471
+ // results leave the crawl's data, so a check reads the signed-in visit.
3472
+ const plan = this.authLoss.takeReattach({ revisits: (t) => targets.includes(t) });
3473
+ if (plan) {
3474
+ const applied = await this.applyLatestProfile();
3475
+ if (!applied.ok) {
3476
+ this.authLoss.abortReattach(applied.why);
3477
+ }
3478
+ else {
3479
+ // Only this sweep's own paths: a streak that began with scout_navigate holds full URLs the sweep cannot join to its base.
3480
+ const again = [plan.returnTo, ...plan.retry];
3481
+ this.crawlHealth = this.crawlHealth.filter((h) => !(h.loginRedirect && again.includes(h.path)));
3482
+ for (let k = problems.length - 1; k >= 0; k--) {
3483
+ if (again.some((p) => problems[k].startsWith(`${p} → redirected to login`)))
3484
+ problems.splice(k, 1);
3485
+ }
3486
+ queue.splice(i + 1, 0, ...again);
3487
+ summary.push(`↻ re-attached from role '${this.role}''s saved profile; visiting ${again.join(", ")} again`);
3488
+ }
3489
+ }
3034
3490
  }
3035
3491
  // Crawl leaves the page wherever it ended — refs from before are gone.
3036
3492
  this.refs.clear();
@@ -3164,11 +3620,11 @@ export class BrowserEngine {
3164
3620
  preState = await this.stateHolding(probe, preState, preState !== null && preState === lastCapture);
3165
3621
  form = { kind: "click", probe };
3166
3622
  }
3167
- forcedClick = (await this.resilientClick(loc, ACTION_TIMEOUT_MS)).forced;
3623
+ forcedClick = (await this.resilientClick(loc, this.limits.actionMs)).forced;
3168
3624
  }
3169
3625
  else if (step.action === "hover") {
3170
3626
  const { before, bodyBefore, churning } = await this.hoverBaselines();
3171
- await loc.hover({ timeout: ACTION_TIMEOUT_MS });
3627
+ await loc.hover({ timeout: this.limits.actionMs });
3172
3628
  const { revealed } = await this.detectHoverReveal(before, bodyBefore, churning);
3173
3629
  transcript.push(revealed.length > 0
3174
3630
  ? ` hover revealed: ${revealed.join(" · ").slice(0, 300)}`
@@ -3192,12 +3648,12 @@ export class BrowserEngine {
3192
3648
  preState = await this.stateHolding(probe, preState, preState !== null && preState === lastCapture);
3193
3649
  form = { kind: "enter", probe };
3194
3650
  }
3195
- await loc.press("Enter", { timeout: ACTION_TIMEOUT_MS });
3651
+ await loc.press("Enter", { timeout: this.limits.actionMs });
3196
3652
  }
3197
3653
  }
3198
3654
  else if (step.action === "select") {
3199
3655
  const options = await readSelectOptions(loc);
3200
- const picked = await loc.selectOption(step.value ?? "", { timeout: ACTION_TIMEOUT_MS });
3656
+ const picked = await loc.selectOption(step.value ?? "", { timeout: this.limits.actionMs });
3201
3657
  if (options)
3202
3658
  chose = { options, picked };
3203
3659
  }
@@ -3290,7 +3746,7 @@ export class BrowserEngine {
3290
3746
  const diagnosticLine = actionabilityDiagnostic(fullMsg);
3291
3747
  let hint = "";
3292
3748
  if (/Timeout/i.test(firstLine)) {
3293
- hint = ` (timeout${diagnosticLine ? ` — ${diagnosticLine}` : ""} — the target may no longer match: element relabeled, removed, or genuinely covered by an overlay; re-snapshot to see current state)`;
3749
+ hint = ` (timeout${diagnosticLine ? ` — ${diagnosticLine}` : ""} — the target may no longer match: element relabeled, removed, or genuinely covered by an overlay; re-snapshot to see current state. Or ${limitHint("action", this.limits.actionMs)})`;
3294
3750
  }
3295
3751
  else if (/Input of type "file" cannot be filled/i.test(firstLine)) {
3296
3752
  hint = ` (this is a file input — use an {action:"upload"} step, or scout_upload)`;
@@ -3376,7 +3832,7 @@ export class BrowserEngine {
3376
3832
  violations.push({ path, violation: { kind: v.kind, severity: v.severity, detail: v.detail, url: v.url, ...(v.embed ? { embed: v.embed } : {}) } });
3377
3833
  };
3378
3834
  const poll = async (done) => {
3379
- const until = Date.now() + FLOW_STEP_TIMEOUT_MS;
3835
+ const until = Date.now() + this.limits.actionMs;
3380
3836
  for (;;) {
3381
3837
  if (done())
3382
3838
  return true;
@@ -3402,7 +3858,9 @@ export class BrowserEngine {
3402
3858
  const current = this.requirePage();
3403
3859
  if (step.action === "navigate") {
3404
3860
  const url = `${this.baseUrl}${step.target}`;
3405
- const resp = await current.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 });
3861
+ const resp = await current
3862
+ .goto(url, { waitUntil: "domcontentloaded", timeout: this.limits.crawlNavMs })
3863
+ .catch((err) => Promise.reject(explainTimeout(err, "nav", this.limits.crawlNavMs)));
3406
3864
  await this.settle();
3407
3865
  const status = resp?.status() ?? null;
3408
3866
  if (status !== null && status >= 400) {
@@ -3422,11 +3880,11 @@ export class BrowserEngine {
3422
3880
  else if (step.action === "click" || step.action === "type" || step.action === "select") {
3423
3881
  const loc = BrowserEngine.locatorFor(current, parseTarget(step.target)).first();
3424
3882
  const found = await loc
3425
- .waitFor({ state: "visible", timeout: FLOW_STEP_TIMEOUT_MS })
3883
+ .waitFor({ state: "visible", timeout: this.limits.actionMs })
3426
3884
  .then(() => true)
3427
3885
  .catch(() => false);
3428
3886
  if (!found) {
3429
- failure = `nothing visible matches ${step.target} within ${FLOW_STEP_TIMEOUT_MS / 1000}s`;
3887
+ failure = `nothing visible matches ${step.target} within ${this.limits.actionMs / 1000}s — ${limitHint("action", this.limits.actionMs)}`;
3430
3888
  }
3431
3889
  else {
3432
3890
  const label = ((await loc.getAttribute("aria-label").catch(() => null)) ??
@@ -3436,10 +3894,10 @@ export class BrowserEngine {
3436
3894
  refusal = destructiveRefusal(label || step.target, this.mode);
3437
3895
  }
3438
3896
  else if (step.action === "click") {
3439
- await loc.click({ timeout: FLOW_STEP_TIMEOUT_MS });
3897
+ await loc.click({ timeout: this.limits.actionMs });
3440
3898
  }
3441
3899
  else if (step.action === "select") {
3442
- await loc.selectOption(step.value, { timeout: FLOW_STEP_TIMEOUT_MS });
3900
+ await loc.selectOption(step.value, { timeout: this.limits.actionMs });
3443
3901
  }
3444
3902
  else {
3445
3903
  await this.noteProbe(step.value, step.target);
@@ -3450,7 +3908,7 @@ export class BrowserEngine {
3450
3908
  if (this.readOnly && isDestructive(submitLabel))
3451
3909
  refusal = destructiveRefusal(submitLabel, this.mode);
3452
3910
  else
3453
- await loc.press("Enter", { timeout: FLOW_STEP_TIMEOUT_MS });
3911
+ await loc.press("Enter", { timeout: this.limits.actionMs });
3454
3912
  }
3455
3913
  }
3456
3914
  if (!refusal)
@@ -3460,11 +3918,11 @@ export class BrowserEngine {
3460
3918
  else if (step.action === "expect-text") {
3461
3919
  const visible = current.getByText(step.text, { exact: false }).filter({ visible: true }).first();
3462
3920
  const ok = await visible
3463
- .waitFor({ state: "visible", timeout: FLOW_STEP_TIMEOUT_MS })
3921
+ .waitFor({ state: "visible", timeout: this.limits.actionMs })
3464
3922
  .then(() => true)
3465
3923
  .catch(() => false);
3466
3924
  if (!ok)
3467
- failure = `no visible text ${JSON.stringify(step.text)} within ${FLOW_STEP_TIMEOUT_MS / 1000}s`;
3925
+ failure = `no visible text ${JSON.stringify(step.text)} within ${this.limits.actionMs / 1000}s — ${limitHint("action", this.limits.actionMs)}`;
3468
3926
  }
3469
3927
  else if (step.action === "expect-url") {
3470
3928
  const ok = await poll(() => urlMatches(step.pattern, this.page?.url() ?? ""));
@@ -3479,7 +3937,7 @@ export class BrowserEngine {
3479
3937
  }
3480
3938
  }
3481
3939
  catch (err) {
3482
- failure = firstLine(err);
3940
+ failure = firstLine(explainTimeout(err, "action", this.limits.actionMs));
3483
3941
  }
3484
3942
  await this.scanForInjections().catch(() => { });
3485
3943
  await this.scanForContradictions().catch(() => { });
@@ -3524,9 +3982,9 @@ export class BrowserEngine {
3524
3982
  this.blockedRequests = [];
3525
3983
  this.oracles.drain(false);
3526
3984
  this.setMode(crawlMode);
3527
- // Pages the session no longer drives are closed only now, at about:blank and under the held rule: in Firefox a
3528
- // page closed straight after it is left sent its leaving beacon unjudged about one time in forty.
3529
- await BrowserEngine.settleWithin(Promise.allSettled(context.pages().map((p) => (p === this.page ? undefined : p.close().catch(() => { })))), 5000);
3985
+ // Pages the session no longer drives are closed only now, at about:blank and under the held rule, and each only
3986
+ // once what it sent as it was left has been judged (leftSettled).
3987
+ await BrowserEngine.settleWithin(Promise.allSettled(context.pages().map(async (p) => (p === this.page ? undefined : (await this.leftSettled(p), await p.close().catch(() => { }))))), 5000);
3530
3988
  page.off("websocket", onSocket);
3531
3989
  context.off("response", onResponse);
3532
3990
  this.refs.clear();
@@ -3579,7 +4037,9 @@ export class BrowserEngine {
3579
4037
  */
3580
4038
  async scroll(to, by, target) {
3581
4039
  this.actionStartedAt = Date.now();
3582
- const { refused, note } = target ? await scrollContainer(this.requirePage(), target, to, by) : await performScroll(this.requirePage(), to, by);
4040
+ const { refused, note } = target
4041
+ ? await scrollContainer(this.requirePage(), target, to, by, this.limits.actionMs)
4042
+ : await performScroll(this.requirePage(), to, by);
3583
4043
  if (refused)
3584
4044
  return refused;
3585
4045
  const amount = Math.trunc(by ?? 600);
@@ -3593,6 +4053,53 @@ export class BrowserEngine {
3593
4053
  this.logAction({ action: "screenshot", url: page.url() });
3594
4054
  return { base64: buf.toString("base64"), mimeType: "image/jpeg" };
3595
4055
  }
4056
+ /**
4057
+ * A PNG of one element: its bounds plus `margin`, scrolled into view, from a
4058
+ * real screenshot. Found by `ref` from the latest snapshot, or by `key` (its
4059
+ * identity, which the same element has on another deployment of the app),
4060
+ * in which case the page is read afresh to find it. Reads only: nothing is
4061
+ * clicked, and the write policy is not involved. The rectangle's rules are
4062
+ * capture.ts.
4063
+ */
4064
+ async captureElement(target, margin) {
4065
+ const page = this.requirePage();
4066
+ let el;
4067
+ if (target.ref) {
4068
+ el = this.refs.get(target.ref);
4069
+ if (!el)
4070
+ throw new Error(`Unknown ref "${target.ref}". Refs are only valid from the latest scout_snapshot — take a new snapshot.`);
4071
+ if (page.url() !== this.snapshotUrl) {
4072
+ this.refs.clear();
4073
+ throw new Error(`Page URL changed since the last snapshot (now ${page.url()}). Take a new scout_snapshot.`);
4074
+ }
4075
+ }
4076
+ else if (target.key) {
4077
+ await this.settle();
4078
+ // Keys carry an ordinal (collect), so the third of three same-named buttons is found as the third.
4079
+ const { elements } = await this.collect();
4080
+ el = elements.find((e) => e.key === target.key);
4081
+ if (!el)
4082
+ throw new Error(`No element with the key "${target.key}" on ${page.url()}.`);
4083
+ }
4084
+ else {
4085
+ throw new Error("Give the ref of the element to capture, from the latest scout_snapshot.");
4086
+ }
4087
+ const locator = this.scopeOf(el).locator(`xpath=${el.xpath}`).first();
4088
+ await locator.scrollIntoViewIfNeeded({ timeout: 5000 });
4089
+ const box = await locator.boundingBox({ timeout: 5000 });
4090
+ if (!box)
4091
+ throw new Error(`${el.name || el.key} is not displayed, so it has no picture.`);
4092
+ const viewport = page.viewportSize() ?? (await page.evaluate("({ width: innerWidth, height: innerHeight })"));
4093
+ const clip = captureClip(box, margin, viewport);
4094
+ if (!clip)
4095
+ throw new Error(`${el.name || el.key} is outside the viewport, so it has no picture.`);
4096
+ const png = await page.screenshot({ type: "png", clip, animations: "disabled" });
4097
+ this.logAction({ action: "capture", target: el.key.slice(0, 200), url: page.url() });
4098
+ // Found by key, the refs were rebuilt without a snapshot: none of them may be acted on.
4099
+ if (target.key)
4100
+ this.refs.clear();
4101
+ return { png, key: el.key, label: el.name, url: page.url() };
4102
+ }
3596
4103
  /**
3597
4104
  * What the live view shows next to a session's name. The task is what the
3598
4105
  * agent said the session is for; the objective is the goal of the journey it
@@ -3794,16 +4301,55 @@ export class BrowserEngine {
3794
4301
  return;
3795
4302
  await page.goto("about:blank", { timeout: 3000 }).catch(() => { });
3796
4303
  }
4304
+ /** Run the route handler's judgement of one request, recorded against the page that sent it until it is answered. */
4305
+ async judgeTracked(route, judge) {
4306
+ let page = null;
4307
+ try {
4308
+ page = route.request().frame().page();
4309
+ }
4310
+ catch {
4311
+ /* no frame: a service worker's request, which no page's close waits on */
4312
+ }
4313
+ const judging = judge(route);
4314
+ if (!page) {
4315
+ await judging;
4316
+ return;
4317
+ }
4318
+ let pending = this.routesInFlight.get(page);
4319
+ if (!pending)
4320
+ this.routesInFlight.set(page, (pending = new Set()));
4321
+ pending.add(judging);
4322
+ try {
4323
+ await judging;
4324
+ }
4325
+ finally {
4326
+ pending.delete(judging);
4327
+ if (pending.size === 0)
4328
+ this.routesInFlight.delete(page);
4329
+ }
4330
+ }
3797
4331
  /**
3798
- * Close a page the engine will not drive, after leaving it (`leave`), so what it sends on its way out meets the policy.
3799
- * Known limit: in Firefox, a close straight after the leave has let the page's leaving beacon out unjudged (seen 2 times
3800
- * in 80 under load). replayFlow's hand-back closes pages only once every page is at about:blank; this and `close` do not yet.
4332
+ * Wait, after a page has been left, until the route handler has answered every request the page sent on its way out,
4333
+ * where a close could otherwise let one out unjudged (browsers.ts closeWaitsForLeavingWrites). A round trip to the
4334
+ * page first, so a request the browser reported before answering it has reached the route handler (0 of 80 escaped in
4335
+ * the unload smoke loop with it). A page that cannot answer (closed, or crashed) has nothing more to send: the catch.
3801
4336
  */
3802
- static async leaveAndClose(page) {
4337
+ async leftSettled(page) {
4338
+ if (page.isClosed() || !closeWaitsForLeavingWrites(this.engineName))
4339
+ return;
4340
+ await BrowserEngine.settleWithin(page.evaluate(() => 0).catch(() => { }), 2000);
4341
+ await BrowserEngine.settleWithin(Promise.allSettled([...(this.routesInFlight.get(page) ?? [])]), 2000);
4342
+ }
4343
+ /** Close a page the engine will not drive, after leaving it (`leave`) and the policy has judged what it sent on its way out. */
4344
+ async leaveAndClose(page) {
3803
4345
  await BrowserEngine.leave(page);
4346
+ await this.leftSettled(page);
3804
4347
  await page.close().catch(() => { });
3805
4348
  }
3806
4349
  async close() {
4350
+ // A refresh write-back holds the role's lock: let it finish or give up, so no other session waits on a closed one.
4351
+ if (this.refreshTasks.size > 0)
4352
+ await BrowserEngine.settleWithin(Promise.allSettled([...this.refreshTasks]), 10000);
3807
4353
  // Marks the end of the time this session held a browser, so the pace
3808
4354
  // section can say how long it was held with nothing happening. Only when a
3809
4355
  // browser is actually open: attach() closes first, and a close of nothing
@@ -3813,7 +4359,7 @@ export class BrowserEngine {
3813
4359
  // Every page is left before it is closed, while the write policy still holds, so what a page sends on its way out
3814
4360
  // is judged and its refusal logged before the flush below.
3815
4361
  const pages = this.context?.pages() ?? [];
3816
- await BrowserEngine.settleWithin(Promise.allSettled(pages.map((p) => BrowserEngine.leave(p))), 5000);
4362
+ await BrowserEngine.settleWithin(Promise.allSettled(pages.map(async (p) => (await BrowserEngine.leave(p), await this.leftSettled(p)))), 5000);
3817
4363
  // Pending debounced coverage writes must land before the process can exit.
3818
4364
  try {
3819
4365
  this.memory?.flush();
@@ -3826,14 +4372,9 @@ export class BrowserEngine {
3826
4372
  this.memory.lastSaveError = err instanceof Error ? err.message : String(err);
3827
4373
  }
3828
4374
  // Bounded teardown: a wedged renderer must not hang scout_close forever.
3829
- // If teardown overruns the cap, the leftover process is reaped by the
3830
- // orphan cleaner on the next attach (or server start).
3831
- await BrowserEngine.settleWithin((async () => {
3832
- for (const p of this.context?.pages() ?? [])
3833
- await p.close().catch(() => { });
3834
- await this.context?.close().catch(() => { });
3835
- await this.browser?.close().catch(() => { });
3836
- })(), 8000);
4375
+ // Past the cap the browser is still closed, on the objects taken here
4376
+ // (teardown.ts): these fields are cleared below.
4377
+ await boundedTeardown(this.context, this.browser, 8000);
3837
4378
  this.page = null;
3838
4379
  this.context = null;
3839
4380
  this.browser = null;