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.
- package/CHANGELOG.md +29 -0
- package/README.md +34 -6
- package/dist/browsers.js +13 -0
- package/dist/check-run.js +2 -0
- package/dist/ci-run.js +134 -16
- package/dist/cli.js +72 -1
- package/dist/engine/authloss.js +149 -8
- package/dist/engine/brief.js +7 -1
- package/dist/engine/browser.js +615 -74
- package/dist/engine/capture.js +121 -0
- package/dist/engine/check.js +18 -0
- package/dist/engine/ci.js +117 -6
- package/dist/engine/dedup.js +50 -22
- package/dist/engine/expiry.js +254 -0
- package/dist/engine/fingerprint.js +2 -2
- package/dist/engine/flow.js +0 -2
- package/dist/engine/limits.js +144 -0
- package/dist/engine/live-page.js +57 -4
- package/dist/engine/oracles.js +8 -0
- package/dist/engine/png.js +238 -0
- package/dist/engine/postmessage.js +282 -0
- package/dist/engine/probes.js +4 -4
- package/dist/engine/profiles.js +392 -0
- package/dist/engine/refresh.js +383 -0
- package/dist/engine/scripted-login.js +400 -0
- package/dist/engine/teardown.js +41 -0
- package/dist/login-run.js +444 -0
- package/dist/mcp-server.js +136 -20
- package/package.json +7 -2
- package/skills/scenescout/SKILL.md +9 -6
package/dist/engine/browser.js
CHANGED
|
@@ -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,
|
|
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 {
|
|
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
|
|
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
|
-
|
|
672
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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
|
-
|
|
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(
|
|
1174
|
+
this.watchesForBounce = Boolean(storageStatePath);
|
|
1066
1175
|
const landed = await this.stableUrl();
|
|
1067
|
-
const authFailed = Boolean(
|
|
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 ${
|
|
1074
|
-
`attaching landed on ${landed}, a login page.
|
|
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
|
-
`${
|
|
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:
|
|
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,
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
2555
|
+
await chooser.setFiles(file.payload, { timeout: this.limits.actionMs });
|
|
2425
2556
|
else
|
|
2426
|
-
await input.setInputFiles(file.payload, { timeout:
|
|
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:
|
|
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:
|
|
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:
|
|
2477
|
-
await this.resilientClick(locator,
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
|
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,
|
|
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:
|
|
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:
|
|
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:
|
|
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() +
|
|
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
|
|
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:
|
|
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 ${
|
|
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:
|
|
3897
|
+
await loc.click({ timeout: this.limits.actionMs });
|
|
3440
3898
|
}
|
|
3441
3899
|
else if (step.action === "select") {
|
|
3442
|
-
await loc.selectOption(step.value, { timeout:
|
|
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:
|
|
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:
|
|
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 ${
|
|
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
|
|
3528
|
-
//
|
|
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
|
|
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
|
-
*
|
|
3799
|
-
*
|
|
3800
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
3830
|
-
//
|
|
3831
|
-
await
|
|
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;
|