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.
@@ -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
+ }