scenescout 1.0.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 +15 -0
- package/LICENSE +21 -0
- package/README.md +429 -0
- package/dist/cli.js +269 -0
- package/dist/engine/authloss.js +125 -0
- package/dist/engine/browser.js +1954 -0
- package/dist/engine/collector.js +266 -0
- package/dist/engine/design.js +716 -0
- package/dist/engine/dispatch.js +100 -0
- package/dist/engine/fingerprint.js +100 -0
- package/dist/engine/fixtures.js +162 -0
- package/dist/engine/journey.js +71 -0
- package/dist/engine/launch.js +25 -0
- package/dist/engine/memory.js +1116 -0
- package/dist/engine/oracles.js +187 -0
- package/dist/engine/ownership.js +223 -0
- package/dist/engine/policy.js +84 -0
- package/dist/engine/probes.js +293 -0
- package/dist/engine/reaper.js +72 -0
- package/dist/engine/report.js +515 -0
- package/dist/engine/uploads.js +74 -0
- package/dist/installer.js +315 -0
- package/dist/mcp-server.js +810 -0
- package/dist/scan.js +335 -0
- package/package.json +86 -0
- package/skills/scenescout/SKILL.md +96 -0
|
@@ -0,0 +1,1954 @@
|
|
|
1
|
+
import { chromium } from "playwright";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { elementKey, fingerprintState, isNonPageRoute, normalizePath } from "./fingerprint.js";
|
|
5
|
+
import { AUTH_LOSS_PREFIX, MemoryStore } from "./memory.js";
|
|
6
|
+
import { AuthLossTracker } from "./authloss.js";
|
|
7
|
+
import { COLLECT_INTERACTABLES_SCRIPT, VISIBLE_SRC, geometryIssues } from "./collector.js";
|
|
8
|
+
import { OracleMonitor, formatViolations } from "./oracles.js";
|
|
9
|
+
import { extractCreatedIds, isOwnedResource, normalizeId } from "./ownership.js";
|
|
10
|
+
import { formatJourney, measureJourney } from "./journey.js";
|
|
11
|
+
import { explainLaunchFailure, isMissingBrowser } from "./launch.js";
|
|
12
|
+
import { ACTION_TIMEOUT_MS, performScroll, probeFocusIndicators, probeOverlays, scrollContainer } from "./probes.js";
|
|
13
|
+
import { BROWSER_MARKER, reapOrphanBrowsers } from "./reaper.js";
|
|
14
|
+
import { planUploadOptions, resolveDiskUpload } from "./uploads.js";
|
|
15
|
+
import { AUTH_FLOW_RE, destructiveRefusal, isDestructive, isDestructiveWire } from "./policy.js";
|
|
16
|
+
import { scanProject } from "../scan.js";
|
|
17
|
+
import { analyzeDesign, DESIGN_COLLECT_SCRIPT } from "./design.js";
|
|
18
|
+
import { acceptMatches, generatedUpload } from "./fixtures.js";
|
|
19
|
+
const SETTLE_MS = 400;
|
|
20
|
+
/**
|
|
21
|
+
* Request URL → pathname, falling back to the raw string for anything
|
|
22
|
+
* unparseable (data: URLs, relative forms Playwright occasionally reports).
|
|
23
|
+
*
|
|
24
|
+
* Extracted because BOTH destructive-wire checks must see the same thing. When
|
|
25
|
+
* the bookkeeping check inlined its own version it drifted into passing the
|
|
26
|
+
* HTTP METHOD as the url and the url as the body — so a destructive GraphQL
|
|
27
|
+
* mutation, whose only evidence lives in the POST body, was never inspected
|
|
28
|
+
* there at all, and a request the policy went on to BLOCK still marked its
|
|
29
|
+
* route "mutated". One helper, two callers, no room to diverge again.
|
|
30
|
+
*/
|
|
31
|
+
function pathnameOf(url) {
|
|
32
|
+
try {
|
|
33
|
+
return new URL(url).pathname;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return url;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Budget for the forced retry of a click whose strict wait timed out — it skips that wait, so needs little. */
|
|
40
|
+
const FORCED_CLICK_TIMEOUT_MS = 1500;
|
|
41
|
+
/** How long after a trigger click resolves a file chooser may still open (apps fetch an upload URL first). */
|
|
42
|
+
const CHOOSER_GRACE_MS = 2000;
|
|
43
|
+
/** How long a hover waits for delay-gated tooltips (component libraries warm up for as long as ~1500ms). */
|
|
44
|
+
const HOVER_REVEAL_WINDOW_MS = 2500;
|
|
45
|
+
/** Non-GET traffic that is auth/telemetry plumbing, not tester-caused state mutation. */
|
|
46
|
+
const BENIGN_MUTATION_RE = /\/auth\/(refresh|token|session)|refresh[-_]?token|\/telemetry|\/analytics|\/heartbeat|\/sentry|\/collect\b|\/logs?\b|\/metrics\b/i;
|
|
47
|
+
/** In-page XPath lookup fragment for string-expression evaluates. */
|
|
48
|
+
function xpathLookup(xpath) {
|
|
49
|
+
return `document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue`;
|
|
50
|
+
}
|
|
51
|
+
/** Runs in the page against one file input (or the one a chooser belongs to). */
|
|
52
|
+
function describeFileInput(node) {
|
|
53
|
+
const input = node;
|
|
54
|
+
const id = input.getAttribute("id");
|
|
55
|
+
const labelEl = id ? document.querySelector(`label[for="${CSS.escape(id)}"]`) : input.closest("label");
|
|
56
|
+
const label = (input.getAttribute("aria-label") || (labelEl && labelEl.textContent) || input.getAttribute("data-testid") || input.name || "")
|
|
57
|
+
.trim()
|
|
58
|
+
.replace(/\s+/g, " ")
|
|
59
|
+
.slice(0, 80);
|
|
60
|
+
return { accept: input.getAttribute("accept"), multiple: input.multiple, label, disabled: input.disabled, probed: true };
|
|
61
|
+
}
|
|
62
|
+
/** Every file input on the page, with the collector's own visibility verdict. */
|
|
63
|
+
const LIST_FILE_INPUTS_SCRIPT = `(() => {
|
|
64
|
+
const visible = ${VISIBLE_SRC};
|
|
65
|
+
return Array.from(document.querySelectorAll('input[type="file"]')).map((i) => ({
|
|
66
|
+
testid: i.getAttribute("data-testid"), id: i.id || null, name: i.name || null,
|
|
67
|
+
accept: i.getAttribute("accept"), multiple: i.multiple, visible: visible(i),
|
|
68
|
+
}));
|
|
69
|
+
})()`;
|
|
70
|
+
/** One name for a file input wherever it is mentioned — a refusal and a snapshot line must agree on who is who. */
|
|
71
|
+
function fileInputLabel(d) {
|
|
72
|
+
const who = d.testid ? `testid=${d.testid}` : d.id ? `id=${d.id}` : d.name ? `name=${d.name}` : "unnamed";
|
|
73
|
+
return `input[type=file] ${who}${d.accept ? ` accept=${d.accept}` : ""}${d.multiple ? " multiple" : ""}`;
|
|
74
|
+
}
|
|
75
|
+
/** What an upload will send, however it was chosen. */
|
|
76
|
+
/** The generated-fixture case: kind inferred from the input's accept unless given. */
|
|
77
|
+
function generatedPayload(meta, opts) {
|
|
78
|
+
const { file, source } = generatedUpload(meta.probed ? meta.accept : undefined, opts.fixture, opts.name);
|
|
79
|
+
return { payload: file, name: file.name, mime: file.mimeType, bytes: file.buffer.length, source };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Extract the specific actionability reason from a Playwright locator-action
|
|
83
|
+
* timeout's multi-line error, if present — the useful diagnostic ("intercepts
|
|
84
|
+
* pointer events") lives in the "Call log:" body, not line 1 ("Timeout 5000ms
|
|
85
|
+
* exceeded"). Shared by the forced-click fallback (to decide WHETHER to
|
|
86
|
+
* force) and plan-step failure reporting (to show the reader why).
|
|
87
|
+
*/
|
|
88
|
+
function actionabilityDiagnostic(message) {
|
|
89
|
+
return (message
|
|
90
|
+
.split("\n")
|
|
91
|
+
.map((l) => l.trim())
|
|
92
|
+
.find((l) => /intercepts pointer events|is not visible|is not stable|is not enabled|is outside of the viewport/i.test(l)) ?? null);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The deterministic browser engine. Owns the Playwright lifecycle, executes
|
|
96
|
+
* actions by ref, runs oracles after every action, and records everything in
|
|
97
|
+
* the persistent memory store. Contains no LLM calls — the MCP client is the brain.
|
|
98
|
+
*/
|
|
99
|
+
export class BrowserEngine {
|
|
100
|
+
browser = null;
|
|
101
|
+
context = null;
|
|
102
|
+
page = null;
|
|
103
|
+
oracles = new OracleMonitor();
|
|
104
|
+
refs = new Map();
|
|
105
|
+
refCounter = 0;
|
|
106
|
+
currentFingerprint = "";
|
|
107
|
+
/** URL at the time of the last snapshot — refs are valid only while it matches. */
|
|
108
|
+
snapshotUrl = "";
|
|
109
|
+
/** Last snapshot's identity map (per route) — enables stable refs + diff snapshots. */
|
|
110
|
+
lastSnap = null;
|
|
111
|
+
/** Non-parameterized routes discovered by the project scan — the objective completion contract. */
|
|
112
|
+
knownRoutes = [];
|
|
113
|
+
/** Real path of the attached project — the fence for scout_upload's filePath. */
|
|
114
|
+
projectDir = "";
|
|
115
|
+
/** Set when the project's real path could not be resolved — named in fence refusals, which it may then cause. */
|
|
116
|
+
projectDirNote = "";
|
|
117
|
+
memory = null;
|
|
118
|
+
mode = "read-only";
|
|
119
|
+
/** Human label for the auth identity driving this session (the server sets it from the storage-state filename). */
|
|
120
|
+
role = "anonymous";
|
|
121
|
+
/** Named-session id (the server sets it; every engine shares one MemoryStore, so
|
|
122
|
+
* per-session reads of the interleaved action log must filter by this). */
|
|
123
|
+
sessionKey = "default";
|
|
124
|
+
/** Login-bounce streak, the SESSION AUTH LOST verdict, and the per-call notice. */
|
|
125
|
+
authLoss = new AuthLossTracker();
|
|
126
|
+
/** UI-label blocking applies only in read-only mode (safe-write enforces at the network layer instead). */
|
|
127
|
+
get readOnly() {
|
|
128
|
+
return this.mode === "read-only";
|
|
129
|
+
}
|
|
130
|
+
/** Append to the shared action log, stamped with THIS session so per-session
|
|
131
|
+
* reads (journey paths) can separate concurrent roles' interleaved actions. */
|
|
132
|
+
logAction(entry) {
|
|
133
|
+
this.memory?.logAction({ ...entry, session: this.sessionKey });
|
|
134
|
+
}
|
|
135
|
+
/** Requests blocked by the write policy since the last action (timestamped for attribution). */
|
|
136
|
+
blockedRequests = [];
|
|
137
|
+
/** When the current action began — requests recorded before this are late arrivals from a previous action. */
|
|
138
|
+
actionStartedAt = 0;
|
|
139
|
+
/**
|
|
140
|
+
* Resources created by this RUN (safe-write): id → collection paths.
|
|
141
|
+
* Backed by the shared MemoryStore so every named session in a multi-role
|
|
142
|
+
* run shares one ownership set — role A creating a record that role B must
|
|
143
|
+
* act on is the point of multi-role testing. Falls back to a local map
|
|
144
|
+
* before attach (no store yet).
|
|
145
|
+
*/
|
|
146
|
+
get ownedIds() {
|
|
147
|
+
return this.memory?.ownedIds ?? this.localOwnedIds;
|
|
148
|
+
}
|
|
149
|
+
localOwnedIds = new Map();
|
|
150
|
+
/**
|
|
151
|
+
* In-flight recordCreation() calls (safe-write). A single UI action often
|
|
152
|
+
* fires POST-then-immediately-PUT (create, then save the content it just
|
|
153
|
+
* got an id for) — the PUT's route handler runs synchronously, but
|
|
154
|
+
* recordCreation awaits the POST response body, so without this the PUT
|
|
155
|
+
* could be evaluated and wrongly blocked before its own creation had
|
|
156
|
+
* finished registering.
|
|
157
|
+
*/
|
|
158
|
+
pendingCreations = new Set();
|
|
159
|
+
/** Await `p` for at most `ms`, clearing the timer either way (never rejects, never leaks a timer). */
|
|
160
|
+
static async settleWithin(p, ms) {
|
|
161
|
+
let timer;
|
|
162
|
+
await Promise.race([
|
|
163
|
+
p.catch(() => { }).finally(() => clearTimeout(timer)),
|
|
164
|
+
new Promise((resolve) => {
|
|
165
|
+
timer = setTimeout(resolve, ms);
|
|
166
|
+
}),
|
|
167
|
+
]);
|
|
168
|
+
}
|
|
169
|
+
/** Human-readable creation log for the run — shared across sessions, becomes the report's cleanup list. */
|
|
170
|
+
get createdResources() {
|
|
171
|
+
return this.memory?.createdResources ?? this.localCreatedResources;
|
|
172
|
+
}
|
|
173
|
+
localCreatedResources = [];
|
|
174
|
+
/** Design audits run this session — the report gate requires at least one. */
|
|
175
|
+
designAuditCount = 0;
|
|
176
|
+
/** Active task-efficiency measurement (scout_journey), if any. */
|
|
177
|
+
journey = null;
|
|
178
|
+
/**
|
|
179
|
+
* Begin measuring a user JOURNEY — the interaction cost of completing one
|
|
180
|
+
* real task ("create an order", "approve a document"). E2E suites assert
|
|
181
|
+
* a task is *possible*; this measures whether it is *easy*, which is the
|
|
182
|
+
* question no pass/fail assertion answers.
|
|
183
|
+
*/
|
|
184
|
+
startJourney(goal) {
|
|
185
|
+
const page = this.requirePage();
|
|
186
|
+
this.journey = {
|
|
187
|
+
goal,
|
|
188
|
+
startedAt: Date.now(),
|
|
189
|
+
fromLog: this.memory?.actionLog.length ?? 0,
|
|
190
|
+
startUrl: page.url(),
|
|
191
|
+
};
|
|
192
|
+
this.logAction({ action: "journey:start", target: goal, url: page.url() });
|
|
193
|
+
return `JOURNEY STARTED — "${goal}"\nFrom: ${page.url()}\nNow perform the task the way a first-time user would (click through the UI; don't jump straight to a known deep URL, or the measurement is meaningless). Call scout_journey {action:"end"} when the task is complete or you conclude it can't be.`;
|
|
194
|
+
}
|
|
195
|
+
/** Close the journey and report its interaction cost + friction signals. */
|
|
196
|
+
endJourney(completed, note) {
|
|
197
|
+
const j = this.journey;
|
|
198
|
+
if (!j)
|
|
199
|
+
return `No journey in progress — start one with scout_journey {action:"start", goal:"…"}.`;
|
|
200
|
+
const page = this.requirePage();
|
|
201
|
+
this.journey = null;
|
|
202
|
+
// Filter to THIS session's actions: the action log is shared across every
|
|
203
|
+
// role in a multi-role run, so a concurrent session navigating during this
|
|
204
|
+
// journey would otherwise contaminate its path, screen count, and backtracks.
|
|
205
|
+
const log = (this.memory?.actionLog ?? []).slice(j.fromLog).filter((e) => (e.session ?? this.sessionKey) === this.sessionKey);
|
|
206
|
+
const seconds = Math.round((Date.now() - j.startedAt) / 1000);
|
|
207
|
+
const measured = measureJourney(log, completed);
|
|
208
|
+
try {
|
|
209
|
+
// Only a COMPLETED journey is a measurement of task ease. An abandoned
|
|
210
|
+
// one proves the task is blocked — a high-severity finding — and must
|
|
211
|
+
// not also close the gap that says ease was never measured.
|
|
212
|
+
this.memory?.markRouteFact(normalizePath(j.startUrl), { journeys: 1, journeysCompleted: completed ? 1 : 0 });
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
/* fact recording is best-effort */
|
|
216
|
+
}
|
|
217
|
+
this.logAction({ action: "journey:end", target: j.goal, url: page.url(), result: completed ? "completed" : "abandoned" });
|
|
218
|
+
return formatJourney({ goal: j.goal, completed, seconds, note }, measured);
|
|
219
|
+
}
|
|
220
|
+
/** Whether the browser window is visible — headed hover results carry a physical-cursor caveat. */
|
|
221
|
+
headed = false;
|
|
222
|
+
/** Non-GET requests fired since the last action — surfaces silent state mutation in read-only runs (timestamped for attribution). */
|
|
223
|
+
mutationRequests = [];
|
|
224
|
+
/**
|
|
225
|
+
* Requests the write policy aborted, by identity. The request event fires for
|
|
226
|
+
* every non-GET the page ATTEMPTS, before the route handler decides its fate,
|
|
227
|
+
* so without this the same DELETE was reported twice with opposite meanings:
|
|
228
|
+
* "server state may have mutated despite read-only mode" and "WRITE-POLICY
|
|
229
|
+
* blocked". Identity rather than URL matching: two requests to one URL can
|
|
230
|
+
* meet different fates, and a blocked /items/7/archive must not hide an
|
|
231
|
+
* allowed POST /items that shares its prefix.
|
|
232
|
+
*/
|
|
233
|
+
abortedByPolicy = new WeakSet();
|
|
234
|
+
/** Raw mutation sigs of the most recent action (pre-dedup) — double-submit detection. */
|
|
235
|
+
lastActionMutationSigs = [];
|
|
236
|
+
/** Write-policy blocks drained by the last action — counted, so an action can know a request fired even when the policy stopped it. */
|
|
237
|
+
lastActionBlocked = 0;
|
|
238
|
+
baseUrl = "";
|
|
239
|
+
get attached() {
|
|
240
|
+
return this.page !== null;
|
|
241
|
+
}
|
|
242
|
+
async attach(opts) {
|
|
243
|
+
await this.close();
|
|
244
|
+
if (opts.storageStatePath && !fs.existsSync(opts.storageStatePath)) {
|
|
245
|
+
throw new Error(`storageStatePath does not exist: ${opts.storageStatePath}`);
|
|
246
|
+
}
|
|
247
|
+
this.mode = opts.mode ?? "read-only";
|
|
248
|
+
this.headed = opts.headed ?? false;
|
|
249
|
+
this.blockedRequests = [];
|
|
250
|
+
this.pendingCreations = new Set();
|
|
251
|
+
this.baseUrl = opts.url.replace(/\/$/, "");
|
|
252
|
+
// Ownership (ownedIds/createdResources) deliberately NOT reset here: it
|
|
253
|
+
// lives on the shared MemoryStore for the whole run, so re-attaching one
|
|
254
|
+
// role must not discard what another role already created — otherwise
|
|
255
|
+
// every multi-role handoff would be blocked as "not yours".
|
|
256
|
+
this.memory = opts.memoryStore ?? new MemoryStore(opts.projectDir);
|
|
257
|
+
// The REAL path, not merely the resolved one: on macOS the temp tree is a
|
|
258
|
+
// symlink, and a fence comparing a real path against an unreal one would
|
|
259
|
+
// refuse every upload from inside the project.
|
|
260
|
+
try {
|
|
261
|
+
this.projectDir = fs.realpathSync(path.resolve(opts.projectDir));
|
|
262
|
+
this.projectDirNote = "";
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
// Keep the unreal path rather than fail attach — but a fence built on
|
|
266
|
+
// it may refuse in-project uploads on a symlinked project, so every
|
|
267
|
+
// such refusal names this as a possible cause instead of hiding it.
|
|
268
|
+
this.projectDir = path.resolve(opts.projectDir);
|
|
269
|
+
this.projectDirNote = ` (its real path could not be resolved: ${err instanceof Error ? err.message : String(err)} — a symlinked project path may be wrongly refused)`;
|
|
270
|
+
}
|
|
271
|
+
this.oracles = new OracleMonitor();
|
|
272
|
+
this.oracles.setPolicyAbortCheck((req) => this.abortedByPolicy.has(req));
|
|
273
|
+
this.lastSnap = null;
|
|
274
|
+
this.designAuditCount = 0;
|
|
275
|
+
// The engine learns the route list itself so completion is an objective,
|
|
276
|
+
// enforceable contract (scout_report refuses while known routes are unvisited)
|
|
277
|
+
// rather than a prompt suggestion the driver may ignore.
|
|
278
|
+
try {
|
|
279
|
+
this.knownRoutes = scanProject(opts.projectDir)
|
|
280
|
+
.routes.filter((r) => !r.includes(":"))
|
|
281
|
+
.slice(0, 200);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
this.knownRoutes = [];
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
this.browser = await this.launchWithRecovery(opts.headed ?? false);
|
|
288
|
+
this.context = await this.browser.newContext({
|
|
289
|
+
storageState: opts.storageStatePath,
|
|
290
|
+
viewport: opts.viewport ?? { width: 1280, height: 900 },
|
|
291
|
+
});
|
|
292
|
+
this.page = await this.context.newPage();
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
await this.close();
|
|
296
|
+
throw err;
|
|
297
|
+
}
|
|
298
|
+
this.oracles.attach(this.page);
|
|
299
|
+
// Label-based read-only blocking can't catch every mutation (an innocuous
|
|
300
|
+
// "Add to Cart" fires a POST). Track non-GET traffic so actions that
|
|
301
|
+
// changed server state are at least REPORTED in read-only runs.
|
|
302
|
+
this.context.on("request", (req) => {
|
|
303
|
+
const type = req.resourceType();
|
|
304
|
+
if (type === "xhr" || type === "fetch")
|
|
305
|
+
this.xhrCount += 1;
|
|
306
|
+
const method = req.method();
|
|
307
|
+
if (method === "GET" || method === "HEAD" || method === "OPTIONS")
|
|
308
|
+
return;
|
|
309
|
+
// Infrastructure POSTs (token refresh, telemetry) are not state the
|
|
310
|
+
// tester mutated — reporting them trains the driver to ignore the notice.
|
|
311
|
+
if (BENIGN_MUTATION_RE.test(req.url()))
|
|
312
|
+
return;
|
|
313
|
+
if (this.mutationRequests.length < 20) {
|
|
314
|
+
this.mutationRequests.push({ at: Date.now(), sig: `${method} ${req.url().slice(0, 120)}`, req });
|
|
315
|
+
}
|
|
316
|
+
// Gap-ledger fact: this route's forms/actions were actually EXERCISED,
|
|
317
|
+
// not just looked at — the difference between visited and tested.
|
|
318
|
+
//
|
|
319
|
+
// Only for requests the policy will actually let through. This event
|
|
320
|
+
// fires BEFORE the route handler aborts a blocked one, so counting it
|
|
321
|
+
// here let a REFUSED destructive POST mark the route as mutated — a form
|
|
322
|
+
// that was never submitted reading as tested, in read-only mode where by
|
|
323
|
+
// definition nothing is.
|
|
324
|
+
if (this.readOnly && isDestructiveWire(pathnameOf(req.url()), req.postData()))
|
|
325
|
+
return;
|
|
326
|
+
const pageUrl = this.page?.url();
|
|
327
|
+
if (pageUrl && this.memory) {
|
|
328
|
+
try {
|
|
329
|
+
this.memory.markRouteFact(normalizePath(pageUrl), { mutated: true });
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
/* fact recording is best-effort */
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
// Write policy — enforced on the wire, where the truth lives.
|
|
337
|
+
if (this.mode !== "destructive") {
|
|
338
|
+
await this.context.route("**/*", async (route) => {
|
|
339
|
+
const req = route.request();
|
|
340
|
+
const method = req.method();
|
|
341
|
+
if (method === "GET" || method === "HEAD" || method === "OPTIONS")
|
|
342
|
+
return route.continue();
|
|
343
|
+
const url = req.url();
|
|
344
|
+
const pathname = pathnameOf(url);
|
|
345
|
+
// Auth/session flows (login, refresh, logout) must work in every mode.
|
|
346
|
+
if (AUTH_FLOW_RE.test(pathname) && method === "POST")
|
|
347
|
+
return route.continue();
|
|
348
|
+
const destructiveWire = isDestructiveWire(pathname, req.postData());
|
|
349
|
+
let owned = this.isOwnedResource(pathname);
|
|
350
|
+
// A single UI action commonly fires create-then-immediately-save
|
|
351
|
+
// (POST gets an id, PUT saves content under it) faster than the
|
|
352
|
+
// POST's recordCreation() can finish awaiting its response body.
|
|
353
|
+
// The POST registered itself in pendingCreations SYNCHRONOUSLY in
|
|
354
|
+
// this same handler (below) before its request even went out, so by
|
|
355
|
+
// the time the follow-up PUT arrives here the pending entry is
|
|
356
|
+
// guaranteed visible — give it a bounded moment to land before the
|
|
357
|
+
// mutation is judged not-owned, otherwise the session's own,
|
|
358
|
+
// just-created resource gets wrongly blocked by a timing accident.
|
|
359
|
+
if (!owned && this.mode === "safe-write" && method !== "POST" && this.pendingCreations.size > 0) {
|
|
360
|
+
await BrowserEngine.settleWithin(Promise.allSettled([...this.pendingCreations]), 1500);
|
|
361
|
+
owned = this.isOwnedResource(pathname);
|
|
362
|
+
}
|
|
363
|
+
// POST: creation/RPC passes unless it smells destructive and isn't ours.
|
|
364
|
+
// PUT/PATCH/DELETE: only in safe-write, only on our own resources.
|
|
365
|
+
const allow = method === "POST" ? !destructiveWire || owned : this.mode === "safe-write" && owned;
|
|
366
|
+
if (allow) {
|
|
367
|
+
// Ownership tracking (safe-write): register the creation-tracking
|
|
368
|
+
// task BEFORE the POST goes out. Registering from a context
|
|
369
|
+
// "response" listener instead would race the follow-up PUT's route
|
|
370
|
+
// handler — the response event fires asynchronously, so a PUT
|
|
371
|
+
// chained directly off the POST's json() could be judged before
|
|
372
|
+
// the listener ever ran. Here the registration is synchronous with
|
|
373
|
+
// request dispatch, which closes that window completely.
|
|
374
|
+
if (this.mode === "safe-write" && method === "POST" && !BENIGN_MUTATION_RE.test(url)) {
|
|
375
|
+
const task = req
|
|
376
|
+
.response()
|
|
377
|
+
.then((res) => (res && res.ok() ? this.recordCreation(res) : undefined))
|
|
378
|
+
.catch(() => { })
|
|
379
|
+
.finally(() => this.pendingCreations.delete(task));
|
|
380
|
+
this.pendingCreations.add(task);
|
|
381
|
+
}
|
|
382
|
+
return route.continue();
|
|
383
|
+
}
|
|
384
|
+
if (this.blockedRequests.length < 20)
|
|
385
|
+
this.blockedRequests.push({ at: Date.now(), sig: `${method} ${url.slice(0, 140)}` });
|
|
386
|
+
this.logAction({ action: "write-policy:blocked", target: `${method} ${pathname}`, url: this.page?.url() ?? "" });
|
|
387
|
+
this.abortedByPolicy.add(req);
|
|
388
|
+
this.oracles.notePolicyBlock();
|
|
389
|
+
return route.abort("blockedbyclient");
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
// Popups / target=_blank: adopt same-origin pages as the active page (with
|
|
393
|
+
// oracles attached); close foreign-origin popups so exploration cannot
|
|
394
|
+
// silently escape the app under test.
|
|
395
|
+
this.context.on("page", (newPage) => {
|
|
396
|
+
newPage
|
|
397
|
+
.waitForLoadState("domcontentloaded", { timeout: 10000 })
|
|
398
|
+
.then(() => {
|
|
399
|
+
if (newPage === this.page || newPage.isClosed())
|
|
400
|
+
return;
|
|
401
|
+
const sameOrigin = this.isSameOrigin(newPage.url());
|
|
402
|
+
this.logAction({
|
|
403
|
+
action: sameOrigin ? "popup:adopted" : "popup:closed-foreign",
|
|
404
|
+
target: newPage.url().slice(0, 200),
|
|
405
|
+
url: this.page?.url() ?? "",
|
|
406
|
+
});
|
|
407
|
+
if (sameOrigin) {
|
|
408
|
+
this.oracles.attach(newPage);
|
|
409
|
+
this.wireDialogHandler(newPage);
|
|
410
|
+
this.page = newPage;
|
|
411
|
+
this.refs.clear();
|
|
412
|
+
this.snapshotUrl = "";
|
|
413
|
+
this.lastSnap = null;
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
void newPage.close().catch(() => { });
|
|
417
|
+
}
|
|
418
|
+
})
|
|
419
|
+
.catch(() => { });
|
|
420
|
+
});
|
|
421
|
+
this.wireDialogHandler(this.page);
|
|
422
|
+
try {
|
|
423
|
+
await this.page.goto(opts.url, { waitUntil: "domcontentloaded", timeout: 20000 });
|
|
424
|
+
}
|
|
425
|
+
catch (err) {
|
|
426
|
+
// Don't leave a half-attached engine (leaked browser, snapshots of about:blank).
|
|
427
|
+
await this.close();
|
|
428
|
+
throw new Error(`Could not load ${opts.url} — is the app running? (${err instanceof Error ? err.message.split("\n")[0] : err})`);
|
|
429
|
+
}
|
|
430
|
+
await this.settle();
|
|
431
|
+
this.logAction({ action: "attach", url: this.page.url() });
|
|
432
|
+
// Attaching WITH a storage state and landing on a login page means the
|
|
433
|
+
// stored credentials are dead. Saying "Attached … auth=<file>" here — which
|
|
434
|
+
// is all this used to do — reads as success and names the very file that
|
|
435
|
+
// just failed, so a run would proceed for hundreds of calls against a
|
|
436
|
+
// logged-out browser. The file existing was the only thing ever checked.
|
|
437
|
+
const authFailed = Boolean(opts.storageStatePath) && this.authLoss.isLoginRedirect(normalizePath(opts.url), this.page.url(), this.baseUrl);
|
|
438
|
+
const authWarning = authFailed
|
|
439
|
+
? `\n⚠ AUTH FAILED — the storage state at ${opts.storageStatePath} did not produce a signed-in session: ` +
|
|
440
|
+
`attaching landed on ${this.page.url()}, a login page. Regenerate it (its token has most likely expired) and re-attach. ` +
|
|
441
|
+
`Continuing now tests a logged-out app.`
|
|
442
|
+
: "";
|
|
443
|
+
return (`Attached to ${this.page.url()} (mode=${this.mode}` +
|
|
444
|
+
`${opts.storageStatePath ? `, auth=${opts.storageStatePath}` : ""}). ` +
|
|
445
|
+
`Memory: ${this.memory.dir}.${this.memory.loadWarning ? ` WARNING: ${this.memory.loadWarning}` : ""}` +
|
|
446
|
+
`${this.memory.legacyDirNote ? ` ${this.memory.legacyDirNote}` : ""}` +
|
|
447
|
+
`${this.memory.gitIgnoreNote ? ` ${this.memory.gitIgnoreNote}` : ""} Call scout_snapshot to see the current state.` +
|
|
448
|
+
authWarning);
|
|
449
|
+
}
|
|
450
|
+
/** Dialogs (confirm/alert): dismiss in read-only mode, accept otherwise. Must be wired on every page we drive, including adopted popups. */
|
|
451
|
+
wireDialogHandler(page) {
|
|
452
|
+
page.on("dialog", (dialog) => {
|
|
453
|
+
const action = this.readOnly ? "dismiss" : "accept";
|
|
454
|
+
this.logAction({
|
|
455
|
+
action: `dialog:${action}`,
|
|
456
|
+
target: dialog.message().slice(0, 120),
|
|
457
|
+
url: this.page?.url() ?? "",
|
|
458
|
+
});
|
|
459
|
+
void (this.readOnly ? dialog.dismiss() : dialog.accept()).catch(() => { });
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
requirePage() {
|
|
463
|
+
if (!this.page || !this.memory) {
|
|
464
|
+
throw new Error("Not attached. Call scout_attach first with the app URL and project path.");
|
|
465
|
+
}
|
|
466
|
+
return this.page;
|
|
467
|
+
}
|
|
468
|
+
async settle() {
|
|
469
|
+
const page = this.requirePage();
|
|
470
|
+
await page.waitForLoadState("domcontentloaded").catch(() => { });
|
|
471
|
+
// A fixed floor, deliberately: waitForLoadState("networkidle") is latched
|
|
472
|
+
// once reached and resolves instantly forever after, so it cannot be used
|
|
473
|
+
// to wait out in-flight requests here — and draining oracles before a
|
|
474
|
+
// just-fired request lands would misattribute its violations.
|
|
475
|
+
await page.waitForTimeout(SETTLE_MS);
|
|
476
|
+
}
|
|
477
|
+
/** Collect the current page's interactables into SnapshotElements with stable refs. */
|
|
478
|
+
async collect() {
|
|
479
|
+
const page = this.requirePage();
|
|
480
|
+
// SPAs (and dev servers mid-recompile) can present an empty shell for a
|
|
481
|
+
// few seconds — and a shell that already renders its chrome (sidebar,
|
|
482
|
+
// nav) passes a zero-check while the route content is still loading.
|
|
483
|
+
// Retry until two consecutive probes agree on the element count, so we
|
|
484
|
+
// snapshot the hydrated page, not the loading state.
|
|
485
|
+
let rawElements = (await page.evaluate(COLLECT_INTERACTABLES_SCRIPT));
|
|
486
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
487
|
+
await page.waitForTimeout(rawElements.length === 0 ? 500 : 300);
|
|
488
|
+
const probe = (await page.evaluate(COLLECT_INTERACTABLES_SCRIPT));
|
|
489
|
+
const stable = probe.length === rawElements.length && probe.length > 0;
|
|
490
|
+
rawElements = probe;
|
|
491
|
+
if (stable)
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
// Element identity is the coverage key (testid or role+name, ordinal-
|
|
495
|
+
// disambiguated). When a key persists across snapshots of the same route,
|
|
496
|
+
// its ref is REUSED — diffs stay meaningful and the agent's mental model
|
|
497
|
+
// (and previously issued refs) survive re-snapshots.
|
|
498
|
+
const route = normalizePath(page.url());
|
|
499
|
+
const prevByKey = this.lastSnap?.route === route ? this.lastSnap.byKey : null;
|
|
500
|
+
this.refs.clear();
|
|
501
|
+
const keyCounts = new Map();
|
|
502
|
+
const elements = rawElements.map((el) => {
|
|
503
|
+
const baseKey = elementKey(el);
|
|
504
|
+
const count = keyCounts.get(baseKey) ?? 0;
|
|
505
|
+
keyCounts.set(baseKey, count + 1);
|
|
506
|
+
const key = count === 0 ? baseKey : `${baseKey}~${count}`;
|
|
507
|
+
const ref = prevByKey?.get(key)?.ref ?? `e${++this.refCounter}`;
|
|
508
|
+
const full = {
|
|
509
|
+
...el,
|
|
510
|
+
ref,
|
|
511
|
+
key,
|
|
512
|
+
destructive: isDestructive(el.name, el.testid),
|
|
513
|
+
};
|
|
514
|
+
this.refs.set(ref, full);
|
|
515
|
+
return full;
|
|
516
|
+
});
|
|
517
|
+
this.harvestRoutes(elements);
|
|
518
|
+
return { elements, truncated: rawElements.length >= 150 };
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Collect the current page and REGISTER it as a visited state, returning the
|
|
522
|
+
* fingerprint and elements. Registration matters: markExercised refuses a key
|
|
523
|
+
* the state never listed (that refusal is what stops invented keys becoming
|
|
524
|
+
* phantom coverage), so a state has to exist before anything in it can be
|
|
525
|
+
* marked. Used by plans to capture where an element lived before acting.
|
|
526
|
+
*/
|
|
527
|
+
async captureCoverageState() {
|
|
528
|
+
const page = this.requirePage();
|
|
529
|
+
const { elements } = await this.collect();
|
|
530
|
+
const url = page.url();
|
|
531
|
+
const fp = fingerprintState(url, elements);
|
|
532
|
+
this.memory?.visitState(fp, url, normalizePath(url), elements.map((el) => el.key));
|
|
533
|
+
return { fp, elements, url };
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Link harvesting — the generic route discovery. Same-origin hrefs become
|
|
537
|
+
* route classes (including ?tab= screens) that feed the completion contract,
|
|
538
|
+
* so the contract works for any app, not just filesystem-routed Next.
|
|
539
|
+
*/
|
|
540
|
+
harvestRoutes(elements) {
|
|
541
|
+
if (!this.memory)
|
|
542
|
+
return;
|
|
543
|
+
const found = [];
|
|
544
|
+
for (const el of elements) {
|
|
545
|
+
if (!el.href || /^(javascript|mailto|tel):/i.test(el.href))
|
|
546
|
+
continue;
|
|
547
|
+
if (el.href.startsWith("#") && !el.href.startsWith("#/"))
|
|
548
|
+
continue; // in-page anchor, not a hash route
|
|
549
|
+
// Never queue session-ending links for crawling.
|
|
550
|
+
if (/log-?out|sign-?out/i.test(el.href))
|
|
551
|
+
continue;
|
|
552
|
+
let abs;
|
|
553
|
+
try {
|
|
554
|
+
abs = new URL(el.href, this.page?.url() ?? this.baseUrl);
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
if (!this.isSameOrigin(abs.toString()))
|
|
560
|
+
continue;
|
|
561
|
+
// API endpoints and file downloads are links, not UI routes — crawling
|
|
562
|
+
// them GETs payloads/downloads instead of pages.
|
|
563
|
+
if (/^\/api\//i.test(abs.pathname) || /\.(pdf|zip|csv|xlsx?|docx?|pptx?|png|jpe?g|gif|svg|ico|mp4|webm|json|xml)$/i.test(abs.pathname))
|
|
564
|
+
continue;
|
|
565
|
+
found.push({ route: normalizePath(abs.toString()), example: abs.pathname + abs.search + abs.hash });
|
|
566
|
+
}
|
|
567
|
+
if (found.length > 0)
|
|
568
|
+
this.memory.addDiscoveredRoutes(found);
|
|
569
|
+
}
|
|
570
|
+
/** Every file input on the page. A failed probe is logged, not passed off as "none". */
|
|
571
|
+
async listFileInputs(page) {
|
|
572
|
+
try {
|
|
573
|
+
return (await page.evaluate(LIST_FILE_INPUTS_SCRIPT));
|
|
574
|
+
}
|
|
575
|
+
catch (err) {
|
|
576
|
+
console.error(`[scenescout] file-input probe failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
577
|
+
return [];
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* File inputs the collector skipped for being invisible. The dominant upload
|
|
582
|
+
* pattern hides the `<input type=file>` behind a styled button or label, so
|
|
583
|
+
* the affordance was absent from every snapshot and the driver could not
|
|
584
|
+
* know an upload existed — let alone that the page's form would never leave
|
|
585
|
+
* the gap ledger without one. The verdict uses the collector's own rule
|
|
586
|
+
* (shared source, VISIBLE_SRC), so "hidden here" means "not in the element
|
|
587
|
+
* list" — except past the collector's 150-element cap, where a visible input
|
|
588
|
+
* can be unlisted too.
|
|
589
|
+
*/
|
|
590
|
+
async hiddenFileInputs(page) {
|
|
591
|
+
return (await this.listFileInputs(page))
|
|
592
|
+
.filter((d) => !d.visible)
|
|
593
|
+
.slice(0, 5)
|
|
594
|
+
.map(fileInputLabel);
|
|
595
|
+
}
|
|
596
|
+
async snapshot(full = false) {
|
|
597
|
+
const page = this.requirePage();
|
|
598
|
+
const memory = this.memory;
|
|
599
|
+
await this.settle();
|
|
600
|
+
const { elements, truncated } = await this.collect();
|
|
601
|
+
const url = page.url();
|
|
602
|
+
this.snapshotUrl = url;
|
|
603
|
+
const route = normalizePath(url);
|
|
604
|
+
const fp = fingerprintState(url, elements);
|
|
605
|
+
this.currentFingerprint = fp;
|
|
606
|
+
const isNew = memory.visitState(fp, url, route, elements.map((el) => el.key));
|
|
607
|
+
memory.recordRoleAccess(this.role, route, "reached");
|
|
608
|
+
this.logAction({ action: "snapshot", url, result: fp });
|
|
609
|
+
const line = (el) => {
|
|
610
|
+
const dup = el.key.match(/~(\d+)$/);
|
|
611
|
+
const flags = [
|
|
612
|
+
el.testid ? `testid=${el.testid}` : null,
|
|
613
|
+
dup ? `copy#${Number(dup[1]) + 1}` : null,
|
|
614
|
+
el.disabled ? "disabled" : null,
|
|
615
|
+
el.destructive ? "DESTRUCTIVE" : null,
|
|
616
|
+
memory.wasExercised(fp, el.key) ? "done" : null,
|
|
617
|
+
el.href ? `href=${el.href.slice(0, 60)}` : null,
|
|
618
|
+
].filter(Boolean);
|
|
619
|
+
return `${el.ref} ${el.role} "${el.name || "(unnamed)"}"${flags.length ? ` [${flags.join(", ")}]` : ""}`;
|
|
620
|
+
};
|
|
621
|
+
// Diff mode: when re-snapshotting the same route, report only what
|
|
622
|
+
// changed — same idea as UI reconciliation, applied to agent context.
|
|
623
|
+
const prev = this.lastSnap?.route === route ? this.lastSnap : null;
|
|
624
|
+
this.lastSnap = { route, byKey: new Map(elements.map((el) => [el.key, { ref: el.ref, label: el.name, disabled: el.disabled }])) };
|
|
625
|
+
let body;
|
|
626
|
+
if (!full && prev) {
|
|
627
|
+
const currentKeys = new Set(elements.map((el) => el.key));
|
|
628
|
+
const added = elements.filter((el) => !prev.byKey.has(el.key));
|
|
629
|
+
const removed = [...prev.byKey.entries()].filter(([key]) => !currentKeys.has(key));
|
|
630
|
+
const relabeled = elements.filter((el) => {
|
|
631
|
+
const old = prev.byKey.get(el.key);
|
|
632
|
+
return old !== undefined && old.label !== el.name;
|
|
633
|
+
});
|
|
634
|
+
// An element can change WITHOUT being added, removed or relabeled: the
|
|
635
|
+
// submit that was greyed out is now clickable. Reporting that as "No
|
|
636
|
+
// element changes" is the diff lying about the single most informative
|
|
637
|
+
// outcome of filling a form — so enable/disable transitions are their
|
|
638
|
+
// own diff line.
|
|
639
|
+
// Independent of the label: a control that goes from disabled "Saving…"
|
|
640
|
+
// to enabled "Save" changed BOTH ways, and reporting only the relabel
|
|
641
|
+
// swallows the enable — the exact fact this line exists to surface.
|
|
642
|
+
const retoggled = elements.filter((el) => {
|
|
643
|
+
const old = prev.byKey.get(el.key);
|
|
644
|
+
return old !== undefined && old.disabled !== el.disabled;
|
|
645
|
+
});
|
|
646
|
+
const changedKeys = new Set([...relabeled, ...retoggled].map((el) => el.key));
|
|
647
|
+
const unchanged = elements.length - added.length - changedKeys.size;
|
|
648
|
+
if (added.length === 0 && removed.length === 0 && relabeled.length === 0 && retoggled.length === 0) {
|
|
649
|
+
body = `No element changes since the last snapshot (${unchanged} interactables, refs unchanged).`;
|
|
650
|
+
}
|
|
651
|
+
else {
|
|
652
|
+
body =
|
|
653
|
+
`DIFF vs last snapshot (${unchanged} unchanged, refs stable):\n` +
|
|
654
|
+
[
|
|
655
|
+
...added.map((el) => `+ ${line(el)}`),
|
|
656
|
+
...removed.map(([key, v]) => `- ${v.ref} "${v.label}" (gone: ${key})`),
|
|
657
|
+
...relabeled.map((el) => `~ ${el.ref} relabeled → "${el.name}"`),
|
|
658
|
+
...retoggled.map((el) => `~ ${el.ref} "${el.name}" is now ${el.disabled ? "DISABLED" : "ENABLED"}`),
|
|
659
|
+
].join("\n");
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
else {
|
|
663
|
+
const missingTestids = elements.filter((el) => !el.testid && !el.disabled).length;
|
|
664
|
+
body =
|
|
665
|
+
`Interactables (${elements.length}${truncated ? "+ — TRUNCATED at 150, dense page" : ""}${missingTestids ? `, ${missingTestids} missing data-testid` : ""}):\n` +
|
|
666
|
+
elements.map(line).join("\n");
|
|
667
|
+
}
|
|
668
|
+
const geometry = geometryIssues(elements, page.viewportSize() ?? { width: 1280, height: 900 });
|
|
669
|
+
geometry.push(...(await probeOverlays(page)));
|
|
670
|
+
const hiddenFileInputs = await this.hiddenFileInputs(page);
|
|
671
|
+
const cov = memory.coverage();
|
|
672
|
+
const unvisited = this.unvisitedKnownRoutes();
|
|
673
|
+
const title = await page.title();
|
|
674
|
+
return (`URL: ${url}\nTitle: ${title}\nState: ${fp} ${isNew ? "(NEW state)" : "(revisited)"}\n` +
|
|
675
|
+
`Coverage: ${cov.states} states known · ${cov.elementsExercised}/${cov.elementsTotal} elements exercised` +
|
|
676
|
+
(this.allKnownRoutes().length > 0 ? ` · routes ${this.allKnownRoutes().length - unvisited.length}/${this.allKnownRoutes().length} visited` : "") +
|
|
677
|
+
`\n` +
|
|
678
|
+
body +
|
|
679
|
+
(geometry.length > 0 ? `\nGEOMETRY issues:\n` + geometry.map((g) => ` ⚠ ${g}`).join("\n") : "") +
|
|
680
|
+
(hiddenFileInputs.length > 0
|
|
681
|
+
? `\nFILE INPUTS not listed above (hidden behind a styled control — a user never sees the input itself): ${hiddenFileInputs.join("; ")}. ` +
|
|
682
|
+
`scout_upload {ref} on the control that opens one, or scout_upload {} when it is the page's only file input.`
|
|
683
|
+
: "") +
|
|
684
|
+
formatViolations(this.oracles.drain()) +
|
|
685
|
+
(elements.length === 0 ? "\n⚠ DEAD END: no interactable elements found on this page." : ""));
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Resolve a ref and re-verify the live element at action time. Refs are
|
|
689
|
+
* trusted only while the page URL exactly matches the snapshot's, and even
|
|
690
|
+
* then the located element's live identity is re-read so the destructive
|
|
691
|
+
* policy applies to what is actually acted on — SPA re-renders can put a
|
|
692
|
+
* different element under a previously-safe XPath.
|
|
693
|
+
*/
|
|
694
|
+
async resolveForAction(ref) {
|
|
695
|
+
this.actionStartedAt = Date.now();
|
|
696
|
+
const page = this.requirePage();
|
|
697
|
+
const el = this.refs.get(ref);
|
|
698
|
+
if (!el) {
|
|
699
|
+
throw new Error(`Unknown ref "${ref}". Refs are only valid from the latest scout_snapshot — take a new snapshot.`);
|
|
700
|
+
}
|
|
701
|
+
if (page.url() !== this.snapshotUrl) {
|
|
702
|
+
this.refs.clear();
|
|
703
|
+
throw new Error(`Page URL changed since the last snapshot (now ${page.url()}). Take a new scout_snapshot.`);
|
|
704
|
+
}
|
|
705
|
+
// String EXPRESSION via page.evaluate (locator.evaluate treats a string as
|
|
706
|
+
// an expression, not a function — the element arg never binds).
|
|
707
|
+
const live = (await page
|
|
708
|
+
.evaluate(`(() => { const node = ${xpathLookup(el.xpath)}; if (!node) return null; ` +
|
|
709
|
+
`return { testid: node.getAttribute('data-testid'), label: (node.getAttribute('aria-label') || node.innerText || node.textContent || node.getAttribute('placeholder') || '').trim().slice(0, 120) }; })()`)
|
|
710
|
+
.catch(() => null));
|
|
711
|
+
if (!live) {
|
|
712
|
+
throw new Error(`Element ${ref} no longer exists in the DOM — take a new scout_snapshot.`);
|
|
713
|
+
}
|
|
714
|
+
if (el.testid && live.testid !== el.testid) {
|
|
715
|
+
this.refs.clear();
|
|
716
|
+
throw new Error(`Element under ${ref} changed (expected testid=${el.testid}, found ${live.testid ?? "none"}) — the DOM shifted; take a new scout_snapshot.`);
|
|
717
|
+
}
|
|
718
|
+
return { el, liveLabel: live.label };
|
|
719
|
+
}
|
|
720
|
+
actionPolicyCheck(el, liveLabel) {
|
|
721
|
+
if (!this.readOnly)
|
|
722
|
+
return null;
|
|
723
|
+
// Same-origin navigation links are exempt: navigation is non-destructive
|
|
724
|
+
// under the origin fence, and blocking "Reset filters"-style nav links
|
|
725
|
+
// would make whole read-only sections unexplorable.
|
|
726
|
+
if (el.role === "link" && el.href && !/^javascript:/i.test(el.href))
|
|
727
|
+
return null;
|
|
728
|
+
// Typing into a text field is never itself destructive (labels like
|
|
729
|
+
// "Type DELETE to confirm" would false-positive) — the destructive risk
|
|
730
|
+
// is submission, which the pressEnter/submit path vets separately. The
|
|
731
|
+
// same holds for choosing a file: selection is not the send.
|
|
732
|
+
if (el.role === "textbox" || el.role === "file")
|
|
733
|
+
return null;
|
|
734
|
+
if (el.destructive || isDestructive(liveLabel)) {
|
|
735
|
+
return destructiveRefusal(liveLabel || el.name || el.testid || el.ref);
|
|
736
|
+
}
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
async afterAction(action, target) {
|
|
740
|
+
const page = this.requirePage();
|
|
741
|
+
await this.settle();
|
|
742
|
+
let url = page.url();
|
|
743
|
+
if (url !== "about:blank" && !this.isSameOrigin(url)) {
|
|
744
|
+
// A click carried us off the app's origin — bounce back and say so.
|
|
745
|
+
this.logAction({ action, target, url });
|
|
746
|
+
this.logAction({ action: "origin-fence:bounced", target: url.slice(0, 200), url });
|
|
747
|
+
await page.goBack({ waitUntil: "domcontentloaded", timeout: 10000 }).catch(() => { });
|
|
748
|
+
url = page.url();
|
|
749
|
+
this.refs.clear();
|
|
750
|
+
return (`OK: ${action} ${target}\nNavigated off-origin and was bounced back to ${url}. Exploration is fenced to ${this.baseUrl}.` +
|
|
751
|
+
formatViolations(this.oracles.drain()));
|
|
752
|
+
}
|
|
753
|
+
this.logAction({ action, target, url });
|
|
754
|
+
const violations = this.oracles.drain();
|
|
755
|
+
const mutations = this.drainMutations() + this.drainBlocked();
|
|
756
|
+
const navigated = this.snapshotUrl !== "" && url !== this.snapshotUrl;
|
|
757
|
+
if (navigated) {
|
|
758
|
+
// Refs point into the previous page's DOM; invalidate so a stale ref
|
|
759
|
+
// errors ("take a new snapshot") instead of acting on the wrong element.
|
|
760
|
+
this.refs.clear();
|
|
761
|
+
}
|
|
762
|
+
return `OK: ${action} ${target}\nURL now: ${url}` + (navigated ? " (page changed — take a new snapshot)" : "") + mutations + formatViolations(violations);
|
|
763
|
+
}
|
|
764
|
+
/** Does this request path address a record this run created? Rules live in ownership.ts. */
|
|
765
|
+
isOwnedResource(pathname) {
|
|
766
|
+
return isOwnedResource(this.ownedIds, pathname);
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Register what a successful POST created. The decision — which ids a
|
|
770
|
+
* response genuinely minted — is the pure extractCreatedIds() in
|
|
771
|
+
* ownership.ts; this method only gathers the evidence from the response and
|
|
772
|
+
* records the verdict.
|
|
773
|
+
*/
|
|
774
|
+
async recordCreation(res) {
|
|
775
|
+
let body;
|
|
776
|
+
if ((res.headers()["content-type"] ?? "").includes("json")) {
|
|
777
|
+
try {
|
|
778
|
+
body = await res.json();
|
|
779
|
+
}
|
|
780
|
+
catch {
|
|
781
|
+
/* non-JSON or oversized body — the Location header may still cover it */
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
const verdict = extractCreatedIds({
|
|
785
|
+
pathname: new URL(res.url()).pathname,
|
|
786
|
+
status: res.status(),
|
|
787
|
+
location: res.headers()["location"],
|
|
788
|
+
body,
|
|
789
|
+
requestBody: res.request().postData() ?? "",
|
|
790
|
+
});
|
|
791
|
+
for (const id of verdict.ids) {
|
|
792
|
+
// Identity/account collections still appear on the cleanup list (the
|
|
793
|
+
// record was genuinely created), but never grant mutation rights.
|
|
794
|
+
if (!verdict.identityCollection) {
|
|
795
|
+
const key = normalizeId(id);
|
|
796
|
+
if (!this.ownedIds.has(key))
|
|
797
|
+
this.ownedIds.set(key, new Set());
|
|
798
|
+
this.ownedIds.get(key).add(verdict.collection);
|
|
799
|
+
}
|
|
800
|
+
const desc = `${verdict.collection} id=${id}`;
|
|
801
|
+
if (!this.createdResources.includes(desc)) {
|
|
802
|
+
this.createdResources.push(desc);
|
|
803
|
+
this.logAction({ action: "created-resource", target: desc, url: this.page?.url() ?? "" });
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
/** A late request started before the current action — attribute it honestly. */
|
|
808
|
+
lateMark(entry) {
|
|
809
|
+
return entry.at < this.actionStartedAt ? `${entry.sig} (late — likely from a previous action)` : entry.sig;
|
|
810
|
+
}
|
|
811
|
+
/** Report (and clear) write-policy blocks since the last action. */
|
|
812
|
+
drainBlocked() {
|
|
813
|
+
this.lastActionBlocked = this.blockedRequests.length;
|
|
814
|
+
if (this.blockedRequests.length === 0)
|
|
815
|
+
return "";
|
|
816
|
+
const list = this.blockedRequests
|
|
817
|
+
.slice(0, 5)
|
|
818
|
+
.map((e) => this.lateMark(e))
|
|
819
|
+
.join("; ");
|
|
820
|
+
const extra = this.blockedRequests.length > 5 ? ` (+${this.blockedRequests.length - 5} more)` : "";
|
|
821
|
+
this.blockedRequests = [];
|
|
822
|
+
return (`\n🛡 WRITE-POLICY blocked (${this.mode}): ${list}${extra}. ` +
|
|
823
|
+
`This is the tester's safety policy, NOT an app bug — do not file a finding for the resulting error UI. ` +
|
|
824
|
+
(this.mode === "read-only"
|
|
825
|
+
? `Re-attach with mode="safe-write" to test create/edit flows, or "destructive" (user-approved disposable env only).`
|
|
826
|
+
: `In safe-write, updates/deletes are only allowed on resources this session created (${this.createdResources.length} so far).`));
|
|
827
|
+
}
|
|
828
|
+
/** xhr/fetch requests seen this session — lets clicks detect silent no-op submits. */
|
|
829
|
+
xhrCount = 0;
|
|
830
|
+
reportedMutationSigs = new Set();
|
|
831
|
+
/** Report (and clear) non-GET requests since the last action — loud in read-only mode, each endpoint once per session. */
|
|
832
|
+
drainMutations() {
|
|
833
|
+
// Raw (pre-dedup) sigs from this action — double-submit detection needs
|
|
834
|
+
// to see the DUPLICATES that the reporting dedup below intentionally hides.
|
|
835
|
+
this.lastActionMutationSigs = this.mutationRequests.map((e) => e.sig);
|
|
836
|
+
const fresh = this.mutationRequests
|
|
837
|
+
.filter((entry) => !this.abortedByPolicy.has(entry.req))
|
|
838
|
+
.filter((entry) => {
|
|
839
|
+
const key = entry.sig.split("?")[0];
|
|
840
|
+
if (this.reportedMutationSigs.has(key))
|
|
841
|
+
return false;
|
|
842
|
+
this.reportedMutationSigs.add(key);
|
|
843
|
+
return true;
|
|
844
|
+
});
|
|
845
|
+
this.mutationRequests = [];
|
|
846
|
+
if (fresh.length === 0)
|
|
847
|
+
return "";
|
|
848
|
+
const list = fresh
|
|
849
|
+
.slice(0, 5)
|
|
850
|
+
.map((e) => this.lateMark(e))
|
|
851
|
+
.join("; ");
|
|
852
|
+
const extra = fresh.length > 5 ? ` (+${fresh.length - 5} more)` : "";
|
|
853
|
+
return this.readOnly
|
|
854
|
+
? `\n⚠ READ-ONLY notice: this action fired state-changing requests — server state may have mutated despite read-only mode: ${list}${extra}. Consider whether this flow should be avoided or the environment confirmed disposable.`
|
|
855
|
+
: `\n(state-changing requests: ${list}${extra})`;
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Click a locator the way a real user's click resolves, not the way
|
|
859
|
+
* Playwright's strict actionability protocol insists on. `.click()` polls
|
|
860
|
+
* until the exact target node is the topmost element receiving pointer
|
|
861
|
+
* events, stable across frames — but a styled control (a `<label>`/`<span>`
|
|
862
|
+
* wrapper forwarding to a visually-hidden `<input>`, an icon layered over a
|
|
863
|
+
* button, a CSS transition that never settles) can make that check spin
|
|
864
|
+
* forever even though a human's click at the same point lands correctly
|
|
865
|
+
* (native hit-testing / label-forwarding resolves it, or the browser just
|
|
866
|
+
* doesn't wait for two stable animation frames). On a timeout, retry once
|
|
867
|
+
* with `force: true`, which skips the hit-test/stability wait but still
|
|
868
|
+
* dispatches the click at the element's real coordinates — matching what a
|
|
869
|
+
* physical click does. The caller is told a fallback was needed so it can
|
|
870
|
+
* still weigh whether the underlying instability is itself worth reporting
|
|
871
|
+
* (e.g. alongside a geometry overlap oracle hit), rather than the tool
|
|
872
|
+
* hard-failing on a control a real user can click fine.
|
|
873
|
+
*
|
|
874
|
+
* The retry is gated on WHY the wait failed, not just that it timed out:
|
|
875
|
+
* `force: true` also skips the "is visible" and "is enabled" checks, so
|
|
876
|
+
* forcing unconditionally would report a genuinely disabled or hidden
|
|
877
|
+
* control as a successful click — the opposite of what this tool exists to
|
|
878
|
+
* catch. Only "intercepts pointer events" (something else is the hit-test
|
|
879
|
+
* target) and "is not stable" (still animating) describe a control a real
|
|
880
|
+
* click resolves fine; "is not visible"/"is not enabled"/"is outside of
|
|
881
|
+
* the viewport" describe a control that is actually unreachable, and those
|
|
882
|
+
* timeouts are left to fail as real failures.
|
|
883
|
+
*/
|
|
884
|
+
async resilientClick(locator, timeout, clicks = 1) {
|
|
885
|
+
const clickCount = Math.max(1, Math.min(3, clicks));
|
|
886
|
+
try {
|
|
887
|
+
await locator.click({ timeout, clickCount });
|
|
888
|
+
return { forced: false };
|
|
889
|
+
}
|
|
890
|
+
catch (err) {
|
|
891
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
892
|
+
if (!/Timeout/i.test(msg))
|
|
893
|
+
throw err;
|
|
894
|
+
const diagnostic = actionabilityDiagnostic(msg);
|
|
895
|
+
if (!diagnostic || !/intercepts pointer events|is not stable/i.test(diagnostic))
|
|
896
|
+
throw err;
|
|
897
|
+
// A shorter budget here: the forced click skips the wait that
|
|
898
|
+
// consumed the first `timeout`, so it needs very little of its own.
|
|
899
|
+
await locator.click({ timeout: Math.min(timeout, FORCED_CLICK_TIMEOUT_MS), force: true, clickCount });
|
|
900
|
+
return { forced: true };
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
async click(ref, clicks = 1) {
|
|
904
|
+
const page = this.requirePage();
|
|
905
|
+
const { el, liveLabel } = await this.resolveForAction(ref);
|
|
906
|
+
const refusal = this.actionPolicyCheck(el, liveLabel);
|
|
907
|
+
if (refusal) {
|
|
908
|
+
this.logAction({ action: "click:refused", target: liveLabel || el.name, url: page.url() });
|
|
909
|
+
return refusal;
|
|
910
|
+
}
|
|
911
|
+
// Submit-shaped clicks that fire zero network requests are a smell
|
|
912
|
+
// (silent no-op forms): capture the count before to compare after.
|
|
913
|
+
const xhrBefore = this.xhrCount;
|
|
914
|
+
const submitLike = el.role === "button" && /submit|send|save|create|apply|subscribe|register|sign|post|add\b/i.test(el.name + " " + (el.testid ?? ""));
|
|
915
|
+
const { forced } = await this.resilientClick(page.locator(`xpath=${el.xpath}`), ACTION_TIMEOUT_MS, clicks);
|
|
916
|
+
this.memory.markExercised(this.currentFingerprint, el.key, clicks > 1 ? `click×${clicks}` : "click");
|
|
917
|
+
const result = await this.afterAction(clicks > 1 ? `click×${clicks}` : "click", `${el.role} "${el.name}"`);
|
|
918
|
+
// Impatient-user probe: a rapid multi-click that fires the SAME
|
|
919
|
+
// state-changing request more than once means the action is not guarded
|
|
920
|
+
// against double submission (button not disabled during flight, endpoint
|
|
921
|
+
// not idempotent) — a top real-world bug class invisible to polite tests.
|
|
922
|
+
if (clicks > 1) {
|
|
923
|
+
const counts = new Map();
|
|
924
|
+
for (const sig of this.lastActionMutationSigs)
|
|
925
|
+
counts.set(sig, (counts.get(sig) ?? 0) + 1);
|
|
926
|
+
const dup = [...counts.entries()].filter(([, n]) => n > 1);
|
|
927
|
+
if (dup.length > 0) {
|
|
928
|
+
return (result +
|
|
929
|
+
`\n⚠ DOUBLE-SUBMIT SIGNAL: ${clicks}× click fired the same state-changing request ${dup[0][1]}× (${dup[0][0]}). ` +
|
|
930
|
+
`The control is not guarded against rapid re-clicks — check for duplicate records, then file (category: data-inconsistency).`);
|
|
931
|
+
}
|
|
932
|
+
return result + `\nℹ ${clicks}× rapid click fired no duplicate state-changing requests — double-submit appears guarded on this control.`;
|
|
933
|
+
}
|
|
934
|
+
const forcedNote = forced
|
|
935
|
+
? `\nℹ NOTE: the strict click timed out waiting for this element to be the stable, unobstructed top hit at its coordinates, so a forced click was used instead (which still landed — this succeeded). Something is likely rendered on top of it (an icon, a decorative layer, an animating wrapper) or it delegates via a label; cross-check against any GEOMETRY overlap on this element before treating that as a real bug.`
|
|
936
|
+
: "";
|
|
937
|
+
if (submitLike && this.xhrCount === xhrBefore && page.url() === this.snapshotUrl) {
|
|
938
|
+
return (result +
|
|
939
|
+
`\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).` +
|
|
940
|
+
forcedNote);
|
|
941
|
+
}
|
|
942
|
+
return result + forcedNote;
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* Fill a field the way a real user types: when the field already holds
|
|
946
|
+
* content (a mention chip, a pre-inserted command, a draft), APPEND at the
|
|
947
|
+
* caret end instead of clearing — fill() destroys rich content like
|
|
948
|
+
* @-mention chips that composers insert on menu clicks. A separating space
|
|
949
|
+
* is inserted only at a word-to-word boundary on free-text fields. Returns
|
|
950
|
+
* a note describing what pre-existing content was found (empty string when
|
|
951
|
+
* the field was empty). An empty `text` always clears the field (boundary
|
|
952
|
+
* testing), and reading the existing content is load-bearing: if it fails,
|
|
953
|
+
* the action aborts rather than risk a silent content-destroying replace.
|
|
954
|
+
*/
|
|
955
|
+
async fillOrAppend(locator, text, replace) {
|
|
956
|
+
let state;
|
|
957
|
+
try {
|
|
958
|
+
state = (await locator.evaluate((node) => {
|
|
959
|
+
const n = node;
|
|
960
|
+
if (n.isContentEditable)
|
|
961
|
+
return { existing: (n.innerText || n.textContent || "").trim(), caretAppendable: true };
|
|
962
|
+
if (typeof n.value === "string") {
|
|
963
|
+
// selectionStart is null on number/date/email-style inputs — caret
|
|
964
|
+
// placement is unsupported there, so append must go via fill().
|
|
965
|
+
return { existing: n.value, caretAppendable: typeof n.selectionStart === "number" };
|
|
966
|
+
}
|
|
967
|
+
return { existing: "", caretAppendable: false };
|
|
968
|
+
}, undefined, { timeout: ACTION_TIMEOUT_MS }));
|
|
969
|
+
}
|
|
970
|
+
catch (err) {
|
|
971
|
+
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.`);
|
|
972
|
+
}
|
|
973
|
+
const { existing, caretAppendable } = state;
|
|
974
|
+
if (replace || !existing || text === "") {
|
|
975
|
+
await locator.fill(text, { timeout: ACTION_TIMEOUT_MS });
|
|
976
|
+
return existing && (replace || text === "") ? ` (replaced existing content ${JSON.stringify(existing.slice(0, 60))})` : "";
|
|
977
|
+
}
|
|
978
|
+
const appendNote = ` (APPENDED after existing content ${JSON.stringify(existing.slice(0, 60))} — pass replace=true to overwrite instead)`;
|
|
979
|
+
if (!caretAppendable) {
|
|
980
|
+
// Data-typed inputs (number, email, date): concatenate without a
|
|
981
|
+
// separator — an injected space would invalidate the value.
|
|
982
|
+
await locator.fill(existing + text, { timeout: ACTION_TIMEOUT_MS });
|
|
983
|
+
return appendNote;
|
|
984
|
+
}
|
|
985
|
+
// Native append: focus, move the caret to the end, insert via the
|
|
986
|
+
// keyboard — preserves rich composer content that fill() would clear.
|
|
987
|
+
// Note: insertText fires input events but not keydown/keyup, so
|
|
988
|
+
// keydown-driven triggers (slash commands, mention menus) will not react.
|
|
989
|
+
await locator.evaluate((node) => {
|
|
990
|
+
const n = node;
|
|
991
|
+
n.focus();
|
|
992
|
+
if (typeof n.selectionStart === "number" && typeof n.value === "string") {
|
|
993
|
+
n.setSelectionRange(n.value.length, n.value.length);
|
|
994
|
+
}
|
|
995
|
+
else if (n.isContentEditable) {
|
|
996
|
+
const range = document.createRange();
|
|
997
|
+
range.selectNodeContents(n);
|
|
998
|
+
range.collapse(false);
|
|
999
|
+
const sel = window.getSelection();
|
|
1000
|
+
sel?.removeAllRanges();
|
|
1001
|
+
sel?.addRange(range);
|
|
1002
|
+
}
|
|
1003
|
+
}, undefined, { timeout: ACTION_TIMEOUT_MS });
|
|
1004
|
+
// Space only at a word-to-word boundary: "@mention " + "hi" needs none,
|
|
1005
|
+
// "user@" + "x" must not become "user@ x".
|
|
1006
|
+
const separator = /\w$/.test(existing) && /^\w/.test(text) ? " " : "";
|
|
1007
|
+
await this.requirePage().keyboard.insertText(separator + text);
|
|
1008
|
+
return appendNote;
|
|
1009
|
+
}
|
|
1010
|
+
async type(ref, text, pressEnter = false, replace = false) {
|
|
1011
|
+
const page = this.requirePage();
|
|
1012
|
+
const { el, liveLabel } = await this.resolveForAction(ref);
|
|
1013
|
+
if (el.role === "file") {
|
|
1014
|
+
// fill() refuses a file input — and it used to refuse with a stack
|
|
1015
|
+
// trace, leaving every upload form stranded. Point at the tool that can.
|
|
1016
|
+
return (`${ref} is a file input ("${el.name || el.testid || "unnamed"}") — text cannot be typed into it. ` +
|
|
1017
|
+
`Use scout_upload {ref:"${ref}"}: a small valid fixture is generated and matched to the input's accept attribute, or pass fixture / filePath.`);
|
|
1018
|
+
}
|
|
1019
|
+
const refusal = this.actionPolicyCheck(el, liveLabel);
|
|
1020
|
+
if (refusal)
|
|
1021
|
+
return refusal;
|
|
1022
|
+
const locator = page.locator(`xpath=${el.xpath}`);
|
|
1023
|
+
const fillNote = await this.fillOrAppend(locator, text, replace);
|
|
1024
|
+
if (pressEnter) {
|
|
1025
|
+
// Enter inside a form submits it — check the form's submit target, or
|
|
1026
|
+
// pressEnter becomes a read-only bypass for destructive submits.
|
|
1027
|
+
if (this.readOnly) {
|
|
1028
|
+
const submitLabel = (await page
|
|
1029
|
+
.evaluate(`(() => { const node = ${xpathLookup(el.xpath)}; if (!node) return ''; ` +
|
|
1030
|
+
`const f = node.form || node.closest('form'); if (!f) return ''; ` +
|
|
1031
|
+
`const s = f.querySelector('[type="submit"], button:not([type="button"]):not([type="reset"])'); ` +
|
|
1032
|
+
`return s ? (s.getAttribute('aria-label') || s.innerText || s.getAttribute('data-testid') || '').trim().slice(0, 120) : ''; })()`)
|
|
1033
|
+
.catch(() => ""));
|
|
1034
|
+
if (isDestructive(submitLabel)) {
|
|
1035
|
+
this.logAction({ action: "type:enter-refused", target: submitLabel, url: page.url() });
|
|
1036
|
+
return `Filled ${el.role} "${el.name}" but did NOT press Enter. ` + destructiveRefusal(submitLabel);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
await locator.press("Enter", { timeout: ACTION_TIMEOUT_MS });
|
|
1040
|
+
}
|
|
1041
|
+
this.memory.markExercised(this.currentFingerprint, el.key, "type");
|
|
1042
|
+
return this.afterAction("type", `${el.role} "${el.name}" ← ${JSON.stringify(text.slice(0, 60))}${pressEnter ? " + Enter" : ""}${fillNote}`);
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Attach a file to an upload control the way a user does. `ref` may be the
|
|
1046
|
+
* file input itself or the styled button/label/dropzone that opens the file
|
|
1047
|
+
* chooser — the chooser is intercepted and answered, which is how the hidden
|
|
1048
|
+
* input behind a "Choose file" control is reached. With no `ref`, the page's
|
|
1049
|
+
* only file input is used, hidden or not. The file is a generated in-memory
|
|
1050
|
+
* fixture unless `filePath` names one inside the attached project.
|
|
1051
|
+
*/
|
|
1052
|
+
async upload(opts) {
|
|
1053
|
+
this.actionStartedAt = Date.now();
|
|
1054
|
+
const page = this.requirePage();
|
|
1055
|
+
let el = null;
|
|
1056
|
+
let locator = null;
|
|
1057
|
+
if (opts.ref) {
|
|
1058
|
+
const resolved = await this.resolveForAction(opts.ref);
|
|
1059
|
+
el = resolved.el;
|
|
1060
|
+
const refusal = this.actionPolicyCheck(el, resolved.liveLabel);
|
|
1061
|
+
if (refusal)
|
|
1062
|
+
return refusal;
|
|
1063
|
+
locator = page.locator(`xpath=${el.xpath}`);
|
|
1064
|
+
}
|
|
1065
|
+
const outcome = await this.performUpload(locator, opts);
|
|
1066
|
+
if (outcome.refused)
|
|
1067
|
+
return outcome.refused;
|
|
1068
|
+
if (el)
|
|
1069
|
+
this.memory.markExercised(this.currentFingerprint, el.key, "upload");
|
|
1070
|
+
const after = await this.afterAction("upload", outcome.summary);
|
|
1071
|
+
// Whether the app sends on selection or on submit decides the driver's
|
|
1072
|
+
// next move. Counted from the request log, not read back out of the text
|
|
1073
|
+
// above: drainMutations() reports each endpoint once per session, so a
|
|
1074
|
+
// repeat upload prints nothing there, and a write-policy block prints
|
|
1075
|
+
// something else entirely. Only requests recorded AFTER the file was set
|
|
1076
|
+
// count — the trigger click's own traffic is not the upload.
|
|
1077
|
+
const firedOnSelect = this.lastActionMutationSigs.length > outcome.mutationsBefore || this.lastActionBlocked > outcome.blockedBefore;
|
|
1078
|
+
return (after +
|
|
1079
|
+
outcome.notes +
|
|
1080
|
+
(firedOnSelect
|
|
1081
|
+
? "\nThe app sent a request on selection — see the state-changing (or blocked) requests above."
|
|
1082
|
+
: "\nNo state-changing request fired on selection: either the form sends on submit (click it next) or the app rejected the file client-side — the next snapshot shows which."));
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Shared by scout_upload and plan `upload` steps: pick where the file goes,
|
|
1086
|
+
* resolve what to send, set it, and describe what happened. Refusals come
|
|
1087
|
+
* back as text, not throws — a path outside the fence or an ambiguous page
|
|
1088
|
+
* is an answer the driver acts on, not an engine failure.
|
|
1089
|
+
*/
|
|
1090
|
+
async performUpload(locator, opts) {
|
|
1091
|
+
const refuse = (refused) => ({ refused, summary: "", notes: "", mutationsBefore: 0, blockedBefore: 0 });
|
|
1092
|
+
if (opts.filePath && opts.fixture)
|
|
1093
|
+
return refuse("Pass filePath OR fixture, not both — one names a real file, the other generates one.");
|
|
1094
|
+
// A disk file is validated BEFORE the page is touched: a refusal must not
|
|
1095
|
+
// have already clicked the trigger (state-changing in safe-write) and left
|
|
1096
|
+
// an intercepted chooser unanswered. Only fixture inference needs the
|
|
1097
|
+
// input's accept attribute, so only that waits for the target.
|
|
1098
|
+
const disk = opts.filePath ? this.resolveDiskUpload(opts.filePath, opts.name) : null;
|
|
1099
|
+
if (disk && "refused" in disk)
|
|
1100
|
+
return refuse(disk.refused);
|
|
1101
|
+
const target = await this.pickUploadTarget(locator);
|
|
1102
|
+
if ("refused" in target)
|
|
1103
|
+
return refuse(target.refused);
|
|
1104
|
+
const { input, chooser, how } = target;
|
|
1105
|
+
const meta = await (input ? input.evaluate(describeFileInput, undefined, { timeout: ACTION_TIMEOUT_MS }) : chooser.element().evaluate(describeFileInput)).catch(() => ({ accept: null, multiple: false, label: "", disabled: false, probed: false }));
|
|
1106
|
+
// setInputFiles never checks `disabled` — it would report success on a
|
|
1107
|
+
// control no user can operate.
|
|
1108
|
+
if (meta.disabled) {
|
|
1109
|
+
return refuse(`The file input${meta.label ? ` "${meta.label}"` : ""} is disabled — a user cannot choose a file here. If it should be enabled in this state, that is a finding.`);
|
|
1110
|
+
}
|
|
1111
|
+
const file = disk ?? generatedPayload(meta, opts);
|
|
1112
|
+
const mutationsBefore = this.mutationRequests.length;
|
|
1113
|
+
const blockedBefore = this.blockedRequests.length;
|
|
1114
|
+
if (chooser)
|
|
1115
|
+
await chooser.setFiles(file.payload, { timeout: ACTION_TIMEOUT_MS });
|
|
1116
|
+
else
|
|
1117
|
+
await input.setInputFiles(file.payload, { timeout: ACTION_TIMEOUT_MS });
|
|
1118
|
+
// The set call succeeding means the browser took the payload — not that
|
|
1119
|
+
// the app kept it. Read the input back: an app that rejects client-side
|
|
1120
|
+
// clears it, and then nothing would ever be sent on submit.
|
|
1121
|
+
const kept = await (input
|
|
1122
|
+
? input.evaluate((node) => node.files?.length ?? 0, undefined, { timeout: ACTION_TIMEOUT_MS })
|
|
1123
|
+
: chooser.element().evaluate((node) => node.files?.length ?? 0)).catch(() => -1);
|
|
1124
|
+
const inputDesc = [
|
|
1125
|
+
meta.label ? `"${meta.label}"` : null,
|
|
1126
|
+
meta.probed ? (meta.accept ? `accept=${meta.accept}` : null) : "accept unknown — the input could not be inspected",
|
|
1127
|
+
meta.multiple ? "multiple" : null,
|
|
1128
|
+
]
|
|
1129
|
+
.filter(Boolean)
|
|
1130
|
+
.join(", ");
|
|
1131
|
+
const summary = `${how}${inputDesc ? ` (${inputDesc})` : ""} ← ${file.name} (${file.bytes} bytes, ${file.mime}; ${file.source})`;
|
|
1132
|
+
let notes = "";
|
|
1133
|
+
if (acceptMatches(meta.accept, file.name, file.mime) === false) {
|
|
1134
|
+
notes += `\n⚠ "${file.name}" does NOT match the input's accept="${meta.accept}". A correct app rejects it (picker filter, client validation, or server); if it was accepted, that is a validation gap — file it.`;
|
|
1135
|
+
}
|
|
1136
|
+
if (kept === 0) {
|
|
1137
|
+
notes += `\n⚠ After selection the input reports NO file — the app cleared it (client-side rejection, or a re-render that reset the field). Nothing will be sent on submit.`;
|
|
1138
|
+
}
|
|
1139
|
+
else if (kept < 0) {
|
|
1140
|
+
notes += ` (post-selection state of the input could not be re-read)`;
|
|
1141
|
+
}
|
|
1142
|
+
return { summary, notes, mutationsBefore, blockedBefore };
|
|
1143
|
+
}
|
|
1144
|
+
/** Where the file goes: the input itself, the chooser a control opens, or the page's only file input. */
|
|
1145
|
+
async pickUploadTarget(locator) {
|
|
1146
|
+
const page = this.requirePage();
|
|
1147
|
+
const fileInputs = page.locator('input[type="file"]');
|
|
1148
|
+
const listAll = async () => (await this.listFileInputs(page)).map(fileInputLabel).join("; ");
|
|
1149
|
+
if (!locator) {
|
|
1150
|
+
const n = await fileInputs.count();
|
|
1151
|
+
if (n === 0)
|
|
1152
|
+
return { refused: "No file input on this page. If the upload sits behind a button or dropzone, pass its ref." };
|
|
1153
|
+
if (n > 1)
|
|
1154
|
+
return { refused: `${n} file inputs on this page (${await listAll()}) — pass the ref of the one you mean, or of the control that opens it.` };
|
|
1155
|
+
return { input: fileInputs.first(), chooser: null, how: "set on the page's only file input" };
|
|
1156
|
+
}
|
|
1157
|
+
const isFileInput = await locator
|
|
1158
|
+
.evaluate((node) => node instanceof HTMLInputElement && node.type === "file", undefined, { timeout: ACTION_TIMEOUT_MS })
|
|
1159
|
+
.catch(() => false);
|
|
1160
|
+
if (isFileInput)
|
|
1161
|
+
return { input: locator, chooser: null, how: "set directly on the file input" };
|
|
1162
|
+
// A styled control: click it and answer the chooser it opens. The listener
|
|
1163
|
+
// is armed BEFORE the click (the event fires during it) with a budget that
|
|
1164
|
+
// outlasts the click's own — forced retry included — plus a grace period
|
|
1165
|
+
// after it, for apps that fetch an upload URL before opening the picker. A
|
|
1166
|
+
// wait shorter than the click reported such controls as "uploads nothing".
|
|
1167
|
+
const opened = page.waitForEvent("filechooser", { timeout: ACTION_TIMEOUT_MS + FORCED_CLICK_TIMEOUT_MS + CHOOSER_GRACE_MS }).catch(() => null);
|
|
1168
|
+
await this.resilientClick(locator, ACTION_TIMEOUT_MS);
|
|
1169
|
+
const chooser = await Promise.race([opened, page.waitForTimeout(CHOOSER_GRACE_MS).then(() => null)]);
|
|
1170
|
+
if (chooser)
|
|
1171
|
+
return { input: null, chooser, how: "via the file chooser the click opened" };
|
|
1172
|
+
const n = await fileInputs.count();
|
|
1173
|
+
if (n === 1)
|
|
1174
|
+
return { input: fileInputs.first(), chooser: null, how: "the click opened no file chooser, so the file was set on the page's only file input" };
|
|
1175
|
+
if (n === 0)
|
|
1176
|
+
return { refused: "The click opened no file chooser and the page has no file input — this control does not upload anything." };
|
|
1177
|
+
return {
|
|
1178
|
+
refused: `The click opened no file chooser and the page has ${n} file inputs (${await listAll()}) — pass the ref of the file input, or of the control that opens the one you want.`,
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
/** A file from disk, fenced to the project under test — the rule itself is resolveDiskUpload() in uploads.ts. */
|
|
1182
|
+
resolveDiskUpload(filePath, name) {
|
|
1183
|
+
return resolveDiskUpload({ projectDir: this.projectDir, projectDirNote: this.projectDirNote }, filePath, name);
|
|
1184
|
+
}
|
|
1185
|
+
/** Selector for transient hover-revealed surfaces (tooltips, poppers, hover cards). */
|
|
1186
|
+
static OVERLAY_SELECTOR = '[role="tooltip"], [data-tippy-root], [data-radix-popper-content-wrapper], [data-floating-ui-portal], ' +
|
|
1187
|
+
'[data-slot*="tooltip" i], [data-slot*="popover" i], ' +
|
|
1188
|
+
'[class*="tooltip" i], [class*="hovercard" i], [class*="popover" i]';
|
|
1189
|
+
/**
|
|
1190
|
+
* Visible overlay texts right now — diffed before/after a hover to isolate
|
|
1191
|
+
* what the hover revealed. Returns null when the read itself failed, so a
|
|
1192
|
+
* failed baseline is never mistaken for "no overlays were open".
|
|
1193
|
+
*/
|
|
1194
|
+
async collectOverlayTexts() {
|
|
1195
|
+
const page = this.requirePage();
|
|
1196
|
+
return (await page
|
|
1197
|
+
.evaluate(`(() => Array.from(document.querySelectorAll(${JSON.stringify(BrowserEngine.OVERLAY_SELECTOR)}))
|
|
1198
|
+
.filter((el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; })
|
|
1199
|
+
.map((el) => (el.innerText || el.textContent || "").trim().replace(/\\s+/g, " ").slice(0, 300))
|
|
1200
|
+
.filter(Boolean))()`)
|
|
1201
|
+
.catch(() => null));
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Shared reveal detection used by scout_hover and plan hover steps. Takes the
|
|
1205
|
+
* pre-hover baselines, polls the overlay selector across the reveal window
|
|
1206
|
+
* (tooltips are delay-gated — component libraries commonly warm up for as
|
|
1207
|
+
* long as 1500ms), and falls back to a visible-text diff for tooltips built
|
|
1208
|
+
* from arbitrary markup. A baseline that failed to read (null) disables its
|
|
1209
|
+
* detection path rather than fabricating an empty comparison. The text-diff
|
|
1210
|
+
* fallback is skipped on churning pages (content that was already changing
|
|
1211
|
+
* on its own) so unrelated live updates are not attributed to the hover.
|
|
1212
|
+
*/
|
|
1213
|
+
async detectHoverReveal(before, bodyBefore, churning) {
|
|
1214
|
+
const page = this.requirePage();
|
|
1215
|
+
let revealed = [];
|
|
1216
|
+
if (before !== null) {
|
|
1217
|
+
for (let waited = 0; waited < HOVER_REVEAL_WINDOW_MS; waited += 250) {
|
|
1218
|
+
await page.waitForTimeout(250);
|
|
1219
|
+
const after = await this.collectOverlayTexts();
|
|
1220
|
+
if (after === null)
|
|
1221
|
+
break;
|
|
1222
|
+
revealed = after.filter((t) => !before.includes(t));
|
|
1223
|
+
if (revealed.length > 0)
|
|
1224
|
+
return { revealed, fallbackUsed: false };
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
else {
|
|
1228
|
+
await page.waitForTimeout(HOVER_REVEAL_WINDOW_MS);
|
|
1229
|
+
}
|
|
1230
|
+
if (bodyBefore === null || churning)
|
|
1231
|
+
return { revealed: [], fallbackUsed: false };
|
|
1232
|
+
const bodyAfter = (await page.evaluate(`document.body ? document.body.innerText : ""`).catch(() => null));
|
|
1233
|
+
if (bodyAfter === null)
|
|
1234
|
+
return { revealed: [], fallbackUsed: false };
|
|
1235
|
+
const beforeLines = new Set(bodyBefore
|
|
1236
|
+
.split("\n")
|
|
1237
|
+
.map((l) => l.trim())
|
|
1238
|
+
.filter(Boolean));
|
|
1239
|
+
revealed = bodyAfter
|
|
1240
|
+
.split("\n")
|
|
1241
|
+
.map((l) => l.trim())
|
|
1242
|
+
.filter((l) => l && !beforeLines.has(l))
|
|
1243
|
+
.slice(0, 5)
|
|
1244
|
+
.map((l) => l.slice(0, 300));
|
|
1245
|
+
return { revealed, fallbackUsed: revealed.length > 0 };
|
|
1246
|
+
}
|
|
1247
|
+
/** Pre-hover baselines: overlay texts, body text, and whether the page is already churning on its own. */
|
|
1248
|
+
async hoverBaselines() {
|
|
1249
|
+
const page = this.requirePage();
|
|
1250
|
+
const before = await this.collectOverlayTexts();
|
|
1251
|
+
const body1 = (await page.evaluate(`document.body ? document.body.innerText : ""`).catch(() => null));
|
|
1252
|
+
await page.waitForTimeout(300);
|
|
1253
|
+
const body2 = (await page.evaluate(`document.body ? document.body.innerText : ""`).catch(() => null));
|
|
1254
|
+
return { before, bodyBefore: body2, churning: body1 === null || body2 === null || body1 !== body2 };
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Hover an element like a real user pausing the pointer on it, then report
|
|
1258
|
+
* what appeared: tooltip/popover overlays (diffed against pre-hover state),
|
|
1259
|
+
* any other new page text, the title attribute, and aria-describedby text.
|
|
1260
|
+
* Hover-gated UI (error badges, truncated-text tooltips, row action
|
|
1261
|
+
* reveals) is invisible to snapshots and clicks — this is the only way to
|
|
1262
|
+
* see it. Hovering does NOT mark the element as exercised: a hover is a
|
|
1263
|
+
* look, not an interaction, and the element still deserves a click.
|
|
1264
|
+
*/
|
|
1265
|
+
async hover(ref) {
|
|
1266
|
+
const page = this.requirePage();
|
|
1267
|
+
const { el } = await this.resolveForAction(ref);
|
|
1268
|
+
const { before, bodyBefore, churning } = await this.hoverBaselines();
|
|
1269
|
+
const locator = page.locator(`xpath=${el.xpath}`);
|
|
1270
|
+
await locator.hover({ timeout: ACTION_TIMEOUT_MS });
|
|
1271
|
+
// Wiggle inside the element: pointer-tracking libraries distinguish real
|
|
1272
|
+
// movement from a single synthetic hover event.
|
|
1273
|
+
const box = await locator.boundingBox().catch(() => null);
|
|
1274
|
+
if (box) {
|
|
1275
|
+
await page.mouse.move(box.x + box.width / 2 + 2, box.y + box.height / 2 + 1);
|
|
1276
|
+
await page.mouse.move(box.x + box.width / 2 - 2, box.y + box.height / 2 - 1);
|
|
1277
|
+
}
|
|
1278
|
+
const { revealed, fallbackUsed } = await this.detectHoverReveal(before, bodyBefore, churning);
|
|
1279
|
+
const attrTexts = (await page
|
|
1280
|
+
.evaluate(`(() => { const node = ${xpathLookup(el.xpath)}; if (!node) return []; const out = []; ` +
|
|
1281
|
+
`const t = node.getAttribute('title'); if (t) out.push('title: ' + t.slice(0, 300)); ` +
|
|
1282
|
+
`const d = node.getAttribute('aria-describedby'); if (d) { for (const id of d.split(/\\s+/)) { ` +
|
|
1283
|
+
`const refEl = document.getElementById(id); const txt = refEl ? (refEl.innerText || refEl.textContent || '').trim() : ''; ` +
|
|
1284
|
+
`if (txt) out.push(txt.replace(/\\s+/g, ' ').slice(0, 300)); } } return out; })()`)
|
|
1285
|
+
.catch(() => []));
|
|
1286
|
+
const result = await this.afterAction("hover", `${el.role} "${el.name}"`);
|
|
1287
|
+
const notes = [...new Set([...revealed, ...attrTexts])];
|
|
1288
|
+
const caveat = fallbackUsed ? "\n (from page-text diff — may include unrelated page activity)" : "";
|
|
1289
|
+
return (result +
|
|
1290
|
+
(notes.length > 0
|
|
1291
|
+
? `\nRevealed on hover:\n${notes.map((t) => ` · ${t}`).join("\n")}${caveat}`
|
|
1292
|
+
: `\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" : ""})`));
|
|
1293
|
+
}
|
|
1294
|
+
async select(ref, value) {
|
|
1295
|
+
const page = this.requirePage();
|
|
1296
|
+
const { el, liveLabel } = await this.resolveForAction(ref);
|
|
1297
|
+
const refusal = this.actionPolicyCheck(el, liveLabel);
|
|
1298
|
+
if (refusal)
|
|
1299
|
+
return refusal;
|
|
1300
|
+
if (this.readOnly) {
|
|
1301
|
+
// Bulk-action dropdowns fire on change — vet the chosen option itself.
|
|
1302
|
+
const optionLabel = (await page
|
|
1303
|
+
.evaluate(`(() => { const node = ${xpathLookup(el.xpath)}; if (!node) return ''; const v = ${JSON.stringify(value)}; ` +
|
|
1304
|
+
`const opts = Array.from(node.options || []); ` +
|
|
1305
|
+
`const o = opts.find(o => o.value === v || o.label === v || (o.textContent || '').trim() === v); ` +
|
|
1306
|
+
`return o ? (o.label || o.textContent || '').trim().slice(0, 120) : ''; })()`)
|
|
1307
|
+
.catch(() => ""));
|
|
1308
|
+
if (isDestructive(value) || isDestructive(optionLabel)) {
|
|
1309
|
+
this.logAction({ action: "select:refused", target: optionLabel || value, url: page.url() });
|
|
1310
|
+
return destructiveRefusal(optionLabel || value);
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
await page.locator(`xpath=${el.xpath}`).selectOption(value, { timeout: ACTION_TIMEOUT_MS });
|
|
1314
|
+
this.memory.markExercised(this.currentFingerprint, el.key, "select");
|
|
1315
|
+
return this.afterAction("select", `${el.role} "${el.name}" = ${value}`);
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* Enter/Space activate the focused element — apply the same destructive
|
|
1319
|
+
* policy as click, or the keyboard becomes a read-only bypass. Shared by
|
|
1320
|
+
* scout_press and plan press steps; returns a refusal message or null.
|
|
1321
|
+
*/
|
|
1322
|
+
async vetFocusedActivation(key) {
|
|
1323
|
+
if (!(this.readOnly && /^(Enter|NumpadEnter|Space| )$/i.test(key)))
|
|
1324
|
+
return null;
|
|
1325
|
+
const page = this.requirePage();
|
|
1326
|
+
const focusedLabel = await page
|
|
1327
|
+
.evaluate(`(() => { const el = document.activeElement; if (!el) return ""; ` +
|
|
1328
|
+
`return (el.getAttribute("aria-label") || el.getAttribute("data-testid") || el.innerText || el.textContent || "").trim().slice(0, 120); })()`)
|
|
1329
|
+
.catch(() => "");
|
|
1330
|
+
if (typeof focusedLabel === "string" && isDestructive(focusedLabel)) {
|
|
1331
|
+
this.logAction({ action: "press:refused", target: focusedLabel, url: page.url() });
|
|
1332
|
+
return destructiveRefusal(focusedLabel);
|
|
1333
|
+
}
|
|
1334
|
+
return null;
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* Keys that ACT on the focused control rather than just moving between
|
|
1338
|
+
* controls. Activating a button with Enter is the same interaction as
|
|
1339
|
+
* clicking it, and the `extensive` contract explicitly asks for a
|
|
1340
|
+
* keyboard-only pass — so a run driven entirely from the keyboard used to
|
|
1341
|
+
* record zero coverage and could never satisfy the gate it was told to meet.
|
|
1342
|
+
* Tab and Escape stay out: traversal and dismissal are not interactions with
|
|
1343
|
+
* the element under focus.
|
|
1344
|
+
*
|
|
1345
|
+
* Deliberately IDENTICAL to the set `vetFocusedActivation` guards. Arrow keys
|
|
1346
|
+
* genuinely act on selects and radio groups, but the vet does not screen
|
|
1347
|
+
* them, so counting them here would have let the keyboard record coverage on
|
|
1348
|
+
* a path that skips the destructive-label check — a read-only bypass. The two
|
|
1349
|
+
* lists must be changed together.
|
|
1350
|
+
*/
|
|
1351
|
+
static ACTIVATION_KEY_RE = /^(Enter|NumpadEnter|Space| )$/i;
|
|
1352
|
+
async press(key) {
|
|
1353
|
+
this.actionStartedAt = Date.now();
|
|
1354
|
+
const page = this.requirePage();
|
|
1355
|
+
const refusal = await this.vetFocusedActivation(key);
|
|
1356
|
+
if (refusal)
|
|
1357
|
+
return refusal;
|
|
1358
|
+
// Identify the focused control BEFORE the key lands — activating it may
|
|
1359
|
+
// navigate, close a dialog, or otherwise destroy the element.
|
|
1360
|
+
const focused = BrowserEngine.ACTIVATION_KEY_RE.test(key) ? await this.focusedInteractable() : null;
|
|
1361
|
+
await page.keyboard.press(key);
|
|
1362
|
+
if (focused && this.memory && this.currentFingerprint) {
|
|
1363
|
+
this.memory.markExercised(this.currentFingerprint, focused, `press:${key}`);
|
|
1364
|
+
}
|
|
1365
|
+
return this.afterAction("press", key);
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* The focused element's coverage key, taken FROM THE SNAPSHOT.
|
|
1369
|
+
*
|
|
1370
|
+
* Deliberately not re-derived from the DOM. Computing role and name here
|
|
1371
|
+
* independently looked equivalent and was not: the collector resolves a
|
|
1372
|
+
* checkbox to role `checkbox` and names an input from its `label[for]`,
|
|
1373
|
+
* while a second implementation drifts to `textbox` and an empty name. Since
|
|
1374
|
+
* markExercised CREATES an unknown key rather than rejecting it, every
|
|
1375
|
+
* divergence minted a phantom exercised element — inflating coverage while
|
|
1376
|
+
* the control the user actually pressed stayed an open gap. Matching the
|
|
1377
|
+
* live element against the snapshot's own xpaths means the key is, by
|
|
1378
|
+
* construction, one the snapshot produced.
|
|
1379
|
+
*
|
|
1380
|
+
* Returns null when focus is on nothing, on an element this snapshot never
|
|
1381
|
+
* listed, or when the page cannot be evaluated — no coverage is better than
|
|
1382
|
+
* coverage attributed to the wrong control.
|
|
1383
|
+
*/
|
|
1384
|
+
async focusedInteractable() {
|
|
1385
|
+
if (this.refs.size === 0)
|
|
1386
|
+
return null;
|
|
1387
|
+
const known = [...this.refs.values()].map((el) => el.xpath);
|
|
1388
|
+
const idx = (await this.requirePage()
|
|
1389
|
+
.evaluate(`((paths) => { const el = document.activeElement;` +
|
|
1390
|
+
` if (!el || el === document.body) return -1;` +
|
|
1391
|
+
` for (let i = 0; i < paths.length; i++) {` +
|
|
1392
|
+
` const r = document.evaluate(paths[i], document, null, 9, null);` +
|
|
1393
|
+
` if (r && r.singleNodeValue === el) return i;` +
|
|
1394
|
+
` }` +
|
|
1395
|
+
` return -1; })`, known)
|
|
1396
|
+
.catch(() => -1));
|
|
1397
|
+
if (typeof idx !== "number" || idx < 0)
|
|
1398
|
+
return null;
|
|
1399
|
+
return [...this.refs.values()][idx]?.key ?? null;
|
|
1400
|
+
}
|
|
1401
|
+
/** Origin fence: exploration stays on the attached app's origin. */
|
|
1402
|
+
isSameOrigin(url) {
|
|
1403
|
+
try {
|
|
1404
|
+
return new URL(url).origin === new URL(this.baseUrl).origin;
|
|
1405
|
+
}
|
|
1406
|
+
catch {
|
|
1407
|
+
return false;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
async navigate(target) {
|
|
1411
|
+
this.actionStartedAt = Date.now();
|
|
1412
|
+
const page = this.requirePage();
|
|
1413
|
+
const url = target.startsWith("http") ? target : `${this.baseUrl}${target.startsWith("/") ? "" : "/"}${target}`;
|
|
1414
|
+
if (!this.isSameOrigin(url)) {
|
|
1415
|
+
return `REFUSED: ${url} is outside the attached origin (${this.baseUrl}). Exploration is fenced to the app under test.`;
|
|
1416
|
+
}
|
|
1417
|
+
// A notice describes ONE navigation. Clearing up front means a notice left
|
|
1418
|
+
// undelivered by a previous throw can never prepend itself to this result.
|
|
1419
|
+
this.authLoss.clear();
|
|
1420
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 20000 });
|
|
1421
|
+
// Settle BEFORE judging where we landed. A client-side auth guard redirects
|
|
1422
|
+
// after hydration, not during goto, so reading page.url() here showed the
|
|
1423
|
+
// requested path and the bounce went unnoticed — which is precisely how a
|
|
1424
|
+
// dead session stayed invisible in a single-page app.
|
|
1425
|
+
//
|
|
1426
|
+
// The bookkeeping runs in a `finally` so a throw during settling — a closed
|
|
1427
|
+
// page, a torn-down execution context — still records the attempt. And if
|
|
1428
|
+
// it did throw, the notice is attached to the ERROR rather than dropped:
|
|
1429
|
+
// returning a bare exception for the one call that detected the auth loss
|
|
1430
|
+
// is how the whole failure mode stayed invisible in the first place.
|
|
1431
|
+
let settled = "";
|
|
1432
|
+
try {
|
|
1433
|
+
settled = await this.afterAction("navigate", url);
|
|
1434
|
+
}
|
|
1435
|
+
catch (err) {
|
|
1436
|
+
this.recordNavigationOutcome(url, page.url());
|
|
1437
|
+
const notice = this.authLoss.take();
|
|
1438
|
+
if (!notice)
|
|
1439
|
+
throw err;
|
|
1440
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1441
|
+
throw new Error(`${notice}${msg}`);
|
|
1442
|
+
}
|
|
1443
|
+
this.recordNavigationOutcome(url, page.url());
|
|
1444
|
+
return this.authLoss.take() + settled;
|
|
1445
|
+
}
|
|
1446
|
+
/** Where did we ASK to go, where did we END UP, and does that count as coverage? */
|
|
1447
|
+
recordNavigationOutcome(requestedUrl, landedUrl) {
|
|
1448
|
+
const requestedRoute = normalizePath(requestedUrl);
|
|
1449
|
+
const landedRoute = normalizePath(landedUrl);
|
|
1450
|
+
const bounced = this.authLoss.isLoginRedirect(requestedRoute, landedUrl, this.baseUrl);
|
|
1451
|
+
if (landedRoute !== requestedRoute) {
|
|
1452
|
+
const outcome = bounced ? `${AUTH_LOSS_PREFIX}${landedRoute}` : `landed:${landedRoute}`;
|
|
1453
|
+
this.memory?.markAttempted(requestedRoute, outcome, this.role);
|
|
1454
|
+
this.memory?.recordRoleAccess(this.role, requestedRoute, outcome);
|
|
1455
|
+
}
|
|
1456
|
+
this.authLoss.record({ requestedRoute, landedRoute, bounced, role: this.role });
|
|
1457
|
+
}
|
|
1458
|
+
async goBack() {
|
|
1459
|
+
this.actionStartedAt = Date.now();
|
|
1460
|
+
const page = this.requirePage();
|
|
1461
|
+
await page.goBack({ waitUntil: "domcontentloaded", timeout: 10000 }).catch(() => { });
|
|
1462
|
+
return this.afterAction("back", "");
|
|
1463
|
+
}
|
|
1464
|
+
/** The full route contract: scanned filesystem routes ∪ link-discovered route classes. */
|
|
1465
|
+
allKnownRoutes() {
|
|
1466
|
+
const discovered = this.memory ? Object.keys(this.memory.discoveredRoutes) : [];
|
|
1467
|
+
return [...new Set([...this.knownRoutes, ...discovered])].filter((r) => !isNonPageRoute(r));
|
|
1468
|
+
}
|
|
1469
|
+
/**
|
|
1470
|
+
* Contract routes neither visited nor attempted by THIS role.
|
|
1471
|
+
*
|
|
1472
|
+
* A permission redirect satisfies the contract — an operator who cannot reach
|
|
1473
|
+
* an admin route must not block the run forever. Two things it deliberately
|
|
1474
|
+
* does NOT satisfy it with:
|
|
1475
|
+
* - another role's redirect. Attempts are role-scoped, so the operator
|
|
1476
|
+
* bouncing off an admin route no longer erases that route from the
|
|
1477
|
+
* admin's ledger.
|
|
1478
|
+
* - an auth-loss bounce (`authloss:`), filtered out in attemptedByRole. A
|
|
1479
|
+
* dead token used to certify every route the run had not reached yet.
|
|
1480
|
+
*
|
|
1481
|
+
* Visited/attempted keys are stored NORMALIZED, so the normalized form of
|
|
1482
|
+
* each known route is compared too — normalizePath is idempotent, so this
|
|
1483
|
+
* only adds matches for routes that genuinely were reached.
|
|
1484
|
+
*/
|
|
1485
|
+
unvisitedKnownRoutes() {
|
|
1486
|
+
if (!this.memory)
|
|
1487
|
+
return [];
|
|
1488
|
+
const all = this.allKnownRoutes();
|
|
1489
|
+
if (all.length === 0)
|
|
1490
|
+
return [];
|
|
1491
|
+
const visited = new Set(Object.values(this.memory.states).map((s) => s.route));
|
|
1492
|
+
const attempted = this.memory.attemptedByRole(this.role);
|
|
1493
|
+
return all.filter((r) => {
|
|
1494
|
+
const n = normalizePath(r);
|
|
1495
|
+
return !visited.has(r) && !(r in attempted) && !visited.has(n) && !(n in attempted);
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1498
|
+
/** Map a route class to something goto-able (discovered classes carry a concrete example). */
|
|
1499
|
+
navigablePath(routeClass) {
|
|
1500
|
+
return this.memory?.discoveredRoutes[routeClass] ?? routeClass;
|
|
1501
|
+
}
|
|
1502
|
+
/**
|
|
1503
|
+
* Engine-side route sweep: visit each path, record the state in memory, and
|
|
1504
|
+
* collect per-route health — one tool call instead of one LLM turn per route.
|
|
1505
|
+
* Output is anomaly-oriented: a summary line per route, details only where
|
|
1506
|
+
* something is wrong. Navigation-only, so it is safe in read-only mode.
|
|
1507
|
+
*/
|
|
1508
|
+
async crawl(paths) {
|
|
1509
|
+
const page = this.requirePage();
|
|
1510
|
+
const memory = this.memory;
|
|
1511
|
+
const targets = (paths && paths.length > 0 ? paths : this.unvisitedKnownRoutes().map((r) => this.navigablePath(r))).slice(0, 150);
|
|
1512
|
+
if (targets.length === 0) {
|
|
1513
|
+
return this.allKnownRoutes().length > 0
|
|
1514
|
+
? "Nothing to crawl: every known route has been visited. Use scout_coverage for remaining unexercised elements."
|
|
1515
|
+
: "No routes to crawl yet: no scanned or link-discovered routes. Take a snapshot first (links harvest routes) or pass explicit paths.";
|
|
1516
|
+
}
|
|
1517
|
+
const summary = [];
|
|
1518
|
+
const problems = [];
|
|
1519
|
+
for (const path of targets) {
|
|
1520
|
+
const url = `${this.baseUrl}${path.startsWith("/") ? "" : "/"}${path}`;
|
|
1521
|
+
if (!this.isSameOrigin(url)) {
|
|
1522
|
+
summary.push(`${path} — SKIPPED (off-origin)`);
|
|
1523
|
+
continue;
|
|
1524
|
+
}
|
|
1525
|
+
this.actionStartedAt = Date.now();
|
|
1526
|
+
this.oracles.drain(false); // discard pre-route leftovers WITHOUT marking their signatures as reported
|
|
1527
|
+
let status = "ERR";
|
|
1528
|
+
try {
|
|
1529
|
+
const resp = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 });
|
|
1530
|
+
status = resp?.status() ?? "no-response";
|
|
1531
|
+
}
|
|
1532
|
+
catch (err) {
|
|
1533
|
+
summary.push(`${path} — LOAD FAILED`);
|
|
1534
|
+
problems.push(`${path}: ${err instanceof Error ? err.message.split("\n")[0] : err}`);
|
|
1535
|
+
continue;
|
|
1536
|
+
}
|
|
1537
|
+
await this.settle();
|
|
1538
|
+
const { elements } = await this.collect();
|
|
1539
|
+
const finalUrl = page.url();
|
|
1540
|
+
const route = normalizePath(finalUrl);
|
|
1541
|
+
const fp = fingerprintState(finalUrl, elements);
|
|
1542
|
+
memory.visitState(fp, finalUrl, route, elements.map((el) => el.key));
|
|
1543
|
+
memory.recordRoleAccess(this.role, route, "reached");
|
|
1544
|
+
// If we landed somewhere else (auth wall, canonical redirect), the
|
|
1545
|
+
// REQUESTED route still counts as covered for THIS role — a role that
|
|
1546
|
+
// can't see /admin must not block the completion contract forever. An
|
|
1547
|
+
// auth-loss bounce is recorded under a prefix that does NOT count.
|
|
1548
|
+
const requestedRoute = normalizePath(url);
|
|
1549
|
+
const loginRedirect = this.authLoss.isLoginRedirect(path, finalUrl, this.baseUrl);
|
|
1550
|
+
if (route !== requestedRoute) {
|
|
1551
|
+
const outcome = loginRedirect ? `${AUTH_LOSS_PREFIX}${route}` : `landed:${route}`;
|
|
1552
|
+
memory.markAttempted(requestedRoute, outcome, this.role);
|
|
1553
|
+
memory.recordRoleAccess(this.role, requestedRoute, outcome);
|
|
1554
|
+
}
|
|
1555
|
+
// Feeds the streak. The per-route notice is discarded — crawl already
|
|
1556
|
+
// flags AUTH-REDIRECT per route in its own summary, and the verdict for
|
|
1557
|
+
// the sweep as a whole is emitted once at the end via batchVerdict().
|
|
1558
|
+
this.authLoss.record({ requestedRoute, landedRoute: route, bounced: loginRedirect, role: this.role });
|
|
1559
|
+
this.authLoss.clear();
|
|
1560
|
+
// Error-status routes render but would otherwise be re-crawled forever —
|
|
1561
|
+
// an attempt with the status satisfies the contract.
|
|
1562
|
+
if (typeof status === "number" && status >= 400)
|
|
1563
|
+
memory.markAttempted(requestedRoute, `status:${status}`, this.role);
|
|
1564
|
+
this.logAction({ action: "crawl", target: path, url: finalUrl });
|
|
1565
|
+
const violations = this.oracles.drain();
|
|
1566
|
+
const deadEnd = elements.length === 0;
|
|
1567
|
+
const unnamed = elements.filter((el) => !el.name).length;
|
|
1568
|
+
const missingTestid = elements.filter((el) => !el.testid && !el.disabled).length;
|
|
1569
|
+
const flags = [loginRedirect ? "AUTH-REDIRECT" : null, deadEnd ? "DEAD-END" : null, violations.length > 0 ? `${violations.length}⚠` : null].filter(Boolean);
|
|
1570
|
+
summary.push(`${path} — ${status} · ${elements.length} el` +
|
|
1571
|
+
(missingTestid ? ` · ${missingTestid} no-testid` : "") +
|
|
1572
|
+
(unnamed ? ` · ${unnamed} unnamed` : "") +
|
|
1573
|
+
(flags.length ? ` · ${flags.join(" ")}` : ""));
|
|
1574
|
+
if (violations.length > 0 || deadEnd || loginRedirect || (typeof status === "number" && status >= 400)) {
|
|
1575
|
+
const detail = violations
|
|
1576
|
+
.slice(0, 3)
|
|
1577
|
+
.map((v) => ` ${v.kind}: ${v.detail.slice(0, 160)}`)
|
|
1578
|
+
.join("\n");
|
|
1579
|
+
problems.push(`${path}${loginRedirect ? " → redirected to login (auth missing/expired?)" : ""}${deadEnd ? " → dead end" : ""}${detail ? `\n${detail}` : ""}`);
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
// Crawl leaves the page wherever it ended — refs from before are gone.
|
|
1583
|
+
this.refs.clear();
|
|
1584
|
+
this.lastSnap = null;
|
|
1585
|
+
this.snapshotUrl = "";
|
|
1586
|
+
const unvisited = this.unvisitedKnownRoutes();
|
|
1587
|
+
return (
|
|
1588
|
+
// Crawl is the bulk navigator and the place a mid-run token death shows
|
|
1589
|
+
// up first — a 150-route sweep against dead credentials. Emitting the
|
|
1590
|
+
// banner only from navigate() left exactly that case silent until someone
|
|
1591
|
+
// happened to call scout_navigate afterwards.
|
|
1592
|
+
this.authLoss.batchVerdict() +
|
|
1593
|
+
`CRAWL of ${targets.length} route(s):\n` +
|
|
1594
|
+
summary.join("\n") +
|
|
1595
|
+
(problems.length > 0 ? `\n\nPROBLEM ROUTES (${problems.length}):\n` + problems.join("\n") : "\n\nAll crawled routes healthy.") +
|
|
1596
|
+
(this.allKnownRoutes().length > 0
|
|
1597
|
+
? `\n\nRoutes visited: ${this.allKnownRoutes().length - unvisited.length}/${this.allKnownRoutes().length}${unvisited.length > 0 ? ` — still unvisited: ${unvisited.slice(0, 20).join(", ")}${unvisited.length > 20 ? " …" : ""}` : ""}`
|
|
1598
|
+
: "") +
|
|
1599
|
+
`\nTake scout_snapshot to inspect the current page, or navigate into a problem route.`);
|
|
1600
|
+
}
|
|
1601
|
+
/**
|
|
1602
|
+
* Execute a batch of actions in one call — targets resolve at execution
|
|
1603
|
+
* time by semantic locator (testid= / text= / label=), never by snapshot
|
|
1604
|
+
* ref, so the plan is immune to DOM drift. Aborts on the first oracle
|
|
1605
|
+
* violation or policy refusal so the driver re-enters at the interesting moment.
|
|
1606
|
+
*/
|
|
1607
|
+
async runPlan(steps) {
|
|
1608
|
+
const page = this.requirePage();
|
|
1609
|
+
const transcript = [];
|
|
1610
|
+
const resolveTarget = (target) => {
|
|
1611
|
+
if (target.startsWith("testid="))
|
|
1612
|
+
return page.locator(`[data-testid=${JSON.stringify(target.slice(7))}]`).first();
|
|
1613
|
+
if (target.startsWith("text="))
|
|
1614
|
+
return page.getByText(target.slice(5), { exact: false }).first();
|
|
1615
|
+
if (target.startsWith("label="))
|
|
1616
|
+
return page.getByLabel(target.slice(6)).first();
|
|
1617
|
+
throw new Error(`Plan targets must be "testid=…", "text=…", or "label=…" (got: ${target})`);
|
|
1618
|
+
};
|
|
1619
|
+
/** Last state captured this plan — reused as the next step's pre-state while the page has not moved. */
|
|
1620
|
+
let lastCapture = null;
|
|
1621
|
+
const liveLabel = async (loc) => {
|
|
1622
|
+
const [aria, testid, txt] = await Promise.all([
|
|
1623
|
+
loc.getAttribute("aria-label").catch(() => null),
|
|
1624
|
+
loc.getAttribute("data-testid").catch(() => null),
|
|
1625
|
+
loc.textContent({ timeout: 1000 }).catch(() => null),
|
|
1626
|
+
]);
|
|
1627
|
+
return (aria ?? txt ?? testid ?? "").trim().slice(0, 120);
|
|
1628
|
+
};
|
|
1629
|
+
for (const [i, step] of steps.slice(0, 20).entries()) {
|
|
1630
|
+
this.actionStartedAt = Date.now();
|
|
1631
|
+
const desc = `${i + 1}. ${step.action} ${step.target ?? step.value ?? ""}`;
|
|
1632
|
+
// Identity captured BEFORE the action — buttons that relabel themselves
|
|
1633
|
+
// (Add to Cart → View Cart) are unmatchable in the post-action DOM.
|
|
1634
|
+
let preTestid = null;
|
|
1635
|
+
let preLabel = "";
|
|
1636
|
+
let forcedClick = false;
|
|
1637
|
+
let preState = null;
|
|
1638
|
+
try {
|
|
1639
|
+
if (step.action === "navigate") {
|
|
1640
|
+
const result = await this.navigate(step.target ?? step.value ?? "/");
|
|
1641
|
+
if (result.startsWith("REFUSED")) {
|
|
1642
|
+
transcript.push(`${desc} → ${result}`);
|
|
1643
|
+
break;
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
else if (step.action === "scroll") {
|
|
1647
|
+
// performScroll, not this.scroll(): the shared drain below must
|
|
1648
|
+
// still see scroll-triggered oracle violations (afterAction would
|
|
1649
|
+
// consume them), and plans inherit the same native-user refusals.
|
|
1650
|
+
const v = (step.value ?? step.target ?? "bottom").trim();
|
|
1651
|
+
const edge = v === "top" || v === "bottom" ? v : undefined;
|
|
1652
|
+
const r = await performScroll(this.requirePage(), edge, edge ? undefined : Number.parseInt(v, 10) || 600);
|
|
1653
|
+
if (r.refused) {
|
|
1654
|
+
transcript.push(`${desc} → ${r.refused}`);
|
|
1655
|
+
break;
|
|
1656
|
+
}
|
|
1657
|
+
if (r.note)
|
|
1658
|
+
transcript.push(` ${r.note.trim()}`);
|
|
1659
|
+
}
|
|
1660
|
+
else if (step.action === "press") {
|
|
1661
|
+
const key = step.value ?? step.target ?? "Enter";
|
|
1662
|
+
const refusal = await this.vetFocusedActivation(key);
|
|
1663
|
+
if (refusal) {
|
|
1664
|
+
transcript.push(`${desc} → ${refusal}`);
|
|
1665
|
+
break;
|
|
1666
|
+
}
|
|
1667
|
+
await page.keyboard.press(key);
|
|
1668
|
+
}
|
|
1669
|
+
else {
|
|
1670
|
+
if (!step.target)
|
|
1671
|
+
throw new Error(`${step.action} needs a target`);
|
|
1672
|
+
const loc = resolveTarget(step.target);
|
|
1673
|
+
// Coverage is recorded against the state the element LIVED IN, so it
|
|
1674
|
+
// has to be captured before the action changes the page. Marking it
|
|
1675
|
+
// afterwards (as this did) recorded against the state the click
|
|
1676
|
+
// LANDED on: a tab switch or navigation produces a different
|
|
1677
|
+
// fingerprint, that state never listed the clicked key, and
|
|
1678
|
+
// markExercised correctly refuses an unknown key — so the click
|
|
1679
|
+
// vanished and the route stayed "visited but NOTHING exercised" no
|
|
1680
|
+
// matter how many plan steps hit it.
|
|
1681
|
+
if (step.action === "click" || step.action === "type" || step.action === "select" || step.action === "upload") {
|
|
1682
|
+
// Reuse the previous step's post-action capture when the page has
|
|
1683
|
+
// not moved since: collect() waits for two stable probes, so a
|
|
1684
|
+
// fresh one per step would add seconds per step against the plan's
|
|
1685
|
+
// watchdog for a result identical to the one just taken.
|
|
1686
|
+
preState = lastCapture?.url === page.url() ? lastCapture : await this.captureCoverageState().catch(() => null);
|
|
1687
|
+
}
|
|
1688
|
+
preTestid = await loc.getAttribute("data-testid").catch(() => null);
|
|
1689
|
+
const label = await liveLabel(loc);
|
|
1690
|
+
preLabel = label;
|
|
1691
|
+
if (this.readOnly && (step.action === "click" || step.action === "select" || step.action === "upload") && isDestructive(label, step.value)) {
|
|
1692
|
+
transcript.push(`${desc} → ${destructiveRefusal(label || step.target)}`);
|
|
1693
|
+
break;
|
|
1694
|
+
}
|
|
1695
|
+
if (step.action === "click")
|
|
1696
|
+
forcedClick = (await this.resilientClick(loc, ACTION_TIMEOUT_MS)).forced;
|
|
1697
|
+
else if (step.action === "hover") {
|
|
1698
|
+
const { before, bodyBefore, churning } = await this.hoverBaselines();
|
|
1699
|
+
await loc.hover({ timeout: ACTION_TIMEOUT_MS });
|
|
1700
|
+
const { revealed } = await this.detectHoverReveal(before, bodyBefore, churning);
|
|
1701
|
+
transcript.push(revealed.length > 0
|
|
1702
|
+
? ` hover revealed: ${revealed.join(" · ").slice(0, 300)}`
|
|
1703
|
+
: ` hover revealed nothing within ${HOVER_REVEAL_WINDOW_MS / 1000}s`);
|
|
1704
|
+
}
|
|
1705
|
+
else if (step.action === "type") {
|
|
1706
|
+
const fillNote = await this.fillOrAppend(loc, step.value ?? "", step.replace ?? false);
|
|
1707
|
+
if (fillNote)
|
|
1708
|
+
transcript.push(` ${fillNote.trim()}`);
|
|
1709
|
+
if (step.pressEnter) {
|
|
1710
|
+
if (this.readOnly) {
|
|
1711
|
+
const submit = loc.locator("xpath=ancestor::form[1]").locator('[type="submit"], button:not([type="button"]):not([type="reset"])').first();
|
|
1712
|
+
const submitLabel = (await submit.textContent({ timeout: 1000 }).catch(() => "")) ?? "";
|
|
1713
|
+
if (isDestructive(submitLabel)) {
|
|
1714
|
+
transcript.push(`${desc} → filled, Enter withheld: ${destructiveRefusal(submitLabel.trim())}`);
|
|
1715
|
+
break;
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
await loc.press("Enter", { timeout: ACTION_TIMEOUT_MS });
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
else if (step.action === "select")
|
|
1722
|
+
await loc.selectOption(step.value ?? "", { timeout: ACTION_TIMEOUT_MS });
|
|
1723
|
+
else if (step.action === "upload") {
|
|
1724
|
+
const r = await this.performUpload(loc, planUploadOptions(step.value));
|
|
1725
|
+
if (r.refused) {
|
|
1726
|
+
transcript.push(`${desc} → ${r.refused}`);
|
|
1727
|
+
break;
|
|
1728
|
+
}
|
|
1729
|
+
transcript.push(` ${r.summary}${r.notes}`);
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
await this.settle();
|
|
1733
|
+
this.logAction({ action: `plan:${step.action}`, target: step.target ?? step.value, url: page.url() });
|
|
1734
|
+
// Plans must feed coverage like ref-based actions do: record the
|
|
1735
|
+
// state and mark the acted-on element class as exercised.
|
|
1736
|
+
// Hover is deliberately excluded: a hover is a look, not an
|
|
1737
|
+
// interaction — marking it exercised would hide the element from
|
|
1738
|
+
// scout_coverage before it was ever clicked.
|
|
1739
|
+
if (step.action === "click" || step.action === "type" || step.action === "select" || step.action === "upload") {
|
|
1740
|
+
try {
|
|
1741
|
+
// Record the state the action LANDED on (it may be a new screen
|
|
1742
|
+
// this plan just reached, and it deserves coverage of its own)…
|
|
1743
|
+
const { elements } = await this.collect();
|
|
1744
|
+
const url = page.url();
|
|
1745
|
+
const fp = fingerprintState(url, elements);
|
|
1746
|
+
this.memory.visitState(fp, url, normalizePath(url), elements.map((el) => el.key));
|
|
1747
|
+
this.memory.recordRoleAccess(this.role, normalizePath(url), "reached");
|
|
1748
|
+
lastCapture = { fp, elements, url };
|
|
1749
|
+
// …but mark the acted-on element in the state it came FROM, using
|
|
1750
|
+
// the pre-action capture. Falls back to the post-action state when
|
|
1751
|
+
// the pre-capture failed, which is the old behaviour and still
|
|
1752
|
+
// correct whenever the action did not change the page.
|
|
1753
|
+
const spec = step.target ?? "";
|
|
1754
|
+
const value = spec.slice(spec.indexOf("=") + 1);
|
|
1755
|
+
// Match by pre-action identity first: testid survives relabeling;
|
|
1756
|
+
// fall back to name only when nothing better exists.
|
|
1757
|
+
const wantedTestid = spec.startsWith("testid=") ? value : preTestid;
|
|
1758
|
+
const findIn = (list) => (wantedTestid ? list.find((el) => el.testid === wantedTestid) : undefined) ??
|
|
1759
|
+
list.find((el) => el.name.toLowerCase().includes(value.toLowerCase())) ??
|
|
1760
|
+
(preLabel ? list.find((el) => el.name.toLowerCase().includes(preLabel.toLowerCase().slice(0, 30))) : undefined);
|
|
1761
|
+
// Prefer the state the element lived in; fall back to the landed
|
|
1762
|
+
// state so a target the pre-capture missed (or a failed capture)
|
|
1763
|
+
// still records something rather than nothing.
|
|
1764
|
+
const preHit = preState ? findIn(preState.elements) : undefined;
|
|
1765
|
+
if (preHit)
|
|
1766
|
+
this.memory.markExercised(preState.fp, preHit.key, `plan:${step.action}`);
|
|
1767
|
+
else {
|
|
1768
|
+
const postHit = findIn(elements);
|
|
1769
|
+
if (postHit)
|
|
1770
|
+
this.memory.markExercised(fp, postHit.key, `plan:${step.action}`);
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
catch {
|
|
1774
|
+
/* coverage bookkeeping must never fail the plan */
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
const violations = this.oracles.drain();
|
|
1778
|
+
const mutations = this.drainMutations() + this.drainBlocked();
|
|
1779
|
+
// Abort only on NEW violations: a known-failing endpoint repeating on
|
|
1780
|
+
// every navigation must not make every plan abort at step 1.
|
|
1781
|
+
if (violations.some((v) => !v.repeat)) {
|
|
1782
|
+
transcript.push(`${desc} → OK, but oracle fired:${formatViolations(violations)}${mutations}`);
|
|
1783
|
+
transcript.push(`PLAN ABORTED at step ${i + 1} — investigate before continuing.`);
|
|
1784
|
+
break;
|
|
1785
|
+
}
|
|
1786
|
+
const forcedNote = forcedClick
|
|
1787
|
+
? " (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)"
|
|
1788
|
+
: "";
|
|
1789
|
+
transcript.push(`${desc} → OK (${page.url()})${mutations}${forcedNote}`);
|
|
1790
|
+
}
|
|
1791
|
+
catch (err) {
|
|
1792
|
+
const fullMsg = err instanceof Error ? err.message : String(err);
|
|
1793
|
+
const firstLine = fullMsg.split("\n")[0];
|
|
1794
|
+
// The actionable diagnostic (which element actually intercepted the
|
|
1795
|
+
// hit-test) lives further down the multi-line Playwright error, not
|
|
1796
|
+
// on line 1 — surface it instead of discarding it, or a genuine
|
|
1797
|
+
// "wrong element is covering this" gets misread as a dead control.
|
|
1798
|
+
const diagnosticLine = actionabilityDiagnostic(fullMsg);
|
|
1799
|
+
let hint = "";
|
|
1800
|
+
if (/Timeout/i.test(firstLine)) {
|
|
1801
|
+
hint = ` (timeout${diagnosticLine ? ` — ${diagnosticLine}` : ""} — the target may no longer match: element relabeled, removed, or genuinely covered by an overlay; re-snapshot to see current state)`;
|
|
1802
|
+
}
|
|
1803
|
+
else if (/Input of type "file" cannot be filled/i.test(firstLine)) {
|
|
1804
|
+
hint = ` (this is a file input — use an {action:"upload"} step, or scout_upload)`;
|
|
1805
|
+
}
|
|
1806
|
+
transcript.push(`${desc} → FAILED: ${firstLine}${hint}`);
|
|
1807
|
+
break;
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
this.refs.clear();
|
|
1811
|
+
this.lastSnap = null;
|
|
1812
|
+
this.snapshotUrl = "";
|
|
1813
|
+
// Count step lines, not transcript lines — hover reveals and scroll
|
|
1814
|
+
// positions push informational entries that are not steps.
|
|
1815
|
+
const ran = transcript.filter((l) => /^\d+\. /.test(l)).length;
|
|
1816
|
+
return `PLAN (${ran}/${Math.min(steps.length, 20)} steps ran):\n${transcript.join("\n")}\nTake scout_snapshot to see the resulting state.`;
|
|
1817
|
+
}
|
|
1818
|
+
/** Computed-style design audit of the current page — visual judgment material without pixels. */
|
|
1819
|
+
async designAudit() {
|
|
1820
|
+
const page = this.requirePage();
|
|
1821
|
+
await this.settle();
|
|
1822
|
+
const payload = (await page.evaluate(DESIGN_COLLECT_SCRIPT));
|
|
1823
|
+
payload.page.focusSamples = await probeFocusIndicators(page);
|
|
1824
|
+
this.designAuditCount += 1;
|
|
1825
|
+
// The census is built from previous audits, so the first few pages of a run
|
|
1826
|
+
// score with chrome included and later ones don't. That is the same warm-up
|
|
1827
|
+
// the coverage census has: nothing is knowable as "shared" until it has been
|
|
1828
|
+
// seen on several routes.
|
|
1829
|
+
const { report, score, signatures } = analyzeDesign(payload, page.viewportSize() ?? { width: 1280, height: 900 }, this.memory?.designChromeKeys() ?? new Set());
|
|
1830
|
+
const route = normalizePath(page.url());
|
|
1831
|
+
this.memory?.recordDesignElements(route, signatures);
|
|
1832
|
+
// Only a page that actually produced a score counts as audited. An empty or
|
|
1833
|
+
// unhydrated page (and, since the chrome partition, one that is entirely
|
|
1834
|
+
// shell) returns no score — recording it as audited anyway dropped it from
|
|
1835
|
+
// the "never design-audited" ledger while it had never been measured.
|
|
1836
|
+
if (score) {
|
|
1837
|
+
this.memory?.setPageScore(route, { ...score, at: new Date().toISOString(), url: page.url() });
|
|
1838
|
+
this.memory?.markRouteFact(route, { audited: true });
|
|
1839
|
+
}
|
|
1840
|
+
this.logAction({ action: "design-audit", url: page.url(), result: score ? `score:${score.overall}` : undefined });
|
|
1841
|
+
return `URL: ${page.url()}\n` + report;
|
|
1842
|
+
}
|
|
1843
|
+
/**
|
|
1844
|
+
* Scroll like a user — and notice when the page won't let you. Reports the
|
|
1845
|
+
* resulting scroll position, and flags the case where scrollable content
|
|
1846
|
+
* exists but scrolling does nothing (leaked modal scroll-lock: users are
|
|
1847
|
+
* silently cut off from everything below the fold).
|
|
1848
|
+
*/
|
|
1849
|
+
async scroll(to, by, target) {
|
|
1850
|
+
this.actionStartedAt = Date.now();
|
|
1851
|
+
const { refused, note } = target ? await scrollContainer(this.requirePage(), target, to, by) : await performScroll(this.requirePage(), to, by);
|
|
1852
|
+
if (refused)
|
|
1853
|
+
return refused;
|
|
1854
|
+
const amount = Math.trunc(by ?? 600);
|
|
1855
|
+
const label = to ?? `${amount >= 0 ? "down" : "up"} ${Math.abs(amount)}px`;
|
|
1856
|
+
const result = await this.afterAction("scroll", target ? `${label} in ${target}` : label);
|
|
1857
|
+
return result + note;
|
|
1858
|
+
}
|
|
1859
|
+
async screenshot() {
|
|
1860
|
+
const page = this.requirePage();
|
|
1861
|
+
const buf = await page.screenshot({ type: "jpeg", quality: 60, fullPage: false });
|
|
1862
|
+
this.logAction({ action: "screenshot", url: page.url() });
|
|
1863
|
+
return { base64: buf.toString("base64"), mimeType: "image/jpeg" };
|
|
1864
|
+
}
|
|
1865
|
+
get currentState() {
|
|
1866
|
+
return this.currentFingerprint;
|
|
1867
|
+
}
|
|
1868
|
+
get currentUrl() {
|
|
1869
|
+
return this.page?.url() ?? "";
|
|
1870
|
+
}
|
|
1871
|
+
get oracleLog() {
|
|
1872
|
+
return this.oracles;
|
|
1873
|
+
}
|
|
1874
|
+
/**
|
|
1875
|
+
* Launch the browser with a hard timeout and one self-healing retry: a
|
|
1876
|
+
* leftover browser from a crashed previous run has been observed to wedge
|
|
1877
|
+
* fresh launches indefinitely (the failure surfaces as scout_attach hanging).
|
|
1878
|
+
* On the first failure or timeout, reap orphaned Playwright processes and
|
|
1879
|
+
* try once more before giving up with a diagnosable error.
|
|
1880
|
+
*/
|
|
1881
|
+
async launchWithRecovery(headed) {
|
|
1882
|
+
const attempt = async () => {
|
|
1883
|
+
// The marker is what makes reapOrphanBrowsers safe to run at startup:
|
|
1884
|
+
// it appears in the child's command line, so the sweep can tell a browser
|
|
1885
|
+
// WE leaked from one belonging to somebody else's Playwright run.
|
|
1886
|
+
// `--enable-features` takes arbitrary names and ignores unknown ones.
|
|
1887
|
+
const launch = chromium.launch({
|
|
1888
|
+
headless: !headed,
|
|
1889
|
+
args: [`--enable-features=${BROWSER_MARKER}`],
|
|
1890
|
+
});
|
|
1891
|
+
let timer;
|
|
1892
|
+
try {
|
|
1893
|
+
return await Promise.race([
|
|
1894
|
+
launch,
|
|
1895
|
+
new Promise((_, reject) => {
|
|
1896
|
+
timer = setTimeout(() => reject(new Error("browser launch timed out after 30s")), 30_000);
|
|
1897
|
+
}),
|
|
1898
|
+
]);
|
|
1899
|
+
}
|
|
1900
|
+
catch (err) {
|
|
1901
|
+
// If the launch resolves late, close that browser instead of leaking it.
|
|
1902
|
+
void launch.then((b) => b.close().catch(() => { })).catch(() => { });
|
|
1903
|
+
throw err;
|
|
1904
|
+
}
|
|
1905
|
+
finally {
|
|
1906
|
+
clearTimeout(timer);
|
|
1907
|
+
}
|
|
1908
|
+
};
|
|
1909
|
+
try {
|
|
1910
|
+
return await attempt();
|
|
1911
|
+
}
|
|
1912
|
+
catch (firstErr) {
|
|
1913
|
+
const firstMessage = firstErr instanceof Error ? firstErr.message : String(firstErr);
|
|
1914
|
+
// A browser that was never downloaded will not appear on a second try.
|
|
1915
|
+
if (isMissingBrowser(firstMessage))
|
|
1916
|
+
throw new Error(explainLaunchFailure(firstMessage, 0));
|
|
1917
|
+
const reaped = reapOrphanBrowsers();
|
|
1918
|
+
try {
|
|
1919
|
+
return await attempt();
|
|
1920
|
+
}
|
|
1921
|
+
catch {
|
|
1922
|
+
throw new Error(explainLaunchFailure(firstMessage, reaped));
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
async close() {
|
|
1927
|
+
// Pending debounced coverage writes must land before the process can exit.
|
|
1928
|
+
try {
|
|
1929
|
+
this.memory?.flush();
|
|
1930
|
+
}
|
|
1931
|
+
catch (err) {
|
|
1932
|
+
// A failed final flush must not block browser teardown, but it must
|
|
1933
|
+
// not vanish either — record it so scout_close can tell the caller the
|
|
1934
|
+
// very last save may not have landed.
|
|
1935
|
+
if (this.memory)
|
|
1936
|
+
this.memory.lastSaveError = err instanceof Error ? err.message : String(err);
|
|
1937
|
+
}
|
|
1938
|
+
// Bounded teardown: a wedged renderer must not hang scout_close forever.
|
|
1939
|
+
// If teardown overruns the cap, the leftover process is reaped by the
|
|
1940
|
+
// orphan cleaner on the next attach (or server start).
|
|
1941
|
+
await BrowserEngine.settleWithin((async () => {
|
|
1942
|
+
await this.page?.close().catch(() => { });
|
|
1943
|
+
await this.context?.close().catch(() => { });
|
|
1944
|
+
await this.browser?.close().catch(() => { });
|
|
1945
|
+
})(), 8000);
|
|
1946
|
+
this.page = null;
|
|
1947
|
+
this.context = null;
|
|
1948
|
+
this.browser = null;
|
|
1949
|
+
this.refs.clear();
|
|
1950
|
+
this.snapshotUrl = "";
|
|
1951
|
+
this.currentFingerprint = "";
|
|
1952
|
+
this.lastSnap = null;
|
|
1953
|
+
}
|
|
1954
|
+
}
|