scenescout 3.10.0 → 3.11.1
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 +18 -0
- package/README.md +12 -3
- package/dist/browsers.js +64 -0
- package/dist/check-run.js +79 -5
- package/dist/cli.js +32 -6
- package/dist/engine/bench.js +154 -17
- package/dist/engine/browser.js +426 -26
- package/dist/engine/calibration.js +99 -14
- package/dist/engine/check.js +251 -33
- package/dist/engine/fingerprint.js +12 -7
- package/dist/engine/flow.js +398 -0
- package/dist/engine/lane.js +81 -0
- package/dist/engine/memory.js +48 -6
- package/dist/engine/unload.js +201 -0
- package/dist/engine/verify.js +119 -0
- package/dist/mcp-server.js +4 -4
- package/package.json +4 -2
- package/skills/scenescout/SKILL.md +1 -1
package/dist/engine/browser.js
CHANGED
|
@@ -2,19 +2,20 @@ 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
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 } from "./oracles.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
|
-
import { defaultEngine, focusAdvanceKey, REMOVE_SHARED_WORKER_SCRIPT, screencastSupport, serviceWorkerPolicy, sharedWorkersAllowed, } from "../browsers.js";
|
|
18
|
+
import { defaultEngine, focusAdvanceKey, REMOVE_SHARED_WORKER_SCRIPT, screencastSupport, serviceWorkerPolicy, sharedWorkersAllowed, unloadWriteInterception, } from "../browsers.js";
|
|
18
19
|
import { revealedLines } from "./hover.js";
|
|
19
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";
|
|
20
21
|
import { explainLaunchFailure, isMissingBrowser } from "./launch.js";
|
|
@@ -25,6 +26,7 @@ import { answersWithRefusal, destructiveRefusal, isDestructive, isDestructiveWir
|
|
|
25
26
|
import { scanProject } from "../scan.js";
|
|
26
27
|
import { analyzeDesign, DESIGN_COLLECT_SCRIPT } from "./design.js";
|
|
27
28
|
import { acceptMatches, generatedUpload } from "./fixtures.js";
|
|
29
|
+
import { bodyDigest, headerOf, isReadMethod, judgeUnseenWrite, pausedRequestBytes, RoutedWrites, UnseenRefusals, unseenWriteSource, } from "./unload.js";
|
|
28
30
|
const SETTLE_MS = 400;
|
|
29
31
|
/**
|
|
30
32
|
* Request URL → pathname, falling back to the raw string for anything
|
|
@@ -503,6 +505,19 @@ export class BrowserEngine {
|
|
|
503
505
|
engineName = "chromium";
|
|
504
506
|
/** Non-GET requests fired since the last action — surfaces silent state mutation in read-only runs (timestamped for attribution). */
|
|
505
507
|
mutationRequests = [];
|
|
508
|
+
/**
|
|
509
|
+
* Chromium only (browsers.ts `unloadWriteInterception`): the writes the
|
|
510
|
+
* route handler already let through, so the browser-level interception that
|
|
511
|
+
* catches what it never sees does not judge them twice. That session lives
|
|
512
|
+
* as long as the browser, so a page closed at the end still meets it.
|
|
513
|
+
*/
|
|
514
|
+
routedWrites = new RoutedWrites();
|
|
515
|
+
/** Writes refused at the browser level, so the driver's own report of them (a redirect hop failing) is known as the policy's doing. */
|
|
516
|
+
unseenRefusals = new UnseenRefusals();
|
|
517
|
+
/** Whether the write policy stopped this request, at the route handler or at the browser level. */
|
|
518
|
+
refusedByAnyPolicy(req) {
|
|
519
|
+
return this.refusedByPolicy.has(req) || this.unseenRefusals.has(req.method(), req.url());
|
|
520
|
+
}
|
|
506
521
|
/**
|
|
507
522
|
* Every request answered since the last action, with its status. The HTTP
|
|
508
523
|
* oracle sees each 4xx as it happens and reports it on its own; this ledger
|
|
@@ -579,7 +594,7 @@ export class BrowserEngine {
|
|
|
579
594
|
url: req.url(),
|
|
580
595
|
status,
|
|
581
596
|
resourceType: req.resourceType(),
|
|
582
|
-
blockedByPolicy: this.
|
|
597
|
+
blockedByPolicy: this.refusedByAnyPolicy(req),
|
|
583
598
|
});
|
|
584
599
|
}
|
|
585
600
|
/**
|
|
@@ -699,7 +714,7 @@ export class BrowserEngine {
|
|
|
699
714
|
this.projectDirNote = ` (its real path could not be resolved: ${err instanceof Error ? err.message : String(err)} — a symlinked project path may be wrongly refused)`;
|
|
700
715
|
}
|
|
701
716
|
this.oracles = new OracleMonitor();
|
|
702
|
-
this.oracles.setPolicyRefusalCheck((req) => this.
|
|
717
|
+
this.oracles.setPolicyRefusalCheck((req) => this.refusedByAnyPolicy(req));
|
|
703
718
|
// A request an embed sends to the app is the app's to answer: only one headed outside the app is the embed's.
|
|
704
719
|
this.oracles.setEmbedAttribution((req) => {
|
|
705
720
|
let site = null;
|
|
@@ -849,7 +864,7 @@ export class BrowserEngine {
|
|
|
849
864
|
const why = ESCAPE_REFUSAL;
|
|
850
865
|
// Reported like any refusal, so a click whose navigation this stopped does not read as a click that did nothing.
|
|
851
866
|
if (this.blockedRequests.length < 20)
|
|
852
|
-
this.blockedRequests.push({ at: Date.now(), sig: `navigation to ${req.url().slice(0, 140)}`, answered: false, why });
|
|
867
|
+
this.blockedRequests.push({ at: Date.now(), sig: `navigation to ${req.url().slice(0, 140)}`, answered: false, why, type: req.resourceType() });
|
|
853
868
|
this.logAction({ action: "write-policy:blocked", target: `navigation to ${req.url().slice(0, 140)} (${why})`, url: this.page?.url() ?? "" });
|
|
854
869
|
this.refusedByPolicy.add(req);
|
|
855
870
|
this.oracles.notePolicyBlock();
|
|
@@ -909,7 +924,7 @@ export class BrowserEngine {
|
|
|
909
924
|
const refuse = (why) => {
|
|
910
925
|
const answered = answersWithRefusal(req.resourceType());
|
|
911
926
|
if (this.blockedRequests.length < 20)
|
|
912
|
-
this.blockedRequests.push({ at: Date.now(), sig: `${method} ${url.slice(0, 140)}`, answered, why });
|
|
927
|
+
this.blockedRequests.push({ at: Date.now(), sig: `${method} ${url.slice(0, 140)}`, answered, why, type: req.resourceType() });
|
|
913
928
|
this.logAction({ action: "write-policy:blocked", target: `${method} ${pathname}${why ? ` (${why})` : ""}`, url: this.page?.url() ?? "" });
|
|
914
929
|
this.refusedByPolicy.add(req);
|
|
915
930
|
this.oracles.notePolicyBlock();
|
|
@@ -933,8 +948,10 @@ export class BrowserEngine {
|
|
|
933
948
|
return refuse(`sent from a page of ${offApp}, outside the app`);
|
|
934
949
|
// Auth/session flows must work in every mode — but never a destructive
|
|
935
950
|
// one, and in observe only the requests a login itself needs.
|
|
936
|
-
if (isAuthExempt(this.mode, method, pathname, destructiveWire))
|
|
951
|
+
if (isAuthExempt(this.mode, method, pathname, destructiveWire)) {
|
|
952
|
+
this.routedWrites.note(this.mode, method, url, bodyDigest(req.postDataBuffer()));
|
|
937
953
|
return route.continue();
|
|
954
|
+
}
|
|
938
955
|
let owned = this.isOwnedResource(pathname);
|
|
939
956
|
// A single UI action commonly fires create-then-immediately-save
|
|
940
957
|
// (POST gets an id, PUT saves content under it) faster than the
|
|
@@ -968,10 +985,22 @@ export class BrowserEngine {
|
|
|
968
985
|
.finally(() => this.pendingCreations.delete(task));
|
|
969
986
|
this.pendingCreations.add(task);
|
|
970
987
|
}
|
|
988
|
+
this.routedWrites.note(this.mode, method, url, bodyDigest(req.postDataBuffer()));
|
|
971
989
|
return route.continue();
|
|
972
990
|
}
|
|
973
991
|
return refuse();
|
|
974
992
|
});
|
|
993
|
+
// Chromium never routes a write a page sends as it is being left; it is judged at the browser level instead.
|
|
994
|
+
if (unloadWriteInterception(this.engineName) === "browser-fetch" && this.browser) {
|
|
995
|
+
try {
|
|
996
|
+
await this.interceptUnseenWrites(this.browser);
|
|
997
|
+
}
|
|
998
|
+
catch (err) {
|
|
999
|
+
// Fail closed: without it, a page being left would write past the policy.
|
|
1000
|
+
await this.close();
|
|
1001
|
+
throw new Error(`Could not start the write policy's browser-level interception: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
975
1004
|
}
|
|
976
1005
|
// Popups / target=_blank: adopt same-origin pages as the active page (with
|
|
977
1006
|
// oracles attached); close foreign-origin popups so exploration cannot
|
|
@@ -998,7 +1027,7 @@ export class BrowserEngine {
|
|
|
998
1027
|
this.lastSnap = null;
|
|
999
1028
|
}
|
|
1000
1029
|
else {
|
|
1001
|
-
void
|
|
1030
|
+
void BrowserEngine.leaveAndClose(newPage);
|
|
1002
1031
|
}
|
|
1003
1032
|
})
|
|
1004
1033
|
.catch(() => { });
|
|
@@ -1389,7 +1418,11 @@ export class BrowserEngine {
|
|
|
1389
1418
|
* route classes (including ?tab= screens) that feed the completion contract,
|
|
1390
1419
|
* so the contract works for any app, not just filesystem-routed Next.
|
|
1391
1420
|
*/
|
|
1421
|
+
/** Set while a measure-only crawl reads a page: its links are not the app's route list. */
|
|
1422
|
+
harvestPaused = false;
|
|
1392
1423
|
harvestRoutes(elements) {
|
|
1424
|
+
if (this.harvestPaused)
|
|
1425
|
+
return;
|
|
1393
1426
|
if (!this.memory)
|
|
1394
1427
|
return;
|
|
1395
1428
|
const found = [];
|
|
@@ -1813,6 +1846,121 @@ export class BrowserEngine {
|
|
|
1813
1846
|
return null;
|
|
1814
1847
|
return foreign;
|
|
1815
1848
|
}
|
|
1849
|
+
/**
|
|
1850
|
+
* Pause, at the browser level, every request the context's route handler
|
|
1851
|
+
* lets through or never sees (unload.ts says why), and judge the writes it
|
|
1852
|
+
* never saw. Everything else is sent on at once.
|
|
1853
|
+
*/
|
|
1854
|
+
async interceptUnseenWrites(browser) {
|
|
1855
|
+
this.routedWrites.clear();
|
|
1856
|
+
const session = await browser.newBrowserCDPSession();
|
|
1857
|
+
session.on("Fetch.requestPaused", (event) => void this.judgeUnseenRequest(session, event));
|
|
1858
|
+
await session.send("Fetch.enable", { patterns: [{ urlPattern: "*" }] });
|
|
1859
|
+
}
|
|
1860
|
+
async judgeUnseenRequest(session, event) {
|
|
1861
|
+
const { url, method, headers } = event.request;
|
|
1862
|
+
// Every paused request gets exactly one answer, whatever throws on the way: a request left paused hangs its page.
|
|
1863
|
+
let answered = false;
|
|
1864
|
+
const answer = async (allow) => {
|
|
1865
|
+
answered = true;
|
|
1866
|
+
await (allow
|
|
1867
|
+
? session.send("Fetch.continueRequest", { requestId: event.requestId })
|
|
1868
|
+
: session.send("Fetch.failRequest", { requestId: event.requestId, errorReason: "BlockedByClient" })).catch(() => {
|
|
1869
|
+
/* the request or the browser is already gone */
|
|
1870
|
+
});
|
|
1871
|
+
};
|
|
1872
|
+
try {
|
|
1873
|
+
// Reads, and everything in destructive, go on. A later hop of a redirect is judged like any write: the route handler never sees one.
|
|
1874
|
+
if (isReadMethod(method) || this.mode === "destructive")
|
|
1875
|
+
return await answer(true);
|
|
1876
|
+
const bytes = pausedRequestBytes(event.request);
|
|
1877
|
+
if (this.routedWrites.claim(this.mode, method, url, bodyDigest(bytes)))
|
|
1878
|
+
return await answer(true);
|
|
1879
|
+
let verdict;
|
|
1880
|
+
let pathname = url;
|
|
1881
|
+
try {
|
|
1882
|
+
pathname = pathnameOf(url);
|
|
1883
|
+
const { foreign, offApp } = unseenWriteSource({
|
|
1884
|
+
appUrl: this.baseUrl,
|
|
1885
|
+
mode: this.mode,
|
|
1886
|
+
url,
|
|
1887
|
+
originHeader: headerOf(headers, "origin"),
|
|
1888
|
+
referer: headerOf(headers, "referer"),
|
|
1889
|
+
currentPageUrl: this.page?.url(),
|
|
1890
|
+
trustedEmbeds: this.trustedEmbeds,
|
|
1891
|
+
movedByEmbed: this.embedMoves.movedTo,
|
|
1892
|
+
});
|
|
1893
|
+
verdict = judgeUnseenWrite({
|
|
1894
|
+
mode: this.mode,
|
|
1895
|
+
method,
|
|
1896
|
+
pathname,
|
|
1897
|
+
destructiveWire: isDestructiveWire(pathname, bytes?.toString("utf8")),
|
|
1898
|
+
foreign,
|
|
1899
|
+
offApp,
|
|
1900
|
+
owned: this.isOwnedResource(pathname),
|
|
1901
|
+
});
|
|
1902
|
+
}
|
|
1903
|
+
catch (err) {
|
|
1904
|
+
// Fail closed: a write that cannot be judged is refused, and reported as refused below.
|
|
1905
|
+
console.error(`[scenescout] write policy: could not judge ${method} at the browser level, refused: ${err instanceof Error ? err.message : String(err)}`);
|
|
1906
|
+
verdict = { allow: false };
|
|
1907
|
+
}
|
|
1908
|
+
// Known as refused before the browser fails it, so the driver's report of the failure finds it.
|
|
1909
|
+
if (!verdict.allow)
|
|
1910
|
+
this.unseenRefusals.note(method, url);
|
|
1911
|
+
await answer(verdict.allow);
|
|
1912
|
+
if (verdict.allow) {
|
|
1913
|
+
// Reported like any other write that went out, as the request event does for a routed one.
|
|
1914
|
+
if (!BENIGN_MUTATION_RE.test(url) && this.mutationRequests.length < 20)
|
|
1915
|
+
this.mutationRequests.push({ at: Date.now(), sig: `${method} ${url.slice(0, 120)}` });
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
// Refused and reported as any refusal is. Dropped rather than answered: the page that sent it is going or gone.
|
|
1919
|
+
if (this.blockedRequests.length < 20)
|
|
1920
|
+
this.blockedRequests.push({
|
|
1921
|
+
at: Date.now(),
|
|
1922
|
+
sig: `${method} ${url.slice(0, 140)}`,
|
|
1923
|
+
answered: false,
|
|
1924
|
+
why: verdict.why,
|
|
1925
|
+
type: (event.resourceType ?? "other").toLowerCase(),
|
|
1926
|
+
});
|
|
1927
|
+
this.logAction({ action: "write-policy:blocked", target: `${method} ${pathname}${verdict.why ? ` (${verdict.why})` : ""}`, url: this.page?.url() ?? "" });
|
|
1928
|
+
this.oracles.notePolicyBlock();
|
|
1929
|
+
}
|
|
1930
|
+
catch (err) {
|
|
1931
|
+
console.error(`[scenescout] write policy: browser-level interception failed on ${method}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1932
|
+
}
|
|
1933
|
+
finally {
|
|
1934
|
+
if (!answered) {
|
|
1935
|
+
// Fail closed, and still say so: a refusal nobody hears about reads as a write that went out.
|
|
1936
|
+
try {
|
|
1937
|
+
this.unseenRefusals.note(method, url);
|
|
1938
|
+
}
|
|
1939
|
+
catch {
|
|
1940
|
+
/* the error above is already logged */
|
|
1941
|
+
}
|
|
1942
|
+
await answer(false);
|
|
1943
|
+
try {
|
|
1944
|
+
if (this.blockedRequests.length < 20)
|
|
1945
|
+
this.blockedRequests.push({
|
|
1946
|
+
at: Date.now(),
|
|
1947
|
+
sig: `${method} ${url.slice(0, 140)}`,
|
|
1948
|
+
answered: false,
|
|
1949
|
+
type: (event.resourceType ?? "other").toLowerCase(),
|
|
1950
|
+
});
|
|
1951
|
+
this.logAction({
|
|
1952
|
+
action: "write-policy:blocked",
|
|
1953
|
+
target: `${method} ${url.slice(0, 140)} (not judged: an error in the policy)`,
|
|
1954
|
+
url: this.page?.url() ?? "",
|
|
1955
|
+
});
|
|
1956
|
+
this.oracles.notePolicyBlock();
|
|
1957
|
+
}
|
|
1958
|
+
catch (err) {
|
|
1959
|
+
console.error(`[scenescout] write policy: could not report a refusal: ${err instanceof Error ? err.message : String(err)}`);
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1816
1964
|
/** Report (and clear) write-policy blocks since the last action. */
|
|
1817
1965
|
drainBlocked() {
|
|
1818
1966
|
this.lastActionBlocked = this.blockedRequests.length;
|
|
@@ -1863,7 +2011,7 @@ export class BrowserEngine {
|
|
|
1863
2011
|
// to see the DUPLICATES that the reporting dedup below intentionally hides.
|
|
1864
2012
|
this.lastActionMutationSigs = this.mutationRequests.map((e) => e.sig);
|
|
1865
2013
|
const fresh = this.mutationRequests
|
|
1866
|
-
.filter((entry) => !this.
|
|
2014
|
+
.filter((entry) => !entry.req || !this.refusedByAnyPolicy(entry.req))
|
|
1867
2015
|
.filter((entry) => {
|
|
1868
2016
|
const key = entry.sig.split("?")[0];
|
|
1869
2017
|
if (this.reportedMutationSigs.has(key))
|
|
@@ -2733,6 +2881,12 @@ export class BrowserEngine {
|
|
|
2733
2881
|
* Output is anomaly-oriented: a summary line per route, details only where
|
|
2734
2882
|
* something is wrong. Navigation-only, so it is safe in read-only mode.
|
|
2735
2883
|
*/
|
|
2884
|
+
/**
|
|
2885
|
+
* `measureOnly`: load and measure the pages and nothing else. No route is
|
|
2886
|
+
* harvested from their links, none is marked visited or attempted, and a
|
|
2887
|
+
* failed load is not remembered, so the pages and what they link to stay on
|
|
2888
|
+
* the unvisited list exactly as before. A check's re-test loads use it.
|
|
2889
|
+
*/
|
|
2736
2890
|
async crawl(paths, opts = {}) {
|
|
2737
2891
|
const page = this.requirePage();
|
|
2738
2892
|
const memory = this.memory;
|
|
@@ -2768,7 +2922,8 @@ export class BrowserEngine {
|
|
|
2768
2922
|
// Not retried by later crawls in this process (a check would otherwise try it
|
|
2769
2923
|
// on every discovery round). Deliberately not written to memory: one outage
|
|
2770
2924
|
// must not count a route as covered in every later run's gap ledger.
|
|
2771
|
-
|
|
2925
|
+
if (!opts.measureOnly)
|
|
2926
|
+
this.loadFailedRoutes.add(normalizePath(url));
|
|
2772
2927
|
// The browser commits its own error page for this failure tens of
|
|
2773
2928
|
// milliseconds after goto has thrown, and that commit interrupts the next
|
|
2774
2929
|
// navigation, charging this route's failure to the next one. Wait for it;
|
|
@@ -2793,21 +2948,24 @@ export class BrowserEngine {
|
|
|
2793
2948
|
continue;
|
|
2794
2949
|
}
|
|
2795
2950
|
await this.settle();
|
|
2796
|
-
|
|
2951
|
+
this.harvestPaused = opts.measureOnly === true;
|
|
2952
|
+
const { elements, forms } = await this.collect().finally(() => (this.harvestPaused = false));
|
|
2797
2953
|
const finalUrl = page.url();
|
|
2798
2954
|
const route = normalizePath(finalUrl);
|
|
2799
2955
|
const fp = fingerprintState(finalUrl, elements);
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2956
|
+
if (!opts.measureOnly) {
|
|
2957
|
+
memory.visitState(fp, finalUrl, route, elements.map((el) => el.key));
|
|
2958
|
+
for (const f of forms)
|
|
2959
|
+
memory.recordForm(fp, f.key, f.guarded);
|
|
2960
|
+
memory.recordRoleAccess(this.role, route, "reached");
|
|
2961
|
+
}
|
|
2804
2962
|
// If we landed somewhere else (auth wall, canonical redirect), the
|
|
2805
2963
|
// REQUESTED route still counts as covered for THIS role — a role that
|
|
2806
2964
|
// can't see /admin must not block the completion contract forever. An
|
|
2807
2965
|
// auth-loss bounce is recorded under a prefix that does NOT count.
|
|
2808
2966
|
const requestedRoute = normalizePath(url);
|
|
2809
2967
|
const loginRedirect = this.authLoss.isLoginRedirect(path, finalUrl, this.baseUrl);
|
|
2810
|
-
if (route !== requestedRoute) {
|
|
2968
|
+
if (route !== requestedRoute && !opts.measureOnly) {
|
|
2811
2969
|
const outcome = loginRedirect ? `${AUTH_LOSS_PREFIX}${route}` : `landed:${route}`;
|
|
2812
2970
|
memory.markAttempted(requestedRoute, outcome, this.role);
|
|
2813
2971
|
memory.recordRoleAccess(this.role, requestedRoute, outcome);
|
|
@@ -2819,7 +2977,7 @@ export class BrowserEngine {
|
|
|
2819
2977
|
this.authLoss.clear();
|
|
2820
2978
|
// Error-status routes render but would otherwise be re-crawled forever —
|
|
2821
2979
|
// an attempt with the status satisfies the contract.
|
|
2822
|
-
if (typeof status === "number" && status >= 400)
|
|
2980
|
+
if (typeof status === "number" && status >= 400 && !opts.measureOnly)
|
|
2823
2981
|
memory.markAttempted(requestedRoute, `status:${status}`, this.role);
|
|
2824
2982
|
this.logAction({ action: "crawl", target: path, url: finalUrl, ...(await this.frameFor("crawl")) });
|
|
2825
2983
|
await this.scanForInjections();
|
|
@@ -2889,13 +3047,10 @@ export class BrowserEngine {
|
|
|
2889
3047
|
const page = this.requirePage();
|
|
2890
3048
|
const transcript = [];
|
|
2891
3049
|
const resolveTarget = (target) => {
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
if (target.startsWith("label="))
|
|
2897
|
-
return page.getByLabel(target.slice(6)).first();
|
|
2898
|
-
throw new Error(`Plan targets must be "testid=…", "text=…", or "label=…" (got: ${target})`);
|
|
3050
|
+
const parsed = parseTarget(target);
|
|
3051
|
+
if (!parsed)
|
|
3052
|
+
throw new Error(`Plan targets must be ${TARGET_HELP} (got: ${target})`);
|
|
3053
|
+
return BrowserEngine.locatorFor(page, parsed).first();
|
|
2899
3054
|
};
|
|
2900
3055
|
/** Last state captured this plan — reused as the next step's pre-state while the page has not moved. */
|
|
2901
3056
|
let lastCapture = null;
|
|
@@ -3138,6 +3293,227 @@ export class BrowserEngine {
|
|
|
3138
3293
|
const ran = transcript.filter((l) => /^\d+\. /.test(l)).length;
|
|
3139
3294
|
return `PLAN (${ran}/${Math.min(steps.length, 20)} steps ran):\n${transcript.join("\n")}\nTake scout_snapshot to see the resulting state.`;
|
|
3140
3295
|
}
|
|
3296
|
+
/** A plan's or a flow's target as a Playwright locator (every match; callers pick). */
|
|
3297
|
+
static locatorFor(page, target) {
|
|
3298
|
+
switch (target.by) {
|
|
3299
|
+
case "testid":
|
|
3300
|
+
return page.locator(`[data-testid=${JSON.stringify(target.value)}]`);
|
|
3301
|
+
case "text":
|
|
3302
|
+
return page.getByText(target.value, { exact: false });
|
|
3303
|
+
case "label":
|
|
3304
|
+
return page.getByLabel(target.value);
|
|
3305
|
+
case "role":
|
|
3306
|
+
return page.getByRole(target.role, target.name === undefined ? {} : { name: target.name });
|
|
3307
|
+
}
|
|
3308
|
+
}
|
|
3309
|
+
/**
|
|
3310
|
+
* Replay one saved flow for `scenescout check`. Acts and reports only: the
|
|
3311
|
+
* schema, the matching rules and what an outcome means live in flow.ts and
|
|
3312
|
+
* check.ts. It stops at the first step that breaks, or that the write policy
|
|
3313
|
+
* refuses, and hands back what the oracles caught on the way.
|
|
3314
|
+
*
|
|
3315
|
+
* Stricter than a plan on purpose: a click is never forced through
|
|
3316
|
+
* something covering its target (a user could not click it either), and a
|
|
3317
|
+
* step does not count as done until the page has settled.
|
|
3318
|
+
*/
|
|
3319
|
+
async replayFlow(steps, mode = this.mode === "observe" ? "observe" : "read-only") {
|
|
3320
|
+
const page = this.requirePage();
|
|
3321
|
+
// The write policy reads this.mode on every request, so the flow's rule holds for exactly as long as the flow runs.
|
|
3322
|
+
const crawlMode = this.mode;
|
|
3323
|
+
this.mode = mode;
|
|
3324
|
+
const context = this.context;
|
|
3325
|
+
const violations = [];
|
|
3326
|
+
const here = () => {
|
|
3327
|
+
try {
|
|
3328
|
+
const u = new URL(this.page?.url() ?? "");
|
|
3329
|
+
return `${u.pathname}${u.search}`;
|
|
3330
|
+
}
|
|
3331
|
+
catch {
|
|
3332
|
+
return "/";
|
|
3333
|
+
}
|
|
3334
|
+
};
|
|
3335
|
+
/** Responses since the most recent action step: what an expect-request looks through. */
|
|
3336
|
+
let seen = [];
|
|
3337
|
+
const onResponse = (res) => {
|
|
3338
|
+
seen.push({ method: res.request().method(), url: res.url(), status: res.status() });
|
|
3339
|
+
};
|
|
3340
|
+
context.on("response", onResponse);
|
|
3341
|
+
const appOrigin = new URL(this.baseUrl).origin;
|
|
3342
|
+
const refusedBackground = [];
|
|
3343
|
+
const websockets = new Set();
|
|
3344
|
+
const onSocket = (ws) => void websockets.add(ws.url().slice(0, 120));
|
|
3345
|
+
page.on("websocket", onSocket);
|
|
3346
|
+
/** Refused writes since the last call that a step is charged with; background ones (beacons, pings) are only listed (splitRefusals). */
|
|
3347
|
+
const ownRefusals = () => {
|
|
3348
|
+
const { charged, background } = splitRefusals(this.blockedRequests.splice(0), appOrigin);
|
|
3349
|
+
for (const sig of background)
|
|
3350
|
+
if (!refusedBackground.includes(sig) && refusedBackground.length < 20)
|
|
3351
|
+
refusedBackground.push(sig);
|
|
3352
|
+
return charged;
|
|
3353
|
+
};
|
|
3354
|
+
const done = (outcome) => ({ outcome, violations, refusedBackground, websockets: [...websockets] });
|
|
3355
|
+
// Nothing from the crawl before this flow is charged to it.
|
|
3356
|
+
this.oracles.drain(false);
|
|
3357
|
+
this.blockedRequests = [];
|
|
3358
|
+
const collect = (except) => {
|
|
3359
|
+
const path = here();
|
|
3360
|
+
for (const v of this.oracles.drain())
|
|
3361
|
+
if (v.detail !== except)
|
|
3362
|
+
violations.push({ path, violation: { kind: v.kind, severity: v.severity, detail: v.detail, url: v.url, ...(v.embed ? { embed: v.embed } : {}) } });
|
|
3363
|
+
};
|
|
3364
|
+
const poll = async (done) => {
|
|
3365
|
+
const until = Date.now() + FLOW_STEP_TIMEOUT_MS;
|
|
3366
|
+
for (;;) {
|
|
3367
|
+
if (done())
|
|
3368
|
+
return true;
|
|
3369
|
+
if (Date.now() >= until)
|
|
3370
|
+
return false;
|
|
3371
|
+
await (this.page ?? page).waitForTimeout(100).catch(() => { });
|
|
3372
|
+
}
|
|
3373
|
+
};
|
|
3374
|
+
const firstLine = (err) => (err instanceof Error ? err.message : String(err))
|
|
3375
|
+
.split("\n")[0]
|
|
3376
|
+
.replace(/^[a-z]+\.[a-zA-Z]+: /, "")
|
|
3377
|
+
.trim();
|
|
3378
|
+
try {
|
|
3379
|
+
for (const [i, step] of steps.entries()) {
|
|
3380
|
+
const n = i + 1;
|
|
3381
|
+
const did = describeStep(step);
|
|
3382
|
+
if (isAction(step))
|
|
3383
|
+
seen = [];
|
|
3384
|
+
let failure = null;
|
|
3385
|
+
let refusal = null;
|
|
3386
|
+
this.actionStartedAt = Date.now();
|
|
3387
|
+
try {
|
|
3388
|
+
const current = this.requirePage();
|
|
3389
|
+
if (step.action === "navigate") {
|
|
3390
|
+
const url = `${this.baseUrl}${step.target}`;
|
|
3391
|
+
const resp = await current.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 });
|
|
3392
|
+
await this.settle();
|
|
3393
|
+
const status = resp?.status() ?? null;
|
|
3394
|
+
if (status !== null && status >= 400) {
|
|
3395
|
+
failure = `the page answered HTTP ${status}`;
|
|
3396
|
+
// The page's own status is this step's failure; the oracle's record of the same response is not a second issue.
|
|
3397
|
+
// The oracle stores its details redacted (redactViolation), so the comparison is redacted too.
|
|
3398
|
+
collect(redactSecrets(httpErrorDetail("GET", current.url().replace(/#.*$/, ""), status)));
|
|
3399
|
+
}
|
|
3400
|
+
}
|
|
3401
|
+
else if (step.action === "press") {
|
|
3402
|
+
refusal = await this.vetFocusedActivation(step.value);
|
|
3403
|
+
if (!refusal) {
|
|
3404
|
+
await current.keyboard.press(step.value);
|
|
3405
|
+
await this.settle();
|
|
3406
|
+
}
|
|
3407
|
+
}
|
|
3408
|
+
else if (step.action === "click" || step.action === "type" || step.action === "select") {
|
|
3409
|
+
const loc = BrowserEngine.locatorFor(current, parseTarget(step.target)).first();
|
|
3410
|
+
const found = await loc
|
|
3411
|
+
.waitFor({ state: "visible", timeout: FLOW_STEP_TIMEOUT_MS })
|
|
3412
|
+
.then(() => true)
|
|
3413
|
+
.catch(() => false);
|
|
3414
|
+
if (!found) {
|
|
3415
|
+
failure = `nothing visible matches ${step.target} within ${FLOW_STEP_TIMEOUT_MS / 1000}s`;
|
|
3416
|
+
}
|
|
3417
|
+
else {
|
|
3418
|
+
const label = ((await loc.getAttribute("aria-label").catch(() => null)) ??
|
|
3419
|
+
(await loc.textContent({ timeout: 1000 }).catch(() => null)) ??
|
|
3420
|
+
"").trim();
|
|
3421
|
+
if (this.readOnly && step.action !== "type" && isDestructive(label, step.action === "select" ? step.value : undefined)) {
|
|
3422
|
+
refusal = destructiveRefusal(label || step.target, this.mode);
|
|
3423
|
+
}
|
|
3424
|
+
else if (step.action === "click") {
|
|
3425
|
+
await loc.click({ timeout: FLOW_STEP_TIMEOUT_MS });
|
|
3426
|
+
}
|
|
3427
|
+
else if (step.action === "select") {
|
|
3428
|
+
await loc.selectOption(step.value, { timeout: FLOW_STEP_TIMEOUT_MS });
|
|
3429
|
+
}
|
|
3430
|
+
else {
|
|
3431
|
+
await this.noteProbe(step.value, step.target);
|
|
3432
|
+
await this.fillOrAppend(loc, step.value, step.replace ?? false);
|
|
3433
|
+
if (step.pressEnter) {
|
|
3434
|
+
const submit = loc.locator("xpath=ancestor::form[1]").locator('[type="submit"], button:not([type="button"]):not([type="reset"])').first();
|
|
3435
|
+
const submitLabel = ((await submit.textContent({ timeout: 1000 }).catch(() => "")) ?? "").trim();
|
|
3436
|
+
if (this.readOnly && isDestructive(submitLabel))
|
|
3437
|
+
refusal = destructiveRefusal(submitLabel, this.mode);
|
|
3438
|
+
else
|
|
3439
|
+
await loc.press("Enter", { timeout: FLOW_STEP_TIMEOUT_MS });
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
if (!refusal)
|
|
3443
|
+
await this.settle();
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
else if (step.action === "expect-text") {
|
|
3447
|
+
const visible = current.getByText(step.text, { exact: false }).filter({ visible: true }).first();
|
|
3448
|
+
const ok = await visible
|
|
3449
|
+
.waitFor({ state: "visible", timeout: FLOW_STEP_TIMEOUT_MS })
|
|
3450
|
+
.then(() => true)
|
|
3451
|
+
.catch(() => false);
|
|
3452
|
+
if (!ok)
|
|
3453
|
+
failure = `no visible text ${JSON.stringify(step.text)} within ${FLOW_STEP_TIMEOUT_MS / 1000}s`;
|
|
3454
|
+
}
|
|
3455
|
+
else if (step.action === "expect-url") {
|
|
3456
|
+
const ok = await poll(() => urlMatches(step.pattern, this.page?.url() ?? ""));
|
|
3457
|
+
if (!ok)
|
|
3458
|
+
failure = `the page is at ${here()}, which does not match /${step.pattern}/`;
|
|
3459
|
+
}
|
|
3460
|
+
else {
|
|
3461
|
+
let last = { ok: false, reason: "" };
|
|
3462
|
+
await poll(() => (last = matchRequest(step, seen)).ok);
|
|
3463
|
+
if (!last.ok)
|
|
3464
|
+
failure = last.reason;
|
|
3465
|
+
}
|
|
3466
|
+
}
|
|
3467
|
+
catch (err) {
|
|
3468
|
+
failure = firstLine(err);
|
|
3469
|
+
}
|
|
3470
|
+
await this.scanForInjections().catch(() => { });
|
|
3471
|
+
await this.scanForContradictions().catch(() => { });
|
|
3472
|
+
// What the write policy refused of the app's own requests while this step ran is the step's, whatever caused it.
|
|
3473
|
+
const blocked = ownRefusals();
|
|
3474
|
+
if (!refusal && blocked.length > 0)
|
|
3475
|
+
refusal = `the ${this.mode} write policy refused ${blocked.join(", ")}`;
|
|
3476
|
+
collect();
|
|
3477
|
+
if (refusal)
|
|
3478
|
+
return done({ status: "refused", step: n, did, reason: refusal.split("\n")[0], path: here() });
|
|
3479
|
+
if (failure)
|
|
3480
|
+
return done({ status: "failed", step: n, did, reason: failure, path: here() });
|
|
3481
|
+
}
|
|
3482
|
+
// A write the last step set off can land after its checks ran (a debounced save): wait a moment, settle, look
|
|
3483
|
+
// again; then leave the page while the flow's rule still holds, so nothing it sends on the way out goes under
|
|
3484
|
+
// the crawl's rule, and look once more.
|
|
3485
|
+
await (this.page ?? page).waitForTimeout(FLOW_AFTER_LAST_STEP_MS).catch(() => { });
|
|
3486
|
+
await this.settle().catch(() => { });
|
|
3487
|
+
const late = ownRefusals();
|
|
3488
|
+
collect();
|
|
3489
|
+
await (this.page ?? page).goto("about:blank").catch(() => { });
|
|
3490
|
+
await this.settle().catch(() => { });
|
|
3491
|
+
late.push(...ownRefusals());
|
|
3492
|
+
const last = steps[steps.length - 1];
|
|
3493
|
+
if (late.length > 0 && last) {
|
|
3494
|
+
return done({
|
|
3495
|
+
status: "refused",
|
|
3496
|
+
step: steps.length,
|
|
3497
|
+
did: describeStep(last),
|
|
3498
|
+
reason: `after the last step, the ${this.mode} write policy refused ${late.join(", ")}`,
|
|
3499
|
+
path: here(),
|
|
3500
|
+
});
|
|
3501
|
+
}
|
|
3502
|
+
return done({ status: "passed" });
|
|
3503
|
+
}
|
|
3504
|
+
finally {
|
|
3505
|
+
// Leave the flow's page before the crawl's rule comes back, so nothing it still sends goes out under a looser one.
|
|
3506
|
+
await (this.page ?? page).goto("about:blank").catch(() => { });
|
|
3507
|
+
this.blockedRequests = [];
|
|
3508
|
+
this.oracles.drain(false);
|
|
3509
|
+
this.mode = crawlMode;
|
|
3510
|
+
page.off("websocket", onSocket);
|
|
3511
|
+
context.off("response", onResponse);
|
|
3512
|
+
this.refs.clear();
|
|
3513
|
+
this.lastSnap = null;
|
|
3514
|
+
this.snapshotUrl = "";
|
|
3515
|
+
}
|
|
3516
|
+
}
|
|
3141
3517
|
/** Computed-style design audit of the current page — visual judgment material without pixels. */
|
|
3142
3518
|
async designAudit() {
|
|
3143
3519
|
const { url, report } = await this.auditPage();
|
|
@@ -3381,6 +3757,23 @@ export class BrowserEngine {
|
|
|
3381
3757
|
}
|
|
3382
3758
|
}
|
|
3383
3759
|
}
|
|
3760
|
+
/**
|
|
3761
|
+
* Leave a page for about:blank. A write a page sends as it is closed (a
|
|
3762
|
+
* sendBeacon or keepalive fetch on pagehide) is not routed in Firefox or
|
|
3763
|
+
* WebKit, so it reached the server in every mode; one it sends as it
|
|
3764
|
+
* navigates is routed and judged like any other. (Chromium routes neither;
|
|
3765
|
+
* its browser-level interception catches both.)
|
|
3766
|
+
*/
|
|
3767
|
+
static async leave(page) {
|
|
3768
|
+
if (page.isClosed() || page.url() === "about:blank")
|
|
3769
|
+
return;
|
|
3770
|
+
await page.goto("about:blank", { timeout: 3000 }).catch(() => { });
|
|
3771
|
+
}
|
|
3772
|
+
/** Close a page the engine will not drive, after leaving it (`leave`), so what it sends on its way out meets the policy. */
|
|
3773
|
+
static async leaveAndClose(page) {
|
|
3774
|
+
await BrowserEngine.leave(page);
|
|
3775
|
+
await page.close().catch(() => { });
|
|
3776
|
+
}
|
|
3384
3777
|
async close() {
|
|
3385
3778
|
// Marks the end of the time this session held a browser, so the pace
|
|
3386
3779
|
// section can say how long it was held with nothing happening. Only when a
|
|
@@ -3388,6 +3781,10 @@ export class BrowserEngine {
|
|
|
3388
3781
|
// is not an event.
|
|
3389
3782
|
if (this.page)
|
|
3390
3783
|
this.logAction({ action: "close", url: this.page.isClosed() ? "" : this.page.url() });
|
|
3784
|
+
// Every page is left before it is closed, while the write policy still holds, so what a page sends on its way out
|
|
3785
|
+
// is judged and its refusal logged before the flush below.
|
|
3786
|
+
const pages = this.context?.pages() ?? [];
|
|
3787
|
+
await BrowserEngine.settleWithin(Promise.allSettled(pages.map((p) => BrowserEngine.leave(p))), 5000);
|
|
3391
3788
|
// Pending debounced coverage writes must land before the process can exit.
|
|
3392
3789
|
try {
|
|
3393
3790
|
this.memory?.flush();
|
|
@@ -3403,13 +3800,16 @@ export class BrowserEngine {
|
|
|
3403
3800
|
// If teardown overruns the cap, the leftover process is reaped by the
|
|
3404
3801
|
// orphan cleaner on the next attach (or server start).
|
|
3405
3802
|
await BrowserEngine.settleWithin((async () => {
|
|
3406
|
-
|
|
3803
|
+
for (const p of this.context?.pages() ?? [])
|
|
3804
|
+
await p.close().catch(() => { });
|
|
3407
3805
|
await this.context?.close().catch(() => { });
|
|
3408
3806
|
await this.browser?.close().catch(() => { });
|
|
3409
3807
|
})(), 8000);
|
|
3410
3808
|
this.page = null;
|
|
3411
3809
|
this.context = null;
|
|
3412
3810
|
this.browser = null;
|
|
3811
|
+
this.routedWrites.clear();
|
|
3812
|
+
this.unseenRefusals.clear();
|
|
3413
3813
|
this.refs.clear();
|
|
3414
3814
|
this.snapshotUrl = "";
|
|
3415
3815
|
this.currentFingerprint = "";
|