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.
@@ -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.refusedByPolicy.has(req),
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.refusedByPolicy.has(req));
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 newPage.close().catch(() => { });
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.refusedByPolicy.has(entry.req))
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
- this.loadFailedRoutes.add(normalizePath(url));
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
- const { elements, forms } = await this.collect();
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
- memory.visitState(fp, finalUrl, route, elements.map((el) => el.key));
2801
- for (const f of forms)
2802
- memory.recordForm(fp, f.key, f.guarded);
2803
- memory.recordRoleAccess(this.role, route, "reached");
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
- if (target.startsWith("testid="))
2893
- return page.locator(`[data-testid=${JSON.stringify(target.slice(7))}]`).first();
2894
- if (target.startsWith("text="))
2895
- return page.getByText(target.slice(5), { exact: false }).first();
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
- await this.page?.close().catch(() => { });
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 = "";