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