scenescout 3.9.0 → 3.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +61 -3
- package/dist/browsers.js +32 -0
- package/dist/check-run.js +150 -0
- package/dist/cli.js +97 -6
- package/dist/engine/bench.js +154 -17
- package/dist/engine/browser.js +526 -38
- package/dist/engine/check.js +705 -0
- package/dist/engine/collector.js +94 -18
- package/dist/engine/design.js +24 -8
- package/dist/engine/flow.js +398 -0
- package/dist/engine/forms.js +290 -0
- package/dist/engine/lane.js +179 -2
- package/dist/engine/memory.js +64 -3
- package/dist/engine/oracles.js +79 -2
- package/dist/engine/pace.js +14 -1
- package/dist/engine/verify.js +119 -0
- package/dist/mcp-server.js +40 -9
- package/package.json +8 -4
- package/skills/scenescout/SKILL.md +3 -3
package/dist/engine/browser.js
CHANGED
|
@@ -2,20 +2,22 @@ import { chromium, firefox, webkit, } from "playwright";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { elementKey, fingerprintState, isNonPageRoute, normalizePath } from "./fingerprint.js";
|
|
5
|
-
import { AUTH_LOSS_PREFIX, JOURNEY_END, JOURNEY_START, MemoryStore, TASK_SET } from "./memory.js";
|
|
5
|
+
import { AUTH_LOSS_PREFIX, JOURNEY_END, JOURNEY_START, MemoryStore, redactSecrets, TASK_SET } from "./memory.js";
|
|
6
6
|
import { normalizeTask } from "./task.js";
|
|
7
7
|
import { CLAIM_SCAN_SCRIPT, findContradictions } from "./claims.js";
|
|
8
8
|
import { describeInjection, newInjections, probeQueries, probeScript, probeShape, rememberProbe } from "./injection.js";
|
|
9
9
|
import { AuthLossTracker } from "./authloss.js";
|
|
10
|
-
import { COLLECT_INTERACTABLES_SCRIPT, VISIBLE_SRC, geometryIssues, BROKEN_IMAGES_SCRIPT, brokenImageIssues, frameLines, hasVisibleFrame, frameElementKey, frameLabel, masksForeignName, MASKED_NAME, capForeignName, stripForeignHref, frameToPageRect, displayName, missingName, } from "./collector.js";
|
|
11
|
-
import { OracleMonitor, formatViolations } from "./oracles.js";
|
|
10
|
+
import { COLLECT_INTERACTABLES_SCRIPT, VISIBLE_SRC, geometryIssues, BROKEN_IMAGES_SCRIPT, brokenImageIssues, frameLines, hasVisibleFrame, frameElementKey, frameLabel, masksForeignName, MASKED_NAME, capForeignName, stripForeignHref, frameToPageRect, displayName, missingName, placeholderOnly, placeholderEvidence, describeControl, labelFlag, } from "./collector.js";
|
|
11
|
+
import { OracleMonitor, formatViolations, httpErrorDetail } from "./oracles.js";
|
|
12
12
|
import { extractCreatedIds, isOwnedResource, normalizeId } from "./ownership.js";
|
|
13
13
|
import { formatJourney, measureJourney } from "./journey.js";
|
|
14
|
+
import { describeStep, FLOW_AFTER_LAST_STEP_MS, FLOW_STEP_TIMEOUT_MS, isAction, matchRequest, parseTarget, splitRefusals, TARGET_HELP, urlMatches, } from "./flow.js";
|
|
14
15
|
import { framePath, RECORD_MAX_FRAMES } from "./replay.js";
|
|
15
16
|
import { keepWatchingUrl, normalizePace, SETTLE_TICK_MS, shouldKeepWaiting } from "./settle.js";
|
|
16
17
|
import { buildRequestScript, formatReplay, replaySignature, requestHeaders, resolveMethod, resolveRequestUrl, toReplayResult } from "./request.js";
|
|
17
18
|
import { defaultEngine, focusAdvanceKey, REMOVE_SHARED_WORKER_SCRIPT, screencastSupport, serviceWorkerPolicy, sharedWorkersAllowed, } from "../browsers.js";
|
|
18
19
|
import { revealedLines } from "./hover.js";
|
|
20
|
+
import { FORMS_INVENTORY_SCRIPT, FORM_PROBE_BODY, formProbeExpression, formIdentity, formStatus, FORMS_READ_FAILED, FORMS_SUBMIT_UNMATCHED, isEmptySubmit, sameControl, isNavigationTeardown, submits, tracksForm, } from "./forms.js";
|
|
19
21
|
import { explainLaunchFailure, isMissingBrowser } from "./launch.js";
|
|
20
22
|
import { ACTION_TIMEOUT_MS, performScroll, probeFocusIndicators, probeOverlays, scrollContainer } from "./probes.js";
|
|
21
23
|
import { BROWSER_MARKER, reapOrphanBrowsers } from "./reaper.js";
|
|
@@ -81,6 +83,23 @@ const ESCAPE_REFUSAL = "a possible frame escape";
|
|
|
81
83
|
const MAX_READ_FRAMES = 10;
|
|
82
84
|
/** How long a snapshot waits for its frames' elements to answer. */
|
|
83
85
|
const FRAME_READ_MS = 1500;
|
|
86
|
+
/** The coverage key of the page's own element at this xpath (never one in a frame: its xpath names a node in another document). */
|
|
87
|
+
function keyAtXpath(elements, xpath) {
|
|
88
|
+
return elements.find((el) => !el.frame && el.xpath === xpath)?.key ?? null;
|
|
89
|
+
}
|
|
90
|
+
/** The page's own element at the probe's submit xpath, when it really is that control (forms.ts sameControl). */
|
|
91
|
+
function submitControlIn(elements, probe) {
|
|
92
|
+
const el = elements.find((e) => !e.frame && e.xpath === probe.submit);
|
|
93
|
+
return el && sameControl(el, probe) ? el : null;
|
|
94
|
+
}
|
|
95
|
+
/** Page-side: the form an element belongs to, and what its fields hold now (forms.ts). */
|
|
96
|
+
const probeFormOf = new Function("node", FORM_PROBE_BODY);
|
|
97
|
+
/**
|
|
98
|
+
* How long a form read may take: the inventory on every collect, the probe
|
|
99
|
+
* before a submit-capable action. One pass over document.forms takes a few
|
|
100
|
+
* milliseconds; this only stops a wedged page stalling the action.
|
|
101
|
+
*/
|
|
102
|
+
const FORM_READ_MS = 1000;
|
|
84
103
|
/** In-page XPath lookup fragment for string-expression evaluates. */
|
|
85
104
|
function xpathLookup(xpath) {
|
|
86
105
|
return `document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue`;
|
|
@@ -696,6 +715,8 @@ export class BrowserEngine {
|
|
|
696
715
|
});
|
|
697
716
|
this.lastSnap = null;
|
|
698
717
|
this.designAuditCount = 0;
|
|
718
|
+
// A new attach may be a restarted app: what failed to load before gets another try.
|
|
719
|
+
this.loadFailedRoutes = new Set();
|
|
699
720
|
// The engine learns the route list itself so completion is an objective,
|
|
700
721
|
// enforceable contract (scout_report refuses while known routes are unvisited)
|
|
701
722
|
// rather than a prompt suggestion the driver may ignore.
|
|
@@ -829,7 +850,7 @@ export class BrowserEngine {
|
|
|
829
850
|
const why = ESCAPE_REFUSAL;
|
|
830
851
|
// Reported like any refusal, so a click whose navigation this stopped does not read as a click that did nothing.
|
|
831
852
|
if (this.blockedRequests.length < 20)
|
|
832
|
-
this.blockedRequests.push({ at: Date.now(), sig: `navigation to ${req.url().slice(0, 140)}`, answered: false, why });
|
|
853
|
+
this.blockedRequests.push({ at: Date.now(), sig: `navigation to ${req.url().slice(0, 140)}`, answered: false, why, type: req.resourceType() });
|
|
833
854
|
this.logAction({ action: "write-policy:blocked", target: `navigation to ${req.url().slice(0, 140)} (${why})`, url: this.page?.url() ?? "" });
|
|
834
855
|
this.refusedByPolicy.add(req);
|
|
835
856
|
this.oracles.notePolicyBlock();
|
|
@@ -889,7 +910,7 @@ export class BrowserEngine {
|
|
|
889
910
|
const refuse = (why) => {
|
|
890
911
|
const answered = answersWithRefusal(req.resourceType());
|
|
891
912
|
if (this.blockedRequests.length < 20)
|
|
892
|
-
this.blockedRequests.push({ at: Date.now(), sig: `${method} ${url.slice(0, 140)}`, answered, why });
|
|
913
|
+
this.blockedRequests.push({ at: Date.now(), sig: `${method} ${url.slice(0, 140)}`, answered, why, type: req.resourceType() });
|
|
893
914
|
this.logAction({ action: "write-policy:blocked", target: `${method} ${pathname}${why ? ` (${why})` : ""}`, url: this.page?.url() ?? "" });
|
|
894
915
|
this.refusedByPolicy.add(req);
|
|
895
916
|
this.oracles.notePolicyBlock();
|
|
@@ -1184,6 +1205,9 @@ export class BrowserEngine {
|
|
|
1184
1205
|
break;
|
|
1185
1206
|
}
|
|
1186
1207
|
const mainCount = rawElements.length;
|
|
1208
|
+
// The page's forms, read in the same settled page as its elements. The
|
|
1209
|
+
// page's own document only: an xpath names a node in one document.
|
|
1210
|
+
const rawForms = ((await this.formRead("form inventory", page.evaluate(FORMS_INVENTORY_SCRIPT))) ?? []);
|
|
1187
1211
|
// Then each frame's own controls, read once, each within its own limit.
|
|
1188
1212
|
const framed = await this.collectFrames(page);
|
|
1189
1213
|
// Element identity is the coverage key (testid or role+name, ordinal-
|
|
@@ -1223,8 +1247,18 @@ export class BrowserEngine {
|
|
|
1223
1247
|
});
|
|
1224
1248
|
this.harvestRoutes(elements.filter((el) => !el.frame?.foreign));
|
|
1225
1249
|
this.framesRead = new Set(framed.map((g) => g.frame));
|
|
1226
|
-
|
|
1250
|
+
// A form is known by its own attributes, else by its submit control's coverage
|
|
1251
|
+
// key (forms.ts formIdentity); a form with neither is not tracked.
|
|
1252
|
+
const forms = rawForms.flatMap((f) => {
|
|
1253
|
+
const status = formStatus(f);
|
|
1254
|
+
const key = status === "untracked" ? null : (formIdentity(f.attrs) ?? keyAtXpath(elements, f.submit));
|
|
1255
|
+
return key ? [{ key, guarded: status === "guarded" }] : [];
|
|
1256
|
+
});
|
|
1257
|
+
this.lastCollectTruncated = mainCount >= 150;
|
|
1258
|
+
return { elements, truncated: mainCount >= 150, forms };
|
|
1227
1259
|
}
|
|
1260
|
+
/** Whether the latest collect stopped at the element cap, so some controls have no key at all. */
|
|
1261
|
+
lastCollectTruncated = false;
|
|
1228
1262
|
/**
|
|
1229
1263
|
* The controls inside the page's frames, up to MAX_READ_FRAMES visible ones,
|
|
1230
1264
|
* each read within FRAME_READ_MS so one busy frame cannot stall a snapshot.
|
|
@@ -1343,10 +1377,12 @@ export class BrowserEngine {
|
|
|
1343
1377
|
*/
|
|
1344
1378
|
async captureCoverageState() {
|
|
1345
1379
|
const page = this.requirePage();
|
|
1346
|
-
const { elements } = await this.collect();
|
|
1380
|
+
const { elements, forms } = await this.collect();
|
|
1347
1381
|
const url = page.url();
|
|
1348
1382
|
const fp = fingerprintState(url, elements);
|
|
1349
1383
|
this.memory?.visitState(fp, url, normalizePath(url), elements.map((el) => el.key));
|
|
1384
|
+
for (const f of forms)
|
|
1385
|
+
this.memory?.recordForm(fp, f.key, f.guarded);
|
|
1350
1386
|
return { fp, elements, url };
|
|
1351
1387
|
}
|
|
1352
1388
|
/**
|
|
@@ -1354,7 +1390,11 @@ export class BrowserEngine {
|
|
|
1354
1390
|
* route classes (including ?tab= screens) that feed the completion contract,
|
|
1355
1391
|
* so the contract works for any app, not just filesystem-routed Next.
|
|
1356
1392
|
*/
|
|
1393
|
+
/** Set while a measure-only crawl reads a page: its links are not the app's route list. */
|
|
1394
|
+
harvestPaused = false;
|
|
1357
1395
|
harvestRoutes(elements) {
|
|
1396
|
+
if (this.harvestPaused)
|
|
1397
|
+
return;
|
|
1358
1398
|
if (!this.memory)
|
|
1359
1399
|
return;
|
|
1360
1400
|
const found = [];
|
|
@@ -1414,13 +1454,15 @@ export class BrowserEngine {
|
|
|
1414
1454
|
const page = this.requirePage();
|
|
1415
1455
|
const memory = this.memory;
|
|
1416
1456
|
await this.settle();
|
|
1417
|
-
const { elements, truncated } = await this.collect();
|
|
1457
|
+
const { elements, truncated, forms } = await this.collect();
|
|
1418
1458
|
const url = page.url();
|
|
1419
1459
|
this.snapshotUrl = url;
|
|
1420
1460
|
const route = normalizePath(url);
|
|
1421
1461
|
const fp = fingerprintState(url, elements);
|
|
1422
1462
|
this.currentFingerprint = fp;
|
|
1423
1463
|
const isNew = memory.visitState(fp, url, route, elements.map((el) => el.key));
|
|
1464
|
+
for (const f of forms)
|
|
1465
|
+
memory.recordForm(fp, f.key, f.guarded);
|
|
1424
1466
|
memory.recordRoleAccess(this.role, route, "reached");
|
|
1425
1467
|
// A snapshot is what an agent takes when it wants to LOOK at something, so
|
|
1426
1468
|
// it is the frame a reader most wants beside the step. Recording only the
|
|
@@ -1436,6 +1478,7 @@ export class BrowserEngine {
|
|
|
1436
1478
|
dup ? `copy#${Number(dup[1]) + 1}` : null,
|
|
1437
1479
|
el.disabled ? "disabled" : null,
|
|
1438
1480
|
el.destructive ? "DESTRUCTIVE" : null,
|
|
1481
|
+
labelFlag(el),
|
|
1439
1482
|
memory.wasExercised(fp, el.key) ? "done" : null,
|
|
1440
1483
|
el.href ? `href=${el.href.slice(0, 60)}` : null,
|
|
1441
1484
|
].filter(Boolean);
|
|
@@ -1490,16 +1533,8 @@ export class BrowserEngine {
|
|
|
1490
1533
|
}
|
|
1491
1534
|
// Per document: the page's own controls together, and each frame's apart —
|
|
1492
1535
|
// an embed's controls are not laid out against the page's.
|
|
1493
|
-
const
|
|
1494
|
-
const byDocument = new Map();
|
|
1495
|
-
for (const el of elements) {
|
|
1496
|
-
const doc = el.frame ? el.key.slice(0, el.key.indexOf("|") + 1) : "";
|
|
1497
|
-
byDocument.set(doc, [...(byDocument.get(doc) ?? []), el]);
|
|
1498
|
-
}
|
|
1499
|
-
const geometry = [...byDocument.values()].flatMap((group) => geometryIssues(group, viewport));
|
|
1500
|
-
geometry.push(...(await probeOverlays(page)));
|
|
1536
|
+
const { geometry, brokenImages } = await this.measureLayout(page, elements, url);
|
|
1501
1537
|
const hiddenFileInputs = await this.hiddenFileInputs(page);
|
|
1502
|
-
const brokenImages = brokenImageIssues((await page.evaluate(BROKEN_IMAGES_SCRIPT).catch(() => null)) ?? { images: [], total: 0 }, url);
|
|
1503
1538
|
const { frames, nested: nestedFrames } = await this.frameInventory(page);
|
|
1504
1539
|
const cov = memory.coverage();
|
|
1505
1540
|
const unvisited = this.unvisitedKnownRoutes();
|
|
@@ -1899,6 +1934,88 @@ export class BrowserEngine {
|
|
|
1899
1934
|
return { forced: true };
|
|
1900
1935
|
}
|
|
1901
1936
|
}
|
|
1937
|
+
/**
|
|
1938
|
+
* A form read (the inventory, or the probe before a submit), bounded by
|
|
1939
|
+
* FORM_READ_MS. This bookkeeping must never fail or stall the action, so a
|
|
1940
|
+
* failure yields null — but only a page navigating away under the read is
|
|
1941
|
+
* expected; a timeout or any other error goes in the action log.
|
|
1942
|
+
*/
|
|
1943
|
+
async formRead(what, work) {
|
|
1944
|
+
let timer;
|
|
1945
|
+
const limit = new Promise((resolve) => {
|
|
1946
|
+
timer = setTimeout(() => resolve("timeout"), FORM_READ_MS);
|
|
1947
|
+
});
|
|
1948
|
+
const outcome = await Promise.race([
|
|
1949
|
+
work.then((value) => ({ value }), (err) => ({ err })),
|
|
1950
|
+
limit,
|
|
1951
|
+
]).finally(() => clearTimeout(timer));
|
|
1952
|
+
const url = this.page?.url() ?? "";
|
|
1953
|
+
if (outcome === "timeout") {
|
|
1954
|
+
this.logAction({ action: FORMS_READ_FAILED, target: `${what} took over ${FORM_READ_MS} ms`, url });
|
|
1955
|
+
return null;
|
|
1956
|
+
}
|
|
1957
|
+
if ("err" in outcome) {
|
|
1958
|
+
const message = outcome.err instanceof Error ? outcome.err.message.split("\n")[0] : String(outcome.err);
|
|
1959
|
+
if (!isNavigationTeardown(message))
|
|
1960
|
+
this.logAction({ action: FORMS_READ_FAILED, target: `${what}: ${message}`.slice(0, 300), url });
|
|
1961
|
+
return null;
|
|
1962
|
+
}
|
|
1963
|
+
return outcome.value;
|
|
1964
|
+
}
|
|
1965
|
+
/** The form a located element belongs to, read just before an action that may submit it (forms.ts). */
|
|
1966
|
+
probeForm(target) {
|
|
1967
|
+
return this.formRead("form probe", target.evaluate(probeFormOf, undefined, { timeout: FORM_READ_MS }));
|
|
1968
|
+
}
|
|
1969
|
+
/** The form the focused element belongs to, for a key press that may submit through it. */
|
|
1970
|
+
probeFocusedForm() {
|
|
1971
|
+
return this.formRead("form probe", this.requirePage().evaluate(formProbeExpression("document.activeElement")));
|
|
1972
|
+
}
|
|
1973
|
+
/**
|
|
1974
|
+
* A plan's pre-action state, fit to record a submit of this form against.
|
|
1975
|
+
* A plan reuses its previous capture while the URL has not changed, but the
|
|
1976
|
+
* page can shift under the same URL (a control inserted above the form
|
|
1977
|
+
* moves every xpath after it). Only a REUSED capture is suspect: when the
|
|
1978
|
+
* form has no identity of its own and the reused state does not hold its
|
|
1979
|
+
* submit control, the state is captured afresh. A capture this step just
|
|
1980
|
+
* took is never taken twice, and a step with no state gets one.
|
|
1981
|
+
*/
|
|
1982
|
+
async stateHolding(probe, state, reused) {
|
|
1983
|
+
if (state && (!reused || formIdentity(probe.attrs) || submitControlIn(state.elements, probe)))
|
|
1984
|
+
return state;
|
|
1985
|
+
return (await this.captureCoverageState().catch(() => null)) ?? state;
|
|
1986
|
+
}
|
|
1987
|
+
/** Whether this probe describes a submit of a tracked form, so the submit is worth recording. */
|
|
1988
|
+
static isTrackedSubmit(kind, probe) {
|
|
1989
|
+
return probe !== null && tracksForm(probe.fields) && submits(kind, probe);
|
|
1990
|
+
}
|
|
1991
|
+
/**
|
|
1992
|
+
* Mark a submit of a listed form against the state it was made in. The
|
|
1993
|
+
* form is known by its own attributes when it has any; otherwise by the
|
|
1994
|
+
* snapshot element at its submit control's xpath, accepted only when that
|
|
1995
|
+
* element's testid and name agree with the control the probe found (for a
|
|
1996
|
+
* click on that control, the element clicked). A submit that matches no
|
|
1997
|
+
* listed form is logged, never dropped silently and never added to the
|
|
1998
|
+
* list. Whether it was a submit, and an empty one, is forms.ts's rule.
|
|
1999
|
+
*/
|
|
2000
|
+
noteFormSubmit(state, kind, probe) {
|
|
2001
|
+
if (!this.memory || !BrowserEngine.isTrackedSubmit(kind, probe))
|
|
2002
|
+
return;
|
|
2003
|
+
const empty = isEmptySubmit(kind, probe);
|
|
2004
|
+
const key = formIdentity(probe.attrs) ?? (state ? (submitControlIn(state.elements, probe)?.key ?? null) : null);
|
|
2005
|
+
if (state?.fp && key && this.memory.recordFormSubmit(state.fp, key, empty))
|
|
2006
|
+
return;
|
|
2007
|
+
// A page past the snapshot's element cap may never list this control, so
|
|
2008
|
+
// a fresh snapshot would not help: say it is untracked, not what to retry.
|
|
2009
|
+
const beyondCap = !key && this.lastCollectTruncated && !state?.elements.some((e) => !e.frame && e.xpath === probe.submit);
|
|
2010
|
+
const what = `${empty ? "empty " : ""}${kind} submit of a form (submit control ${JSON.stringify(probe.submitName)} at ${probe.submit})`;
|
|
2011
|
+
this.logAction({
|
|
2012
|
+
action: FORMS_SUBMIT_UNMATCHED,
|
|
2013
|
+
target: beyondCap
|
|
2014
|
+
? `${what} is untracked: the page has more controls than a snapshot lists, so this form, which has no id, name or action, cannot be keyed`
|
|
2015
|
+
: `${what} matches no form listed for this page, so it is not counted — take a scout_snapshot and submit again`,
|
|
2016
|
+
url: this.page?.url() ?? "",
|
|
2017
|
+
});
|
|
2018
|
+
}
|
|
1902
2019
|
async click(ref, clicks = 1) {
|
|
1903
2020
|
const page = this.requirePage();
|
|
1904
2021
|
const { el, liveLabel } = await this.resolveForAction(ref);
|
|
@@ -1916,8 +2033,14 @@ export class BrowserEngine {
|
|
|
1916
2033
|
// (silent no-op forms): capture the count before to compare after.
|
|
1917
2034
|
const xhrBefore = this.xhrCount;
|
|
1918
2035
|
const submitLike = el.role === "button" && /submit|send|save|create|apply|subscribe|register|sign|post|add\b/i.test(el.name + " " + (el.testid ?? ""));
|
|
1919
|
-
const
|
|
2036
|
+
const clickTarget = this.scopeOf(el).locator(`xpath=${el.xpath}`);
|
|
2037
|
+
// Read before the click: what the form's fields hold when it goes. Only a
|
|
2038
|
+
// button or an input can submit a form; nothing else is asked.
|
|
2039
|
+
const form = !el.frame && (el.tag === "button" || el.tag === "input") ? await this.probeForm(clickTarget) : null;
|
|
2040
|
+
const formState = { fp: this.currentFingerprint, elements: [...this.refs.values()] };
|
|
2041
|
+
const { forced } = await this.resilientClick(clickTarget, ACTION_TIMEOUT_MS, clicks);
|
|
1920
2042
|
this.memory.markExercised(this.currentFingerprint, el.key, clicks > 1 ? `click×${clicks}` : "click");
|
|
2043
|
+
this.noteFormSubmit(formState, "click", form);
|
|
1921
2044
|
const result = await this.afterAction(clicks > 1 ? `click×${clicks}` : "click", `${el.role} "${el.name}"`);
|
|
1922
2045
|
// Impatient-user probe: a rapid multi-click that fires the SAME
|
|
1923
2046
|
// state-changing request more than once means the action is not guarded
|
|
@@ -2058,7 +2181,11 @@ export class BrowserEngine {
|
|
|
2058
2181
|
return `Filled ${el.role} "${el.name}" but did NOT press Enter. ` + destructiveRefusal(submitLabel, this.mode);
|
|
2059
2182
|
}
|
|
2060
2183
|
}
|
|
2184
|
+
// After the fill, before the key: the fields as they are when it submits.
|
|
2185
|
+
const form = !el.frame && el.tag === "input" ? await this.probeForm(locator) : null;
|
|
2186
|
+
const formState = { fp: this.currentFingerprint, elements: [...this.refs.values()] };
|
|
2061
2187
|
await locator.press("Enter", { timeout: ACTION_TIMEOUT_MS });
|
|
2188
|
+
this.noteFormSubmit(formState, "enter", form);
|
|
2062
2189
|
}
|
|
2063
2190
|
this.memory.markExercised(this.currentFingerprint, el.key, "type");
|
|
2064
2191
|
return this.afterAction("type", `${el.role} "${el.name}" ← ${JSON.stringify(text.slice(0, 60))}${pressEnter ? " + Enter" : ""}${fillNote}`);
|
|
@@ -2211,6 +2338,23 @@ export class BrowserEngine {
|
|
|
2211
2338
|
static OVERLAY_SELECTOR = '[role="tooltip"], [data-tippy-root], [data-radix-popper-content-wrapper], [data-floating-ui-portal], ' +
|
|
2212
2339
|
'[data-slot*="tooltip" i], [data-slot*="popover" i], ' +
|
|
2213
2340
|
'[class*="tooltip" i], [class*="hovercard" i], [class*="popover" i]';
|
|
2341
|
+
/**
|
|
2342
|
+
* The layout checks a snapshot prints: geometry per document (the page's own
|
|
2343
|
+
* controls together, each frame's apart, since an embed's controls are not
|
|
2344
|
+
* laid out against the page's), the overlay probe, and images that failed.
|
|
2345
|
+
*/
|
|
2346
|
+
async measureLayout(page, elements, url) {
|
|
2347
|
+
const viewport = page.viewportSize() ?? { width: 1280, height: 900 };
|
|
2348
|
+
const byDocument = new Map();
|
|
2349
|
+
for (const el of elements) {
|
|
2350
|
+
const doc = el.frame ? el.key.slice(0, el.key.indexOf("|") + 1) : "";
|
|
2351
|
+
byDocument.set(doc, [...(byDocument.get(doc) ?? []), el]);
|
|
2352
|
+
}
|
|
2353
|
+
const geometry = [...byDocument.values()].flatMap((group) => geometryIssues(group, viewport));
|
|
2354
|
+
geometry.push(...(await probeOverlays(page)));
|
|
2355
|
+
const brokenImages = brokenImageIssues((await page.evaluate(BROKEN_IMAGES_SCRIPT).catch(() => null)) ?? { images: [], total: 0 }, url);
|
|
2356
|
+
return { geometry, brokenImages };
|
|
2357
|
+
}
|
|
2214
2358
|
/**
|
|
2215
2359
|
* Visible overlay texts right now — diffed before/after a hover to isolate
|
|
2216
2360
|
* what the hover revealed. Returns null when the read itself failed, so a
|
|
@@ -2413,7 +2557,13 @@ export class BrowserEngine {
|
|
|
2413
2557
|
// Identify the focused control BEFORE the key lands — activating it may
|
|
2414
2558
|
// navigate, close a dialog, or otherwise destroy the element.
|
|
2415
2559
|
const focused = BrowserEngine.ACTIVATION_KEY_RE.test(key) ? await this.focusedInteractable() : null;
|
|
2560
|
+
// Enter submits from a field or a submit control; Space only activates a control.
|
|
2561
|
+
const formKind = BrowserEngine.ACTIVATION_KEY_RE.test(key) ? (/enter/i.test(key) ? "enter" : "click") : null;
|
|
2562
|
+
const form = formKind ? await this.probeFocusedForm() : null;
|
|
2563
|
+
const formState = { fp: this.currentFingerprint, elements: [...this.refs.values()] };
|
|
2416
2564
|
await page.keyboard.press(key);
|
|
2565
|
+
if (formKind)
|
|
2566
|
+
this.noteFormSubmit(formState, formKind, form);
|
|
2417
2567
|
if (focused && this.memory && this.currentFingerprint) {
|
|
2418
2568
|
this.memory.markExercised(this.currentFingerprint, focused, `press:${key}`);
|
|
2419
2569
|
}
|
|
@@ -2554,17 +2704,57 @@ export class BrowserEngine {
|
|
|
2554
2704
|
navigablePath(routeClass) {
|
|
2555
2705
|
return this.memory?.discoveredRoutes[routeClass] ?? routeClass;
|
|
2556
2706
|
}
|
|
2707
|
+
/** Routes whose page failed to load in this process: still unvisited, but not tried again by a crawl that picks its own targets. */
|
|
2708
|
+
loadFailedRoutes = new Set();
|
|
2709
|
+
/** Unvisited known routes a crawl would still try: those that already failed to load in this process are left out. */
|
|
2710
|
+
crawlableRoutes() {
|
|
2711
|
+
return this.unvisitedKnownRoutes().filter((r) => !this.loadFailedRoutes.has(normalizePath(r)));
|
|
2712
|
+
}
|
|
2713
|
+
/** What the last crawl measured on each route, as data: `scenescout check` builds its verdict from this. */
|
|
2714
|
+
crawlHealth = [];
|
|
2715
|
+
/** The per-route results of the most recent crawl. */
|
|
2716
|
+
get lastCrawlHealth() {
|
|
2717
|
+
return this.crawlHealth;
|
|
2718
|
+
}
|
|
2719
|
+
/** A check's extra measurements on a crawled page: the snapshot's layout checks and the design audit's defects. */
|
|
2720
|
+
async inspectRoute(page, elements, url) {
|
|
2721
|
+
const { geometry, brokenImages } = await this.measureLayout(page, elements, url);
|
|
2722
|
+
try {
|
|
2723
|
+
const { defects, sampled } = await this.auditPage();
|
|
2724
|
+
// Nothing styled to read is not a clean page: contrast, focus and target size went unmeasured.
|
|
2725
|
+
return sampled > 0
|
|
2726
|
+
? { geometry, brokenImages, design: defects }
|
|
2727
|
+
: { geometry, brokenImages, design: [], auditError: "no visible styled elements to measure" };
|
|
2728
|
+
}
|
|
2729
|
+
catch (err) {
|
|
2730
|
+
// A page that navigated away mid-audit has nothing to measure. The
|
|
2731
|
+
// route's other results still stand, and the report says the audit is missing.
|
|
2732
|
+
return { geometry, brokenImages, design: [], auditError: err instanceof Error ? err.message.split("\n")[0] : String(err) };
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2557
2735
|
/**
|
|
2558
2736
|
* Engine-side route sweep: visit each path, record the state in memory, and
|
|
2559
2737
|
* collect per-route health — one tool call instead of one LLM turn per route.
|
|
2560
2738
|
* Output is anomaly-oriented: a summary line per route, details only where
|
|
2561
2739
|
* something is wrong. Navigation-only, so it is safe in read-only mode.
|
|
2562
2740
|
*/
|
|
2563
|
-
|
|
2741
|
+
/**
|
|
2742
|
+
* `measureOnly`: load and measure the pages and nothing else. No route is
|
|
2743
|
+
* harvested from their links, none is marked visited or attempted, and a
|
|
2744
|
+
* failed load is not remembered, so the pages and what they link to stay on
|
|
2745
|
+
* the unvisited list exactly as before. A check's re-test loads use it.
|
|
2746
|
+
*/
|
|
2747
|
+
async crawl(paths, opts = {}) {
|
|
2564
2748
|
const page = this.requirePage();
|
|
2565
2749
|
const memory = this.memory;
|
|
2566
|
-
|
|
2750
|
+
this.crawlHealth = [];
|
|
2751
|
+
const targets = (paths && paths.length > 0 ? paths : this.crawlableRoutes().map((r) => this.navigablePath(r))).slice(0, Math.min(150, opts.limit ?? 150));
|
|
2567
2752
|
if (targets.length === 0) {
|
|
2753
|
+
const failed = this.unvisitedKnownRoutes().filter((r) => this.loadFailedRoutes.has(normalizePath(r)));
|
|
2754
|
+
if (failed.length > 0) {
|
|
2755
|
+
return (`Nothing new to crawl. ${failed.length} route(s) failed to load earlier in this session and are still unvisited: ${failed.slice(0, 20).join(", ")}${failed.length > 20 ? " …" : ""}. ` +
|
|
2756
|
+
`A crawl without paths does not retry them; pass them as paths once the app is reachable, e.g. scout_crawl {paths:${JSON.stringify(failed.slice(0, 3).map((r) => this.navigablePath(r)))}}.`);
|
|
2757
|
+
}
|
|
2568
2758
|
return this.allKnownRoutes().length > 0
|
|
2569
2759
|
? "Nothing to crawl: every known route has been visited. Use scout_coverage for remaining unexercised elements."
|
|
2570
2760
|
: "No routes to crawl yet: no scanned or link-discovered routes. Take a snapshot first (links harvest routes) or pass explicit paths.";
|
|
@@ -2585,24 +2775,54 @@ export class BrowserEngine {
|
|
|
2585
2775
|
status = resp?.status() ?? "no-response";
|
|
2586
2776
|
}
|
|
2587
2777
|
catch (err) {
|
|
2778
|
+
const reason = err instanceof Error ? err.message.split("\n")[0] : String(err);
|
|
2779
|
+
// Not retried by later crawls in this process (a check would otherwise try it
|
|
2780
|
+
// on every discovery round). Deliberately not written to memory: one outage
|
|
2781
|
+
// must not count a route as covered in every later run's gap ledger.
|
|
2782
|
+
if (!opts.measureOnly)
|
|
2783
|
+
this.loadFailedRoutes.add(normalizePath(url));
|
|
2784
|
+
// The browser commits its own error page for this failure tens of
|
|
2785
|
+
// milliseconds after goto has thrown, and that commit interrupts the next
|
|
2786
|
+
// navigation, charging this route's failure to the next one. Wait for it;
|
|
2787
|
+
// a browser that shows no error page simply lets the wait time out.
|
|
2788
|
+
await page.waitForEvent("framenavigated", { predicate: (f) => f === page.mainFrame(), timeout: 1500 }).catch(() => undefined);
|
|
2588
2789
|
summary.push(`${path} — LOAD FAILED`);
|
|
2589
|
-
problems.push(`${path}: ${
|
|
2790
|
+
problems.push(`${path}: ${reason}`);
|
|
2791
|
+
this.crawlHealth.push({
|
|
2792
|
+
path,
|
|
2793
|
+
url,
|
|
2794
|
+
status: null,
|
|
2795
|
+
loadError: reason,
|
|
2796
|
+
loginRedirect: false,
|
|
2797
|
+
elements: 0,
|
|
2798
|
+
unnamed: [],
|
|
2799
|
+
placeholderOnly: [],
|
|
2800
|
+
violations: [],
|
|
2801
|
+
geometry: [],
|
|
2802
|
+
brokenImages: [],
|
|
2803
|
+
design: [],
|
|
2804
|
+
});
|
|
2590
2805
|
continue;
|
|
2591
2806
|
}
|
|
2592
2807
|
await this.settle();
|
|
2593
|
-
|
|
2808
|
+
this.harvestPaused = opts.measureOnly === true;
|
|
2809
|
+
const { elements, forms } = await this.collect().finally(() => (this.harvestPaused = false));
|
|
2594
2810
|
const finalUrl = page.url();
|
|
2595
2811
|
const route = normalizePath(finalUrl);
|
|
2596
2812
|
const fp = fingerprintState(finalUrl, elements);
|
|
2597
|
-
|
|
2598
|
-
|
|
2813
|
+
if (!opts.measureOnly) {
|
|
2814
|
+
memory.visitState(fp, finalUrl, route, elements.map((el) => el.key));
|
|
2815
|
+
for (const f of forms)
|
|
2816
|
+
memory.recordForm(fp, f.key, f.guarded);
|
|
2817
|
+
memory.recordRoleAccess(this.role, route, "reached");
|
|
2818
|
+
}
|
|
2599
2819
|
// If we landed somewhere else (auth wall, canonical redirect), the
|
|
2600
2820
|
// REQUESTED route still counts as covered for THIS role — a role that
|
|
2601
2821
|
// can't see /admin must not block the completion contract forever. An
|
|
2602
2822
|
// auth-loss bounce is recorded under a prefix that does NOT count.
|
|
2603
2823
|
const requestedRoute = normalizePath(url);
|
|
2604
2824
|
const loginRedirect = this.authLoss.isLoginRedirect(path, finalUrl, this.baseUrl);
|
|
2605
|
-
if (route !== requestedRoute) {
|
|
2825
|
+
if (route !== requestedRoute && !opts.measureOnly) {
|
|
2606
2826
|
const outcome = loginRedirect ? `${AUTH_LOSS_PREFIX}${route}` : `landed:${route}`;
|
|
2607
2827
|
memory.markAttempted(requestedRoute, outcome, this.role);
|
|
2608
2828
|
memory.recordRoleAccess(this.role, requestedRoute, outcome);
|
|
@@ -2614,14 +2834,33 @@ export class BrowserEngine {
|
|
|
2614
2834
|
this.authLoss.clear();
|
|
2615
2835
|
// Error-status routes render but would otherwise be re-crawled forever —
|
|
2616
2836
|
// an attempt with the status satisfies the contract.
|
|
2617
|
-
if (typeof status === "number" && status >= 400)
|
|
2837
|
+
if (typeof status === "number" && status >= 400 && !opts.measureOnly)
|
|
2618
2838
|
memory.markAttempted(requestedRoute, `status:${status}`, this.role);
|
|
2619
2839
|
this.logAction({ action: "crawl", target: path, url: finalUrl, ...(await this.frameFor("crawl")) });
|
|
2620
2840
|
await this.scanForInjections();
|
|
2621
2841
|
await this.scanForContradictions();
|
|
2842
|
+
// Measured before the drain: the audit's Tab presses can load things too.
|
|
2843
|
+
const inspected = opts.inspect ? await this.inspectRoute(page, elements, finalUrl) : null;
|
|
2622
2844
|
const violations = this.oracles.drain();
|
|
2845
|
+
const own = elements.filter((el) => !el.frame?.foreign);
|
|
2846
|
+
this.crawlHealth.push({
|
|
2847
|
+
path,
|
|
2848
|
+
url: finalUrl,
|
|
2849
|
+
status: typeof status === "number" ? status : null,
|
|
2850
|
+
loginRedirect,
|
|
2851
|
+
elements: elements.length,
|
|
2852
|
+
unnamed: own.filter(missingName).map(describeControl),
|
|
2853
|
+
placeholderOnly: own.filter(placeholderOnly).map(placeholderEvidence),
|
|
2854
|
+
violations: violations.map(({ kind, severity, detail, url, embed }) => ({ kind, severity, detail, url, ...(embed ? { embed } : {}) })),
|
|
2855
|
+
geometry: inspected?.geometry ?? [],
|
|
2856
|
+
brokenImages: inspected?.brokenImages ?? [],
|
|
2857
|
+
design: inspected?.design ?? [],
|
|
2858
|
+
...(inspected?.auditError ? { auditError: inspected.auditError } : {}),
|
|
2859
|
+
});
|
|
2623
2860
|
const deadEnd = elements.length === 0;
|
|
2624
|
-
|
|
2861
|
+
// A field labelled only by its placeholder counts here too: to someone reading the crawl, it has no label.
|
|
2862
|
+
// Counted over the page's own controls, as the check's lists are: another site's frame is not this app's to fix.
|
|
2863
|
+
const unnamed = own.filter((el) => missingName(el) || placeholderOnly(el)).length;
|
|
2625
2864
|
const missingTestid = elements.filter((el) => !el.testid && !el.disabled).length;
|
|
2626
2865
|
const flags = [loginRedirect ? "AUTH-REDIRECT" : null, deadEnd ? "DEAD-END" : null, violations.length > 0 ? `${violations.length}⚠` : null].filter(Boolean);
|
|
2627
2866
|
summary.push(`${path} — ${status} · ${elements.length} el` +
|
|
@@ -2665,13 +2904,10 @@ export class BrowserEngine {
|
|
|
2665
2904
|
const page = this.requirePage();
|
|
2666
2905
|
const transcript = [];
|
|
2667
2906
|
const resolveTarget = (target) => {
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
if (target.startsWith("label="))
|
|
2673
|
-
return page.getByLabel(target.slice(6)).first();
|
|
2674
|
-
throw new Error(`Plan targets must be "testid=…", "text=…", or "label=…" (got: ${target})`);
|
|
2907
|
+
const parsed = parseTarget(target);
|
|
2908
|
+
if (!parsed)
|
|
2909
|
+
throw new Error(`Plan targets must be ${TARGET_HELP} (got: ${target})`);
|
|
2910
|
+
return BrowserEngine.locatorFor(page, parsed).first();
|
|
2675
2911
|
};
|
|
2676
2912
|
/** Last state captured this plan — reused as the next step's pre-state while the page has not moved. */
|
|
2677
2913
|
let lastCapture = null;
|
|
@@ -2697,6 +2933,8 @@ export class BrowserEngine {
|
|
|
2697
2933
|
// A select step's options and choice, recorded against the dropdown the bookkeeping below finds.
|
|
2698
2934
|
let chose = null;
|
|
2699
2935
|
let preState = null;
|
|
2936
|
+
// The form this step may submit, read before it went (forms.ts).
|
|
2937
|
+
let form = null;
|
|
2700
2938
|
try {
|
|
2701
2939
|
if (step.action === "navigate") {
|
|
2702
2940
|
const result = await this.navigate(step.target ?? step.value ?? "/");
|
|
@@ -2726,6 +2964,15 @@ export class BrowserEngine {
|
|
|
2726
2964
|
transcript.push(`${desc} → ${refusal}`);
|
|
2727
2965
|
break;
|
|
2728
2966
|
}
|
|
2967
|
+
if (BrowserEngine.ACTIVATION_KEY_RE.test(key)) {
|
|
2968
|
+
const kind = /enter/i.test(key) ? "enter" : "click";
|
|
2969
|
+
const probe = await this.probeFocusedForm();
|
|
2970
|
+
if (BrowserEngine.isTrackedSubmit(kind, probe)) {
|
|
2971
|
+
const reusable = lastCapture?.url === page.url() ? lastCapture : null;
|
|
2972
|
+
preState = await this.stateHolding(probe, reusable, reusable !== null);
|
|
2973
|
+
form = { kind, probe };
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2729
2976
|
await page.keyboard.press(key);
|
|
2730
2977
|
}
|
|
2731
2978
|
else {
|
|
@@ -2754,8 +3001,14 @@ export class BrowserEngine {
|
|
|
2754
3001
|
transcript.push(`${desc} → ${destructiveRefusal(label || step.target, this.mode)}`);
|
|
2755
3002
|
break;
|
|
2756
3003
|
}
|
|
2757
|
-
if (step.action === "click")
|
|
3004
|
+
if (step.action === "click") {
|
|
3005
|
+
const probe = await this.probeForm(loc);
|
|
3006
|
+
if (BrowserEngine.isTrackedSubmit("click", probe)) {
|
|
3007
|
+
preState = await this.stateHolding(probe, preState, preState !== null && preState === lastCapture);
|
|
3008
|
+
form = { kind: "click", probe };
|
|
3009
|
+
}
|
|
2758
3010
|
forcedClick = (await this.resilientClick(loc, ACTION_TIMEOUT_MS)).forced;
|
|
3011
|
+
}
|
|
2759
3012
|
else if (step.action === "hover") {
|
|
2760
3013
|
const { before, bodyBefore, churning } = await this.hoverBaselines();
|
|
2761
3014
|
await loc.hover({ timeout: ACTION_TIMEOUT_MS });
|
|
@@ -2777,6 +3030,11 @@ export class BrowserEngine {
|
|
|
2777
3030
|
break;
|
|
2778
3031
|
}
|
|
2779
3032
|
}
|
|
3033
|
+
const probe = await this.probeForm(loc);
|
|
3034
|
+
if (BrowserEngine.isTrackedSubmit("enter", probe)) {
|
|
3035
|
+
preState = await this.stateHolding(probe, preState, preState !== null && preState === lastCapture);
|
|
3036
|
+
form = { kind: "enter", probe };
|
|
3037
|
+
}
|
|
2780
3038
|
await loc.press("Enter", { timeout: ACTION_TIMEOUT_MS });
|
|
2781
3039
|
}
|
|
2782
3040
|
}
|
|
@@ -2796,6 +3054,8 @@ export class BrowserEngine {
|
|
|
2796
3054
|
}
|
|
2797
3055
|
}
|
|
2798
3056
|
await this.settle();
|
|
3057
|
+
if (form)
|
|
3058
|
+
this.noteFormSubmit(preState, form.kind, form.probe);
|
|
2799
3059
|
// A plan is the RECOMMENDED way to run a mechanical sequence, so its
|
|
2800
3060
|
// steps are where most of a recorded run actually happens. Leaving
|
|
2801
3061
|
// them unframed reproduced, inside run_plan, the same hole that crawl
|
|
@@ -2810,10 +3070,12 @@ export class BrowserEngine {
|
|
|
2810
3070
|
try {
|
|
2811
3071
|
// Record the state the action LANDED on (it may be a new screen
|
|
2812
3072
|
// this plan just reached, and it deserves coverage of its own)…
|
|
2813
|
-
const { elements } = await this.collect();
|
|
3073
|
+
const { elements, forms } = await this.collect();
|
|
2814
3074
|
const url = page.url();
|
|
2815
3075
|
const fp = fingerprintState(url, elements);
|
|
2816
3076
|
this.memory.visitState(fp, url, normalizePath(url), elements.map((el) => el.key));
|
|
3077
|
+
for (const f of forms)
|
|
3078
|
+
this.memory.recordForm(fp, f.key, f.guarded);
|
|
2817
3079
|
this.memory.recordRoleAccess(this.role, normalizePath(url), "reached");
|
|
2818
3080
|
lastCapture = { fp, elements, url };
|
|
2819
3081
|
// …but mark the acted-on element in the state it came FROM, using
|
|
@@ -2888,8 +3150,234 @@ export class BrowserEngine {
|
|
|
2888
3150
|
const ran = transcript.filter((l) => /^\d+\. /.test(l)).length;
|
|
2889
3151
|
return `PLAN (${ran}/${Math.min(steps.length, 20)} steps ran):\n${transcript.join("\n")}\nTake scout_snapshot to see the resulting state.`;
|
|
2890
3152
|
}
|
|
3153
|
+
/** A plan's or a flow's target as a Playwright locator (every match; callers pick). */
|
|
3154
|
+
static locatorFor(page, target) {
|
|
3155
|
+
switch (target.by) {
|
|
3156
|
+
case "testid":
|
|
3157
|
+
return page.locator(`[data-testid=${JSON.stringify(target.value)}]`);
|
|
3158
|
+
case "text":
|
|
3159
|
+
return page.getByText(target.value, { exact: false });
|
|
3160
|
+
case "label":
|
|
3161
|
+
return page.getByLabel(target.value);
|
|
3162
|
+
case "role":
|
|
3163
|
+
return page.getByRole(target.role, target.name === undefined ? {} : { name: target.name });
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
/**
|
|
3167
|
+
* Replay one saved flow for `scenescout check`. Acts and reports only: the
|
|
3168
|
+
* schema, the matching rules and what an outcome means live in flow.ts and
|
|
3169
|
+
* check.ts. It stops at the first step that breaks, or that the write policy
|
|
3170
|
+
* refuses, and hands back what the oracles caught on the way.
|
|
3171
|
+
*
|
|
3172
|
+
* Stricter than a plan on purpose: a click is never forced through
|
|
3173
|
+
* something covering its target (a user could not click it either), and a
|
|
3174
|
+
* step does not count as done until the page has settled.
|
|
3175
|
+
*/
|
|
3176
|
+
async replayFlow(steps, mode = this.mode === "observe" ? "observe" : "read-only") {
|
|
3177
|
+
const page = this.requirePage();
|
|
3178
|
+
// The write policy reads this.mode on every request, so the flow's rule holds for exactly as long as the flow runs.
|
|
3179
|
+
const crawlMode = this.mode;
|
|
3180
|
+
this.mode = mode;
|
|
3181
|
+
const context = this.context;
|
|
3182
|
+
const violations = [];
|
|
3183
|
+
const here = () => {
|
|
3184
|
+
try {
|
|
3185
|
+
const u = new URL(this.page?.url() ?? "");
|
|
3186
|
+
return `${u.pathname}${u.search}`;
|
|
3187
|
+
}
|
|
3188
|
+
catch {
|
|
3189
|
+
return "/";
|
|
3190
|
+
}
|
|
3191
|
+
};
|
|
3192
|
+
/** Responses since the most recent action step: what an expect-request looks through. */
|
|
3193
|
+
let seen = [];
|
|
3194
|
+
const onResponse = (res) => {
|
|
3195
|
+
seen.push({ method: res.request().method(), url: res.url(), status: res.status() });
|
|
3196
|
+
};
|
|
3197
|
+
context.on("response", onResponse);
|
|
3198
|
+
const appOrigin = new URL(this.baseUrl).origin;
|
|
3199
|
+
const refusedBackground = [];
|
|
3200
|
+
const websockets = new Set();
|
|
3201
|
+
const onSocket = (ws) => void websockets.add(ws.url().slice(0, 120));
|
|
3202
|
+
page.on("websocket", onSocket);
|
|
3203
|
+
/** Refused writes since the last call that a step is charged with; background ones (beacons, pings) are only listed (splitRefusals). */
|
|
3204
|
+
const ownRefusals = () => {
|
|
3205
|
+
const { charged, background } = splitRefusals(this.blockedRequests.splice(0), appOrigin);
|
|
3206
|
+
for (const sig of background)
|
|
3207
|
+
if (!refusedBackground.includes(sig) && refusedBackground.length < 20)
|
|
3208
|
+
refusedBackground.push(sig);
|
|
3209
|
+
return charged;
|
|
3210
|
+
};
|
|
3211
|
+
const done = (outcome) => ({ outcome, violations, refusedBackground, websockets: [...websockets] });
|
|
3212
|
+
// Nothing from the crawl before this flow is charged to it.
|
|
3213
|
+
this.oracles.drain(false);
|
|
3214
|
+
this.blockedRequests = [];
|
|
3215
|
+
const collect = (except) => {
|
|
3216
|
+
const path = here();
|
|
3217
|
+
for (const v of this.oracles.drain())
|
|
3218
|
+
if (v.detail !== except)
|
|
3219
|
+
violations.push({ path, violation: { kind: v.kind, severity: v.severity, detail: v.detail, url: v.url, ...(v.embed ? { embed: v.embed } : {}) } });
|
|
3220
|
+
};
|
|
3221
|
+
const poll = async (done) => {
|
|
3222
|
+
const until = Date.now() + FLOW_STEP_TIMEOUT_MS;
|
|
3223
|
+
for (;;) {
|
|
3224
|
+
if (done())
|
|
3225
|
+
return true;
|
|
3226
|
+
if (Date.now() >= until)
|
|
3227
|
+
return false;
|
|
3228
|
+
await (this.page ?? page).waitForTimeout(100).catch(() => { });
|
|
3229
|
+
}
|
|
3230
|
+
};
|
|
3231
|
+
const firstLine = (err) => (err instanceof Error ? err.message : String(err))
|
|
3232
|
+
.split("\n")[0]
|
|
3233
|
+
.replace(/^[a-z]+\.[a-zA-Z]+: /, "")
|
|
3234
|
+
.trim();
|
|
3235
|
+
try {
|
|
3236
|
+
for (const [i, step] of steps.entries()) {
|
|
3237
|
+
const n = i + 1;
|
|
3238
|
+
const did = describeStep(step);
|
|
3239
|
+
if (isAction(step))
|
|
3240
|
+
seen = [];
|
|
3241
|
+
let failure = null;
|
|
3242
|
+
let refusal = null;
|
|
3243
|
+
this.actionStartedAt = Date.now();
|
|
3244
|
+
try {
|
|
3245
|
+
const current = this.requirePage();
|
|
3246
|
+
if (step.action === "navigate") {
|
|
3247
|
+
const url = `${this.baseUrl}${step.target}`;
|
|
3248
|
+
const resp = await current.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 });
|
|
3249
|
+
await this.settle();
|
|
3250
|
+
const status = resp?.status() ?? null;
|
|
3251
|
+
if (status !== null && status >= 400) {
|
|
3252
|
+
failure = `the page answered HTTP ${status}`;
|
|
3253
|
+
// The page's own status is this step's failure; the oracle's record of the same response is not a second issue.
|
|
3254
|
+
// The oracle stores its details redacted (redactViolation), so the comparison is redacted too.
|
|
3255
|
+
collect(redactSecrets(httpErrorDetail("GET", current.url().replace(/#.*$/, ""), status)));
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
else if (step.action === "press") {
|
|
3259
|
+
refusal = await this.vetFocusedActivation(step.value);
|
|
3260
|
+
if (!refusal) {
|
|
3261
|
+
await current.keyboard.press(step.value);
|
|
3262
|
+
await this.settle();
|
|
3263
|
+
}
|
|
3264
|
+
}
|
|
3265
|
+
else if (step.action === "click" || step.action === "type" || step.action === "select") {
|
|
3266
|
+
const loc = BrowserEngine.locatorFor(current, parseTarget(step.target)).first();
|
|
3267
|
+
const found = await loc
|
|
3268
|
+
.waitFor({ state: "visible", timeout: FLOW_STEP_TIMEOUT_MS })
|
|
3269
|
+
.then(() => true)
|
|
3270
|
+
.catch(() => false);
|
|
3271
|
+
if (!found) {
|
|
3272
|
+
failure = `nothing visible matches ${step.target} within ${FLOW_STEP_TIMEOUT_MS / 1000}s`;
|
|
3273
|
+
}
|
|
3274
|
+
else {
|
|
3275
|
+
const label = ((await loc.getAttribute("aria-label").catch(() => null)) ??
|
|
3276
|
+
(await loc.textContent({ timeout: 1000 }).catch(() => null)) ??
|
|
3277
|
+
"").trim();
|
|
3278
|
+
if (this.readOnly && step.action !== "type" && isDestructive(label, step.action === "select" ? step.value : undefined)) {
|
|
3279
|
+
refusal = destructiveRefusal(label || step.target, this.mode);
|
|
3280
|
+
}
|
|
3281
|
+
else if (step.action === "click") {
|
|
3282
|
+
await loc.click({ timeout: FLOW_STEP_TIMEOUT_MS });
|
|
3283
|
+
}
|
|
3284
|
+
else if (step.action === "select") {
|
|
3285
|
+
await loc.selectOption(step.value, { timeout: FLOW_STEP_TIMEOUT_MS });
|
|
3286
|
+
}
|
|
3287
|
+
else {
|
|
3288
|
+
await this.noteProbe(step.value, step.target);
|
|
3289
|
+
await this.fillOrAppend(loc, step.value, step.replace ?? false);
|
|
3290
|
+
if (step.pressEnter) {
|
|
3291
|
+
const submit = loc.locator("xpath=ancestor::form[1]").locator('[type="submit"], button:not([type="button"]):not([type="reset"])').first();
|
|
3292
|
+
const submitLabel = ((await submit.textContent({ timeout: 1000 }).catch(() => "")) ?? "").trim();
|
|
3293
|
+
if (this.readOnly && isDestructive(submitLabel))
|
|
3294
|
+
refusal = destructiveRefusal(submitLabel, this.mode);
|
|
3295
|
+
else
|
|
3296
|
+
await loc.press("Enter", { timeout: FLOW_STEP_TIMEOUT_MS });
|
|
3297
|
+
}
|
|
3298
|
+
}
|
|
3299
|
+
if (!refusal)
|
|
3300
|
+
await this.settle();
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
else if (step.action === "expect-text") {
|
|
3304
|
+
const visible = current.getByText(step.text, { exact: false }).filter({ visible: true }).first();
|
|
3305
|
+
const ok = await visible
|
|
3306
|
+
.waitFor({ state: "visible", timeout: FLOW_STEP_TIMEOUT_MS })
|
|
3307
|
+
.then(() => true)
|
|
3308
|
+
.catch(() => false);
|
|
3309
|
+
if (!ok)
|
|
3310
|
+
failure = `no visible text ${JSON.stringify(step.text)} within ${FLOW_STEP_TIMEOUT_MS / 1000}s`;
|
|
3311
|
+
}
|
|
3312
|
+
else if (step.action === "expect-url") {
|
|
3313
|
+
const ok = await poll(() => urlMatches(step.pattern, this.page?.url() ?? ""));
|
|
3314
|
+
if (!ok)
|
|
3315
|
+
failure = `the page is at ${here()}, which does not match /${step.pattern}/`;
|
|
3316
|
+
}
|
|
3317
|
+
else {
|
|
3318
|
+
let last = { ok: false, reason: "" };
|
|
3319
|
+
await poll(() => (last = matchRequest(step, seen)).ok);
|
|
3320
|
+
if (!last.ok)
|
|
3321
|
+
failure = last.reason;
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
catch (err) {
|
|
3325
|
+
failure = firstLine(err);
|
|
3326
|
+
}
|
|
3327
|
+
await this.scanForInjections().catch(() => { });
|
|
3328
|
+
await this.scanForContradictions().catch(() => { });
|
|
3329
|
+
// What the write policy refused of the app's own requests while this step ran is the step's, whatever caused it.
|
|
3330
|
+
const blocked = ownRefusals();
|
|
3331
|
+
if (!refusal && blocked.length > 0)
|
|
3332
|
+
refusal = `the ${this.mode} write policy refused ${blocked.join(", ")}`;
|
|
3333
|
+
collect();
|
|
3334
|
+
if (refusal)
|
|
3335
|
+
return done({ status: "refused", step: n, did, reason: refusal.split("\n")[0], path: here() });
|
|
3336
|
+
if (failure)
|
|
3337
|
+
return done({ status: "failed", step: n, did, reason: failure, path: here() });
|
|
3338
|
+
}
|
|
3339
|
+
// A write the last step set off can land after its checks ran (a debounced save): wait a moment, settle, look
|
|
3340
|
+
// again; then leave the page while the flow's rule still holds, so nothing it sends on the way out goes under
|
|
3341
|
+
// the crawl's rule, and look once more.
|
|
3342
|
+
await (this.page ?? page).waitForTimeout(FLOW_AFTER_LAST_STEP_MS).catch(() => { });
|
|
3343
|
+
await this.settle().catch(() => { });
|
|
3344
|
+
const late = ownRefusals();
|
|
3345
|
+
collect();
|
|
3346
|
+
await (this.page ?? page).goto("about:blank").catch(() => { });
|
|
3347
|
+
await this.settle().catch(() => { });
|
|
3348
|
+
late.push(...ownRefusals());
|
|
3349
|
+
const last = steps[steps.length - 1];
|
|
3350
|
+
if (late.length > 0 && last) {
|
|
3351
|
+
return done({
|
|
3352
|
+
status: "refused",
|
|
3353
|
+
step: steps.length,
|
|
3354
|
+
did: describeStep(last),
|
|
3355
|
+
reason: `after the last step, the ${this.mode} write policy refused ${late.join(", ")}`,
|
|
3356
|
+
path: here(),
|
|
3357
|
+
});
|
|
3358
|
+
}
|
|
3359
|
+
return done({ status: "passed" });
|
|
3360
|
+
}
|
|
3361
|
+
finally {
|
|
3362
|
+
// Leave the flow's page before the crawl's rule comes back, so nothing it still sends goes out under a looser one.
|
|
3363
|
+
await (this.page ?? page).goto("about:blank").catch(() => { });
|
|
3364
|
+
this.blockedRequests = [];
|
|
3365
|
+
this.oracles.drain(false);
|
|
3366
|
+
this.mode = crawlMode;
|
|
3367
|
+
page.off("websocket", onSocket);
|
|
3368
|
+
context.off("response", onResponse);
|
|
3369
|
+
this.refs.clear();
|
|
3370
|
+
this.lastSnap = null;
|
|
3371
|
+
this.snapshotUrl = "";
|
|
3372
|
+
}
|
|
3373
|
+
}
|
|
2891
3374
|
/** Computed-style design audit of the current page — visual judgment material without pixels. */
|
|
2892
3375
|
async designAudit() {
|
|
3376
|
+
const { url, report } = await this.auditPage();
|
|
3377
|
+
return `URL: ${url}\n` + report;
|
|
3378
|
+
}
|
|
3379
|
+
/** The design audit's measurements, as data as well as prose; scout_design_audit and a check's crawl share it. */
|
|
3380
|
+
async auditPage() {
|
|
2893
3381
|
const page = this.requirePage();
|
|
2894
3382
|
await this.settle();
|
|
2895
3383
|
const payload = (await page.evaluate(DESIGN_COLLECT_SCRIPT));
|
|
@@ -2901,7 +3389,7 @@ export class BrowserEngine {
|
|
|
2901
3389
|
// score with chrome included and later ones don't. That is the same warm-up
|
|
2902
3390
|
// the coverage census has: nothing is knowable as "shared" until it has been
|
|
2903
3391
|
// seen on several routes.
|
|
2904
|
-
const { report, score, signatures } = analyzeDesign(payload, page.viewportSize() ?? { width: 1280, height: 900 }, this.memory?.designChromeKeys() ?? new Set());
|
|
3392
|
+
const { report, score, signatures, defects } = analyzeDesign(payload, page.viewportSize() ?? { width: 1280, height: 900 }, this.memory?.designChromeKeys() ?? new Set());
|
|
2905
3393
|
const route = normalizePath(page.url());
|
|
2906
3394
|
this.memory?.recordDesignElements(route, signatures);
|
|
2907
3395
|
// Only a page that actually produced a score counts as audited. An empty or
|
|
@@ -2913,7 +3401,7 @@ export class BrowserEngine {
|
|
|
2913
3401
|
this.memory?.markRouteFact(route, { audited: true });
|
|
2914
3402
|
}
|
|
2915
3403
|
this.logAction({ action: "design-audit", url: page.url(), result: score ? `score:${score.overall}` : undefined, ...(await this.frameFor("design-audit")) });
|
|
2916
|
-
return
|
|
3404
|
+
return { url: page.url(), report, defects, sampled: payload.records.length };
|
|
2917
3405
|
}
|
|
2918
3406
|
/**
|
|
2919
3407
|
* Scroll like a user — and notice when the page won't let you. Reports the
|