scenescout 3.15.0 → 3.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +87 -0
- package/README.md +70 -18
- package/dist/browsers.js +28 -0
- package/dist/check-run.js +191 -14
- package/dist/ci-run.js +268 -52
- package/dist/cli.js +107 -47
- package/dist/commands.js +3 -2
- package/dist/engine/baseline.js +377 -0
- package/dist/engine/brief.js +16 -7
- package/dist/engine/browser.js +1147 -286
- package/dist/engine/calibration.js +61 -30
- package/dist/engine/capture.js +164 -0
- package/dist/engine/check.js +244 -42
- package/dist/engine/ci-lanes.js +215 -0
- package/dist/engine/ci.js +136 -18
- package/dist/engine/claims.js +159 -3
- package/dist/engine/collector.js +561 -30
- package/dist/engine/crawl.js +49 -0
- package/dist/engine/design.js +281 -38
- 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 +181 -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 +501 -47
- package/dist/engine/open.js +118 -0
- package/dist/engine/oracles.js +41 -1
- 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/refresh.js +68 -3
- package/dist/engine/replay.js +63 -10
- package/dist/engine/report.js +241 -40
- package/dist/engine/request.js +317 -23
- package/dist/engine/sarif.js +120 -0
- package/dist/engine/settle.js +67 -0
- package/dist/engine/signed-in.js +256 -0
- package/dist/engine/status-pane-page.js +441 -0
- package/dist/engine/status-pane.js +128 -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 +5 -0
- package/dist/installer.js +378 -8
- package/dist/intake.js +104 -0
- package/dist/login-run.js +250 -36
- package/dist/mcp-server.js +660 -65
- package/dist/playbook.js +5 -0
- package/dist/prompts.js +106 -0
- package/package.json +8 -5
- package/skills/scenescout/SKILL.md +49 -16
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, INFRASTRUCTURE_WRITE_RE, OPEN_DIALOGS_SCRIPT } from "./claims.js";
|
|
7
|
+
import { ACTED_CONTROL_SRC, CLAIM_SCAN_SCRIPT, findContradictions, INFRASTRUCTURE_WRITE_RE, isBackgroundRequest, OPEN_DIALOGS_SCRIPT, quietAnswer, saidSince, } from "./claims.js";
|
|
8
8
|
import { POSTMESSAGE_BINDING, describeTokenPost, postMessageCaptureScript, tokenHits, tokenPostKey } from "./postmessage.js";
|
|
9
9
|
import { describeInjection, newInjections, probeQueries, probeScript, probeShape, rememberProbe } from "./injection.js";
|
|
10
10
|
import { AuthLossTracker } from "./authloss.js";
|
|
11
|
-
import { captureClip } from "./capture.js";
|
|
12
|
-
import {
|
|
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, brokerDecision, brokerEnabled, cookieMayCount, endpointKey, endpointsPathFor, headersForResend, isStaticAsset, learnableEndpoint, lockPathFor, planRefresh, profileAfterRotation, readLearnedEndpoints, REFRESH_BROKER_ENV, refreshTokenSlots, rotatedCookies, rotatedFromResponse, rotationStored, swapProfileToken, swapRequest, swapToken, withLearnedEndpoint, writeLearnedEndpoints, } from "./refresh.js";
|
|
36
|
+
import { reloginCommand, permissionNote, resolveAttachAuth, roleLabel, sessionStorageInitScript, splitProfile, summarizeState, writeProfile, } from "./profiles.js";
|
|
37
|
+
import { acquireLock, brokerDecision, brokerEnabled, cookieMayCount, endpointKey, endpointsPathFor, headersForResend, isStaticAsset, learnableEndpoint, lockPathFor, planRefresh, profileAfterRotation, profileLoadWhileHeld, readLearnedEndpoints, REFRESH_BROKER_ENV, refreshNotice, refreshTokenSlots, rotatedCookies, rotatedFromResponse, rotationStored, swapProfileToken, swapRequest, swapToken, withLearnedEndpoint, writeBackStorageFrom, writeLearnedEndpoints, } from "./refresh.js";
|
|
38
|
+
/** How many routes' last snapshots are kept for diffs. */
|
|
39
|
+
const SNAPSHOTS_KEPT = 20;
|
|
36
40
|
const SETTLE_MS = 400;
|
|
37
41
|
/**
|
|
38
42
|
* Request URL → pathname, falling back to the raw string for anything
|
|
@@ -84,8 +88,6 @@ function requestSource(req) {
|
|
|
84
88
|
}
|
|
85
89
|
return { frameChain, frameUrl };
|
|
86
90
|
}
|
|
87
|
-
/** The `why` of a top-window navigation refused as a possible frame escape; the notice words it on its own. */
|
|
88
|
-
const ESCAPE_REFUSAL = "a possible frame escape";
|
|
89
91
|
/** The most frames a snapshot reads controls from. */
|
|
90
92
|
const MAX_READ_FRAMES = 10;
|
|
91
93
|
/** How long a snapshot waits for its frames' elements to answer. */
|
|
@@ -99,6 +101,15 @@ function submitControlIn(elements, probe) {
|
|
|
99
101
|
const el = elements.find((e) => !e.frame && e.xpath === probe.submit);
|
|
100
102
|
return el && sameControl(el, probe) ? el : null;
|
|
101
103
|
}
|
|
104
|
+
/** Page-side, built from constants only: an element's LiveFacts. */
|
|
105
|
+
const LIVE_FACTS_BODY = `return { testid: node.getAttribute('data-testid'), ` +
|
|
106
|
+
`label: (node.getAttribute('aria-label') || node.innerText || node.textContent || node.getAttribute('placeholder') || '').trim().slice(0, 120), ` +
|
|
107
|
+
`name: (${NAME_SRC})(node).name, ...(${POLICY_TEXT_SRC})(node) };`;
|
|
108
|
+
const liveFactsOf = new Function("node", LIVE_FACTS_BODY);
|
|
109
|
+
/** Page-side: the one element carrying a test id, with its path and LiveFacts; null when none or several do. */
|
|
110
|
+
const uniqueByTestid = new Function("testid", `const all = document.querySelectorAll('[data-testid="' + CSS.escape(testid) + '"]'); ` +
|
|
111
|
+
`if (all.length !== 1) return null; const node = all[0]; ` +
|
|
112
|
+
`return { xpath: (${XPATH_OF_SRC})(node), live: (function (node) { ${LIVE_FACTS_BODY} })(node) };`);
|
|
102
113
|
/** Page-side: the form an element belongs to, and what its fields hold now (forms.ts). */
|
|
103
114
|
const probeFormOf = new Function("node", FORM_PROBE_BODY);
|
|
104
115
|
/**
|
|
@@ -113,10 +124,12 @@ function xpathLookup(xpath) {
|
|
|
113
124
|
}
|
|
114
125
|
/**
|
|
115
126
|
* Runs in the page against one dropdown, BEFORE a choice: its options' values
|
|
116
|
-
* and labels. Read first because a dropdown may reset
|
|
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. */
|
|
@@ -245,6 +336,8 @@ export class BrowserEngine {
|
|
|
245
336
|
refreshTasks = new Set();
|
|
246
337
|
/** What the broker did this session, for the lane report: counts only. */
|
|
247
338
|
refreshCounts = { refreshed: 0, swapped: 0, failed: 0, learned: 0 };
|
|
339
|
+
/** What the broker did since the last action result, reported (and cleared) by drainRefresh. */
|
|
340
|
+
refreshEvents = [];
|
|
248
341
|
/** UI-label blocking applies only in read-only mode (safe-write enforces at the network layer instead). */
|
|
249
342
|
get readOnly() {
|
|
250
343
|
// observe is read-only and then some: every UI-level refusal applies to it too.
|
|
@@ -257,6 +350,14 @@ export class BrowserEngine {
|
|
|
257
350
|
}
|
|
258
351
|
/** Requests blocked by the write policy since the last action (timestamped for attribution). */
|
|
259
352
|
blockedRequests = [];
|
|
353
|
+
/** What this session has been told about blocked writes, so each endpoint is explained once. */
|
|
354
|
+
blockNotices = new BlockNotices();
|
|
355
|
+
/** Native dialogs the page opened since the last action's result, and how each was answered. */
|
|
356
|
+
dialogsSeen = [];
|
|
357
|
+
/** The caller's answer to a leave confirmation for the action in progress (dialogResponse); undefined lets the mode decide. */
|
|
358
|
+
leaveChoice = undefined;
|
|
359
|
+
/** Resolves the navigation in progress when the page's leave confirmation is answered "stay". */
|
|
360
|
+
onLeaveRefused = null;
|
|
260
361
|
/**
|
|
261
362
|
* WebSockets this session's pages opened. The write policy works on HTTP
|
|
262
363
|
* requests; frames sent over a socket are not inspected. In observe mode that
|
|
@@ -318,6 +419,33 @@ export class BrowserEngine {
|
|
|
318
419
|
}
|
|
319
420
|
/** Design audits run by this session. The report gate counts the whole run's, on the shared store (MemoryStore.auditsThisRun). */
|
|
320
421
|
designAuditCount = 0;
|
|
422
|
+
/** The data requests the driven page made since its document loaded (scout_network). */
|
|
423
|
+
pageRequests = new PageRequests();
|
|
424
|
+
/** Add a fetch/XHR to the page's request list when the driven page (any of its frames) sent it. */
|
|
425
|
+
notePageRequest(req) {
|
|
426
|
+
let fromPage = false;
|
|
427
|
+
try {
|
|
428
|
+
fromPage = !!this.page && req.frame().page() === this.page;
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
/* a service worker's request has no frame: not the page's */
|
|
432
|
+
}
|
|
433
|
+
if (!fromPage)
|
|
434
|
+
return;
|
|
435
|
+
let route = "";
|
|
436
|
+
try {
|
|
437
|
+
route = new URL(this.page.url()).pathname;
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
/* about:blank and the like have no route */
|
|
441
|
+
}
|
|
442
|
+
this.pageRequests.started(req, { method: req.method(), url: req.url(), route, at: Date.now() });
|
|
443
|
+
}
|
|
444
|
+
/** The data requests the page made since it loaded, as scout_network lists them. */
|
|
445
|
+
listPageRequests(opts = {}) {
|
|
446
|
+
this.requirePage();
|
|
447
|
+
return formatPageRequests(this.pageRequests.snapshot, { now: Date.now(), ...opts });
|
|
448
|
+
}
|
|
321
449
|
/** Active task-efficiency measurement (scout_journey), if any. */
|
|
322
450
|
journey = null;
|
|
323
451
|
/** The session's objective: the whole remit the agent was given at scout_attach. Empty when none was given. */
|
|
@@ -334,17 +462,40 @@ export class BrowserEngine {
|
|
|
334
462
|
/** How many frames could not be written. The first one says so in the log; the rest are counted. */
|
|
335
463
|
framesFailed = 0;
|
|
336
464
|
/**
|
|
337
|
-
* The Authorization header the app itself last sent,
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
* whatever gets replayed.
|
|
465
|
+
* The Authorization header the app itself last sent to its own origin, on
|
|
466
|
+
* any request (authToRemember says which), replayed by scout_request so a
|
|
467
|
+
* call with the UI bypassed carries the same credential as a click. Nothing
|
|
468
|
+
* here parses it: whatever scheme the app uses is whatever gets replayed.
|
|
341
469
|
*/
|
|
342
470
|
lastAuthHeader = null;
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
471
|
+
/** The status of the page's latest authorised script request to its own origin, for staleCredentialNote. */
|
|
472
|
+
lastAuthAnswer = null;
|
|
473
|
+
rememberAuthHeader(req) {
|
|
474
|
+
const value = this.authOf(req);
|
|
475
|
+
if (value)
|
|
346
476
|
this.lastAuthHeader = value;
|
|
347
477
|
}
|
|
478
|
+
/** The Authorization header a request carries that counts as the app's own (authToRemember), or null. */
|
|
479
|
+
authOf(req) {
|
|
480
|
+
let frameUrl;
|
|
481
|
+
try {
|
|
482
|
+
frameUrl = req.frame().url();
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
/* a worker's request has no frame */
|
|
486
|
+
}
|
|
487
|
+
return authToRemember({ url: req.url(), baseUrl: this.baseUrl, headers: req.headers(), replay: this.replayRequests.has(req), frameUrl });
|
|
488
|
+
}
|
|
489
|
+
/** Note the status of the page's own authorised script request to its origin; a replay's is not the page's. */
|
|
490
|
+
noteAuthAnswer(res) {
|
|
491
|
+
const req = res.request();
|
|
492
|
+
const type = req.resourceType();
|
|
493
|
+
if (type !== "fetch" && type !== "xhr")
|
|
494
|
+
return;
|
|
495
|
+
if (this.authOf(req) === null)
|
|
496
|
+
return;
|
|
497
|
+
this.lastAuthAnswer = { status: res.status() };
|
|
498
|
+
}
|
|
348
499
|
/**
|
|
349
500
|
* Call the app's own API as this session, with the UI bypassed.
|
|
350
501
|
*
|
|
@@ -362,11 +513,15 @@ export class BrowserEngine {
|
|
|
362
513
|
const target = resolveRequestUrl(this.baseUrl, input.path);
|
|
363
514
|
if ("problem" in target)
|
|
364
515
|
return `REFUSED: ${target.problem}`;
|
|
516
|
+
const headers = requestHeaders({ given: input.headers, auth: this.lastAuthHeader, body: input.body });
|
|
517
|
+
// Whether the credential sent is the one replayed from the page, not one the caller chose.
|
|
518
|
+
const replayedAuth = this.lastAuthHeader !== null && headers["authorization"] === this.lastAuthHeader;
|
|
365
519
|
const script = buildRequestScript({
|
|
366
520
|
url: target.url,
|
|
367
521
|
method: method.method,
|
|
368
522
|
body: input.body,
|
|
369
|
-
headers
|
|
523
|
+
headers,
|
|
524
|
+
...(wantsView(input.view) ? { keep: BODY_FETCH_MAX } : {}),
|
|
370
525
|
});
|
|
371
526
|
let raw;
|
|
372
527
|
// Claimed by the first matching request the page sends (the request
|
|
@@ -375,6 +530,7 @@ export class BrowserEngine {
|
|
|
375
530
|
this.oracles.replayStarted(target.url);
|
|
376
531
|
try {
|
|
377
532
|
raw = (await page.evaluate(script));
|
|
533
|
+
this.pageRequests.markReplay(method.method, target.url);
|
|
378
534
|
}
|
|
379
535
|
catch (err) {
|
|
380
536
|
// The page could not run the fetch at all (a navigation mid-call, a
|
|
@@ -389,7 +545,7 @@ export class BrowserEngine {
|
|
|
389
545
|
for (const hop of this.replayHops.splice(0))
|
|
390
546
|
this.oracles.replayEnded(hop);
|
|
391
547
|
}
|
|
392
|
-
const result = toReplayResult(raw);
|
|
548
|
+
const result = toReplayResult(raw, input.view);
|
|
393
549
|
this.logAction({
|
|
394
550
|
action: "request",
|
|
395
551
|
target: `${method.method} ${input.path}`,
|
|
@@ -397,7 +553,7 @@ export class BrowserEngine {
|
|
|
397
553
|
// Not a status signature: the trail must not record the stand-in as the server's answer.
|
|
398
554
|
result: result.refusedByPolicy ? `blocked: write policy (${result.refusedByPolicy})` : replaySignature(method.method, result.url, result.status),
|
|
399
555
|
});
|
|
400
|
-
return formatReplay(method.method, result);
|
|
556
|
+
return formatReplay(method.method, result) + (result.refusedByPolicy ? "" : staleCredentialNote(result.status, replayedAuth, this.lastAuthAnswer));
|
|
401
557
|
}
|
|
402
558
|
/** The scout_request call in flight, until the page's request for it is seen. */
|
|
403
559
|
pendingReplay = null;
|
|
@@ -540,7 +696,7 @@ export class BrowserEngine {
|
|
|
540
696
|
// role in a multi-role run, so a concurrent session navigating during this
|
|
541
697
|
// journey would otherwise contaminate its path, screen count, and backtracks.
|
|
542
698
|
const log = (this.memory?.actionLog ?? []).slice(j.fromLog).filter((e) => (e.session ?? this.sessionKey) === this.sessionKey);
|
|
543
|
-
const
|
|
699
|
+
const time = journeyTime(j.startedAt, log.map((e) => Date.parse(e.at)), Date.now());
|
|
544
700
|
const measured = measureJourney(log, completed);
|
|
545
701
|
try {
|
|
546
702
|
// Only a COMPLETED journey is a measurement of task ease. An abandoned
|
|
@@ -552,7 +708,7 @@ export class BrowserEngine {
|
|
|
552
708
|
/* fact recording is best-effort */
|
|
553
709
|
}
|
|
554
710
|
this.logAction({ action: JOURNEY_END, target: j.goal, url: page.url(), result: completed ? "completed" : "abandoned" });
|
|
555
|
-
return formatJourney({ goal: j.goal, completed,
|
|
711
|
+
return formatJourney({ goal: j.goal, completed, time, note }, measured);
|
|
556
712
|
}
|
|
557
713
|
/** Whether the browser window is visible — headed hover results carry a physical-cursor caveat. */
|
|
558
714
|
headed = false;
|
|
@@ -653,14 +809,27 @@ export class BrowserEngine {
|
|
|
653
809
|
if (this.watchedResponses.length >= BrowserEngine.MAX_WATCHED_RESPONSES)
|
|
654
810
|
return;
|
|
655
811
|
const started = this.requestStartedAt.get(req) ?? 0;
|
|
812
|
+
let isDocumentLoad = false;
|
|
813
|
+
try {
|
|
814
|
+
isDocumentLoad = req.isNavigationRequest() && this.page !== null && req.frame() === this.page.mainFrame();
|
|
815
|
+
}
|
|
816
|
+
catch {
|
|
817
|
+
/* a worker's request: no frame, so not the page's navigation */
|
|
818
|
+
}
|
|
656
819
|
this.watchedResponses.push({
|
|
657
820
|
method: req.method(),
|
|
658
821
|
url: req.url(),
|
|
659
822
|
status,
|
|
660
823
|
resourceType: req.resourceType(),
|
|
661
824
|
blockedByPolicy: this.refusedByAnyPolicy(req),
|
|
662
|
-
// The current action is a user input only when it is the one that set the input mark.
|
|
663
|
-
background:
|
|
825
|
+
// The current action is a user input only when it is the one that set the input mark (claims.ts isBackgroundRequest).
|
|
826
|
+
background: isBackgroundRequest({
|
|
827
|
+
started,
|
|
828
|
+
inputSince: this.inputSince,
|
|
829
|
+
actionStartedAt: this.actionStartedAt,
|
|
830
|
+
documentLoadSince: this.documentLoadSince,
|
|
831
|
+
isDocumentLoad,
|
|
832
|
+
}),
|
|
664
833
|
});
|
|
665
834
|
}
|
|
666
835
|
/**
|
|
@@ -672,6 +841,12 @@ export class BrowserEngine {
|
|
|
672
841
|
inputSince = null;
|
|
673
842
|
/** What the page said as the current input began, for the contradiction rules; null when it could not be read. */
|
|
674
843
|
claimBaseline = null;
|
|
844
|
+
/** When the current input started loading a new main-frame document, or null while it has loaded none (claims.ts isBackgroundRequest). */
|
|
845
|
+
documentLoadSince = null;
|
|
846
|
+
/** The control the current click acted on, as the click found it, for the kept-change rule (claims.ts keptChange). */
|
|
847
|
+
actedControl = null;
|
|
848
|
+
/** When the write policy last refused a request, for the snapshot's "after a write-policy block" tag. */
|
|
849
|
+
lastBlockAt = 0;
|
|
675
850
|
/**
|
|
676
851
|
* Mark the start of a user input: read what the page says now, so a claim
|
|
677
852
|
* already on screen (a status badge, a heading) is not taken for the
|
|
@@ -681,6 +856,8 @@ export class BrowserEngine {
|
|
|
681
856
|
async beginInput() {
|
|
682
857
|
this.inputSince = this.actionStartedAt;
|
|
683
858
|
this.claimBaseline = null;
|
|
859
|
+
this.documentLoadSince = null;
|
|
860
|
+
this.actedControl = null;
|
|
684
861
|
const page = this.page;
|
|
685
862
|
if (!page || page.isClosed())
|
|
686
863
|
return;
|
|
@@ -691,6 +868,31 @@ export class BrowserEngine {
|
|
|
691
868
|
// A page mid-navigation has nothing on screen to excuse.
|
|
692
869
|
}
|
|
693
870
|
}
|
|
871
|
+
/** The page's claims as they stand (CLAIM_SCAN_SCRIPT), or null on a page that cannot be read. */
|
|
872
|
+
async readClaims(page) {
|
|
873
|
+
try {
|
|
874
|
+
return (await page.evaluate(CLAIM_SCAN_SCRIPT));
|
|
875
|
+
}
|
|
876
|
+
catch {
|
|
877
|
+
// A page mid-navigation has nothing to read.
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* Read the control a click is about to act on, so a refused write it sends
|
|
883
|
+
* can be judged by whether the control then shows the change as kept
|
|
884
|
+
* (claims.ts keptChange). Never fails the click: unread, the rule is skipped.
|
|
885
|
+
*/
|
|
886
|
+
async readActedControl(el) {
|
|
887
|
+
const scope = this.scopeOf(el);
|
|
888
|
+
try {
|
|
889
|
+
const before = (await scope.evaluate(`(${ACTED_CONTROL_SRC})(${xpathLookup(el.xpath)})`));
|
|
890
|
+
this.actedControl = before ? { scope, xpath: el.xpath, before } : null;
|
|
891
|
+
}
|
|
892
|
+
catch {
|
|
893
|
+
this.actedControl = null;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
694
896
|
/**
|
|
695
897
|
* Did the page agree with what the network just did? Runs in the same slot
|
|
696
898
|
* as the injection scan, so it sees the DOM the action settled on.
|
|
@@ -702,6 +904,8 @@ export class BrowserEngine {
|
|
|
702
904
|
async scanForContradictions() {
|
|
703
905
|
const requests = this.watchedResponses;
|
|
704
906
|
this.watchedResponses = [];
|
|
907
|
+
const acted = this.actedControl;
|
|
908
|
+
this.actedControl = null;
|
|
705
909
|
const page = this.page;
|
|
706
910
|
if (!page || page.isClosed() || requests.length === 0)
|
|
707
911
|
return;
|
|
@@ -718,7 +922,17 @@ export class BrowserEngine {
|
|
|
718
922
|
}
|
|
719
923
|
// A baseline belongs to the input that took it, and to its page.
|
|
720
924
|
const before = this.inputSince !== null && this.inputSince >= this.actionStartedAt ? this.claimBaseline : null;
|
|
721
|
-
|
|
925
|
+
// The acted-on control as the click left it, read only when a refusal makes it matter.
|
|
926
|
+
let actedNow = null;
|
|
927
|
+
if (acted && before) {
|
|
928
|
+
try {
|
|
929
|
+
actedNow = (await acted.scope.evaluate(`(${ACTED_CONTROL_SRC})(${xpathLookup(acted.xpath)})`));
|
|
930
|
+
}
|
|
931
|
+
catch {
|
|
932
|
+
// The control's document went away: nothing left to show the change.
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
for (const found of findContradictions(requests, state, before, acted && actedNow ? { before: acted.before, after: actedNow } : null)) {
|
|
722
936
|
if (this.contradictionsReported.has(found.evidence))
|
|
723
937
|
continue;
|
|
724
938
|
this.contradictionsReported.add(found.evidence);
|
|
@@ -785,6 +999,10 @@ export class BrowserEngine {
|
|
|
785
999
|
/** Write-policy blocks drained by the last action — counted, so an action can know a request fired even when the policy stopped it. */
|
|
786
1000
|
lastActionBlocked = 0;
|
|
787
1001
|
baseUrl = "";
|
|
1002
|
+
/** The command that records `role` again so this session's next attach finds it (profiles.reloginCommand). */
|
|
1003
|
+
reloginCommand(role) {
|
|
1004
|
+
return reloginCommand({ role, url: this.baseUrl, ...this.loginProject });
|
|
1005
|
+
}
|
|
788
1006
|
get attached() {
|
|
789
1007
|
return this.page !== null;
|
|
790
1008
|
}
|
|
@@ -792,7 +1010,13 @@ export class BrowserEngine {
|
|
|
792
1010
|
// Resolved before anything is closed, so a bad role, a missing profile or
|
|
793
1011
|
// a time limit out of bounds leaves a live session as it was.
|
|
794
1012
|
const limits = resolveTimeLimits(opts, process.env);
|
|
795
|
-
const auth = resolveAttachAuth({
|
|
1013
|
+
const auth = resolveAttachAuth({
|
|
1014
|
+
projectDir: opts.projectDir,
|
|
1015
|
+
url: opts.url,
|
|
1016
|
+
role: opts.role,
|
|
1017
|
+
storageStatePath: opts.storageStatePath,
|
|
1018
|
+
projectChosen: opts.projectChosen,
|
|
1019
|
+
});
|
|
796
1020
|
const brokered = brokerEnabled({ roleSession: auth.kind === "role", option: opts.refreshBroker, env: process.env[REFRESH_BROKER_ENV] });
|
|
797
1021
|
const storageStatePath = auth.kind === "none" ? undefined : auth.storageStatePath;
|
|
798
1022
|
if (storageStatePath && !fs.existsSync(storageStatePath)) {
|
|
@@ -818,7 +1042,11 @@ export class BrowserEngine {
|
|
|
818
1042
|
let profileNote = "";
|
|
819
1043
|
this.refresh = null;
|
|
820
1044
|
this.refreshCounts = { refreshed: 0, swapped: 0, failed: 0, learned: 0 };
|
|
1045
|
+
this.refreshEvents = [];
|
|
821
1046
|
this.brokeredRequests = new WeakSet();
|
|
1047
|
+
// Another attach is another sign-in: the last one's credential is not this one's.
|
|
1048
|
+
this.lastAuthHeader = null;
|
|
1049
|
+
this.lastAuthAnswer = null;
|
|
822
1050
|
if (auth.kind === "role") {
|
|
823
1051
|
const note = permissionNote(auth.storageStatePath, fs.statSync(auth.storageStatePath).mode);
|
|
824
1052
|
if (note)
|
|
@@ -855,6 +1083,16 @@ export class BrowserEngine {
|
|
|
855
1083
|
: "") +
|
|
856
1084
|
(trust.rejected.length > 0 ? ` Not a plain http(s) origin, so not trusted: ${trust.rejected.join(", ")}.` : "") +
|
|
857
1085
|
(trust.overflow.length > 0 ? ` More than ${MAX_TRUSTED_EMBEDS} trusted embeds; not trusted: ${trust.overflow.join(", ")}.` : "");
|
|
1086
|
+
const reads = readPostEntries(readPostsSetting(opts.readPosts, process.env[READ_POSTS_ENV]));
|
|
1087
|
+
this.readPosts = reads.entries;
|
|
1088
|
+
this.readPostNotice =
|
|
1089
|
+
(reads.entries.length > 0
|
|
1090
|
+
? this.mode === "observe"
|
|
1091
|
+
? ` Read POSTs: ${reads.entries.map((e) => e.entry).join(", ")} — named as reads, so observe lets them out unless the path or body looks destructive or the body is a GraphQL mutation; each one is logged.`
|
|
1092
|
+
: ` Read POSTs (${reads.entries.map((e) => e.entry).join(", ")}) apply in observe mode only; ${this.mode} judges POSTs by its own rule.`
|
|
1093
|
+
: "") +
|
|
1094
|
+
(reads.rejected.length > 0 ? ` Not a "POST /path" or "POST https://host/path" entry, so not a read: ${reads.rejected.join(", ")}.` : "") +
|
|
1095
|
+
(reads.overflow.length > 0 ? ` More than ${MAX_READ_POSTS} read POSTs; not reads: ${reads.overflow.join(", ")}.` : "");
|
|
858
1096
|
this.sessionObjective = (opts.objective ?? "").trim().replace(/\s+/g, " ").slice(0, 300);
|
|
859
1097
|
// An agent-supplied task counts as stated; the placeholder does not.
|
|
860
1098
|
this.setTask(opts.task ?? "Attaching and taking stock", opts.task !== undefined);
|
|
@@ -866,6 +1104,8 @@ export class BrowserEngine {
|
|
|
866
1104
|
this.framesFailed = 0;
|
|
867
1105
|
this.headed = opts.headed ?? false;
|
|
868
1106
|
this.blockedRequests = [];
|
|
1107
|
+
this.blockNotices.reset();
|
|
1108
|
+
this.dialogsSeen = [];
|
|
869
1109
|
this.watchedResponses = [];
|
|
870
1110
|
this.contradictionsReported = new Set();
|
|
871
1111
|
this.tokenPostsReported = new Set();
|
|
@@ -877,6 +1117,14 @@ export class BrowserEngine {
|
|
|
877
1117
|
// role must not discard what another role already created — otherwise
|
|
878
1118
|
// every multi-role handoff would be blocked as "not yours".
|
|
879
1119
|
this.memory = opts.memoryStore ?? new MemoryStore(opts.projectDir);
|
|
1120
|
+
// A page refused before an endpoint was named as a read is no longer a gap for it.
|
|
1121
|
+
if (this.readPosts.length > 0 && this.mode === "observe") {
|
|
1122
|
+
const appOrigin = URL.canParse(this.baseUrl) ? new URL(this.baseUrl).origin : "";
|
|
1123
|
+
this.memory.clearObserveRefusedPosts((endpoint) => {
|
|
1124
|
+
const target = endpoint.replace(/^POST /, "");
|
|
1125
|
+
return matchReadPost(this.readPosts, this.baseUrl, target.startsWith("/") ? appOrigin + target : target) !== null;
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
880
1128
|
// The REAL path, not merely the resolved one: on macOS the temp tree is a
|
|
881
1129
|
// symlink, and a fence comparing a real path against an unreal one would
|
|
882
1130
|
// refuse every upload from inside the project.
|
|
@@ -891,6 +1139,7 @@ export class BrowserEngine {
|
|
|
891
1139
|
this.projectDir = path.resolve(opts.projectDir);
|
|
892
1140
|
this.projectDirNote = ` (its real path could not be resolved: ${err instanceof Error ? err.message : String(err)} — a symlinked project path may be wrongly refused)`;
|
|
893
1141
|
}
|
|
1142
|
+
this.loginProject = { projectDir: opts.projectDir, projectChosen: Boolean(opts.projectChosen) };
|
|
894
1143
|
this.oracles = new OracleMonitor();
|
|
895
1144
|
this.oracles.setPolicyRefusalCheck((req) => this.refusedByAnyPolicy(req));
|
|
896
1145
|
this.oracles.setReplayCheck((req) => this.replayRequests.has(req));
|
|
@@ -906,7 +1155,7 @@ export class BrowserEngine {
|
|
|
906
1155
|
}
|
|
907
1156
|
return embedOfRequest(this.baseUrl, req.url(), site);
|
|
908
1157
|
});
|
|
909
|
-
this.
|
|
1158
|
+
this.forgetSnapshots();
|
|
910
1159
|
this.designAuditCount = 0;
|
|
911
1160
|
// A new attach may be a restarted app: what failed to load before gets another try.
|
|
912
1161
|
this.loadFailedRoutes = new Set();
|
|
@@ -927,6 +1176,7 @@ export class BrowserEngine {
|
|
|
927
1176
|
this.context = await this.browser.newContext({
|
|
928
1177
|
storageState: profile?.storageState,
|
|
929
1178
|
viewport: opts.viewport ?? { width: 1280, height: 900 },
|
|
1179
|
+
...(opts.deviceScaleFactor !== undefined ? { deviceScaleFactor: opts.deviceScaleFactor } : {}),
|
|
930
1180
|
serviceWorkers: serviceWorkerPolicy(this.engineName),
|
|
931
1181
|
});
|
|
932
1182
|
const restoreSession = sessionStorageInitScript(profile?.sessionStorage ?? []);
|
|
@@ -950,42 +1200,75 @@ export class BrowserEngine {
|
|
|
950
1200
|
this.openSockets.clear();
|
|
951
1201
|
this.socketsWarned = false;
|
|
952
1202
|
const watchSockets = (p) => void p.on("websocket", (ws) => this.openSockets.add(ws.url().slice(0, 120)));
|
|
1203
|
+
// A request whose document goes away mid-body gets no finished or failed
|
|
1204
|
+
// event, so a frame that commits a navigation, is removed, or closes with
|
|
1205
|
+
// its page drops what it left in flight (the rule is in settle.ts).
|
|
1206
|
+
const requests = (this.requests = new InFlightRequests());
|
|
1207
|
+
const watchFrames = (p) => {
|
|
1208
|
+
p.on("framenavigated", (frame) => requests.navigated(frame));
|
|
1209
|
+
p.on("framedetached", (detached) => requests.gone((frame) => frame === detached));
|
|
1210
|
+
p.on("close", () => requests.gone((frame) => frame.page() === p));
|
|
1211
|
+
};
|
|
1212
|
+
const watchPage = (p) => {
|
|
1213
|
+
watchSockets(p);
|
|
1214
|
+
watchFrames(p);
|
|
1215
|
+
};
|
|
953
1216
|
// The first page already exists by now; later ones (popups) arrive as events.
|
|
954
1217
|
if (this.page)
|
|
955
|
-
|
|
956
|
-
this.context.on("page",
|
|
957
|
-
this.context.on("requestfinished", () =>
|
|
958
|
-
this.inFlight = Math.max(0, this.inFlight - 1);
|
|
959
|
-
});
|
|
1218
|
+
watchPage(this.page);
|
|
1219
|
+
this.context.on("page", watchPage);
|
|
1220
|
+
this.context.on("requestfinished", (req) => requests.ended(req));
|
|
960
1221
|
this.context.on("requestfailed", (req) => {
|
|
961
|
-
|
|
1222
|
+
requests.ended(req, true);
|
|
962
1223
|
this.watchResponse(req, null);
|
|
1224
|
+
this.pageRequests.failed(req, req.failure()?.errorText ?? "", Date.now(), this.refusedByAnyPolicy(req));
|
|
963
1225
|
});
|
|
964
1226
|
this.context.on("response", (res) => {
|
|
1227
|
+
this.noteAuthAnswer(res);
|
|
965
1228
|
this.watchResponse(res.request(), res.status());
|
|
1229
|
+
this.pageRequests.answered(res.request(), res.status(), Date.now(), this.refusedByAnyPolicy(res.request()));
|
|
966
1230
|
});
|
|
967
1231
|
this.context.on("request", (req) => {
|
|
968
1232
|
// Who moved the driven page, decided on its navigation's first request
|
|
969
1233
|
// (this event fires before the route handler judges that page's writes).
|
|
970
1234
|
if (req.isNavigationRequest() && !req.redirectedFrom()) {
|
|
971
1235
|
try {
|
|
972
|
-
if (this.page && req.frame() === this.page.mainFrame())
|
|
1236
|
+
if (this.page && req.frame() === this.page.mainFrame()) {
|
|
973
1237
|
this.embedMoves.navigationStarted(req.url(), req.headers()["referer"], this.embeddedSites());
|
|
1238
|
+
this.pageRequests.loaded(req.url(), Date.now());
|
|
1239
|
+
// The current input moved the page to a new document: what is sent from here on is not its own write.
|
|
1240
|
+
if (this.inputSince !== null && this.documentLoadSince === null && this.inputSince >= this.actionStartedAt)
|
|
1241
|
+
this.documentLoadSince = Date.now();
|
|
1242
|
+
}
|
|
974
1243
|
}
|
|
975
1244
|
catch {
|
|
976
1245
|
/* no frame: not the driven page */
|
|
977
1246
|
}
|
|
978
1247
|
}
|
|
979
|
-
|
|
1248
|
+
let sender;
|
|
1249
|
+
try {
|
|
1250
|
+
sender = req.frame();
|
|
1251
|
+
}
|
|
1252
|
+
catch {
|
|
1253
|
+
/* a worker's request: no frame, so it ends only by finishing or failing */
|
|
1254
|
+
}
|
|
1255
|
+
requests.started(req, sender, req.isNavigationRequest());
|
|
980
1256
|
this.lastRequestStart = Date.now();
|
|
981
1257
|
this.requestStartedAt.set(req, this.lastRequestStart);
|
|
982
1258
|
this.claimReplay(req);
|
|
1259
|
+
// After claimReplay, so a scout_request call's own header is not taken for the app's.
|
|
1260
|
+
this.rememberAuthHeader(req);
|
|
983
1261
|
const type = req.resourceType();
|
|
984
|
-
if (type === "xhr" || type === "fetch")
|
|
1262
|
+
if (type === "xhr" || type === "fetch") {
|
|
985
1263
|
this.xhrCount += 1;
|
|
1264
|
+
this.notePageRequest(req);
|
|
1265
|
+
}
|
|
986
1266
|
const method = req.method();
|
|
987
1267
|
if (method === "GET" || method === "HEAD" || method === "OPTIONS")
|
|
988
1268
|
return;
|
|
1269
|
+
// A POST the user named as a read is not state the tester mutated, nor a form exercised.
|
|
1270
|
+
if (this.readPostOf(this.writeRule.at(), method, req.url(), req.postData()))
|
|
1271
|
+
return;
|
|
989
1272
|
// Infrastructure POSTs (token refresh, telemetry) are not state the
|
|
990
1273
|
// tester mutated — reporting them trains the driver to ignore the notice.
|
|
991
1274
|
if (BENIGN_MUTATION_RE.test(req.url()))
|
|
@@ -1061,8 +1344,7 @@ export class BrowserEngine {
|
|
|
1061
1344
|
if (method === "GET" && req.resourceType() === "document" && this.embedEscapeNavigation(req)) {
|
|
1062
1345
|
const why = ESCAPE_REFUSAL;
|
|
1063
1346
|
// Reported like any refusal, so a click whose navigation this stopped does not read as a click that did nothing.
|
|
1064
|
-
|
|
1065
|
-
this.blockedRequests.push({ at: Date.now(), sig: `navigation to ${req.url().slice(0, 140)}`, answered: false, why, type: req.resourceType() });
|
|
1347
|
+
this.noteBlocked({ at: Date.now(), sig: `navigation to ${req.url().slice(0, 140)}`, answered: false, why, type: req.resourceType() });
|
|
1066
1348
|
this.logAction({ action: "write-policy:blocked", target: `navigation to ${req.url().slice(0, 140)} (${why})`, url: this.page?.url() ?? "" });
|
|
1067
1349
|
this.refusedByPolicy.add(req);
|
|
1068
1350
|
this.oracles.notePolicyBlock();
|
|
@@ -1123,8 +1405,9 @@ export class BrowserEngine {
|
|
|
1123
1405
|
const pathname = pathnameOf(url);
|
|
1124
1406
|
const refuse = (why) => {
|
|
1125
1407
|
const answered = answersWithRefusal(req.resourceType());
|
|
1126
|
-
if (
|
|
1127
|
-
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() });
|
|
1128
1411
|
this.logAction({ action: "write-policy:blocked", target: `${method} ${pathname}${why ? ` (${why})` : ""}`, url: this.page?.url() ?? "" });
|
|
1129
1412
|
this.refusedByPolicy.add(req);
|
|
1130
1413
|
this.oracles.notePolicyBlock();
|
|
@@ -1140,7 +1423,6 @@ export class BrowserEngine {
|
|
|
1140
1423
|
const foreign = this.foreignWriteOf(req);
|
|
1141
1424
|
if (foreign)
|
|
1142
1425
|
return refuse(`sent from a frame of ${foreign}`);
|
|
1143
|
-
this.rememberAuthHeader(req.headers());
|
|
1144
1426
|
const destructiveWire = isDestructiveWire(pathname, req.postData());
|
|
1145
1427
|
// An embed moved the session's page off the app: its writes out are not the app's, a sign-in excepted.
|
|
1146
1428
|
const offApp = offAppPageWrite(this.baseUrl, this.page?.url(), url, this.embedMoves.movedTo);
|
|
@@ -1152,6 +1434,18 @@ export class BrowserEngine {
|
|
|
1152
1434
|
this.routedWrites.note(rule, method, url, bodyDigest(req.postDataBuffer()));
|
|
1153
1435
|
return route.fallback();
|
|
1154
1436
|
}
|
|
1437
|
+
// A POST the user named as a read (observe only, never one that looks destructive): out, and logged.
|
|
1438
|
+
const readPost = this.readPostOf(rule, method, url, req.postData(), destructiveWire);
|
|
1439
|
+
if (readPost) {
|
|
1440
|
+
this.logAction({
|
|
1441
|
+
action: "write-policy:read-post",
|
|
1442
|
+
target: `${method} ${pathname} (named as a read: ${readPost.entry})`,
|
|
1443
|
+
url: this.page?.url() ?? "",
|
|
1444
|
+
});
|
|
1445
|
+
this.routedWrites.note(rule, method, url, bodyDigest(req.postDataBuffer()));
|
|
1446
|
+
this.clearRefusedPost(url);
|
|
1447
|
+
return route.fallback();
|
|
1448
|
+
}
|
|
1155
1449
|
let owned = this.isOwnedResource(pathname);
|
|
1156
1450
|
// A single UI action commonly fires create-then-immediately-save
|
|
1157
1451
|
// (POST gets an id, PUT saves content under it) faster than the
|
|
@@ -1186,6 +1480,8 @@ export class BrowserEngine {
|
|
|
1186
1480
|
this.pendingCreations.add(task);
|
|
1187
1481
|
}
|
|
1188
1482
|
this.routedWrites.note(rule, method, url, bodyDigest(req.postDataBuffer()));
|
|
1483
|
+
if (method === "POST")
|
|
1484
|
+
this.clearRefusedPost(url);
|
|
1189
1485
|
return route.fallback();
|
|
1190
1486
|
}
|
|
1191
1487
|
return refuse();
|
|
@@ -1225,7 +1521,7 @@ export class BrowserEngine {
|
|
|
1225
1521
|
this.page = newPage;
|
|
1226
1522
|
this.refs.clear();
|
|
1227
1523
|
this.snapshotUrl = "";
|
|
1228
|
-
this.
|
|
1524
|
+
this.forgetSnapshots();
|
|
1229
1525
|
}
|
|
1230
1526
|
else {
|
|
1231
1527
|
void this.leaveAndClose(newPage);
|
|
@@ -1265,7 +1561,7 @@ export class BrowserEngine {
|
|
|
1265
1561
|
? `\n⚠ AUTH FAILED — the storage state at ${storageStatePath} did not produce a signed-in session: ` +
|
|
1266
1562
|
`attaching landed on ${landed}, a login page. ` +
|
|
1267
1563
|
(auth.kind === "role"
|
|
1268
|
-
? `Record it again with \`${
|
|
1564
|
+
? `Record it again with \`${this.reloginCommand(auth.role)}\` (its session has most likely expired) and re-attach. `
|
|
1269
1565
|
: `Regenerate it (its token has most likely expired) and re-attach. `) +
|
|
1270
1566
|
`Continuing now tests a logged-out app.` +
|
|
1271
1567
|
(recipe.length > 0
|
|
@@ -1279,7 +1575,7 @@ export class BrowserEngine {
|
|
|
1279
1575
|
`Memory: ${this.memory.dir}.${this.memory.loadWarning ? ` WARNING: ${this.memory.loadWarning}` : ""}` +
|
|
1280
1576
|
(this.memory.prunedStates > 0 ? ` Trimmed ${this.memory.prunedStates} old page state(s) from the history; coverage is unchanged.` : "") +
|
|
1281
1577
|
`${this.memory.legacyDirNote ? ` ${this.memory.legacyDirNote}` : ""}` +
|
|
1282
|
-
`${this.memory.gitIgnoreNote ? ` ${this.memory.gitIgnoreNote}` : ""}${this.trustNotice} Call scout_snapshot to see the current state.` +
|
|
1578
|
+
`${this.memory.gitIgnoreNote ? ` ${this.memory.gitIgnoreNote}` : ""}${this.trustNotice}${this.readPostNotice} Call scout_snapshot to see the current state.` +
|
|
1283
1579
|
profileNote +
|
|
1284
1580
|
authWarning);
|
|
1285
1581
|
}
|
|
@@ -1325,21 +1621,50 @@ export class BrowserEngine {
|
|
|
1325
1621
|
this.foreignFrames.set(frame, origin);
|
|
1326
1622
|
});
|
|
1327
1623
|
}
|
|
1328
|
-
/**
|
|
1624
|
+
/**
|
|
1625
|
+
* Native dialogs, answered by policy.ts dialogResponse: alert, confirm and
|
|
1626
|
+
* prompt dismissed in read-only and observe and accepted otherwise; a leave
|
|
1627
|
+
* confirmation (beforeunload) by the caller's `leave`, else by the mode. Each
|
|
1628
|
+
* is reported in the next action's result (dialogNote). Must be wired on
|
|
1629
|
+
* every page we drive, including adopted popups.
|
|
1630
|
+
*/
|
|
1329
1631
|
wireDialogHandler(page) {
|
|
1330
1632
|
page.on("dialog", (dialog) => {
|
|
1331
1633
|
this.nativeDialogAt = Date.now();
|
|
1332
|
-
const
|
|
1634
|
+
const type = dialog.type();
|
|
1635
|
+
if (type !== "alert")
|
|
1636
|
+
this.nativeQuestionAt = this.nativeDialogAt;
|
|
1637
|
+
const leave = type === "beforeunload" ? this.leaveChoice : undefined;
|
|
1638
|
+
const response = dialogResponse(type, this.readOnly, leave);
|
|
1333
1639
|
this.logAction({
|
|
1334
|
-
action: `dialog:${
|
|
1640
|
+
action: `dialog:${type === "beforeunload" ? "leave-" : ""}${response}`,
|
|
1335
1641
|
target: dialog.message().slice(0, 120),
|
|
1336
1642
|
url: this.page?.url() ?? "",
|
|
1337
1643
|
});
|
|
1338
|
-
|
|
1644
|
+
if (this.dialogsSeen.length < 10)
|
|
1645
|
+
this.dialogsSeen.push({ type, message: dialog.message(), response, ...(leave !== undefined ? { leave } : {}) });
|
|
1646
|
+
if (type === "beforeunload" && response === "dismiss" && page === this.page)
|
|
1647
|
+
this.onLeaveRefused?.();
|
|
1648
|
+
void (response === "dismiss" ? dialog.dismiss() : dialog.accept()).catch((err) => {
|
|
1649
|
+
// A dialog already closed by the page or by the browser going away has nothing left to answer.
|
|
1650
|
+
console.error(`[scenescout] could not answer a ${type} dialog: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`);
|
|
1651
|
+
});
|
|
1339
1652
|
});
|
|
1340
1653
|
}
|
|
1654
|
+
/** The lines for the native dialogs the page opened since the last result, cleared as they are read. */
|
|
1655
|
+
drainDialogs() {
|
|
1656
|
+
const notes = this.dialogsSeen.map((d) => dialogNote(d)).join("");
|
|
1657
|
+
this.dialogsSeen = [];
|
|
1658
|
+
return notes;
|
|
1659
|
+
}
|
|
1660
|
+
/** Whether the page asked to confirm leaving, since the `since`th dialog this action saw, and was answered "stay". */
|
|
1661
|
+
leaveRefused(since) {
|
|
1662
|
+
return this.dialogsSeen.slice(since).some((d) => d.type === "beforeunload" && d.response === "dismiss");
|
|
1663
|
+
}
|
|
1341
1664
|
/** When the page last opened a native dialog (confirm, alert, prompt). */
|
|
1342
1665
|
nativeDialogAt = 0;
|
|
1666
|
+
/** When a native dialog that asks something (confirm, prompt, a leave confirmation) last opened; an alert only tells. */
|
|
1667
|
+
nativeQuestionAt = 0;
|
|
1343
1668
|
requirePage() {
|
|
1344
1669
|
if (!this.page || !this.memory) {
|
|
1345
1670
|
throw new Error("Not attached. Call scout_attach first with the app URL and project path.");
|
|
@@ -1406,8 +1731,12 @@ export class BrowserEngine {
|
|
|
1406
1731
|
}
|
|
1407
1732
|
/** Set at attach: this session was given credentials, so a bounce to a login page is a verdict worth waiting for. */
|
|
1408
1733
|
watchesForBounce = false;
|
|
1409
|
-
/** Requests started and not yet finished
|
|
1410
|
-
|
|
1734
|
+
/** Requests started and not yet finished, failed, or left behind by their frame. */
|
|
1735
|
+
requests = new InFlightRequests();
|
|
1736
|
+
/** How many are in flight. */
|
|
1737
|
+
get inFlight() {
|
|
1738
|
+
return this.requests.count;
|
|
1739
|
+
}
|
|
1411
1740
|
/** When the most recent request started, so a page that fires one late is not read too early. */
|
|
1412
1741
|
lastRequestStart = 0;
|
|
1413
1742
|
/**
|
|
@@ -1424,6 +1753,39 @@ export class BrowserEngine {
|
|
|
1424
1753
|
get pace() {
|
|
1425
1754
|
return this.paceMs;
|
|
1426
1755
|
}
|
|
1756
|
+
/** Forget every kept snapshot: the next one of any route is listed in full, with new refs. */
|
|
1757
|
+
forgetSnapshots() {
|
|
1758
|
+
this.snaps.clear();
|
|
1759
|
+
this.lastSnapRoute = "";
|
|
1760
|
+
this.refsDropped = null;
|
|
1761
|
+
}
|
|
1762
|
+
/** Drop the latest snapshot's refs, remembering why so the next diff does not call them stable. */
|
|
1763
|
+
dropRefs(reason) {
|
|
1764
|
+
this.refs.clear();
|
|
1765
|
+
this.refsDropped ??= reason;
|
|
1766
|
+
}
|
|
1767
|
+
/**
|
|
1768
|
+
* The kept snapshot a route's next snapshot is compared with: the route's
|
|
1769
|
+
* own, else the latest of another tab of the same screen (routeBase).
|
|
1770
|
+
*/
|
|
1771
|
+
prevSnapFor(route) {
|
|
1772
|
+
const own = this.snaps.get(route);
|
|
1773
|
+
if (own)
|
|
1774
|
+
return { snap: own, how: route === this.lastSnapRoute ? "latest" : "revisited" };
|
|
1775
|
+
const base = routeBase(route);
|
|
1776
|
+
const tabs = [...this.snaps.values()].filter((s) => routeBase(s.route) === base);
|
|
1777
|
+
const latest = tabs[tabs.length - 1];
|
|
1778
|
+
return latest ? { snap: latest, how: "tab" } : null;
|
|
1779
|
+
}
|
|
1780
|
+
/** Keep a snapshot as its route's latest, dropping the least recent route past SNAPSHOTS_KEPT. */
|
|
1781
|
+
keepSnapshot(snap) {
|
|
1782
|
+
this.snaps.delete(snap.route);
|
|
1783
|
+
this.snaps.set(snap.route, snap);
|
|
1784
|
+
while (this.snaps.size > SNAPSHOTS_KEPT)
|
|
1785
|
+
this.snaps.delete(this.snaps.keys().next().value);
|
|
1786
|
+
this.lastSnapRoute = snap.route;
|
|
1787
|
+
this.refsDropped = null;
|
|
1788
|
+
}
|
|
1427
1789
|
/** Collect the current page's interactables into SnapshotElements with stable refs. */
|
|
1428
1790
|
async collect() {
|
|
1429
1791
|
const page = this.requirePage();
|
|
@@ -1441,7 +1803,8 @@ export class BrowserEngine {
|
|
|
1441
1803
|
if (stable)
|
|
1442
1804
|
break;
|
|
1443
1805
|
}
|
|
1444
|
-
const
|
|
1806
|
+
const cut = rawElements.filter((r) => r.cut);
|
|
1807
|
+
rawElements = rawElements.filter((r) => !r.cut);
|
|
1445
1808
|
// The page's forms, read in the same settled page as its elements. The
|
|
1446
1809
|
// page's own document only: an xpath names a node in one document.
|
|
1447
1810
|
const rawForms = ((await this.formRead("form inventory", page.evaluate(FORMS_INVENTORY_SCRIPT))) ?? []);
|
|
@@ -1453,36 +1816,48 @@ export class BrowserEngine {
|
|
|
1453
1816
|
// (and previously issued refs) survive re-snapshots. An element inside a
|
|
1454
1817
|
// frame carries the frame in its key (collector.ts frameElementKey).
|
|
1455
1818
|
const route = normalizePath(page.url());
|
|
1456
|
-
const
|
|
1819
|
+
const prev = this.prevSnapFor(route);
|
|
1457
1820
|
this.refs.clear();
|
|
1458
1821
|
this.refFrames.clear();
|
|
1459
|
-
const keyCounts = new Map();
|
|
1460
1822
|
const all = [
|
|
1461
1823
|
...rawElements.map((raw) => ({ raw })),
|
|
1462
1824
|
...framed.flatMap((g) => g.raws.map((raw) => ({ raw: raw, frame: g.frame, tag: g.tag }))),
|
|
1463
1825
|
];
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1826
|
+
// A live region listed for what it says is known by its role, not its
|
|
1827
|
+
// text, so a new message reads as the same region saying something else.
|
|
1828
|
+
const baseKeyOf = (el, name, tag) => frameElementKey(el.liveOnly ? `live:${el.role}` : elementKey({ ...el, name }), tag);
|
|
1829
|
+
const bases = all.map(({ raw: el, tag }) => ({
|
|
1830
|
+
base: baseKeyOf(el, el.name, tag),
|
|
1831
|
+
prior: baseKeyOf(el, el.priorName ?? el.name, tag),
|
|
1832
|
+
tracked: !el.liveOnly,
|
|
1833
|
+
}));
|
|
1834
|
+
const keys = ordinalKeys(bases.map((b) => b.base));
|
|
1835
|
+
const aliases = keyAliases(bases);
|
|
1836
|
+
const built = all.map(({ raw: el, frame, tag }, i) => {
|
|
1837
|
+
const key = keys[i];
|
|
1838
|
+
const { ownText: _ownText, centre: _centre, cut: _cut, priorName: _priorName, ...listed } = el;
|
|
1472
1839
|
const full = {
|
|
1473
|
-
...
|
|
1840
|
+
...listed,
|
|
1474
1841
|
...(tag ? { frame: tag } : {}),
|
|
1475
1842
|
// Judged on the real label, then masked: the policy must see what a click would press.
|
|
1476
1843
|
// A message is not a control: "Could not delete" in an alert is not a Delete button.
|
|
1477
|
-
destructive: el.liveOnly ? false :
|
|
1844
|
+
destructive: el.liveOnly ? false : destructiveLabelOf(el) !== null,
|
|
1478
1845
|
...(tag?.foreign ? { name: masksForeignName(el.tag, el.role) ? MASKED_NAME : capForeignName(el.name) } : {}),
|
|
1479
1846
|
...(tag?.foreign && el.href ? { href: stripForeignHref(el.href) } : {}),
|
|
1480
|
-
ref,
|
|
1847
|
+
ref: "",
|
|
1481
1848
|
key,
|
|
1482
1849
|
};
|
|
1483
|
-
|
|
1850
|
+
return { full, frame };
|
|
1851
|
+
});
|
|
1852
|
+
// An element keeps the ref it had in the snapshot it is matched to, so
|
|
1853
|
+
// refs an agent holds survive a re-snapshot and the diff stays readable.
|
|
1854
|
+
const matched = matchPrevious(prev?.snap.byKey ?? new Map(), built.map(({ full }) => ({ key: full.key, name: full.name, href: full.href, byPosition: full.liveOnly })));
|
|
1855
|
+
const elements = built.map(({ full, frame }, i) => {
|
|
1856
|
+
const was = matched[i];
|
|
1857
|
+
full.ref = (was ? prev?.snap.byKey.get(was)?.ref : undefined) ?? `e${++this.refCounter}`;
|
|
1858
|
+
this.refs.set(full.ref, full);
|
|
1484
1859
|
if (frame)
|
|
1485
|
-
this.refFrames.set(ref, frame);
|
|
1860
|
+
this.refFrames.set(full.ref, frame);
|
|
1486
1861
|
return full;
|
|
1487
1862
|
});
|
|
1488
1863
|
this.harvestRoutes(elements.filter((el) => !el.frame?.foreign));
|
|
@@ -1494,8 +1869,8 @@ export class BrowserEngine {
|
|
|
1494
1869
|
const key = status === "untracked" ? null : (formIdentity(f.attrs) ?? keyAtXpath(elements, f.submit));
|
|
1495
1870
|
return key ? [{ key, guarded: status === "guarded" }] : [];
|
|
1496
1871
|
});
|
|
1497
|
-
this.lastCollectTruncated =
|
|
1498
|
-
return { elements, truncated:
|
|
1872
|
+
this.lastCollectTruncated = cut.length > 0;
|
|
1873
|
+
return { elements, truncated: cut.length > 0, forms, cut, prev, matched, aliases };
|
|
1499
1874
|
}
|
|
1500
1875
|
/** Whether the latest collect stopped at the element cap, so some controls have no key at all. */
|
|
1501
1876
|
lastCollectTruncated = false;
|
|
@@ -1524,7 +1899,9 @@ export class BrowserEngine {
|
|
|
1524
1899
|
return null;
|
|
1525
1900
|
const title = ((await el.getAttribute("title")) || (await el.getAttribute("name")) || "").trim();
|
|
1526
1901
|
const got = (await frame.evaluate(`(() => ({ els: ${COLLECT_INTERACTABLES_SCRIPT}, sx: window.scrollX, sy: window.scrollY }))()`));
|
|
1527
|
-
const raws = got.els
|
|
1902
|
+
const raws = got.els
|
|
1903
|
+
.filter((r) => !r.cut)
|
|
1904
|
+
.map((r) => ({ ...r, rect: frameToPageRect(r.rect, box, { x: got.sx, y: got.sy }, pageScroll) }));
|
|
1528
1905
|
let origin = "";
|
|
1529
1906
|
try {
|
|
1530
1907
|
const u = new URL(frame.url());
|
|
@@ -1617,12 +1994,12 @@ export class BrowserEngine {
|
|
|
1617
1994
|
*/
|
|
1618
1995
|
async captureCoverageState() {
|
|
1619
1996
|
const page = this.requirePage();
|
|
1620
|
-
const { elements, forms } = await this.collect();
|
|
1997
|
+
const { elements, forms, aliases } = await this.collect();
|
|
1621
1998
|
const url = page.url();
|
|
1622
1999
|
const fp = fingerprintState(url, trackedElements(elements));
|
|
1623
|
-
this.memory?.visitState(fp, url, normalizePath(url), trackedElements(elements).map((el) => el.key), inertKeys(elements));
|
|
2000
|
+
this.memory?.visitState(fp, url, normalizePath(url), trackedElements(elements).map((el) => el.key), inertKeys(elements), this.sessionKey, aliases);
|
|
1624
2001
|
for (const f of forms)
|
|
1625
|
-
this.memory?.recordForm(fp, f.key, f.guarded);
|
|
2002
|
+
this.memory?.recordForm(fp, f.key, f.guarded, this.sessionKey);
|
|
1626
2003
|
return { fp, elements, url };
|
|
1627
2004
|
}
|
|
1628
2005
|
/**
|
|
@@ -1704,15 +2081,16 @@ export class BrowserEngine {
|
|
|
1704
2081
|
const page = this.requirePage();
|
|
1705
2082
|
const memory = this.memory;
|
|
1706
2083
|
await this.settle();
|
|
1707
|
-
const { elements, truncated, forms } = await this.collect();
|
|
2084
|
+
const { elements, truncated, forms, cut, prev: prevSnap, matched, aliases } = await this.collect();
|
|
1708
2085
|
const url = page.url();
|
|
1709
2086
|
this.snapshotUrl = url;
|
|
2087
|
+
this.snapshotAt = Date.now();
|
|
1710
2088
|
const route = normalizePath(url);
|
|
1711
2089
|
const fp = fingerprintState(url, trackedElements(elements));
|
|
1712
2090
|
this.currentFingerprint = fp;
|
|
1713
|
-
const isNew = memory.visitState(fp, url, route, trackedElements(elements).map((el) => el.key), inertKeys(elements));
|
|
2091
|
+
const isNew = memory.visitState(fp, url, route, trackedElements(elements).map((el) => el.key), inertKeys(elements), this.sessionKey, aliases);
|
|
1714
2092
|
for (const f of forms)
|
|
1715
|
-
memory.recordForm(fp, f.key, f.guarded);
|
|
2093
|
+
memory.recordForm(fp, f.key, f.guarded, this.sessionKey);
|
|
1716
2094
|
memory.recordRoleAccess(this.role, route, "reached");
|
|
1717
2095
|
// A snapshot is what an agent takes when it wants to LOOK at something, so
|
|
1718
2096
|
// it is the frame a reader most wants beside the step. Recording only the
|
|
@@ -1721,6 +2099,14 @@ export class BrowserEngine {
|
|
|
1721
2099
|
this.logAction({ action: "snapshot", url, result: fp, ...(await this.frameFor("snapshot")) });
|
|
1722
2100
|
await this.scanForInjections();
|
|
1723
2101
|
await this.scanForContradictions();
|
|
2102
|
+
// An alert or live region that appeared after the write policy refused one of
|
|
2103
|
+
// the current input's requests may be the page answering the engine's
|
|
2104
|
+
// refusal, not a defect of its own: said beside it, so a reader of the
|
|
2105
|
+
// snapshot alone does not file it (claims.ts saidSince).
|
|
2106
|
+
const blockedThisInput = this.inputSince !== null && this.inputSince >= this.actionStartedAt && this.lastBlockAt >= this.inputSince;
|
|
2107
|
+
const afterBlock = (el) => blockedThisInput && (el.liveOnly || el.role === "alert" || el.role === "status") && saidSince(el.name, this.claimBaseline)
|
|
2108
|
+
? " (after a write-policy block)"
|
|
2109
|
+
: "";
|
|
1724
2110
|
const line = (el) => {
|
|
1725
2111
|
const dup = el.key.match(/~(\d+)$/);
|
|
1726
2112
|
const flags = [
|
|
@@ -1729,27 +2115,37 @@ export class BrowserEngine {
|
|
|
1729
2115
|
dup ? `copy#${Number(dup[1]) + 1}` : null,
|
|
1730
2116
|
el.disabled ? "disabled" : null,
|
|
1731
2117
|
el.destructive ? "DESTRUCTIVE" : null,
|
|
2118
|
+
...affordanceFlags(el),
|
|
1732
2119
|
labelFlag(el),
|
|
1733
2120
|
// "exercised", not "done": it says an earlier action or run acted on it, and "done" read as the control's own state.
|
|
1734
2121
|
memory.wasExercised(fp, el.key) ? "exercised" : null,
|
|
1735
2122
|
el.href ? `href=${el.href.slice(0, 60)}` : null,
|
|
1736
2123
|
].filter(Boolean);
|
|
1737
|
-
return `${el.ref} ${el.role} "${displayName(el)}"${flags.length ? ` [${flags.join(", ")}]` : ""}${el.frame ? ` ⟨in ${frameLabel(el.frame)}⟩` : ""}`;
|
|
2124
|
+
return `${el.ref} ${el.role} "${displayName(el)}"${flags.length ? ` [${flags.join(", ")}]` : ""}${el.frame ? ` ⟨in ${frameLabel(el.frame)}⟩` : ""}${afterBlock(el)}`;
|
|
1738
2125
|
};
|
|
1739
|
-
// Diff mode: when re-snapshotting
|
|
1740
|
-
// changed — same idea as UI reconciliation,
|
|
1741
|
-
|
|
1742
|
-
|
|
2126
|
+
// Diff mode: when re-snapshotting a route (or another tab of the same
|
|
2127
|
+
// screen), report only what changed — same idea as UI reconciliation,
|
|
2128
|
+
// applied to agent context. Elements are paired with the kept snapshot's
|
|
2129
|
+
// by matchPrevious, the same pairing that gave them their refs.
|
|
2130
|
+
const prev = prevSnap?.snap ?? null;
|
|
2131
|
+
const refsDropped = this.refsDropped;
|
|
2132
|
+
this.keepSnapshot({
|
|
1743
2133
|
route,
|
|
1744
|
-
byKey: new Map(elements.map((el) => [el.key, { ref: el.ref, label: el.name, disabled: el.disabled, state: stateFlags(el.state) }])),
|
|
1745
|
-
};
|
|
2134
|
+
byKey: new Map(elements.map((el) => [el.key, { ref: el.ref, label: el.name, href: el.href, disabled: el.disabled, state: stateFlags(el.state) }])),
|
|
2135
|
+
});
|
|
2136
|
+
const cutLine = truncated ? `TRUNCATED at ${COLLECTOR_CAP}, dense page — cut: ${cutSummary(cut)}` : "";
|
|
1746
2137
|
let body;
|
|
1747
|
-
if (!full && prev) {
|
|
1748
|
-
const
|
|
1749
|
-
const
|
|
1750
|
-
|
|
2138
|
+
if (!full && prev && prevSnap) {
|
|
2139
|
+
const was = new Map(elements.map((el, i) => [el.key, matched[i]]));
|
|
2140
|
+
const oldOf = (el) => {
|
|
2141
|
+
const key = was.get(el.key);
|
|
2142
|
+
return key ? prev.byKey.get(key) : undefined;
|
|
2143
|
+
};
|
|
2144
|
+
const pairedPrev = new Set(matched.filter((k) => k !== null));
|
|
2145
|
+
const added = elements.filter((el) => !oldOf(el));
|
|
2146
|
+
const removed = [...prev.byKey.entries()].filter(([key]) => !pairedPrev.has(key));
|
|
1751
2147
|
const relabeled = elements.filter((el) => {
|
|
1752
|
-
const old =
|
|
2148
|
+
const old = oldOf(el);
|
|
1753
2149
|
return old !== undefined && old.label !== el.name;
|
|
1754
2150
|
});
|
|
1755
2151
|
// An element can change WITHOUT being added, removed or relabeled: the
|
|
@@ -1761,40 +2157,52 @@ export class BrowserEngine {
|
|
|
1761
2157
|
// to enabled "Save" changed BOTH ways, and reporting only the relabel
|
|
1762
2158
|
// swallows the enable — the exact fact this line exists to surface.
|
|
1763
2159
|
const retoggled = elements.filter((el) => {
|
|
1764
|
-
const old =
|
|
2160
|
+
const old = oldOf(el);
|
|
1765
2161
|
return old !== undefined && old.disabled !== el.disabled;
|
|
1766
2162
|
});
|
|
1767
2163
|
// A filter pill that became the active one, a tab now selected, a
|
|
1768
2164
|
// section now open: the same element, in a different state.
|
|
1769
2165
|
const restated = elements.flatMap((el) => {
|
|
1770
|
-
const old =
|
|
2166
|
+
const old = oldOf(el);
|
|
1771
2167
|
const change = old ? stateChange(old.state, stateFlags(el.state)) : null;
|
|
1772
2168
|
return change ? [{ el, change }] : [];
|
|
1773
2169
|
});
|
|
1774
2170
|
const changedKeys = new Set([...relabeled, ...retoggled, ...restated.map((r) => r.el)].map((el) => el.key));
|
|
1775
2171
|
const unchanged = elements.length - added.length - changedKeys.size;
|
|
2172
|
+
// What the diff is against, and whether refs held from the latest snapshot still work.
|
|
2173
|
+
const against = prevSnap.how === "latest"
|
|
2174
|
+
? "the last snapshot"
|
|
2175
|
+
: prevSnap.how === "revisited"
|
|
2176
|
+
? "this route's last snapshot"
|
|
2177
|
+
: `the last snapshot of ${prev.route} (tab changed)`;
|
|
2178
|
+
const refNote = prevSnap.how === "latest" && !refsDropped
|
|
2179
|
+
? "refs stable"
|
|
2180
|
+
: `refs from ${prevSnap.how === "latest" ? "it were dropped when " + refsDropped : "that snapshot"} are re-issued: an unchanged element has its old ref again`;
|
|
1776
2181
|
if (added.length === 0 && removed.length === 0 && relabeled.length === 0 && retoggled.length === 0 && restated.length === 0) {
|
|
1777
|
-
body = `No element changes since
|
|
2182
|
+
body = `No element changes since ${against} (${unchanged} interactables, ${refNote === "refs stable" ? "refs unchanged" : refNote}).`;
|
|
1778
2183
|
}
|
|
1779
2184
|
else {
|
|
1780
2185
|
body =
|
|
1781
|
-
`DIFF vs
|
|
2186
|
+
`DIFF vs ${against} (${unchanged} unchanged, ${refNote}):\n` +
|
|
1782
2187
|
[
|
|
1783
2188
|
...added.map((el) => `+ ${line(el)}`),
|
|
1784
2189
|
...removed.map(([key, v]) => `- ${v.ref} "${v.label}" (gone: ${key})`),
|
|
1785
2190
|
// A live region saying something new is the message itself, so it is shown in full like a new element.
|
|
1786
2191
|
...relabeled.map((el) => el.liveOnly
|
|
1787
|
-
? `~ ${el.ref} ${el.role} "${displayName(el)}" (was ${
|
|
2192
|
+
? `~ ${el.ref} ${el.role} "${displayName(el)}" (was ${oldOf(el)?.label ? `"${oldOf(el)?.label}"` : "empty"})${afterBlock(el)}`
|
|
1788
2193
|
: `~ ${el.ref} relabeled → "${el.name}"`),
|
|
1789
2194
|
...retoggled.map((el) => `~ ${el.ref} "${el.name}" is now ${el.disabled ? "DISABLED" : "ENABLED"}`),
|
|
1790
2195
|
...restated.map(({ el, change }) => `~ ${el.ref} "${displayName(el)}" ${change}`),
|
|
1791
2196
|
].join("\n");
|
|
1792
2197
|
}
|
|
2198
|
+
if (cutLine)
|
|
2199
|
+
body += `\n${cutLine}`;
|
|
1793
2200
|
}
|
|
1794
2201
|
else {
|
|
1795
2202
|
const missingTestids = trackedElements(elements).filter((el) => !el.testid && !el.disabled).length;
|
|
1796
2203
|
body =
|
|
1797
|
-
`Interactables (${elements.length}${truncated ? "+
|
|
2204
|
+
`Interactables (${elements.length}${truncated ? "+" : ""}${missingTestids ? `, ${missingTestids} missing data-testid` : ""}):\n` +
|
|
2205
|
+
(cutLine ? `${cutLine}\n` : "") +
|
|
1798
2206
|
elements.map(line).join("\n");
|
|
1799
2207
|
}
|
|
1800
2208
|
// Per document: the page's own controls together, and each frame's apart —
|
|
@@ -1837,39 +2245,112 @@ export class BrowserEngine {
|
|
|
1837
2245
|
}
|
|
1838
2246
|
/**
|
|
1839
2247
|
* Resolve a ref and re-verify the live element at action time. Refs are
|
|
1840
|
-
* trusted
|
|
1841
|
-
*
|
|
1842
|
-
*
|
|
1843
|
-
*
|
|
2248
|
+
* trusted while the page is on the snapshot's URL, or one that differs from
|
|
2249
|
+
* it only in what refsSurviveUrlChange allows (a search or filter query),
|
|
2250
|
+
* and even then the located element's live identity is re-read so the
|
|
2251
|
+
* destructive policy applies to what is actually acted on — SPA re-renders
|
|
2252
|
+
* can put a different element under a previously-safe XPath. After a query
|
|
2253
|
+
* change, the element must still carry its name as well as its test id.
|
|
2254
|
+
*
|
|
2255
|
+
* When the XPath finds nothing because the page re-rendered around the
|
|
2256
|
+
* element (a footer that appears while typing shifts every path below it),
|
|
2257
|
+
* an element with a test id that is unique on the page is found again by it,
|
|
2258
|
+
* checked by name, and the ref is re-bound to it; the action's result says so (rebindNote).
|
|
1844
2259
|
*/
|
|
1845
2260
|
async resolveForAction(ref) {
|
|
1846
2261
|
this.actionStartedAt = Date.now();
|
|
2262
|
+
this.rebindNote = "";
|
|
1847
2263
|
const page = this.requirePage();
|
|
1848
|
-
|
|
2264
|
+
let el = this.refs.get(ref);
|
|
1849
2265
|
if (!el) {
|
|
1850
|
-
throw new Error(`Unknown ref "${ref}". Refs are only valid from the latest scout_snapshot — take a new snapshot.`);
|
|
2266
|
+
throw new Error(`Unknown ref "${ref}". Refs are only valid from the latest scout_snapshot${this.refsDropped ? ` (they were dropped when ${this.refsDropped})` : ""} — take a new snapshot.`);
|
|
1851
2267
|
}
|
|
1852
|
-
|
|
1853
|
-
|
|
2268
|
+
const urlMoved = page.url() !== this.snapshotUrl;
|
|
2269
|
+
if (urlMoved && !refsSurviveUrlChange(this.snapshotUrl, page.url())) {
|
|
2270
|
+
this.dropRefs(`the page moved to ${page.url()}`);
|
|
1854
2271
|
throw new Error(`Page URL changed since the last snapshot (now ${page.url()}). Take a new scout_snapshot.`);
|
|
1855
2272
|
}
|
|
1856
|
-
//
|
|
1857
|
-
//
|
|
1858
|
-
const
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
2273
|
+
// The element and the test id are passed as arguments: no value of the
|
|
2274
|
+
// page's is written into the code that runs there.
|
|
2275
|
+
const scope = this.scopeOf(el);
|
|
2276
|
+
const handle = await scope.$(`xpath=${el.xpath}`).catch(() => null);
|
|
2277
|
+
let live = handle ? await handle.evaluate(liveFactsOf).catch(() => null) : null;
|
|
2278
|
+
await handle?.dispose().catch(() => undefined);
|
|
2279
|
+
if (!live && el.testid) {
|
|
2280
|
+
// Found again only by a test id the page gives one element: two would leave it guessing.
|
|
2281
|
+
const found = await scope.evaluate(uniqueByTestid, el.testid).catch(() => null);
|
|
2282
|
+
if (found && found.live.name === el.name) {
|
|
2283
|
+
el = { ...el, xpath: found.xpath };
|
|
2284
|
+
this.refs.set(ref, el);
|
|
2285
|
+
live = found.live;
|
|
2286
|
+
this.rebindNote = `\nℹ ${ref} re-bound after a re-render: found again by its unique testid=${el.testid}.`;
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
1862
2289
|
if (!live) {
|
|
1863
2290
|
throw new Error(`Element ${ref} no longer exists in the DOM — take a new scout_snapshot.`);
|
|
1864
2291
|
}
|
|
1865
2292
|
await this.beginInput();
|
|
1866
2293
|
if (el.testid && live.testid !== el.testid) {
|
|
1867
|
-
this.
|
|
2294
|
+
this.dropRefs(`the element under ${ref} changed`);
|
|
1868
2295
|
throw new Error(`Element under ${ref} changed (expected testid=${el.testid}, found ${live.testid ?? "none"}) — the DOM shifted; take a new scout_snapshot.`);
|
|
1869
2296
|
}
|
|
1870
|
-
|
|
2297
|
+
// After a query change a list may hold other rows under the same paths and
|
|
2298
|
+
// the same shared test id, so the name must match too. Another site's frame
|
|
2299
|
+
// shows a masked name (masksForeignName), so there is nothing to compare there.
|
|
2300
|
+
if (urlMoved && !el.frame?.foreign && live.name !== el.name) {
|
|
2301
|
+
this.dropRefs(`the element under ${ref} changed`);
|
|
2302
|
+
throw new Error(`Element under ${ref} changed after the URL did (expected "${el.name}", found "${live.name}") — take a new scout_snapshot.`);
|
|
2303
|
+
}
|
|
2304
|
+
return { el, liveLabel: live.label, live: { ownText: live.ownText, centre: live.centre } };
|
|
2305
|
+
}
|
|
2306
|
+
/** Set when the action's ref had to be found again by its test id (resolveForAction); afterAction reports it once. */
|
|
2307
|
+
rebindNote = "";
|
|
2308
|
+
/** The read-POST entry that lets this request out under `rule`, or null (policy.ts readPostAllowed). */
|
|
2309
|
+
readPostOf(rule, method, url, body, destructiveWire) {
|
|
2310
|
+
if (this.readPosts.length === 0 || rule !== "observe" || method !== "POST")
|
|
2311
|
+
return null;
|
|
2312
|
+
const pathname = pathnameOf(url);
|
|
2313
|
+
return readPostAllowed({
|
|
2314
|
+
mode: rule,
|
|
2315
|
+
method,
|
|
2316
|
+
url,
|
|
2317
|
+
appUrl: this.baseUrl,
|
|
2318
|
+
body,
|
|
2319
|
+
destructiveWire: destructiveWire ?? isDestructiveWire(pathname, body),
|
|
2320
|
+
entries: this.readPosts,
|
|
2321
|
+
});
|
|
2322
|
+
}
|
|
2323
|
+
/** How the gap ledger names a POST's endpoint: "POST /path" on the app's origin, "POST https://host/path" elsewhere; null when unreadable. */
|
|
2324
|
+
refusedPostEndpoint(url) {
|
|
2325
|
+
try {
|
|
2326
|
+
const u = new URL(url);
|
|
2327
|
+
return `POST ${u.origin === new URL(this.baseUrl).origin ? "" : u.origin}${u.pathname}`;
|
|
2328
|
+
}
|
|
2329
|
+
catch {
|
|
2330
|
+
return null;
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
/**
|
|
2334
|
+
* Remember in project memory, for the gap ledger, a script's POST that
|
|
2335
|
+
* observe refused on the page the session is on. Not one that looks
|
|
2336
|
+
* destructive, nor one to an endpoint already named as a read (its body was
|
|
2337
|
+
* a mutation): naming it would change nothing.
|
|
2338
|
+
*/
|
|
2339
|
+
noteObserveRefusedPost(url, body) {
|
|
2340
|
+
const pageUrl = this.page?.url();
|
|
2341
|
+
if (!pageUrl || !this.memory || isDestructiveWire(pathnameOf(url), body) || matchReadPost(this.readPosts, this.baseUrl, url))
|
|
2342
|
+
return;
|
|
2343
|
+
const endpoint = this.refusedPostEndpoint(url);
|
|
2344
|
+
if (endpoint)
|
|
2345
|
+
this.memory.noteObserveRefusedPost(normalizePath(pageUrl), endpoint);
|
|
2346
|
+
}
|
|
2347
|
+
/** A POST to this endpoint went out, so the pages observe refused it on are no longer a gap for it. */
|
|
2348
|
+
clearRefusedPost(url) {
|
|
2349
|
+
const endpoint = this.refusedPostEndpoint(url);
|
|
2350
|
+
if (endpoint && this.memory)
|
|
2351
|
+
this.memory.clearObserveRefusedPosts((e) => e === endpoint);
|
|
1871
2352
|
}
|
|
1872
|
-
actionPolicyCheck(el, liveLabel) {
|
|
2353
|
+
actionPolicyCheck(el, liveLabel, live = {}) {
|
|
1873
2354
|
if (!this.readOnly)
|
|
1874
2355
|
return null;
|
|
1875
2356
|
// Same-origin navigation links are exempt: navigation is non-destructive
|
|
@@ -1883,8 +2364,18 @@ export class BrowserEngine {
|
|
|
1883
2364
|
// same holds for choosing a file: selection is not the send.
|
|
1884
2365
|
if (el.role === "textbox" || el.role === "file")
|
|
1885
2366
|
return null;
|
|
1886
|
-
|
|
1887
|
-
|
|
2367
|
+
// Judged as the snapshot judged it, and again on what is there now (policy.ts destructiveLabelOf).
|
|
2368
|
+
const now = destructiveLabelOf({
|
|
2369
|
+
tag: el.tag,
|
|
2370
|
+
role: el.role,
|
|
2371
|
+
name: liveLabel,
|
|
2372
|
+
testid: el.testid,
|
|
2373
|
+
ownText: live.ownText,
|
|
2374
|
+
centre: live.centre,
|
|
2375
|
+
interactive: el.interactive,
|
|
2376
|
+
});
|
|
2377
|
+
if (el.destructive || now !== null) {
|
|
2378
|
+
return destructiveRefusal(now ?? (liveLabel || el.name || el.testid || el.ref), this.mode);
|
|
1888
2379
|
}
|
|
1889
2380
|
return null;
|
|
1890
2381
|
}
|
|
@@ -1927,12 +2418,13 @@ export class BrowserEngine {
|
|
|
1927
2418
|
this.logAction({ action: policyAbortedNavigation ? "write-policy:navigation-blocked" : "origin-fence:bounced", target: url.slice(0, 200), url });
|
|
1928
2419
|
await page.goBack({ waitUntil: "domcontentloaded", timeout: this.limits.backNavMs }).catch(() => { });
|
|
1929
2420
|
url = page.url();
|
|
1930
|
-
this.
|
|
1931
|
-
const blocked = this.drainBlocked();
|
|
2421
|
+
this.dropRefs("the page left the app's origin");
|
|
2422
|
+
const blocked = this.drainBlocked() + this.drainRefresh();
|
|
1932
2423
|
return ((policyAbortedNavigation
|
|
1933
2424
|
? `OK: ${action} ${target}\nThe page tried to navigate with a request the write policy blocked, so the browser showed an error page; returned to ${url}.`
|
|
1934
2425
|
: `OK: ${action} ${target}\nNavigated off-origin and was bounced back to ${url}. Exploration is fenced to ${this.baseUrl}.`) +
|
|
1935
2426
|
blocked +
|
|
2427
|
+
this.drainDialogs() +
|
|
1936
2428
|
formatViolations(this.oracles.drain()));
|
|
1937
2429
|
}
|
|
1938
2430
|
const frame = await this.recordFrame(action);
|
|
@@ -1942,14 +2434,23 @@ export class BrowserEngine {
|
|
|
1942
2434
|
if (click)
|
|
1943
2435
|
await this.rankRouteCancellations(click, url);
|
|
1944
2436
|
const violations = this.oracles.drain();
|
|
1945
|
-
const mutations = this.drainMutations() + this.drainBlocked() + this.drainCreated();
|
|
1946
|
-
const
|
|
2437
|
+
const mutations = this.drainMutations() + this.drainBlocked() + this.drainRefresh() + this.drainCreated();
|
|
2438
|
+
const moved = this.snapshotUrl !== "" && url !== this.snapshotUrl;
|
|
2439
|
+
// A query that is not UI state (a search, a filter) leaves the same screen: its refs still work.
|
|
2440
|
+
const navigated = moved && !refsSurviveUrlChange(this.snapshotUrl, url);
|
|
1947
2441
|
if (navigated) {
|
|
1948
2442
|
// Refs point into the previous page's DOM; invalidate so a stale ref
|
|
1949
2443
|
// errors ("take a new snapshot") instead of acting on the wrong element.
|
|
1950
|
-
this.
|
|
2444
|
+
this.dropRefs(`the page moved to ${url}`);
|
|
1951
2445
|
}
|
|
1952
|
-
|
|
2446
|
+
const rebound = this.rebindNote;
|
|
2447
|
+
this.rebindNote = "";
|
|
2448
|
+
return (`OK: ${action} ${target}\nURL now: ${url}` +
|
|
2449
|
+
(navigated ? " (page changed — take a new snapshot)" : moved ? " (same screen, query changed — refs still apply)" : "") +
|
|
2450
|
+
rebound +
|
|
2451
|
+
this.drainDialogs() +
|
|
2452
|
+
mutations +
|
|
2453
|
+
formatViolations(violations));
|
|
1953
2454
|
}
|
|
1954
2455
|
/** Does this request path address a record this run created? Rules live in ownership.ts. */
|
|
1955
2456
|
isOwnedResource(pathname) {
|
|
@@ -2149,6 +2650,7 @@ export class BrowserEngine {
|
|
|
2149
2650
|
let pathname = url;
|
|
2150
2651
|
try {
|
|
2151
2652
|
pathname = pathnameOf(url);
|
|
2653
|
+
const readPost = this.readPostOf(rule, method, url, bytes?.toString("utf8"));
|
|
2152
2654
|
const { foreign, offApp } = unseenWriteSource({
|
|
2153
2655
|
appUrl: this.baseUrl,
|
|
2154
2656
|
mode: rule,
|
|
@@ -2167,7 +2669,14 @@ export class BrowserEngine {
|
|
|
2167
2669
|
foreign,
|
|
2168
2670
|
offApp,
|
|
2169
2671
|
owned: this.isOwnedResource(pathname),
|
|
2672
|
+
readPost: readPost !== null,
|
|
2170
2673
|
});
|
|
2674
|
+
if (readPost && verdict.allow)
|
|
2675
|
+
this.logAction({
|
|
2676
|
+
action: "write-policy:read-post",
|
|
2677
|
+
target: `${method} ${pathname} (named as a read: ${readPost.entry})`,
|
|
2678
|
+
url: this.page?.url() ?? "",
|
|
2679
|
+
});
|
|
2171
2680
|
}
|
|
2172
2681
|
catch (err) {
|
|
2173
2682
|
// Fail closed: a write that cannot be judged is refused, and reported as refused below.
|
|
@@ -2185,14 +2694,13 @@ export class BrowserEngine {
|
|
|
2185
2694
|
return;
|
|
2186
2695
|
}
|
|
2187
2696
|
// Refused and reported as any refusal is. Dropped rather than answered: the page that sent it is going or gone.
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
});
|
|
2697
|
+
this.noteBlocked({
|
|
2698
|
+
at: Date.now(),
|
|
2699
|
+
sig: `${method} ${url.slice(0, 140)}`,
|
|
2700
|
+
answered: false,
|
|
2701
|
+
why: verdict.why,
|
|
2702
|
+
type: (event.resourceType ?? "other").toLowerCase(),
|
|
2703
|
+
});
|
|
2196
2704
|
this.logAction({ action: "write-policy:blocked", target: `${method} ${pathname}${verdict.why ? ` (${verdict.why})` : ""}`, url: this.page?.url() ?? "" });
|
|
2197
2705
|
this.oracles.notePolicyBlock();
|
|
2198
2706
|
}
|
|
@@ -2210,13 +2718,12 @@ export class BrowserEngine {
|
|
|
2210
2718
|
}
|
|
2211
2719
|
await answer(false);
|
|
2212
2720
|
try {
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
});
|
|
2721
|
+
this.noteBlocked({
|
|
2722
|
+
at: Date.now(),
|
|
2723
|
+
sig: `${method} ${url.slice(0, 140)}`,
|
|
2724
|
+
answered: false,
|
|
2725
|
+
type: (event.resourceType ?? "other").toLowerCase(),
|
|
2726
|
+
});
|
|
2220
2727
|
this.logAction({
|
|
2221
2728
|
action: "write-policy:blocked",
|
|
2222
2729
|
target: `${method} ${url.slice(0, 140)} (not judged: an error in the policy)`,
|
|
@@ -2230,39 +2737,26 @@ export class BrowserEngine {
|
|
|
2230
2737
|
}
|
|
2231
2738
|
}
|
|
2232
2739
|
}
|
|
2233
|
-
/**
|
|
2740
|
+
/**
|
|
2741
|
+
* Record a refused write for the next notice. Twenty are kept as they come;
|
|
2742
|
+
* past that only an endpoint not already kept, up to sixty, so a page that
|
|
2743
|
+
* beacons on every load cannot crowd out the block an action itself caused.
|
|
2744
|
+
*/
|
|
2745
|
+
noteBlocked(entry) {
|
|
2746
|
+
this.lastBlockAt = Math.max(this.lastBlockAt, entry.at);
|
|
2747
|
+
const list = this.blockedRequests;
|
|
2748
|
+
if (list.length < 20 || (list.length < 60 && !list.some((e) => blockSignature(e.sig) === blockSignature(entry.sig))))
|
|
2749
|
+
list.push(entry);
|
|
2750
|
+
}
|
|
2751
|
+
/** Report (and clear) write-policy blocks since the last action: each endpoint in full once per session, then counted (BlockNotices). */
|
|
2234
2752
|
drainBlocked() {
|
|
2235
2753
|
this.lastActionBlocked = this.blockedRequests.length;
|
|
2236
2754
|
if (this.blockedRequests.length === 0)
|
|
2237
2755
|
return "";
|
|
2238
|
-
const
|
|
2239
|
-
.slice(0, 5)
|
|
2240
|
-
.map((e) => this.lateMark(e))
|
|
2241
|
-
.join("; ");
|
|
2242
|
-
const extra = this.blockedRequests.length > 5 ? ` (+${this.blockedRequests.length - 5} more)` : "";
|
|
2243
|
-
const answered = this.blockedRequests.some((e) => e.answered);
|
|
2244
|
-
const reasons = new Set(this.blockedRequests.map((e) => e.why).filter((w) => !!w));
|
|
2245
|
-
const escaped = reasons.delete(ESCAPE_REFUSAL);
|
|
2246
|
-
const foreign = [...reasons];
|
|
2756
|
+
const blocked = this.blockedRequests.map((e) => ({ sig: e.sig, late: e.at < this.actionStartedAt, answered: e.answered, why: e.why }));
|
|
2247
2757
|
this.blockedRequests = [];
|
|
2248
2758
|
// The rule that judged them, which just after a flow hands back is still the flow's (WriteRule).
|
|
2249
|
-
|
|
2250
|
-
return (`\n🛡 WRITE-POLICY blocked (${rule}): ${list}${extra}. ` +
|
|
2251
|
-
`This is the tester's safety policy, NOT an app bug — do not file a finding for the resulting error UI. ` +
|
|
2252
|
-
(foreign.length > 0
|
|
2253
|
-
? `Refused because it was ${foreign.join("; ")}: it would reach a site embedded in the page rather than the app, which no mode but destructive allows. `
|
|
2254
|
-
: "") +
|
|
2255
|
-
(escaped
|
|
2256
|
-
? `A move of the whole page off the app, with no Referer, was refused: a frame that held another site now sits on a data: or blob: URL, where WebKit drops the frame's sandbox, so the move may be that frame's. No mode but destructive allows it. `
|
|
2257
|
-
: "") +
|
|
2258
|
-
(answered
|
|
2259
|
-
? `The page's own requests were answered with a 403 in the server's place, so the page's handling of a refusal is real: an error message is correct, and a success message is a false_success violation. `
|
|
2260
|
-
: "") +
|
|
2261
|
-
(rule === "observe"
|
|
2262
|
-
? `observe mode blocks every request that is not a GET, so no form submission reaches the server. Re-attach with mode="read-only" ONLY if the user confirms that ordinary form submissions are acceptable on this target.`
|
|
2263
|
-
: rule === "read-only"
|
|
2264
|
-
? `Re-attach with mode="safe-write" to test create/edit flows, or "destructive" (user-approved disposable env only).`
|
|
2265
|
-
: `In safe-write, updates/deletes are only allowed on resources this session created (${this.createdResources.length} so far).`));
|
|
2759
|
+
return this.blockNotices.notice(this.writeRule.at(), blocked, this.createdResources.length);
|
|
2266
2760
|
}
|
|
2267
2761
|
/** Once per session, in observe mode only: say that socket frames are outside the policy. */
|
|
2268
2762
|
socketNotice() {
|
|
@@ -2342,7 +2836,7 @@ export class BrowserEngine {
|
|
|
2342
2836
|
const clickCount = Math.max(1, Math.min(3, clicks));
|
|
2343
2837
|
try {
|
|
2344
2838
|
await locator.click({ timeout, clickCount });
|
|
2345
|
-
return { forced: false };
|
|
2839
|
+
return { forced: false, cover: null, coverReadAt: 0 };
|
|
2346
2840
|
}
|
|
2347
2841
|
catch (err) {
|
|
2348
2842
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -2351,12 +2845,27 @@ export class BrowserEngine {
|
|
|
2351
2845
|
const diagnostic = actionabilityDiagnostic(msg);
|
|
2352
2846
|
if (!diagnostic || !/intercepts pointer events|is not stable/i.test(diagnostic))
|
|
2353
2847
|
throw err;
|
|
2848
|
+
// What is on top at the click point, read before the forced click can
|
|
2849
|
+
// change it (a toast the click dismisses). Only a name for the note: a
|
|
2850
|
+
// read that fails leaves the note unnamed, never the click undone.
|
|
2851
|
+
const cover = await locator.evaluate(readCoverAt, undefined, { timeout: FORCED_CLICK_TIMEOUT_MS }).catch(() => null);
|
|
2852
|
+
// A block the forced click itself causes comes after the cover was there, so it cannot explain it.
|
|
2853
|
+
const coverReadAt = Date.now();
|
|
2354
2854
|
// A shorter budget here: the forced click skips the wait that
|
|
2355
2855
|
// consumed the first `timeout`, so it needs very little of its own.
|
|
2356
2856
|
await locator.click({ timeout: Math.min(timeout, FORCED_CLICK_TIMEOUT_MS), force: true, clickCount });
|
|
2357
|
-
return { forced: true };
|
|
2857
|
+
return { forced: true, cover, coverReadAt };
|
|
2358
2858
|
}
|
|
2359
2859
|
}
|
|
2860
|
+
/**
|
|
2861
|
+
* Whether the write policy refused a request between `since` and `until`
|
|
2862
|
+
* (timestamps). Only the latest block is kept, so a later block outside the
|
|
2863
|
+
* window hides an earlier one inside it: the note then stays unsuffixed.
|
|
2864
|
+
*/
|
|
2865
|
+
blockedBetween(since, until) {
|
|
2866
|
+
const at = this.lastBlockAt;
|
|
2867
|
+
return at > 0 && at >= since && at <= until;
|
|
2868
|
+
}
|
|
2360
2869
|
/**
|
|
2361
2870
|
* A form read (the inventory, or the probe before a submit), bounded by
|
|
2362
2871
|
* FORM_READ_MS. This bookkeeping must never fail or stall the action, so a
|
|
@@ -2391,7 +2900,7 @@ export class BrowserEngine {
|
|
|
2391
2900
|
}
|
|
2392
2901
|
/** The form the focused element belongs to, for a key press that may submit through it. */
|
|
2393
2902
|
probeFocusedForm() {
|
|
2394
|
-
return this.formRead("form probe", this.requirePage().evaluate(
|
|
2903
|
+
return this.formRead("form probe", this.requirePage().evaluate(FORM_PROBE_OF_ACTIVE_ELEMENT));
|
|
2395
2904
|
}
|
|
2396
2905
|
/**
|
|
2397
2906
|
* A plan's pre-action state, fit to record a submit of this form against.
|
|
@@ -2439,13 +2948,20 @@ export class BrowserEngine {
|
|
|
2439
2948
|
url: this.page?.url() ?? "",
|
|
2440
2949
|
});
|
|
2441
2950
|
}
|
|
2442
|
-
|
|
2443
|
-
|
|
2951
|
+
/** `leave` answers a leave confirmation the click raises (policy.ts dialogResponse); undefined lets the mode decide. */
|
|
2952
|
+
async click(ref, clicks = 1, leave) {
|
|
2953
|
+
this.leaveChoice = leave;
|
|
2954
|
+
try {
|
|
2955
|
+
return await this.withinLimit("action", () => this.clickNow(ref, clicks));
|
|
2956
|
+
}
|
|
2957
|
+
finally {
|
|
2958
|
+
this.leaveChoice = undefined;
|
|
2959
|
+
}
|
|
2444
2960
|
}
|
|
2445
2961
|
async clickNow(ref, clicks = 1) {
|
|
2446
2962
|
const page = this.requirePage();
|
|
2447
|
-
const { el, liveLabel } = await this.resolveForAction(ref);
|
|
2448
|
-
const refusal = this.actionPolicyCheck(el, liveLabel);
|
|
2963
|
+
const { el, liveLabel, live } = await this.resolveForAction(ref);
|
|
2964
|
+
const refusal = this.actionPolicyCheck(el, liveLabel, live);
|
|
2449
2965
|
if (refusal) {
|
|
2450
2966
|
this.logAction({ action: "click:refused", target: liveLabel || el.name, url: page.url() });
|
|
2451
2967
|
return refusal;
|
|
@@ -2459,13 +2975,20 @@ export class BrowserEngine {
|
|
|
2459
2975
|
// (silent no-op forms): capture the count before to compare after.
|
|
2460
2976
|
const xhrBefore = this.xhrCount;
|
|
2461
2977
|
const submitLike = isSubmitLike(el.role, el.name, el.testid);
|
|
2462
|
-
const clickContext = {
|
|
2978
|
+
const clickContext = {
|
|
2979
|
+
viaLink: el.role === "link",
|
|
2980
|
+
urlBefore: page.url(),
|
|
2981
|
+
dialogsBefore: this.claimBaseline?.dialogs ?? null,
|
|
2982
|
+
claimsBefore: this.claimBaseline,
|
|
2983
|
+
};
|
|
2463
2984
|
const clickTarget = this.scopeOf(el).locator(`xpath=${el.xpath}`);
|
|
2464
2985
|
// Read before the click: what the form's fields hold when it goes. Only a
|
|
2465
2986
|
// button or an input can submit a form; nothing else is asked.
|
|
2466
2987
|
const form = !el.frame && (el.tag === "button" || el.tag === "input") ? await this.probeForm(clickTarget) : null;
|
|
2467
2988
|
const formState = { fp: this.currentFingerprint, elements: [...this.refs.values()] };
|
|
2468
|
-
|
|
2989
|
+
await this.readActedControl(el);
|
|
2990
|
+
const { forced, cover, coverReadAt } = await this.resilientClick(clickTarget, this.limits.actionMs, clicks);
|
|
2991
|
+
const coverNote = cover ? describeCover(cover, this.blockedBetween(this.snapshotAt, coverReadAt)) : null;
|
|
2469
2992
|
this.memory.markExercised(this.currentFingerprint, el.key, clicks > 1 ? `click×${clicks}` : "click");
|
|
2470
2993
|
this.noteFormSubmit(formState, "click", form);
|
|
2471
2994
|
const result = await this.afterAction(clicks > 1 ? `click×${clicks}` : "click", `${el.role} "${el.name}"`, clickContext);
|
|
@@ -2486,20 +3009,30 @@ export class BrowserEngine {
|
|
|
2486
3009
|
return result + `\nℹ ${clicks}× rapid click fired no duplicate state-changing requests — double-submit appears guarded on this control.`;
|
|
2487
3010
|
}
|
|
2488
3011
|
const forcedNote = forced
|
|
2489
|
-
? `\nℹ NOTE: the strict click timed out waiting for this element to be the stable, unobstructed top hit at its coordinates, so a forced click was used instead (which still landed — this succeeded).
|
|
3012
|
+
? `\nℹ NOTE: the strict click timed out waiting for this element to be the stable, unobstructed top hit at its coordinates, so a forced click was used instead (which still landed — this succeeded). ` +
|
|
3013
|
+
(coverNote
|
|
3014
|
+
? `At its centre it is ${coverNote}; look at that element before treating this as a layout bug.`
|
|
3015
|
+
: `Something is likely rendered on top of it (an icon, a decorative layer, an animating wrapper) or it delegates via a label; cross-check against any GEOMETRY overlap on this element before treating that as a real bug.`)
|
|
2490
3016
|
: "";
|
|
2491
3017
|
// Not when the write policy blocked the submission: a native form POST or a
|
|
2492
3018
|
// beacon is not counted as xhr/fetch, so an aborted one looks exactly like
|
|
2493
3019
|
// "fired nothing" — and the note would blame the app for the tool's block.
|
|
2494
|
-
if (submitLike && this.xhrCount === xhrBefore && this.lastActionBlocked === 0 && page.url() ===
|
|
3020
|
+
if (submitLike && this.xhrCount === xhrBefore && this.lastActionBlocked === 0 && page.url() === clickContext.urlBefore) {
|
|
2495
3021
|
// A client-side router can move the page just after the request-based
|
|
2496
3022
|
// settle, with no request for it to wait on: watch the URL before
|
|
2497
3023
|
// calling the click silent, and look at the requests again after.
|
|
2498
3024
|
const landed = await this.stableUrl();
|
|
2499
|
-
if (landed !==
|
|
3025
|
+
if (landed !== clickContext.urlBefore)
|
|
2500
3026
|
return result + `\nℹ The page then moved client-side to ${landed} (take a new snapshot).` + forcedNote;
|
|
2501
3027
|
if (this.xhrCount !== xhrBefore)
|
|
2502
3028
|
return result + forcedNote;
|
|
3029
|
+
// The page may have answered without a request: a dialog, or validation saying what is missing (claims.ts quietAnswer).
|
|
3030
|
+
// A native alert is not a step: alert("Saved!") with nothing sent is the very case the note is for.
|
|
3031
|
+
const answer = this.nativeQuestionAt >= this.actionStartedAt ? "dialog" : quietAnswer(clickContext.claimsBefore, await this.readClaims(page));
|
|
3032
|
+
if (answer === "dialog")
|
|
3033
|
+
return result + `\nℹ The click opened a dialog (a step, not a submit), so no request was expected yet.` + forcedNote;
|
|
3034
|
+
if (answer === "validation")
|
|
3035
|
+
return result + `\nℹ Client-side validation answered (no request expected).` + forcedNote;
|
|
2503
3036
|
return (result +
|
|
2504
3037
|
`\nℹ NOTE: this submit-style click fired ZERO network requests and no navigation — if the UI showed success, the data may have been silently discarded (worth verifying; category: other/silent-failure).` +
|
|
2505
3038
|
forcedNote);
|
|
@@ -2522,20 +3055,33 @@ export class BrowserEngine {
|
|
|
2522
3055
|
try {
|
|
2523
3056
|
state = (await locator.evaluate((node) => {
|
|
2524
3057
|
const n = node;
|
|
3058
|
+
const type = n.tagName === "INPUT" ? n.type.toLowerCase() : "";
|
|
2525
3059
|
if (n.isContentEditable)
|
|
2526
|
-
return { existing: (n.innerText || n.textContent || "").trim(), caretAppendable: true };
|
|
3060
|
+
return { existing: (n.innerText || n.textContent || "").trim(), caretAppendable: true, type };
|
|
2527
3061
|
if (typeof n.value === "string") {
|
|
2528
3062
|
// selectionStart is null on number/date/email-style inputs — caret
|
|
2529
3063
|
// placement is unsupported there, so append must go via fill().
|
|
2530
|
-
return { existing: n.value, caretAppendable: typeof n.selectionStart === "number" };
|
|
3064
|
+
return { existing: n.value, caretAppendable: typeof n.selectionStart === "number", type };
|
|
2531
3065
|
}
|
|
2532
|
-
return { existing: "", caretAppendable: false };
|
|
3066
|
+
return { existing: "", caretAppendable: false, type };
|
|
2533
3067
|
}, undefined, { timeout: this.limits.actionMs }));
|
|
2534
3068
|
}
|
|
2535
3069
|
catch (err) {
|
|
2536
3070
|
throw new Error(`Could not read the field's existing content before typing — aborting rather than risk overwriting it (${err instanceof Error ? err.message.split("\n")[0] : err}). Take a new scout_snapshot and retry.`);
|
|
2537
3071
|
}
|
|
2538
|
-
const { existing, caretAppendable } = state;
|
|
3072
|
+
const { existing, caretAppendable, type } = state;
|
|
3073
|
+
if (APP_FILLED_TYPES.has(type)) {
|
|
3074
|
+
// A date or time field holds one value in one format: anything else is
|
|
3075
|
+
// refused by the browser as a bare "Malformed value", and appending to
|
|
3076
|
+
// what it holds can only produce that. So the value is put in the
|
|
3077
|
+
// field's format, or refused naming it, and always replaces.
|
|
3078
|
+
const date = normaliseDateValue(type, text);
|
|
3079
|
+
if ("refused" in date)
|
|
3080
|
+
throw new Error(`Not typed: ${date.refused}`);
|
|
3081
|
+
await locator.fill(date.value, { timeout: this.limits.actionMs });
|
|
3082
|
+
return ((date.note ? ` (${date.note})` : "") +
|
|
3083
|
+
(existing && existing !== date.value ? ` (replaced existing content ${JSON.stringify(existing.slice(0, 60))}: a ${type} field holds one value)` : ""));
|
|
3084
|
+
}
|
|
2539
3085
|
if (replace || !existing || text === "") {
|
|
2540
3086
|
await locator.fill(text, { timeout: this.limits.actionMs });
|
|
2541
3087
|
return existing && (replace || text === "") ? ` (replaced existing content ${JSON.stringify(existing.slice(0, 60))})` : "";
|
|
@@ -2577,14 +3123,14 @@ export class BrowserEngine {
|
|
|
2577
3123
|
}
|
|
2578
3124
|
async typeNow(ref, text, pressEnter = false, replace = false) {
|
|
2579
3125
|
const page = this.requirePage();
|
|
2580
|
-
const { el, liveLabel } = await this.resolveForAction(ref);
|
|
3126
|
+
const { el, liveLabel, live } = await this.resolveForAction(ref);
|
|
2581
3127
|
if (el.role === "file") {
|
|
2582
3128
|
// fill() refuses a file input — and it used to refuse with a stack
|
|
2583
3129
|
// trace, leaving every upload form stranded. Point at the tool that can.
|
|
2584
3130
|
return (`${ref} is a file input ("${el.name || el.testid || "unnamed"}") — text cannot be typed into it. ` +
|
|
2585
3131
|
`Use scout_upload {ref:"${ref}"}: a small valid fixture is generated and matched to the input's accept attribute, or pass fixture / filePath.`);
|
|
2586
3132
|
}
|
|
2587
|
-
const refusal = this.actionPolicyCheck(el, liveLabel);
|
|
3133
|
+
const refusal = this.actionPolicyCheck(el, liveLabel, live);
|
|
2588
3134
|
if (refusal)
|
|
2589
3135
|
return refusal;
|
|
2590
3136
|
const embed = this.foreignEmbedOf(el);
|
|
@@ -2647,7 +3193,7 @@ export class BrowserEngine {
|
|
|
2647
3193
|
if (opts.ref) {
|
|
2648
3194
|
const resolved = await this.resolveForAction(opts.ref);
|
|
2649
3195
|
el = resolved.el;
|
|
2650
|
-
const refusal = this.actionPolicyCheck(el, resolved.liveLabel);
|
|
3196
|
+
const refusal = this.actionPolicyCheck(el, resolved.liveLabel, resolved.live);
|
|
2651
3197
|
if (refusal)
|
|
2652
3198
|
return refusal;
|
|
2653
3199
|
const embed = this.foreignEmbedOf(el);
|
|
@@ -2896,44 +3442,77 @@ export class BrowserEngine {
|
|
|
2896
3442
|
? `\nRevealed on hover:\n${notes.map((t) => ` · ${t}`).join("\n")}${caveat}`
|
|
2897
3443
|
: `\n(no tooltip, overlay, or new page text appeared within ${HOVER_REVEAL_WINDOW_MS / 1000}s — this element reveals nothing on hover${churning ? "; page content was changing on its own, so the text-diff fallback was suppressed" : ""}${this.headed ? ". NOTE: in headed mode the PHYSICAL mouse cursor competes with the synthetic pointer — if it is resting over the browser window, hover warm-ups are cancelled; ask the user to move it off the window and retry" : ""})`));
|
|
2898
3444
|
}
|
|
2899
|
-
/**
|
|
3445
|
+
/**
|
|
3446
|
+
* The label of the option a select step picks, matched as selectOption
|
|
3447
|
+
* matches it (value or label). Empty when none matches; null when it could
|
|
3448
|
+
* not be read, which the caller refuses, since the pick cannot be vetted.
|
|
3449
|
+
*/
|
|
3450
|
+
static async chosenOptionLabel(loc, value) {
|
|
3451
|
+
if (value === undefined)
|
|
3452
|
+
return "";
|
|
3453
|
+
return loc
|
|
3454
|
+
.evaluate((node, v) => {
|
|
3455
|
+
const opts = Array.from(node.options ?? []);
|
|
3456
|
+
const o = opts.find((x) => x.value === v || x.label === v || (x.textContent || "").trim() === v);
|
|
3457
|
+
return o ? (o.label || o.textContent || "").trim().slice(0, 120) : "";
|
|
3458
|
+
}, value)
|
|
3459
|
+
.catch(() => null);
|
|
3460
|
+
}
|
|
3461
|
+
/**
|
|
3462
|
+
* What a select is asked to pick, settled against the dropdown's options
|
|
3463
|
+
* BEFORE the pick (forms.ts matchOption): a value naming no option, or more
|
|
3464
|
+
* than one, is refused at once instead of waiting out the action limit. The
|
|
3465
|
+
* pick goes by index, so the option chosen is the one matched. A control
|
|
3466
|
+
* whose options cannot be read (not a native select) keeps selectOption's
|
|
3467
|
+
* own matching, and its label as chosenOptionLabel reads it.
|
|
3468
|
+
*/
|
|
3469
|
+
static async resolveSelectPick(loc, value) {
|
|
3470
|
+
const options = await loc.evaluate(listSelectOptions, undefined, { timeout: 1000 }).catch(() => null);
|
|
3471
|
+
if (!options)
|
|
3472
|
+
return { arg: value, label: await BrowserEngine.chosenOptionLabel(loc, value), option: null };
|
|
3473
|
+
const match = matchOption(options, value);
|
|
3474
|
+
if ("refused" in match)
|
|
3475
|
+
return match;
|
|
3476
|
+
return { arg: { index: match.index }, label: match.option.label, option: match.option };
|
|
3477
|
+
}
|
|
3478
|
+
/** Record a dropdown's options, the one it held before, and the ones picked, by the values selectOption reported. */
|
|
2900
3479
|
recordSelectChoice(fingerprint, key, options, picked) {
|
|
2901
3480
|
const labels = options.map((o) => o.label);
|
|
3481
|
+
const loaded = options.filter((o) => o.selected).map((o) => o.label);
|
|
2902
3482
|
const chosen = picked.map((v) => options.find((o) => o.value === v)?.label).filter((l) => !!l);
|
|
2903
3483
|
if (chosen.length === 0)
|
|
2904
|
-
this.memory.recordSelectChoice(fingerprint, key, labels, "");
|
|
3484
|
+
this.memory.recordSelectChoice(fingerprint, key, labels, "", loaded);
|
|
2905
3485
|
for (const label of chosen)
|
|
2906
|
-
this.memory.recordSelectChoice(fingerprint, key, labels, label);
|
|
3486
|
+
this.memory.recordSelectChoice(fingerprint, key, labels, label, loaded);
|
|
2907
3487
|
}
|
|
2908
3488
|
async select(ref, value) {
|
|
2909
3489
|
return this.withinLimit("action", () => this.selectNow(ref, value));
|
|
2910
3490
|
}
|
|
2911
3491
|
async selectNow(ref, value) {
|
|
2912
3492
|
const page = this.requirePage();
|
|
2913
|
-
const { el, liveLabel } = await this.resolveForAction(ref);
|
|
2914
|
-
const refusal = this.actionPolicyCheck(el, liveLabel);
|
|
3493
|
+
const { el, liveLabel, live } = await this.resolveForAction(ref);
|
|
3494
|
+
const refusal = this.actionPolicyCheck(el, liveLabel, live);
|
|
2915
3495
|
if (refusal)
|
|
2916
3496
|
return refusal;
|
|
2917
|
-
if (this.readOnly) {
|
|
2918
|
-
// Bulk-action dropdowns fire on change — vet the chosen option itself.
|
|
2919
|
-
const optionLabel = (await this.scopeOf(el)
|
|
2920
|
-
.evaluate(`(() => { const node = ${xpathLookup(el.xpath)}; if (!node) return ''; const v = ${JSON.stringify(value)}; ` +
|
|
2921
|
-
`const opts = Array.from(node.options || []); ` +
|
|
2922
|
-
`const o = opts.find(o => o.value === v || o.label === v || (o.textContent || '').trim() === v); ` +
|
|
2923
|
-
`return o ? (o.label || o.textContent || '').trim().slice(0, 120) : ''; })()`)
|
|
2924
|
-
.catch(() => ""));
|
|
2925
|
-
if (isDestructive(value) || isDestructive(optionLabel)) {
|
|
2926
|
-
this.logAction({ action: "select:refused", target: optionLabel || value, url: page.url() });
|
|
2927
|
-
return destructiveRefusal(optionLabel || value, this.mode);
|
|
2928
|
-
}
|
|
2929
|
-
}
|
|
2930
3497
|
const loc = this.scopeOf(el).locator(`xpath=${el.xpath}`);
|
|
3498
|
+
const pick = await BrowserEngine.resolveSelectPick(loc, value);
|
|
3499
|
+
if ("refused" in pick) {
|
|
3500
|
+
this.logAction({ action: "select:no-match", target: `${el.name || el.testid || "dropdown"}: ${value}`.slice(0, 200), url: page.url() });
|
|
3501
|
+
return `NOT SELECTED: ${pick.refused} Nothing was changed.`;
|
|
3502
|
+
}
|
|
3503
|
+
// Bulk-action dropdowns fire on change — vet the chosen option itself. The
|
|
3504
|
+
// dropdown is not judged by its options (destructiveLabelOf), so the pick is the check: one that cannot be read is refused.
|
|
3505
|
+
if (this.readOnly && pickIsDestructive(value, el.testid, pick.label)) {
|
|
3506
|
+
this.logAction({ action: "select:refused", target: pick.label || value, url: page.url() });
|
|
3507
|
+
return destructiveRefusal(pick.label || value, this.mode);
|
|
3508
|
+
}
|
|
2931
3509
|
const options = el.tag === "select" ? await readSelectOptions(loc) : null;
|
|
2932
|
-
const picked = await loc.selectOption(
|
|
3510
|
+
const picked = await loc.selectOption(pick.arg, { timeout: this.limits.actionMs });
|
|
2933
3511
|
this.memory.markExercised(this.currentFingerprint, el.key, "select");
|
|
2934
3512
|
if (options)
|
|
2935
3513
|
this.recordSelectChoice(this.currentFingerprint, el.key, options, picked);
|
|
2936
|
-
|
|
3514
|
+
const matched = pick.option && pick.option.value !== value ? ` (matched option ${JSON.stringify(pick.option.value)}, labelled ${JSON.stringify(pick.option.label)})` : "";
|
|
3515
|
+
return this.afterAction("select", `${el.role} "${el.name}" = ${value}${matched}`);
|
|
2937
3516
|
}
|
|
2938
3517
|
/**
|
|
2939
3518
|
* Enter/Space activate the focused element — apply the same destructive
|
|
@@ -3064,17 +3643,66 @@ export class BrowserEngine {
|
|
|
3064
3643
|
return false;
|
|
3065
3644
|
}
|
|
3066
3645
|
}
|
|
3067
|
-
|
|
3646
|
+
/** `leave` answers a leave confirmation the page raises (policy.ts dialogResponse); undefined lets the mode decide. */
|
|
3647
|
+
async navigate(target, leave) {
|
|
3648
|
+
this.leaveChoice = leave;
|
|
3649
|
+
try {
|
|
3650
|
+
return await this.navigateNow(target);
|
|
3651
|
+
}
|
|
3652
|
+
finally {
|
|
3653
|
+
this.leaveChoice = undefined;
|
|
3654
|
+
this.onLeaveRefused = null;
|
|
3655
|
+
}
|
|
3656
|
+
}
|
|
3657
|
+
async navigateNow(target) {
|
|
3068
3658
|
this.actionStartedAt = Date.now();
|
|
3069
3659
|
const page = this.requirePage();
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3660
|
+
// A path resolves against the origin, not the page the session attached on (request.ts resolveTarget).
|
|
3661
|
+
const resolved = resolveTarget(this.baseUrl, target);
|
|
3662
|
+
if (!("url" in resolved)) {
|
|
3663
|
+
return resolved.offOrigin
|
|
3664
|
+
? `REFUSED: ${target.trim()} is outside the attached origin (${new URL(this.baseUrl).origin}). Exploration is fenced to the app under test.`
|
|
3665
|
+
: `REFUSED: ${resolved.problem}`;
|
|
3666
|
+
}
|
|
3667
|
+
const url = resolved.url;
|
|
3074
3668
|
// A notice describes ONE navigation. Clearing up front means a notice left
|
|
3075
3669
|
// undelivered by a previous throw can never prepend itself to this result.
|
|
3076
3670
|
this.authLoss.clear();
|
|
3077
|
-
|
|
3671
|
+
// A page holding unsent input can ask to confirm leaving. Answered "stay",
|
|
3672
|
+
// the navigation is cancelled, which a browser reports as an aborted load
|
|
3673
|
+
// (or, in some engines, never reports at all), so the answer itself ends
|
|
3674
|
+
// the wait and the result says what happened instead of ERR_ABORTED.
|
|
3675
|
+
// Only a confirmation raised by this navigation explains its failure, not one left over from before it.
|
|
3676
|
+
const dialogsBefore = this.dialogsSeen.length;
|
|
3677
|
+
const stayed = new Promise((resolve) => {
|
|
3678
|
+
this.onLeaveRefused = () => resolve("stayed");
|
|
3679
|
+
});
|
|
3680
|
+
const going = this.withinLimit("nav", () => page.goto(url, { waitUntil: "domcontentloaded", timeout: this.limits.navMs }));
|
|
3681
|
+
let outcome;
|
|
3682
|
+
try {
|
|
3683
|
+
outcome = await Promise.race([going.then(() => "went"), stayed]);
|
|
3684
|
+
}
|
|
3685
|
+
catch (err) {
|
|
3686
|
+
if (!this.leaveRefused(dialogsBefore))
|
|
3687
|
+
throw err;
|
|
3688
|
+
outcome = "stayed";
|
|
3689
|
+
}
|
|
3690
|
+
finally {
|
|
3691
|
+
this.onLeaveRefused = null;
|
|
3692
|
+
}
|
|
3693
|
+
if (outcome === "stayed") {
|
|
3694
|
+
// The cancelled load settles on its own; its error is the cancellation this result reports.
|
|
3695
|
+
going.catch((err) => {
|
|
3696
|
+
this.logAction({
|
|
3697
|
+
action: "navigate:cancelled",
|
|
3698
|
+
target: err instanceof Error ? err.message.split("\n")[0].slice(0, 160) : String(err),
|
|
3699
|
+
url: page.url(),
|
|
3700
|
+
});
|
|
3701
|
+
});
|
|
3702
|
+
// Not a navigation outcome: the session stayed by its own answer, so nothing is recorded for the auth-loss tracker.
|
|
3703
|
+
const result = await this.afterAction("navigate", url);
|
|
3704
|
+
return result.replace(/^OK: navigate /, "NOT NAVIGATED: ");
|
|
3705
|
+
}
|
|
3078
3706
|
// Settle BEFORE judging where we landed. A client-side auth guard redirects
|
|
3079
3707
|
// after hydration, not during goto, so reading page.url() here showed the
|
|
3080
3708
|
// requested path and the bounce went unnoticed — which is precisely how a
|
|
@@ -3183,8 +3811,9 @@ export class BrowserEngine {
|
|
|
3183
3811
|
* Replace this context's cookies and storage with a profile, in place: same
|
|
3184
3812
|
* pages, listeners and policy. Playwright is handed only what it restores;
|
|
3185
3813
|
* the profile's sessionStorage list is not part of its storage state.
|
|
3814
|
+
* With `load` "cookies" only the cookie jar is replaced (profileLoadWhileHeld, refresh.ts).
|
|
3186
3815
|
*/
|
|
3187
|
-
async applyState(state) {
|
|
3816
|
+
async applyState(state, load = "storage") {
|
|
3188
3817
|
if (!this.context)
|
|
3189
3818
|
return { ok: false, why: "no browser is open" };
|
|
3190
3819
|
let storageState;
|
|
@@ -3194,8 +3823,15 @@ export class BrowserEngine {
|
|
|
3194
3823
|
catch (err) {
|
|
3195
3824
|
return { ok: false, why: `the saved profile could not be split (${err instanceof Error ? err.message : String(err)})` };
|
|
3196
3825
|
}
|
|
3826
|
+
const playwrightState = storageState;
|
|
3197
3827
|
try {
|
|
3198
|
-
|
|
3828
|
+
if (load === "cookies") {
|
|
3829
|
+
await this.context.clearCookies();
|
|
3830
|
+
await this.context.addCookies(playwrightState.cookies ?? []);
|
|
3831
|
+
}
|
|
3832
|
+
else {
|
|
3833
|
+
await this.context.setStorageState(playwrightState);
|
|
3834
|
+
}
|
|
3199
3835
|
}
|
|
3200
3836
|
catch (err) {
|
|
3201
3837
|
return { ok: false, why: `the browser refused its saved profile (${err instanceof Error ? err.message.split("\n")[0] : String(err)})` };
|
|
@@ -3267,11 +3903,12 @@ export class BrowserEngine {
|
|
|
3267
3903
|
if (plan.kind === "send") {
|
|
3268
3904
|
this.logAction({ action: "refresh-broker", target: `${where} with ${sent.slot}`, url: this.page?.url() ?? "" });
|
|
3269
3905
|
handedOn = true;
|
|
3270
|
-
this.trackRefreshTask(this.writeBackAfter(this.pageAnswer(req), sent, lock));
|
|
3906
|
+
this.trackRefreshTask(this.writeBackAfter(this.pageAnswer(req), sent, lock, false));
|
|
3271
3907
|
return this.passOn(route);
|
|
3272
3908
|
}
|
|
3273
3909
|
// Another session rotated the token while this one waited: load what it saved, and send the current token.
|
|
3274
|
-
const
|
|
3910
|
+
const loaded = profileLoadWhileHeld(req.isNavigationRequest());
|
|
3911
|
+
const applied = await this.applyState(read.state, loaded);
|
|
3275
3912
|
if (!applied.ok)
|
|
3276
3913
|
throw new Error(applied.why);
|
|
3277
3914
|
this.refreshCounts.swapped += 1;
|
|
@@ -3288,7 +3925,9 @@ export class BrowserEngine {
|
|
|
3288
3925
|
url: this.page?.url() ?? "",
|
|
3289
3926
|
});
|
|
3290
3927
|
handedOn = true;
|
|
3291
|
-
|
|
3928
|
+
// Reported once the request goes out with the current token, so a swap that then fails is reported only as a failure.
|
|
3929
|
+
this.refreshEvents.push("swapped");
|
|
3930
|
+
this.trackRefreshTask(this.writeBackAfter(this.pageAnswer(req), plan.to, lock, true, writeBackStorageFrom(loaded, plan.to)));
|
|
3292
3931
|
return this.passOn(route, {
|
|
3293
3932
|
...(swapped.url ? { url: swapped.url } : {}),
|
|
3294
3933
|
...(swapped.body !== undefined ? { postData: swapped.body } : {}),
|
|
@@ -3323,8 +3962,9 @@ export class BrowserEngine {
|
|
|
3323
3962
|
target: `${where} with ${plan.to.slot} (rotated by another session; loaded its profile and sent the current cookie)`,
|
|
3324
3963
|
url: this.page?.url() ?? "",
|
|
3325
3964
|
});
|
|
3965
|
+
this.refreshEvents.push("swapped");
|
|
3326
3966
|
// The response's cookies are already in the browser's jar, so the write-back does not wait on the page.
|
|
3327
|
-
this.trackRefreshTask(this.writeBackAfter(Promise.resolve(BrowserEngine.fetchedAnswer(response)), plan.to, lock));
|
|
3967
|
+
this.trackRefreshTask(this.writeBackAfter(Promise.resolve(BrowserEngine.fetchedAnswer(response)), plan.to, lock, true));
|
|
3328
3968
|
await route.fulfill({ response }).catch(() => {
|
|
3329
3969
|
/* the page went away before its answer: the rotation is still saved */
|
|
3330
3970
|
});
|
|
@@ -3362,6 +4002,7 @@ export class BrowserEngine {
|
|
|
3362
4002
|
/** The broker could not do its job for this request: it goes on as the page sent it, and the action log says why. */
|
|
3363
4003
|
async unbrokered(route, where, err) {
|
|
3364
4004
|
this.refreshCounts.failed += 1;
|
|
4005
|
+
this.refreshEvents.push("failed");
|
|
3365
4006
|
// Unbrokered after all: if its response rotates the cookie, the rotation watcher saves it.
|
|
3366
4007
|
this.brokeredRequests.delete(route.request());
|
|
3367
4008
|
this.logAction({
|
|
@@ -3469,8 +4110,10 @@ export class BrowserEngine {
|
|
|
3469
4110
|
return;
|
|
3470
4111
|
const isNew = !broker.learned.has(key);
|
|
3471
4112
|
broker.learned.add(key);
|
|
3472
|
-
if (isNew)
|
|
4113
|
+
if (isNew) {
|
|
3473
4114
|
this.refreshCounts.learned += 1;
|
|
4115
|
+
this.refreshEvents.push("learned");
|
|
4116
|
+
}
|
|
3474
4117
|
const names = rotated.map((r) => r.slot).join(", ");
|
|
3475
4118
|
let lock;
|
|
3476
4119
|
try {
|
|
@@ -3500,7 +4143,7 @@ export class BrowserEngine {
|
|
|
3500
4143
|
for (let waited = 0; stillSpent && waited <= 1000; waited += 50) {
|
|
3501
4144
|
const state = await this.context?.storageState().catch(() => undefined);
|
|
3502
4145
|
if (state && rotated.every((r) => rotationStored(state, r))) {
|
|
3503
|
-
now = state;
|
|
4146
|
+
now = await this.withPageIndexedDB(state, (full) => rotated.every((r) => rotationStored(full, r)));
|
|
3504
4147
|
break;
|
|
3505
4148
|
}
|
|
3506
4149
|
await new Promise((r) => setTimeout(r, 50));
|
|
@@ -3534,9 +4177,10 @@ export class BrowserEngine {
|
|
|
3534
4177
|
* that has not stored it in time has the token read from the response
|
|
3535
4178
|
* instead. A refused or failed refresh changes nothing on disk, and nor does
|
|
3536
4179
|
* one whose response left a refresh cookie as it was (a server that does
|
|
3537
|
-
* not rotate refresh tokens).
|
|
4180
|
+
* not rotate refresh tokens). `storageFrom` says whether the origins'
|
|
4181
|
+
* storage written back is the page's or stays the profile's (writeBackStorageFrom, refresh.ts).
|
|
3538
4182
|
*/
|
|
3539
|
-
async writeBackAfter(answer, presented, lock) {
|
|
4183
|
+
async writeBackAfter(answer, presented, lock, swapped, storageFrom = "page") {
|
|
3540
4184
|
const broker = this.refresh;
|
|
3541
4185
|
try {
|
|
3542
4186
|
const res = await answer;
|
|
@@ -3557,8 +4201,9 @@ export class BrowserEngine {
|
|
|
3557
4201
|
for (let waited = 0; waited <= patienceMs; waited += 50) {
|
|
3558
4202
|
const now = await this.context?.storageState().catch(() => null);
|
|
3559
4203
|
if (now && rotationStored(now, presented)) {
|
|
4204
|
+
const full = await this.withPageIndexedDB(now, (read) => rotationStored(read, presented));
|
|
3560
4205
|
const onDisk = this.readRoleProfile();
|
|
3561
|
-
state = profileAfterRotation(
|
|
4206
|
+
state = profileAfterRotation(full, onDisk.ok ? onDisk.state : null, storageFrom);
|
|
3562
4207
|
break;
|
|
3563
4208
|
}
|
|
3564
4209
|
await new Promise((r) => setTimeout(r, 50));
|
|
@@ -3580,6 +4225,7 @@ export class BrowserEngine {
|
|
|
3580
4225
|
}
|
|
3581
4226
|
if (!state) {
|
|
3582
4227
|
this.refreshCounts.failed += 1;
|
|
4228
|
+
this.refreshEvents.push("failed");
|
|
3583
4229
|
this.logAction({
|
|
3584
4230
|
action: "refresh-broker:not-saved",
|
|
3585
4231
|
target: `${presented.slot}: the page stored no rotated token and the response named none`,
|
|
@@ -3590,9 +4236,13 @@ export class BrowserEngine {
|
|
|
3590
4236
|
writeProfile(broker.projectDir, broker.role, state);
|
|
3591
4237
|
this.rememberTokens(refreshTokenSlots(state));
|
|
3592
4238
|
this.refreshCounts.refreshed += 1;
|
|
4239
|
+
// A swap was reported as it happened; this is the session's own refresh.
|
|
4240
|
+
if (!swapped)
|
|
4241
|
+
this.refreshEvents.push("refreshed");
|
|
3593
4242
|
}
|
|
3594
4243
|
catch (err) {
|
|
3595
4244
|
this.refreshCounts.failed += 1;
|
|
4245
|
+
this.refreshEvents.push("failed");
|
|
3596
4246
|
this.logAction({
|
|
3597
4247
|
action: "refresh-broker:not-saved",
|
|
3598
4248
|
target: `${presented.slot}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
|
|
@@ -3603,6 +4253,24 @@ export class BrowserEngine {
|
|
|
3603
4253
|
lock.release();
|
|
3604
4254
|
}
|
|
3605
4255
|
}
|
|
4256
|
+
/**
|
|
4257
|
+
* The page's storage state read again with its IndexedDB, which a plain read
|
|
4258
|
+
* leaves out and a saved profile keeps. The plain state when that read fails
|
|
4259
|
+
* or no longer shows the rotation; profileAfterRotation then keeps the
|
|
4260
|
+
* profile's own IndexedDB, so nothing is dropped either way.
|
|
4261
|
+
*/
|
|
4262
|
+
async withPageIndexedDB(plain, holds) {
|
|
4263
|
+
const full = await this.context?.storageState({ indexedDB: true }).catch(() => null);
|
|
4264
|
+
return full && holds(full) ? full : plain;
|
|
4265
|
+
}
|
|
4266
|
+
/** Report (and clear) what the refresh broker did since the last action: counts only (refreshNotice). */
|
|
4267
|
+
drainRefresh() {
|
|
4268
|
+
if (this.refreshEvents.length === 0)
|
|
4269
|
+
return "";
|
|
4270
|
+
const events = this.refreshEvents;
|
|
4271
|
+
this.refreshEvents = [];
|
|
4272
|
+
return refreshNotice(events);
|
|
4273
|
+
}
|
|
3606
4274
|
/** Take `now` as the role's current tokens; the ones it replaces are kept as spent, so a page that still sends one is caught. */
|
|
3607
4275
|
rememberTokens(now) {
|
|
3608
4276
|
if (!this.refresh)
|
|
@@ -3641,11 +4309,25 @@ export class BrowserEngine {
|
|
|
3641
4309
|
}
|
|
3642
4310
|
this.authLoss.record({ requestedRoute, landedRoute, bounced, role: this.role, target: requestedUrl });
|
|
3643
4311
|
}
|
|
3644
|
-
|
|
4312
|
+
/** `leave` answers a leave confirmation the page raises (policy.ts dialogResponse); undefined lets the mode decide. */
|
|
4313
|
+
async goBack(leave) {
|
|
3645
4314
|
this.actionStartedAt = Date.now();
|
|
3646
4315
|
const page = this.requirePage();
|
|
3647
|
-
|
|
3648
|
-
|
|
4316
|
+
this.leaveChoice = leave;
|
|
4317
|
+
try {
|
|
4318
|
+
// A back that goes nowhere (no history, a cancelled leave) is reported by the URL the result shows.
|
|
4319
|
+
await page.goBack({ waitUntil: "domcontentloaded", timeout: this.limits.backNavMs }).catch((err) => {
|
|
4320
|
+
this.logAction({
|
|
4321
|
+
action: "back:no-navigation",
|
|
4322
|
+
target: err instanceof Error ? err.message.split("\n")[0].slice(0, 160) : String(err),
|
|
4323
|
+
url: page.url(),
|
|
4324
|
+
});
|
|
4325
|
+
});
|
|
4326
|
+
return await this.afterAction("back", "");
|
|
4327
|
+
}
|
|
4328
|
+
finally {
|
|
4329
|
+
this.leaveChoice = undefined;
|
|
4330
|
+
}
|
|
3649
4331
|
}
|
|
3650
4332
|
/** The full route contract: scanned filesystem routes ∪ link-discovered route classes. */
|
|
3651
4333
|
allKnownRoutes() {
|
|
@@ -3666,7 +4348,10 @@ export class BrowserEngine {
|
|
|
3666
4348
|
*
|
|
3667
4349
|
* Visited/attempted keys are stored NORMALIZED, so the normalized form of
|
|
3668
4350
|
* each known route is compared too — normalizePath is idempotent, so this
|
|
3669
|
-
* only adds matches for routes that genuinely were reached.
|
|
4351
|
+
* only adds matches for routes that genuinely were reached. Reached is
|
|
4352
|
+
* memory's reachedRoutes, the gap ledger's own rule: stored routes are read
|
|
4353
|
+
* through today's route identity, and a base path is reached by one of its
|
|
4354
|
+
* tabs or sections.
|
|
3670
4355
|
*/
|
|
3671
4356
|
unvisitedKnownRoutes() {
|
|
3672
4357
|
if (!this.memory)
|
|
@@ -3674,11 +4359,11 @@ export class BrowserEngine {
|
|
|
3674
4359
|
const all = this.allKnownRoutes();
|
|
3675
4360
|
if (all.length === 0)
|
|
3676
4361
|
return [];
|
|
3677
|
-
const
|
|
4362
|
+
const reached = reachedRoutes(Object.values(this.memory.states).map((s) => s.route));
|
|
3678
4363
|
const attempted = this.memory.attemptedByRole(this.role);
|
|
3679
4364
|
return all.filter((r) => {
|
|
3680
4365
|
const n = normalizePath(r);
|
|
3681
|
-
return !
|
|
4366
|
+
return !reached(r) && !(r in attempted) && !(n in attempted);
|
|
3682
4367
|
});
|
|
3683
4368
|
}
|
|
3684
4369
|
/** Map a route class to something goto-able (discovered classes carry a concrete example). */
|
|
@@ -3732,7 +4417,8 @@ export class BrowserEngine {
|
|
|
3732
4417
|
const page = this.requirePage();
|
|
3733
4418
|
const memory = this.memory;
|
|
3734
4419
|
this.crawlHealth = [];
|
|
3735
|
-
const
|
|
4420
|
+
const explicit = !!paths && paths.length > 0;
|
|
4421
|
+
const targets = (explicit ? paths : this.crawlableRoutes().map((r) => this.navigablePath(r))).slice(0, Math.min(150, opts.limit ?? 150));
|
|
3736
4422
|
if (targets.length === 0) {
|
|
3737
4423
|
const failed = this.unvisitedKnownRoutes().filter((r) => this.loadFailedRoutes.has(normalizePath(r)));
|
|
3738
4424
|
if (failed.length > 0) {
|
|
@@ -3755,11 +4441,12 @@ export class BrowserEngine {
|
|
|
3755
4441
|
summary.push(`… stopped at the time limit: ${queue.length - i} route(s) not started`);
|
|
3756
4442
|
break;
|
|
3757
4443
|
}
|
|
3758
|
-
const
|
|
3759
|
-
if (!
|
|
3760
|
-
summary.push(`${path} — SKIPPED (off-origin)`);
|
|
4444
|
+
const resolved = resolveTarget(this.baseUrl, path);
|
|
4445
|
+
if (!("url" in resolved)) {
|
|
4446
|
+
summary.push(`${path} — SKIPPED (${resolved.offOrigin ? "off-origin" : resolved.problem})`);
|
|
3761
4447
|
continue;
|
|
3762
4448
|
}
|
|
4449
|
+
const url = resolved.url;
|
|
3763
4450
|
this.actionStartedAt = Date.now();
|
|
3764
4451
|
this.oracles.drain(false); // discard pre-route leftovers WITHOUT marking their signatures as reported
|
|
3765
4452
|
let status = "ERR";
|
|
@@ -3775,11 +4462,7 @@ export class BrowserEngine {
|
|
|
3775
4462
|
// must not count a route as covered in every later run's gap ledger.
|
|
3776
4463
|
if (!opts.measureOnly)
|
|
3777
4464
|
this.loadFailedRoutes.add(normalizePath(url));
|
|
3778
|
-
|
|
3779
|
-
// milliseconds after goto has thrown, and that commit interrupts the next
|
|
3780
|
-
// navigation, charging this route's failure to the next one. Wait for it;
|
|
3781
|
-
// a browser that shows no error page simply lets the wait time out.
|
|
3782
|
-
await page.waitForEvent("framenavigated", { predicate: (f) => f === page.mainFrame(), timeout: 1500 }).catch(() => undefined);
|
|
4465
|
+
await this.errorPageCommitted(page);
|
|
3783
4466
|
summary.push(`${path} — LOAD FAILED`);
|
|
3784
4467
|
// The page a re-attach went back to never loaded, so nothing says whether it worked.
|
|
3785
4468
|
if (this.authLoss.reattaching)
|
|
@@ -3803,14 +4486,14 @@ export class BrowserEngine {
|
|
|
3803
4486
|
}
|
|
3804
4487
|
await this.settle();
|
|
3805
4488
|
this.harvestPaused = opts.measureOnly === true;
|
|
3806
|
-
const { elements, forms } = await this.collect().finally(() => (this.harvestPaused = false));
|
|
4489
|
+
const { elements, forms, aliases } = await this.collect().finally(() => (this.harvestPaused = false));
|
|
3807
4490
|
const finalUrl = page.url();
|
|
3808
4491
|
const route = normalizePath(finalUrl);
|
|
3809
4492
|
const fp = fingerprintState(finalUrl, trackedElements(elements));
|
|
3810
4493
|
if (!opts.measureOnly) {
|
|
3811
|
-
memory.visitState(fp, finalUrl, route, trackedElements(elements).map((el) => el.key), inertKeys(elements));
|
|
4494
|
+
memory.visitState(fp, finalUrl, route, trackedElements(elements).map((el) => el.key), inertKeys(elements), this.sessionKey, aliases);
|
|
3812
4495
|
for (const f of forms)
|
|
3813
|
-
memory.recordForm(fp, f.key, f.guarded);
|
|
4496
|
+
memory.recordForm(fp, f.key, f.guarded, this.sessionKey);
|
|
3814
4497
|
memory.recordRoleAccess(this.role, route, "reached");
|
|
3815
4498
|
}
|
|
3816
4499
|
// If we landed somewhere else (auth wall, canonical redirect), the
|
|
@@ -3862,18 +4545,32 @@ export class BrowserEngine {
|
|
|
3862
4545
|
// What the page's main area holds besides controls: "41 el" alone cannot tell a page
|
|
3863
4546
|
// of text from a main area that rendered nothing.
|
|
3864
4547
|
const main = await this.readMainRegion(page);
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
4548
|
+
// A main area holding only an alert or a loading placeholder is the usual look of a broken route that answered 200.
|
|
4549
|
+
const shows = main && !deadEnd ? mainState(main) : null;
|
|
4550
|
+
const stateFlag = mainStateFlag(shows);
|
|
4551
|
+
const flags = [
|
|
4552
|
+
loginRedirect ? "AUTH-REDIRECT" : null,
|
|
4553
|
+
deadEnd ? "DEAD-END" : null,
|
|
4554
|
+
stateFlag,
|
|
4555
|
+
violations.length > 0 ? `${violations.length}⚠` : null,
|
|
4556
|
+
].filter((f) => f !== null);
|
|
4557
|
+
const outcome = { path, status, requestedRoute, landedRoute: route, loginRedirect, deadEnd, mainState: shows };
|
|
4558
|
+
summary.push(crawlLine(outcome, { elements: trackedElements(elements).length, missingTestid, unnamed, main: main ? mainRegionTag(main) : null }, flags));
|
|
4559
|
+
// A path asked for by name joins the route contract once it answered as a page (crawl.ts crawledRoute).
|
|
4560
|
+
const joined = explicit && !opts.measureOnly ? crawledRoute(outcome) : null;
|
|
4561
|
+
if (joined)
|
|
4562
|
+
memory.addDiscoveredRoutes([{ route: joined, example: path }]);
|
|
4563
|
+
if (violations.length > 0 || deadEnd || stateFlag || loginRedirect || (typeof status === "number" && status >= 400)) {
|
|
3872
4564
|
const detail = violations
|
|
3873
4565
|
.slice(0, 3)
|
|
3874
4566
|
.map((v) => ` ${v.kind}: ${v.detail.slice(0, 160)}`)
|
|
3875
4567
|
.join("\n");
|
|
3876
|
-
|
|
4568
|
+
const showing = shows === "error"
|
|
4569
|
+
? ` → main area shows only an error view${main?.text ? ` ("${main.text.slice(0, 80)}")` : ""}`
|
|
4570
|
+
: shows === "loading"
|
|
4571
|
+
? ` → main area still shows only a loading placeholder after settling${main?.text ? ` ("${main.text.slice(0, 80)}")` : ""}`
|
|
4572
|
+
: "";
|
|
4573
|
+
problems.push(`${path}${loginRedirect ? " → redirected to login (auth missing/expired?)" : ""}${deadEnd ? " → dead end" : ""}${showing}${detail ? `\n${detail}` : ""}`);
|
|
3877
4574
|
}
|
|
3878
4575
|
// A role session that lost its sign-in on this route re-attaches once
|
|
3879
4576
|
// and visits the routes the loss bounced again, next. Their bounced
|
|
@@ -3899,7 +4596,7 @@ export class BrowserEngine {
|
|
|
3899
4596
|
}
|
|
3900
4597
|
// Crawl leaves the page wherever it ended — refs from before are gone.
|
|
3901
4598
|
this.refs.clear();
|
|
3902
|
-
this.
|
|
4599
|
+
this.forgetSnapshots();
|
|
3903
4600
|
this.snapshotUrl = "";
|
|
3904
4601
|
const unvisited = this.unvisitedKnownRoutes();
|
|
3905
4602
|
return (
|
|
@@ -3921,11 +4618,18 @@ export class BrowserEngine {
|
|
|
3921
4618
|
* time by semantic locator (testid= / text= / label=), never by snapshot
|
|
3922
4619
|
* ref, so the plan is immune to DOM drift. Aborts on the first oracle
|
|
3923
4620
|
* violation or policy refusal so the driver re-enters at the interesting moment.
|
|
4621
|
+
* With onViolation "continue", a new error status (or its console echo) is
|
|
4622
|
+
* listed on its step's line and the plan goes on (oracles.ts planStopsAt):
|
|
4623
|
+
* for a sweep of independent steps, where a later step does not depend on
|
|
4624
|
+
* an earlier one. A failed step or a policy refusal still stops it.
|
|
3924
4625
|
*/
|
|
3925
|
-
async runPlan(steps) {
|
|
4626
|
+
async runPlan(steps, onViolation = "stop") {
|
|
3926
4627
|
const page = this.requirePage();
|
|
3927
4628
|
const transcript = [];
|
|
3928
|
-
const
|
|
4629
|
+
const planStartedAt = Date.now();
|
|
4630
|
+
/** Steps the plan went on past a new violation at (onViolation "continue"). */
|
|
4631
|
+
const continuedPast = [];
|
|
4632
|
+
const locatePlanTarget = (target) => {
|
|
3929
4633
|
const parsed = parseTarget(target);
|
|
3930
4634
|
if (!parsed)
|
|
3931
4635
|
throw new Error(`Plan targets must be ${TARGET_HELP} (got: ${target})`);
|
|
@@ -3952,6 +4656,8 @@ export class BrowserEngine {
|
|
|
3952
4656
|
let preTestid = null;
|
|
3953
4657
|
let preLabel = "";
|
|
3954
4658
|
let forcedClick = false;
|
|
4659
|
+
let cover = null;
|
|
4660
|
+
let coverReadAt = 0;
|
|
3955
4661
|
// What a type step has to say about the field it typed into; it goes on
|
|
3956
4662
|
// the step's own line, so it cannot read as the previous step's.
|
|
3957
4663
|
let note = "";
|
|
@@ -4003,7 +4709,7 @@ export class BrowserEngine {
|
|
|
4003
4709
|
else {
|
|
4004
4710
|
if (!step.target)
|
|
4005
4711
|
throw new Error(`${step.action} needs a target`);
|
|
4006
|
-
const loc =
|
|
4712
|
+
const loc = locatePlanTarget(step.target);
|
|
4007
4713
|
// Coverage is recorded against the state the element LIVED IN, so it
|
|
4008
4714
|
// has to be captured before the action changes the page. Marking it
|
|
4009
4715
|
// afterwards (as this did) recorded against the state the click
|
|
@@ -4022,7 +4728,17 @@ export class BrowserEngine {
|
|
|
4022
4728
|
preTestid = await loc.getAttribute("data-testid").catch(() => null);
|
|
4023
4729
|
const label = await liveLabel(loc);
|
|
4024
4730
|
preLabel = label;
|
|
4025
|
-
|
|
4731
|
+
// A dropdown is named by all its options; a select step is judged by the option it picks (destructiveLabelOf).
|
|
4732
|
+
const isSelect = step.action === "select" && (await loc.evaluate((n) => n.tagName.toLowerCase() === "select").catch(() => false));
|
|
4733
|
+
// The option a select step means, settled before anything is picked: one naming none fails the step at once.
|
|
4734
|
+
const pick = step.action === "select" ? await BrowserEngine.resolveSelectPick(loc, step.value ?? "") : null;
|
|
4735
|
+
if (pick && "refused" in pick) {
|
|
4736
|
+
transcript.push(`${desc} → FAILED: ${pick.refused}`);
|
|
4737
|
+
break;
|
|
4738
|
+
}
|
|
4739
|
+
if (this.readOnly &&
|
|
4740
|
+
(step.action === "click" || step.action === "select" || step.action === "upload") &&
|
|
4741
|
+
(isSelect ? pickIsDestructive(step.value, preTestid, pick ? pick.label : null) : isDestructive(label, step.value))) {
|
|
4026
4742
|
transcript.push(`${desc} → ${destructiveRefusal(label || step.target, this.mode)}`);
|
|
4027
4743
|
break;
|
|
4028
4744
|
}
|
|
@@ -4032,7 +4748,7 @@ export class BrowserEngine {
|
|
|
4032
4748
|
preState = await this.stateHolding(probe, preState, preState !== null && preState === lastCapture);
|
|
4033
4749
|
form = { kind: "click", probe };
|
|
4034
4750
|
}
|
|
4035
|
-
forcedClick =
|
|
4751
|
+
({ forced: forcedClick, cover, coverReadAt } = await this.resilientClick(loc, this.limits.actionMs));
|
|
4036
4752
|
}
|
|
4037
4753
|
else if (step.action === "hover") {
|
|
4038
4754
|
const { before, bodyBefore, churning } = await this.hoverBaselines();
|
|
@@ -4065,7 +4781,7 @@ export class BrowserEngine {
|
|
|
4065
4781
|
}
|
|
4066
4782
|
else if (step.action === "select") {
|
|
4067
4783
|
const options = await readSelectOptions(loc);
|
|
4068
|
-
const picked = await loc.selectOption(step.value ?? "", { timeout: this.limits.actionMs });
|
|
4784
|
+
const picked = await loc.selectOption(pick && !("refused" in pick) ? pick.arg : (step.value ?? ""), { timeout: this.limits.actionMs });
|
|
4069
4785
|
if (options)
|
|
4070
4786
|
chose = { options, picked };
|
|
4071
4787
|
}
|
|
@@ -4095,12 +4811,12 @@ export class BrowserEngine {
|
|
|
4095
4811
|
try {
|
|
4096
4812
|
// Record the state the action LANDED on (it may be a new screen
|
|
4097
4813
|
// this plan just reached, and it deserves coverage of its own)…
|
|
4098
|
-
const { elements, forms } = await this.collect();
|
|
4814
|
+
const { elements, forms, aliases } = await this.collect();
|
|
4099
4815
|
const url = page.url();
|
|
4100
4816
|
const fp = fingerprintState(url, trackedElements(elements));
|
|
4101
|
-
this.memory.visitState(fp, url, normalizePath(url), trackedElements(elements).map((el) => el.key), inertKeys(elements));
|
|
4817
|
+
this.memory.visitState(fp, url, normalizePath(url), trackedElements(elements).map((el) => el.key), inertKeys(elements), this.sessionKey, aliases);
|
|
4102
4818
|
for (const f of forms)
|
|
4103
|
-
this.memory.recordForm(fp, f.key, f.guarded);
|
|
4819
|
+
this.memory.recordForm(fp, f.key, f.guarded, this.sessionKey);
|
|
4104
4820
|
this.memory.recordRoleAccess(this.role, normalizePath(url), "reached");
|
|
4105
4821
|
lastCapture = { fp, elements, url };
|
|
4106
4822
|
// …but mark the acted-on element in the state it came FROM, using
|
|
@@ -4135,17 +4851,24 @@ export class BrowserEngine {
|
|
|
4135
4851
|
await this.scanForInjections();
|
|
4136
4852
|
await this.scanForContradictions();
|
|
4137
4853
|
const violations = this.oracles.drain();
|
|
4138
|
-
const mutations = this.drainMutations() + this.drainBlocked() + this.drainCreated();
|
|
4854
|
+
const mutations = this.drainDialogs() + this.drainMutations() + this.drainBlocked() + this.drainRefresh() + this.drainCreated();
|
|
4855
|
+
const forcedNote = !forcedClick
|
|
4856
|
+
? ""
|
|
4857
|
+
: cover
|
|
4858
|
+
? ` (forced — the strict click timed out because at its centre it is ${describeCover(cover, this.blockedBetween(Math.max(planStartedAt, this.snapshotAt), coverReadAt))}; a forced click still landed)`
|
|
4859
|
+
: " (forced — the strict click timed out on this element's hit-test/stability check but a forced click still landed; something may render on top of it or delegate via a label, cross-check GEOMETRY overlaps before calling it a bug)";
|
|
4139
4860
|
// Abort only on NEW violations: a known-failing endpoint repeating on
|
|
4140
4861
|
// every navigation must not make every plan abort at step 1.
|
|
4141
|
-
if (violations
|
|
4862
|
+
if (planStopsAt(violations, onViolation)) {
|
|
4142
4863
|
transcript.push(`${desc} → OK${note}, but oracle fired:${formatViolations(violations)}${mutations}`);
|
|
4143
4864
|
transcript.push(`PLAN ABORTED at step ${i + 1} — investigate before continuing.`);
|
|
4144
4865
|
break;
|
|
4145
4866
|
}
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
: "";
|
|
4867
|
+
if (violations.some((v) => !v.repeat)) {
|
|
4868
|
+
continuedPast.push(i + 1);
|
|
4869
|
+
transcript.push(`${desc} → OK (${page.url()})${note}${forcedNote}, oracle fired (continuing: onViolation "continue"):${formatViolations(violations)}${mutations}`);
|
|
4870
|
+
continue;
|
|
4871
|
+
}
|
|
4149
4872
|
transcript.push(`${desc} → OK (${page.url()})${note}${mutations}${forcedNote}`);
|
|
4150
4873
|
}
|
|
4151
4874
|
catch (err) {
|
|
@@ -4168,12 +4891,15 @@ export class BrowserEngine {
|
|
|
4168
4891
|
}
|
|
4169
4892
|
}
|
|
4170
4893
|
this.refs.clear();
|
|
4171
|
-
this.
|
|
4894
|
+
this.forgetSnapshots();
|
|
4172
4895
|
this.snapshotUrl = "";
|
|
4173
4896
|
// Count step lines, not transcript lines — hover reveals and scroll
|
|
4174
4897
|
// positions push informational entries that are not steps.
|
|
4175
4898
|
const ran = transcript.filter((l) => /^\d+\. /.test(l)).length;
|
|
4176
|
-
|
|
4899
|
+
const continued = continuedPast.length > 0
|
|
4900
|
+
? `\nCONTINUED PAST new oracle violations at step${continuedPast.length > 1 ? "s" : ""} ${continuedPast.join(", ")} (onViolation "continue"); each is listed on its step's line and kept for the report.`
|
|
4901
|
+
: "";
|
|
4902
|
+
return `PLAN (${ran}/${Math.min(steps.length, 20)} steps ran):\n${transcript.join("\n")}${continued}\nTake scout_snapshot to see the resulting state.`;
|
|
4177
4903
|
}
|
|
4178
4904
|
/** A plan's or a flow's target as a Playwright locator (every match; callers pick). */
|
|
4179
4905
|
static locatorFor(page, target) {
|
|
@@ -4253,10 +4979,6 @@ export class BrowserEngine {
|
|
|
4253
4979
|
await (this.page ?? page).waitForTimeout(100).catch(() => { });
|
|
4254
4980
|
}
|
|
4255
4981
|
};
|
|
4256
|
-
const firstLine = (err) => (err instanceof Error ? err.message : String(err))
|
|
4257
|
-
.split("\n")[0]
|
|
4258
|
-
.replace(/^[a-z]+\.[a-zA-Z]+: /, "")
|
|
4259
|
-
.trim();
|
|
4260
4982
|
try {
|
|
4261
4983
|
for (const [i, step] of steps.entries()) {
|
|
4262
4984
|
const n = i + 1;
|
|
@@ -4271,7 +4993,10 @@ export class BrowserEngine {
|
|
|
4271
4993
|
try {
|
|
4272
4994
|
const current = this.requirePage();
|
|
4273
4995
|
if (step.action === "navigate") {
|
|
4274
|
-
const
|
|
4996
|
+
const resolved = resolveTarget(this.baseUrl, step.target);
|
|
4997
|
+
if (!("url" in resolved))
|
|
4998
|
+
throw new Error(resolved.problem);
|
|
4999
|
+
const url = resolved.url;
|
|
4275
5000
|
const resp = await current
|
|
4276
5001
|
.goto(url, { waitUntil: "domcontentloaded", timeout: this.limits.crawlNavMs })
|
|
4277
5002
|
.catch((err) => Promise.reject(explainTimeout(err, "nav", this.limits.crawlNavMs)));
|
|
@@ -4304,7 +5029,14 @@ export class BrowserEngine {
|
|
|
4304
5029
|
const label = ((await loc.getAttribute("aria-label").catch(() => null)) ??
|
|
4305
5030
|
(await loc.textContent({ timeout: 1000 }).catch(() => null)) ??
|
|
4306
5031
|
"").trim();
|
|
4307
|
-
|
|
5032
|
+
// A dropdown is named by all its options; a select step is judged by the option it picks (destructiveLabelOf).
|
|
5033
|
+
const isSelect = step.action === "select" && (await loc.evaluate((n) => n.tagName.toLowerCase() === "select").catch(() => false));
|
|
5034
|
+
const testid = await loc.getAttribute("data-testid").catch(() => null);
|
|
5035
|
+
if (this.readOnly &&
|
|
5036
|
+
step.action !== "type" &&
|
|
5037
|
+
(isSelect
|
|
5038
|
+
? pickIsDestructive(step.value, testid, await BrowserEngine.chosenOptionLabel(loc, step.value))
|
|
5039
|
+
: isDestructive(label, step.action === "select" ? step.value : undefined))) {
|
|
4308
5040
|
refusal = destructiveRefusal(label || step.target, this.mode);
|
|
4309
5041
|
}
|
|
4310
5042
|
else if (step.action === "click") {
|
|
@@ -4351,7 +5083,7 @@ export class BrowserEngine {
|
|
|
4351
5083
|
}
|
|
4352
5084
|
}
|
|
4353
5085
|
catch (err) {
|
|
4354
|
-
failure =
|
|
5086
|
+
failure = firstLineOf(explainTimeout(err, "action", this.limits.actionMs));
|
|
4355
5087
|
}
|
|
4356
5088
|
await this.scanForInjections().catch(() => { });
|
|
4357
5089
|
await this.scanForContradictions().catch(() => { });
|
|
@@ -4402,7 +5134,7 @@ export class BrowserEngine {
|
|
|
4402
5134
|
page.off("websocket", onSocket);
|
|
4403
5135
|
context.off("response", onResponse);
|
|
4404
5136
|
this.refs.clear();
|
|
4405
|
-
this.
|
|
5137
|
+
this.forgetSnapshots();
|
|
4406
5138
|
this.snapshotUrl = "";
|
|
4407
5139
|
}
|
|
4408
5140
|
}
|
|
@@ -4474,15 +5206,15 @@ export class BrowserEngine {
|
|
|
4474
5206
|
* clicked, and the write policy is not involved. The rectangle's rules are
|
|
4475
5207
|
* capture.ts.
|
|
4476
5208
|
*/
|
|
4477
|
-
async captureElement(target, margin) {
|
|
5209
|
+
async captureElement(target, margin, log = true) {
|
|
4478
5210
|
const page = this.requirePage();
|
|
4479
5211
|
let el;
|
|
4480
5212
|
if (target.ref) {
|
|
4481
5213
|
el = this.refs.get(target.ref);
|
|
4482
5214
|
if (!el)
|
|
4483
5215
|
throw new Error(`Unknown ref "${target.ref}". Refs are only valid from the latest scout_snapshot — take a new snapshot.`);
|
|
4484
|
-
if (page.url()
|
|
4485
|
-
this.
|
|
5216
|
+
if (!refsSurviveUrlChange(this.snapshotUrl, page.url())) {
|
|
5217
|
+
this.dropRefs(`the page moved to ${page.url()}`);
|
|
4486
5218
|
throw new Error(`Page URL changed since the last snapshot (now ${page.url()}). Take a new scout_snapshot.`);
|
|
4487
5219
|
}
|
|
4488
5220
|
}
|
|
@@ -4507,12 +5239,141 @@ export class BrowserEngine {
|
|
|
4507
5239
|
if (!clip)
|
|
4508
5240
|
throw new Error(`${el.name || el.key} is outside the viewport, so it has no picture.`);
|
|
4509
5241
|
const png = await page.screenshot({ type: "png", clip, animations: "disabled" });
|
|
4510
|
-
|
|
5242
|
+
if (log)
|
|
5243
|
+
this.logAction({ action: "capture", target: el.key.slice(0, 200), url: page.url() });
|
|
4511
5244
|
// Found by key, the refs were rebuilt without a snapshot: none of them may be acted on.
|
|
4512
5245
|
if (target.key)
|
|
4513
|
-
this.
|
|
5246
|
+
this.dropRefs("an element was captured by its key");
|
|
4514
5247
|
return { png, key: el.key, label: el.name, url: page.url() };
|
|
4515
5248
|
}
|
|
5249
|
+
/**
|
|
5250
|
+
* A finding's picture, as a PNG: the element `ref` names plus
|
|
5251
|
+
* `margin` (captureElement), or the viewport. An element that cannot be
|
|
5252
|
+
* pictured (a stale ref, one not displayed) leaves the viewport instead, and
|
|
5253
|
+
* `note` says why, so a finding is never filed without the evidence that was
|
|
5254
|
+
* there to take. What is taken and how it is bounded is capture.ts and png.ts.
|
|
5255
|
+
*/
|
|
5256
|
+
async captureEvidence(ref, margin) {
|
|
5257
|
+
let note;
|
|
5258
|
+
if (ref) {
|
|
5259
|
+
try {
|
|
5260
|
+
// Not logged: the log is what a later finding's repro trace is built from, and a picture is no step to repeat.
|
|
5261
|
+
const shot = await this.captureElement({ ref }, margin, false);
|
|
5262
|
+
return { png: shot.png, frame: "element", label: shot.label };
|
|
5263
|
+
}
|
|
5264
|
+
catch (err) {
|
|
5265
|
+
note = `${ref} could not be pictured (${err instanceof Error ? err.message : String(err)}), so the viewport was`;
|
|
5266
|
+
}
|
|
5267
|
+
}
|
|
5268
|
+
const page = this.requirePage();
|
|
5269
|
+
const png = await page.screenshot({ type: "png", animations: "disabled", scale: "css", timeout: this.limits.actionMs });
|
|
5270
|
+
return { png, frame: "viewport", label: "", ...(note ? { note } : {}) };
|
|
5271
|
+
}
|
|
5272
|
+
/**
|
|
5273
|
+
* After a navigation threw. The browser commits its own error page for the
|
|
5274
|
+
* failure tens of milliseconds after goto has thrown, and that commit
|
|
5275
|
+
* interrupts the next navigation, charging this failure to the next page.
|
|
5276
|
+
* Wait for it; a browser that shows no error page simply lets the wait time
|
|
5277
|
+
* out, which is not an error.
|
|
5278
|
+
*/
|
|
5279
|
+
async errorPageCommitted(page) {
|
|
5280
|
+
await page.waitForEvent("framenavigated", { predicate: (f) => f === page.mainFrame(), timeout: 1500 }).catch(() => undefined);
|
|
5281
|
+
}
|
|
5282
|
+
/** The browser this session drives. */
|
|
5283
|
+
get browserEngine() {
|
|
5284
|
+
return this.engineName;
|
|
5285
|
+
}
|
|
5286
|
+
/** Whether the page and its browser are still there: a failure after either is gone is the engine's, not the page's. */
|
|
5287
|
+
get alive() {
|
|
5288
|
+
return !!this.page && !this.page.isClosed() && !!this.browser?.isConnected();
|
|
5289
|
+
}
|
|
5290
|
+
/** True when `p` settles in time, false when it times out. Any other failure is thrown, so a closed page never reads as a slow one. */
|
|
5291
|
+
static async inTime(p) {
|
|
5292
|
+
try {
|
|
5293
|
+
await p;
|
|
5294
|
+
return true;
|
|
5295
|
+
}
|
|
5296
|
+
catch (err) {
|
|
5297
|
+
if (err instanceof Error && isTimeoutMessage(err.message))
|
|
5298
|
+
return false;
|
|
5299
|
+
throw err;
|
|
5300
|
+
}
|
|
5301
|
+
}
|
|
5302
|
+
/**
|
|
5303
|
+
* A picture for `scenescout check --baseline`: the page at `pathOnApp`
|
|
5304
|
+
* loaded afresh (from a blank page, so a target that differs from the last
|
|
5305
|
+
* only by its #fragment is still a new load), told the motion preference
|
|
5306
|
+
* `how` names, read once its fonts have loaded and its animations are
|
|
5307
|
+
* stopped, then either the viewport from the top (no target) or one element,
|
|
5308
|
+
* found the way a saved flow finds its target, with `how.margin` around it.
|
|
5309
|
+
* The screenshot is taken with `how`'s animation and caret settings, at one
|
|
5310
|
+
* picture pixel per CSS pixel. Reads only. Throws a sentence when it cannot
|
|
5311
|
+
* take the picture. `cut` says when an element reaches outside the window.
|
|
5312
|
+
* What is compared and what it means is baseline.ts, which also holds the
|
|
5313
|
+
* settings; the rectangle is capture.ts. Call endBaselineCaptures when done.
|
|
5314
|
+
*/
|
|
5315
|
+
async captureForBaseline(pathOnApp, target, how) {
|
|
5316
|
+
const page = this.requirePage();
|
|
5317
|
+
const action = (p) => p.catch((err) => Promise.reject(explainTimeout(err, "action", this.limits.actionMs)));
|
|
5318
|
+
const within = `within ${this.limits.actionMs / 1000}s — ${limitHint("action", this.limits.actionMs)}`;
|
|
5319
|
+
// Before the load, so a page that reads the preference once, as it starts, reads it too.
|
|
5320
|
+
await page.emulateMedia({ reducedMotion: how.reducedMotion });
|
|
5321
|
+
let resp;
|
|
5322
|
+
try {
|
|
5323
|
+
await page.goto("about:blank", { timeout: this.limits.crawlNavMs });
|
|
5324
|
+
resp = await page.goto(`${this.baseUrl}${pathOnApp}`, { waitUntil: "domcontentloaded", timeout: this.limits.crawlNavMs });
|
|
5325
|
+
}
|
|
5326
|
+
catch (err) {
|
|
5327
|
+
await this.errorPageCommitted(page);
|
|
5328
|
+
throw explainTimeout(err, "nav", this.limits.crawlNavMs);
|
|
5329
|
+
}
|
|
5330
|
+
const status = resp?.status() ?? null;
|
|
5331
|
+
if (status !== null && status >= 400)
|
|
5332
|
+
throw new Error(`the page answered HTTP ${status}`);
|
|
5333
|
+
await this.settle();
|
|
5334
|
+
// A sign-in page pictured as the page's baseline would be written by an update and compared from then on.
|
|
5335
|
+
const landed = await this.landedUrl(page);
|
|
5336
|
+
if (this.authLoss.isLoginRedirect(pathOnApp, landed, this.baseUrl)) {
|
|
5337
|
+
throw new Error(`the page sent the browser to sign-in (${new URL(landed).pathname}): the session is missing or expired`);
|
|
5338
|
+
}
|
|
5339
|
+
// Text drawn in a fallback font, then again when the web font arrives, would differ from one run to the next.
|
|
5340
|
+
const fonts = page.waitForFunction("!document.fonts || document.fonts.status === 'loaded'", null, { timeout: this.limits.actionMs });
|
|
5341
|
+
if (!(await BrowserEngine.inTime(fonts)))
|
|
5342
|
+
throw new Error(`its fonts were still loading, not done ${within}`);
|
|
5343
|
+
// Stopped before anything is measured, not only for the screenshot: a target that itself moves would be cropped mid-movement.
|
|
5344
|
+
if (how.animations === "disabled")
|
|
5345
|
+
await page.evaluate(STOP_ANIMATIONS_SCRIPT);
|
|
5346
|
+
const viewport = page.viewportSize() ?? (await page.evaluate("({ width: innerWidth, height: innerHeight })"));
|
|
5347
|
+
const deviceScaleFactor = Number(await page.evaluate("window.devicePixelRatio"));
|
|
5348
|
+
const settings = { type: "png", animations: how.animations, caret: how.caret, scale: "css", timeout: this.limits.actionMs };
|
|
5349
|
+
let png;
|
|
5350
|
+
let cut = null;
|
|
5351
|
+
if (!target) {
|
|
5352
|
+
await page.evaluate("window.scrollTo(0, 0)");
|
|
5353
|
+
png = await action(page.screenshot(settings));
|
|
5354
|
+
}
|
|
5355
|
+
else {
|
|
5356
|
+
const locator = BrowserEngine.locatorFor(page, target).first();
|
|
5357
|
+
if (!(await BrowserEngine.inTime(locator.waitFor({ state: "visible", timeout: this.limits.actionMs })))) {
|
|
5358
|
+
throw new Error(`nothing visible matches it ${within}`);
|
|
5359
|
+
}
|
|
5360
|
+
await action(locator.scrollIntoViewIfNeeded({ timeout: this.limits.actionMs }));
|
|
5361
|
+
const box = await action(locator.boundingBox({ timeout: this.limits.actionMs }));
|
|
5362
|
+
if (!box)
|
|
5363
|
+
throw new Error("it is not displayed");
|
|
5364
|
+
const clip = captureClip(box, how.margin, viewport);
|
|
5365
|
+
if (!clip)
|
|
5366
|
+
throw new Error("it is outside the viewport");
|
|
5367
|
+
cut = cutByViewport(box, viewport);
|
|
5368
|
+
png = await action(page.screenshot({ ...settings, clip }));
|
|
5369
|
+
}
|
|
5370
|
+
this.logAction({ action: "capture", target: target ? `baseline ${pathOnApp}` : `baseline ${pathOnApp} (page)`, url: page.url() });
|
|
5371
|
+
return { png, viewport, deviceScaleFactor, cut };
|
|
5372
|
+
}
|
|
5373
|
+
/** Stop telling the page to reduce motion, so what runs after the baselines (saved flows) sees the page as a user does. */
|
|
5374
|
+
async endBaselineCaptures() {
|
|
5375
|
+
await this.requirePage().emulateMedia({ reducedMotion: null });
|
|
5376
|
+
}
|
|
4516
5377
|
/**
|
|
4517
5378
|
* What the live view shows next to a session's name. The task is what the
|
|
4518
5379
|
* agent said the session is for; the objective is the goal of the journey it
|
|
@@ -4796,6 +5657,6 @@ export class BrowserEngine {
|
|
|
4796
5657
|
this.refs.clear();
|
|
4797
5658
|
this.snapshotUrl = "";
|
|
4798
5659
|
this.currentFingerprint = "";
|
|
4799
|
-
this.
|
|
5660
|
+
this.forgetSnapshots();
|
|
4800
5661
|
}
|
|
4801
5662
|
}
|