scenescout 3.12.0 → 3.13.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.
@@ -22,7 +22,7 @@ import { explainLaunchFailure, isMissingBrowser } from "./launch.js";
22
22
  import { ACTION_TIMEOUT_MS, performScroll, probeFocusIndicators, probeOverlays, scrollContainer } from "./probes.js";
23
23
  import { BROWSER_MARKER, reapOrphanBrowsers } from "./reaper.js";
24
24
  import { planUploadOptions, resolveDiskUpload } from "./uploads.js";
25
- import { answersWithRefusal, destructiveRefusal, isDestructive, isDestructiveWire, allowsWrite, policyRefusal, foreignFrameOrigin, foreignWrite, withForeignFrameSandbox, offAppPageWrite, embedOfRequest, hostileForEmbed, trustedEmbedOrigins, MAX_TRUSTED_EMBEDS, trustsForeignWrite, embedProbeRefusal, EmbedMoveTracker, sandboxedRedirectPage, allowsForeignWriteOnSignIn, isAuthExempt, } from "./policy.js";
25
+ import { answersWithRefusal, destructiveRefusal, isDestructive, isDestructiveWire, allowsWrite, policyRefusal, foreignFrameOrigin, foreignWrite, withForeignFrameSandbox, offAppPageWrite, embedOfRequest, hostileForEmbed, trustedEmbedOrigins, MAX_TRUSTED_EMBEDS, trustsForeignWrite, embedProbeRefusal, EmbedMoveTracker, sandboxedRedirectPage, allowsForeignWriteOnSignIn, isAuthExempt, WriteRule, } from "./policy.js";
26
26
  import { scanProject } from "../scan.js";
27
27
  import { analyzeDesign, DESIGN_COLLECT_SCRIPT } from "./design.js";
28
28
  import { acceptMatches, generatedUpload } from "./fixtures.js";
@@ -201,7 +201,13 @@ export class BrowserEngine {
201
201
  /** Set when the project's real path could not be resolved — named in fence refusals, which it may then cause. */
202
202
  projectDirNote = "";
203
203
  memory = null;
204
- mode = "read-only";
204
+ currentMode = "read-only";
205
+ /** The rule the write policy judges by: `mode`, and for a moment after a flow hands back, the flow's stricter one. */
206
+ writeRule = new WriteRule("read-only");
207
+ /** The session's write mode. Read-only from outside: it changes only through `setMode`, which keeps `writeRule` in step. */
208
+ get mode() {
209
+ return this.currentMode;
210
+ }
205
211
  /** Origins named as trusted embeds (policy.ts trustsEmbedWrite decides when that counts). */
206
212
  trustedEmbeds = new Set();
207
213
  trustNotice = "";
@@ -665,7 +671,8 @@ export class BrowserEngine {
665
671
  if (opts.storageStatePath && !fs.existsSync(opts.storageStatePath)) {
666
672
  throw new Error(`storageStatePath does not exist: ${opts.storageStatePath}`);
667
673
  }
668
- this.mode = opts.mode ?? "read-only";
674
+ this.currentMode = opts.mode ?? "read-only";
675
+ this.writeRule = new WriteRule(this.currentMode);
669
676
  const trust = trustedEmbedOrigins(opts.trustedEmbeds);
670
677
  this.trustedEmbeds = new Set(trust.origins);
671
678
  this.trustNotice =
@@ -815,16 +822,17 @@ export class BrowserEngine {
815
822
  // that was never submitted reading as tested, in read-only mode where by
816
823
  // definition nothing is.
817
824
  // Same test the route handler uses: in observe only an exempt auth request goes out.
818
- if (this.mode === "observe" && !isAuthExempt(this.mode, method, pathnameOf(req.url()), isDestructiveWire(pathnameOf(req.url()), req.postData())))
825
+ const rule = this.writeRule.at();
826
+ if (rule === "observe" && !isAuthExempt(rule, method, pathnameOf(req.url()), isDestructiveWire(pathnameOf(req.url()), req.postData())))
819
827
  return;
820
- if (this.readOnly && isDestructiveWire(pathnameOf(req.url()), req.postData()))
828
+ if ((rule === "read-only" || rule === "observe") && isDestructiveWire(pathnameOf(req.url()), req.postData()))
821
829
  return;
822
830
  // A foreign frame's write is refused in every mode the route handler runs in.
823
- if (this.mode !== "destructive" && this.foreignWriteOf(req))
831
+ if (rule !== "destructive" && this.foreignWriteOf(req))
824
832
  return;
825
- if (this.mode !== "destructive" &&
833
+ if (rule !== "destructive" &&
826
834
  offAppPageWrite(this.baseUrl, this.page?.url(), req.url(), this.embedMoves.movedTo) &&
827
- !isAuthExempt(this.mode, method, pathnameOf(req.url()), isDestructiveWire(pathnameOf(req.url()), req.postData())))
835
+ !isAuthExempt(rule, method, pathnameOf(req.url()), isDestructiveWire(pathnameOf(req.url()), req.postData())))
828
836
  return;
829
837
  const pageUrl = this.page?.url();
830
838
  if (pageUrl && this.memory) {
@@ -841,6 +849,8 @@ export class BrowserEngine {
841
849
  await this.context.route("**/*", async (route) => {
842
850
  const req = route.request();
843
851
  const method = req.method();
852
+ // Read once, as the request arrives: the rule the page sent it under (policy.ts WriteRule), not whatever holds after an await below.
853
+ const rule = this.writeRule.at();
844
854
  // A redirect is followed by the browser without asking, so its target
845
855
  // would load unsandboxed: a frame's redirect is answered with a
846
856
  // sandboxed page that navigates there itself, and the next hop comes
@@ -932,7 +942,7 @@ export class BrowserEngine {
932
942
  // of one actually runs; a navigation is dropped (policy.ts says why).
933
943
  // Caught: a request the page has already cancelled rejects these, and an unhandled rejection ends the process.
934
944
  if (answered)
935
- return route.fulfill(policyRefusal(this.mode, method, pathname, req.headers()["origin"], why)).catch(() => { });
945
+ return route.fulfill(policyRefusal(rule, method, pathname, req.headers()["origin"], why)).catch(() => { });
936
946
  return route.abort("blockedbyclient").catch(() => { });
937
947
  };
938
948
  // An embedded widget from another site writes to that site, not to the
@@ -944,12 +954,12 @@ export class BrowserEngine {
944
954
  const destructiveWire = isDestructiveWire(pathname, req.postData());
945
955
  // An embed moved the session's page off the app: its writes out are not the app's, a sign-in excepted.
946
956
  const offApp = offAppPageWrite(this.baseUrl, this.page?.url(), url, this.embedMoves.movedTo);
947
- if (offApp && !isAuthExempt(this.mode, method, pathname, destructiveWire))
957
+ if (offApp && !isAuthExempt(rule, method, pathname, destructiveWire))
948
958
  return refuse(`sent from a page of ${offApp}, outside the app`);
949
959
  // Auth/session flows must work in every mode — but never a destructive
950
960
  // one, and in observe only the requests a login itself needs.
951
- if (isAuthExempt(this.mode, method, pathname, destructiveWire)) {
952
- this.routedWrites.note(this.mode, method, url, bodyDigest(req.postDataBuffer()));
961
+ if (isAuthExempt(rule, method, pathname, destructiveWire)) {
962
+ this.routedWrites.note(rule, method, url, bodyDigest(req.postDataBuffer()));
953
963
  return route.continue();
954
964
  }
955
965
  let owned = this.isOwnedResource(pathname);
@@ -962,13 +972,13 @@ export class BrowserEngine {
962
972
  // guaranteed visible — give it a bounded moment to land before the
963
973
  // mutation is judged not-owned, otherwise the session's own,
964
974
  // just-created resource gets wrongly blocked by a timing accident.
965
- if (!owned && this.mode === "safe-write" && method !== "POST" && this.pendingCreations.size > 0) {
975
+ if (!owned && rule === "safe-write" && method !== "POST" && this.pendingCreations.size > 0) {
966
976
  await BrowserEngine.settleWithin(Promise.allSettled([...this.pendingCreations]), 1500);
967
977
  owned = this.isOwnedResource(pathname);
968
978
  }
969
979
  // POST: creation/RPC passes unless it smells destructive and isn't ours.
970
980
  // PUT/PATCH/DELETE: only in safe-write, only on our own resources.
971
- const allow = allowsWrite(this.mode, method, destructiveWire, owned);
981
+ const allow = allowsWrite(rule, method, destructiveWire, owned);
972
982
  if (allow) {
973
983
  // Ownership tracking (safe-write): register the creation-tracking
974
984
  // task BEFORE the POST goes out. Registering from a context
@@ -977,7 +987,7 @@ export class BrowserEngine {
977
987
  // chained directly off the POST's json() could be judged before
978
988
  // the listener ever ran. Here the registration is synchronous with
979
989
  // request dispatch, which closes that window completely.
980
- if (this.mode === "safe-write" && method === "POST" && !BENIGN_MUTATION_RE.test(url)) {
990
+ if (rule === "safe-write" && method === "POST" && !BENIGN_MUTATION_RE.test(url)) {
981
991
  const task = req
982
992
  .response()
983
993
  .then((res) => (res && res.ok() ? this.recordCreation(res) : undefined))
@@ -985,7 +995,7 @@ export class BrowserEngine {
985
995
  .finally(() => this.pendingCreations.delete(task));
986
996
  this.pendingCreations.add(task);
987
997
  }
988
- this.routedWrites.note(this.mode, method, url, bodyDigest(req.postDataBuffer()));
998
+ this.routedWrites.note(rule, method, url, bodyDigest(req.postDataBuffer()));
989
999
  return route.continue();
990
1000
  }
991
1001
  return refuse();
@@ -1806,7 +1816,7 @@ export class BrowserEngine {
1806
1816
  }
1807
1817
  if (frame === frame.page().mainFrame())
1808
1818
  return null;
1809
- if (allowsForeignWriteOnSignIn(this.mode, this.page?.url() ?? "", this.baseUrl))
1819
+ if (allowsForeignWriteOnSignIn(this.writeRule.at(), this.page?.url() ?? "", this.baseUrl))
1810
1820
  return null;
1811
1821
  let url;
1812
1822
  try {
@@ -1839,10 +1849,10 @@ export class BrowserEngine {
1839
1849
  const source = requestSource(req);
1840
1850
  const originHeader = req.headers()["origin"];
1841
1851
  const foreign = foreignWrite(this.baseUrl, { url: req.url(), originHeader, unadoptedPageUrl, pageHasForeignFrame, ...source });
1842
- if (foreign && allowsForeignWriteOnSignIn(this.mode, this.page?.url() ?? "", this.baseUrl))
1852
+ if (foreign && allowsForeignWriteOnSignIn(this.writeRule.at(), this.page?.url() ?? "", this.baseUrl))
1843
1853
  return null;
1844
1854
  // Frames of origins the user named as trusted, in safe-write, every one involved: the ordinary rules.
1845
- if (foreign && trustsForeignWrite(this.mode, this.trustedEmbeds, this.baseUrl, { frameChain: source.frameChain, originHeader, unadoptedPageUrl }))
1855
+ if (foreign && trustsForeignWrite(this.writeRule.at(), this.trustedEmbeds, this.baseUrl, { frameChain: source.frameChain, originHeader, unadoptedPageUrl }))
1846
1856
  return null;
1847
1857
  return foreign;
1848
1858
  }
@@ -1870,11 +1880,13 @@ export class BrowserEngine {
1870
1880
  });
1871
1881
  };
1872
1882
  try {
1883
+ // A request paused here was sent a moment ago, possibly by a page a flow has just left: WriteRule still holds the flow's rule.
1884
+ const rule = this.writeRule.at();
1873
1885
  // 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")
1886
+ if (isReadMethod(method) || rule === "destructive")
1875
1887
  return await answer(true);
1876
1888
  const bytes = pausedRequestBytes(event.request);
1877
- if (this.routedWrites.claim(this.mode, method, url, bodyDigest(bytes)))
1889
+ if (this.routedWrites.claim(rule, method, url, bodyDigest(bytes)))
1878
1890
  return await answer(true);
1879
1891
  let verdict;
1880
1892
  let pathname = url;
@@ -1882,7 +1894,7 @@ export class BrowserEngine {
1882
1894
  pathname = pathnameOf(url);
1883
1895
  const { foreign, offApp } = unseenWriteSource({
1884
1896
  appUrl: this.baseUrl,
1885
- mode: this.mode,
1897
+ mode: rule,
1886
1898
  url,
1887
1899
  originHeader: headerOf(headers, "origin"),
1888
1900
  referer: headerOf(headers, "referer"),
@@ -1891,7 +1903,7 @@ export class BrowserEngine {
1891
1903
  movedByEmbed: this.embedMoves.movedTo,
1892
1904
  });
1893
1905
  verdict = judgeUnseenWrite({
1894
- mode: this.mode,
1906
+ mode: rule,
1895
1907
  method,
1896
1908
  pathname,
1897
1909
  destructiveWire: isDestructiveWire(pathname, bytes?.toString("utf8")),
@@ -1976,7 +1988,9 @@ export class BrowserEngine {
1976
1988
  const escaped = reasons.delete(ESCAPE_REFUSAL);
1977
1989
  const foreign = [...reasons];
1978
1990
  this.blockedRequests = [];
1979
- return (`\n🛡 WRITE-POLICY blocked (${this.mode}): ${list}${extra}. ` +
1991
+ // The rule that judged them, which just after a flow hands back is still the flow's (WriteRule).
1992
+ const rule = this.writeRule.at();
1993
+ return (`\n🛡 WRITE-POLICY blocked (${rule}): ${list}${extra}. ` +
1980
1994
  `This is the tester's safety policy, NOT an app bug — do not file a finding for the resulting error UI. ` +
1981
1995
  (foreign.length > 0
1982
1996
  ? `Refused because it was ${foreign.join("; ")}: it would reach a site embedded in the page rather than the app, which no mode but destructive allows. `
@@ -1987,9 +2001,9 @@ export class BrowserEngine {
1987
2001
  (answered
1988
2002
  ? `The page's own requests were answered with a 403 in the server's place, so the page's handling of a refusal is real: an error message is correct, and a success message is a false_success violation. `
1989
2003
  : "") +
1990
- (this.mode === "observe"
2004
+ (rule === "observe"
1991
2005
  ? `observe mode blocks every request that is not a GET, so no form submission reaches the server. Re-attach with mode="read-only" ONLY if the user confirms that ordinary form submissions are acceptable on this target.`
1992
- : this.mode === "read-only"
2006
+ : rule === "read-only"
1993
2007
  ? `Re-attach with mode="safe-write" to test create/edit flows, or "destructive" (user-approved disposable env only).`
1994
2008
  : `In safe-write, updates/deletes are only allowed on resources this session created (${this.createdResources.length} so far).`));
1995
2009
  }
@@ -3318,9 +3332,9 @@ export class BrowserEngine {
3318
3332
  */
3319
3333
  async replayFlow(steps, mode = this.mode === "observe" ? "observe" : "read-only") {
3320
3334
  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.
3335
+ // The write policy judges by writeRule, so the flow's rule holds from here until the flow has handed back (finally).
3322
3336
  const crawlMode = this.mode;
3323
- this.mode = mode;
3337
+ this.setMode(mode);
3324
3338
  const context = this.context;
3325
3339
  const violations = [];
3326
3340
  const here = () => {
@@ -3472,7 +3486,7 @@ export class BrowserEngine {
3472
3486
  // What the write policy refused of the app's own requests while this step ran is the step's, whatever caused it.
3473
3487
  const blocked = ownRefusals();
3474
3488
  if (!refusal && blocked.length > 0)
3475
- refusal = `the ${this.mode} write policy refused ${blocked.join(", ")}`;
3489
+ refusal = `the ${this.writeRule.at()} write policy refused ${blocked.join(", ")}`;
3476
3490
  collect();
3477
3491
  if (refusal)
3478
3492
  return done({ status: "refused", step: n, did, reason: refusal.split("\n")[0], path: here() });
@@ -3495,18 +3509,24 @@ export class BrowserEngine {
3495
3509
  status: "refused",
3496
3510
  step: steps.length,
3497
3511
  did: describeStep(last),
3498
- reason: `after the last step, the ${this.mode} write policy refused ${late.join(", ")}`,
3512
+ reason: `after the last step, the ${this.writeRule.at()} write policy refused ${late.join(", ")}`,
3499
3513
  path: here(),
3500
3514
  });
3501
3515
  }
3502
3516
  return done({ status: "passed" });
3503
3517
  }
3504
3518
  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(() => { });
3519
+ // The hand-back, in order. Every page in the context is left while the flow's rule holds, the one a step opened
3520
+ // a tab from included (a popup adopted as the session's page leaves it running), so nothing a page the flow used
3521
+ // does runs on under the crawl's rule. Only then is the crawl's rule restored, and WriteRule keeps the flow's for
3522
+ // a moment longer, for a write a page sent as it was left that the engine hears of late.
3523
+ await BrowserEngine.settleWithin(Promise.allSettled(context.pages().map((p) => BrowserEngine.leave(p))), 5000);
3507
3524
  this.blockedRequests = [];
3508
3525
  this.oracles.drain(false);
3509
- this.mode = crawlMode;
3526
+ this.setMode(crawlMode);
3527
+ // Pages the session no longer drives are closed only now, at about:blank and under the held rule: in Firefox a
3528
+ // page closed straight after it is left sent its leaving beacon unjudged about one time in forty.
3529
+ await BrowserEngine.settleWithin(Promise.allSettled(context.pages().map((p) => (p === this.page ? undefined : p.close().catch(() => { })))), 5000);
3510
3530
  page.off("websocket", onSocket);
3511
3531
  context.off("response", onResponse);
3512
3532
  this.refs.clear();
@@ -3514,6 +3534,11 @@ export class BrowserEngine {
3514
3534
  this.snapshotUrl = "";
3515
3535
  }
3516
3536
  }
3537
+ /** Change the write mode; a looser one takes over the judging only after WriteRule's hold (policy.ts). */
3538
+ setMode(mode) {
3539
+ this.currentMode = mode;
3540
+ this.writeRule.set(mode);
3541
+ }
3517
3542
  /** Computed-style design audit of the current page — visual judgment material without pixels. */
3518
3543
  async designAudit() {
3519
3544
  const { url, report } = await this.auditPage();
@@ -3769,7 +3794,11 @@ export class BrowserEngine {
3769
3794
  return;
3770
3795
  await page.goto("about:blank", { timeout: 3000 }).catch(() => { });
3771
3796
  }
3772
- /** Close a page the engine will not drive, after leaving it (`leave`), so what it sends on its way out meets the policy. */
3797
+ /**
3798
+ * Close a page the engine will not drive, after leaving it (`leave`), so what it sends on its way out meets the policy.
3799
+ * Known limit: in Firefox, a close straight after the leave has let the page's leaving beacon out unjudged (seen 2 times
3800
+ * in 80 under load). replayFlow's hand-back closes pages only once every page is at about:blank; this and `close` do not yet.
3801
+ */
3773
3802
  static async leaveAndClose(page) {
3774
3803
  await BrowserEngine.leave(page);
3775
3804
  await page.close().catch(() => { });
@@ -630,8 +630,10 @@ function cell(text) {
630
630
  return text
631
631
  .replace(/\\/g, "\\\\")
632
632
  .replace(/\|/g, "\\|")
633
- .replace(/\s*\n\s*/g, " ");
633
+ .replace(/\s*[\r\n]\s*/g, " ");
634
634
  }
635
+ /** The same escaping, for other markdown tables: the CI run's summary. */
636
+ export const markdownCell = cell;
635
637
  /** A code span that the text cannot close: its fence is one backtick longer than any run of backticks inside it. */
636
638
  function code(text) {
637
639
  const longest = Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length));