scenescout 3.11.1 → 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.
@@ -31,6 +31,9 @@
31
31
  import { createHash } from "node:crypto";
32
32
  import { z } from "zod";
33
33
  import { BUCKET_EDGES, bucketLabel, bucketOf } from "./calibration.js";
34
+ import { laneRoutePaths, stripRouteQuery } from "./fingerprint.js";
35
+ import { isWorthALook } from "./memory.js";
36
+ export { laneRoutePaths };
34
37
  export const LEVELS = ["minimal", "medium", "extensive"];
35
38
  const SEVERITIES = ["high", "medium", "low"];
36
39
  const Pattern = z.string().refine((p) => {
@@ -42,10 +45,24 @@ const Pattern = z.string().refine((p) => {
42
45
  return false;
43
46
  }
44
47
  }, { message: "is not a valid regular expression" });
48
+ /**
49
+ * Other pages the same defect shows on, beyond `route`. A lane that owns any
50
+ * of them owns the defect, so its "not a defect" is a verdict, not a remark
51
+ * about another lane's page.
52
+ */
53
+ const AlsoOn = z.array(z.string().startsWith("/")).min(1).optional();
54
+ /**
55
+ * Every lane can reach the defect: it is in chrome every page carries (a
56
+ * shared nav, header or footer), or in an endpoint any lane can call. No lane
57
+ * can call it another lane's, so a verdict on it is always scored.
58
+ */
59
+ const EveryPage = z.boolean().optional();
45
60
  const Entry = z
46
61
  .object({
47
62
  id: z.string().min(1),
48
63
  route: z.string().startsWith("/"),
64
+ alsoOn: AlsoOn,
65
+ everyPage: EveryPage,
49
66
  title: z.string().min(1),
50
67
  /** The category a finding for it should carry. Reported, not enforced: two categories can both be defensible. */
51
68
  category: z.string().min(1),
@@ -83,6 +100,8 @@ const Contextual = z
83
100
  .object({
84
101
  id: z.string().min(1),
85
102
  route: z.string().startsWith("/"),
103
+ alsoOn: AlsoOn,
104
+ everyPage: EveryPage,
86
105
  title: z.string().min(1),
87
106
  category: z.string().min(1),
88
107
  /** The convention that decides it: where it would be a defect, and why the run cannot tell whether it holds here. */
@@ -130,6 +149,14 @@ export function parseKey(raw) {
130
149
  const dup = ids.find((id, i) => ids.indexOf(id) !== i);
131
150
  if (dup)
132
151
  throw new Error(`The answer key uses the id ${JSON.stringify(dup)} twice.`);
152
+ for (const e of [...parsed.data.defects, ...parsed.data.alsoReal, ...parsed.data.contextual]) {
153
+ if (e.alsoOn && e.everyPage)
154
+ throw new Error(`The entry ${JSON.stringify(e.id)} has both alsoOn and everyPage; an entry on every page names no other pages.`);
155
+ const pages = [e.route, ...(e.alsoOn ?? [])];
156
+ const dup = pages.find((p, i) => pages.indexOf(p) !== i);
157
+ if (dup)
158
+ throw new Error(`The entry ${JSON.stringify(e.id)} names the page ${JSON.stringify(dup)} twice in route and alsoOn.`);
159
+ }
133
160
  const real = new Set([...parsed.data.defects, ...parsed.data.alsoReal].map((e) => e.id));
134
161
  for (const nd of parsed.data.nonDefects) {
135
162
  const unknown = nd.overrides.find((id) => !real.has(id));
@@ -241,14 +268,14 @@ export function lintKey(key) {
241
268
  *
242
269
  * "defect" on a planted or also-real defect is right, and on a known
243
270
  * non-defect is wrong. "not_a_defect" is the reverse. An "unsure" verdict, a
244
- * dismissal as another lane's, a decision the key does not name, one it
245
- * names ambiguously, and one about a contextual entry are not scored — the same rule the in-product calibration
271
+ * "worth_a_look", a dismissal as another lane's, a decision the key does not
272
+ * name, one it names ambiguously, and one about a contextual entry are not scored — the same rule the in-product calibration
246
273
  * follows, for the same reason.
247
274
  */
248
- export function judgeDecision(d, key) {
249
- if (d.verdict === "unsure")
275
+ export function judgeDecision(d, key, laneRoutes) {
276
+ if (d.verdict === "unsure" || d.verdict === "worth_a_look")
250
277
  return null;
251
- if (isScopeDismissal(d))
278
+ if (isOwnershipRemark(d, key, laneRoutes))
252
279
  return null;
253
280
  const m = classify(decisionText(d), key);
254
281
  if (!m || m.kind === "ambiguous" || m.kind === "contextual")
@@ -258,13 +285,79 @@ export function judgeDecision(d, key) {
258
285
  }
259
286
  /** How a lane says "not mine": the wording lanes actually used, none of it about a particular app. */
260
287
  const SCOPE_DISMISSAL_RE = /out.of.lane.scope|out-of-scope|out of (my|this) (lane|scope)|belongs?.to.{0,20}\b(lane|route|page)\b|belongs-to-|(handled|owned) by (the |another )?[\w-]* ?lane|lane owns it|not (in |on |from |part of )?(my|this) (lane|assigned routes|routes?|pages?)\b|outside (my|this) (lane|routes?|pages?)|not part of my (assigned )?routes/i;
261
- /** A not-a-defect verdict whose stated reason is that the thing is another lane's. */
288
+ /**
289
+ * A not-a-defect verdict whose stated reason is that the thing is another
290
+ * lane's. The WORDING rule: all a run archived before lane routes were kept
291
+ * can be judged by, and still what decides for a lane whose routes are not
292
+ * known. It cannot tell a lane's own page from another's, which is why it is
293
+ * not widened (see `isOwnershipRemark`).
294
+ */
262
295
  export function isScopeDismissal(d) {
263
296
  return d.verdict === "not_a_defect" && SCOPE_DISMISSAL_RE.test(decisionText(d));
264
297
  }
265
- export function calibrateAgainstKey(decisions, key) {
298
+ /** The pages a key entry is on, in the same form as `laneRoutePaths`. */
299
+ function entryPaths(e) {
300
+ return [e.route, ...(e.alsoOn ?? [])].flatMap(laneRoutePaths);
301
+ }
302
+ /**
303
+ * A lane's routes as a set of paths, or undefined when they are not known
304
+ * well enough to decide by: none were recorded, or ANY of them names no path
305
+ * ("the orders area"). A route that could not be read may be the very page a
306
+ * verdict is about, and deciding without it would set that verdict aside.
307
+ */
308
+ function lanePaths(lane, laneRoutes) {
309
+ const routes = laneRoutes && Object.hasOwn(laneRoutes, lane) ? laneRoutes[lane] : undefined;
310
+ if (!routes || routes.length === 0)
311
+ return undefined;
312
+ const paths = new Set();
313
+ for (const r of routes) {
314
+ const named = laneRoutePaths(r);
315
+ if (named.length === 0)
316
+ return undefined;
317
+ for (const p of named)
318
+ paths.add(p);
319
+ }
320
+ return paths;
321
+ }
322
+ /**
323
+ * A not-a-defect that is a remark about who owns the thing, not a verdict on
324
+ * whether it is broken.
325
+ *
326
+ * Where the lane's routes are known, decided by WHERE the thing is: the
327
+ * verdict matches a planted or also-real defect whose pages are all outside
328
+ * the lane's routes. The wording no longer matters either way — "raised on /
329
+ * before navigating" from the orders lane is a remark about the dashboard's
330
+ * defect, and "handled by another lane" from the lane that owns the page is
331
+ * its own wrong verdict. A defect in chrome every page carries (`everyPage`)
332
+ * is every lane's, so it is never another lane's. A verdict that matches a
333
+ * known non-defect, a contextual entry, several entries or none is not about
334
+ * a defect with a page to own, and is judged as it would be otherwise.
335
+ *
336
+ * Where they are not known — every archive made before routes were kept, and
337
+ * any lane that reported no route — the wording rule decides, unchanged, so
338
+ * those runs score as they always have.
339
+ */
340
+ export function ownershipRemark(d, key, laneRoutes) {
341
+ if (d.verdict !== "not_a_defect")
342
+ return null;
343
+ const own = lanePaths(d.lane, laneRoutes);
344
+ if (!own)
345
+ return isScopeDismissal(d) ? { by: "wording" } : null;
346
+ const m = classify(decisionText(d), key);
347
+ if (!m || (m.kind !== "defect" && m.kind !== "alsoReal"))
348
+ return null;
349
+ if (m.entry.everyPage)
350
+ return null;
351
+ return entryPaths(m.entry).some((p) => own.has(p)) ? null : { by: "routes", id: m.entry.id };
352
+ }
353
+ export function isOwnershipRemark(d, key, laneRoutes) {
354
+ return ownershipRemark(d, key, laneRoutes) !== null;
355
+ }
356
+ export function calibrateAgainstKey(decisions, key, laneRoutes) {
266
357
  if (decisions.length === 0)
267
358
  return null;
359
+ const lanes = [...new Set(decisions.map((d) => d.lane))];
360
+ const known = lanes.filter((l) => lanePaths(l, laneRoutes) !== undefined).length;
268
361
  const buckets = BUCKET_EDGES.map(() => ({ n: 0, conf: 0, right: 0 }));
269
362
  const out = {
270
363
  judged: 0,
@@ -274,7 +367,10 @@ export function calibrateAgainstKey(decisions, key) {
274
367
  badConfidence: 0,
275
368
  unsure: 0,
276
369
  outOfScope: 0,
370
+ ownership: known === 0 ? "wording" : known === lanes.length ? "routes" : "mixed",
371
+ setAsideByRoute: [],
277
372
  contextual: 0,
373
+ worthALook: 0,
278
374
  buckets: [],
279
375
  ece: 0,
280
376
  brier: 0,
@@ -284,8 +380,15 @@ export function calibrateAgainstKey(decisions, key) {
284
380
  out.unsure += 1;
285
381
  continue;
286
382
  }
287
- if (isScopeDismissal(d)) {
383
+ if (d.verdict === "worth_a_look") {
384
+ out.worthALook += 1;
385
+ continue;
386
+ }
387
+ const remark = ownershipRemark(d, key, laneRoutes);
388
+ if (remark) {
288
389
  out.outOfScope += 1;
390
+ if (remark.by === "routes")
391
+ out.setAsideByRoute.push({ lane: d.lane, id: remark.id, confidence: d.confidence });
289
392
  continue;
290
393
  }
291
394
  const c = d.confidence;
@@ -334,14 +437,19 @@ export function calibrateAgainstKey(decisions, key) {
334
437
  * across runs, and scoring an accumulated one credits a run with what an
335
438
  * earlier run found. Point the scorer at a fresh project directory per run.
336
439
  */
337
- export function score(key, findings, decisions, level = "medium") {
440
+ export function score(key, findings, decisions, level = "medium", laneRoutes) {
338
441
  const hitsByDefect = new Map();
339
442
  const falsePositives = [];
340
443
  const unknown = [];
341
444
  const ambiguous = [];
342
445
  const contextual = [];
446
+ const worthALook = [];
343
447
  let correct = 0;
344
448
  for (const f of findings) {
449
+ if (isWorthALook(f)) {
450
+ worthALook.push({ title: f.title, convention: f.convention ?? "" });
451
+ continue;
452
+ }
345
453
  const m = classify(findingText(f), key);
346
454
  if (!m) {
347
455
  unknown.push({ title: f.title, severity: f.severity, evidence: f.evidence ?? "" });
@@ -410,11 +518,12 @@ export function score(key, findings, decisions, level = "medium") {
410
518
  correct,
411
519
  falsePositives,
412
520
  contextual,
521
+ worthALook,
413
522
  unknown,
414
523
  ambiguous,
415
524
  duplicates,
416
525
  severity,
417
- calibration: calibrateAgainstKey(decisions, key),
526
+ calibration: calibrateAgainstKey(decisions, key, laneRoutes),
418
527
  };
419
528
  }
420
529
  const pct = (n, d) => (d === 0 ? "—" : `${Math.round((n / d) * 100)}%`);
@@ -424,13 +533,18 @@ const pct = (n, d) => (d === 0 ? "—" : `${Math.round((n / d) * 100)}%`);
424
533
  * false claims nobody has judged yet. The bounds can: the lower one counts
425
534
  * every open finding as wrong, the upper one as right. Findings set aside as
426
535
  * contextual are in neither: they are not claims the run could get right.
536
+ * Nor are findings the run filed as worth a look: it did not claim them.
427
537
  */
428
538
  export function precisionBounds(c) {
429
539
  const labelled = c.correct + c.falsePositives.length;
430
540
  const open = c.unknown.length + c.ambiguous.length;
431
- const scored = c.findings - c.contextual.length;
541
+ const scored = c.findings - setAside(c);
432
542
  return { labelled: `${c.correct}/${labelled} (${pct(c.correct, labelled)})`, low: pct(c.correct, scored), high: pct(c.correct + open, scored) };
433
543
  }
544
+ /** Findings in neither precision count: contextual ones, and the run's own worth-a-looks. */
545
+ function setAside(c) {
546
+ return c.contextual.length + (c.worthALook?.length ?? 0);
547
+ }
434
548
  /** The scorecard as a person reads it. Leads with the two numbers, then says what each is made of. */
435
549
  export function formatScorecard(c) {
436
550
  const p = precisionBounds(c);
@@ -441,11 +555,13 @@ export function formatScorecard(c) {
441
555
  `Recall ${c.found.length}/${c.expected} (${pct(c.found.length, c.expected)}) of the planted defects expected at this level`,
442
556
  `Precision ${p.labelled} of the findings the key can label` +
443
557
  (open
444
- ? ` — ${open} of ${c.findings - c.contextual.length} unlabelled, so between ${p.low} and ${p.high} of ${c.contextual.length ? "the findings not set aside" : "all findings"}`
558
+ ? ` — ${open} of ${c.findings - setAside(c)} unlabelled, so between ${p.low} and ${p.high} of ${setAside(c) ? "the findings not set aside" : "all findings"}`
445
559
  : ""),
446
560
  ];
447
561
  if (c.contextual.length)
448
562
  lines.push(`Set aside ${c.contextual.length} of ${c.findings} finding(s): defects only under a convention the run cannot see, so in neither count`);
563
+ if (c.worthALook.length)
564
+ lines.push(`Set aside ${c.worthALook.length} of ${c.findings} finding(s) the run filed as worth a look: not claimed as defects, so in neither recall nor precision`);
449
565
  lines.push(``);
450
566
  if (c.missed.length)
451
567
  lines.push(`Missed: ${c.missed.join(", ")}`);
@@ -463,6 +579,11 @@ export function formatScorecard(c) {
463
579
  for (const x of c.contextual)
464
580
  lines.push(` ${x.title} (${x.id})`);
465
581
  }
582
+ if (c.worthALook.length) {
583
+ lines.push(``, `Filed as worth a look (${c.worthALook.length}):`);
584
+ for (const x of c.worthALook)
585
+ lines.push(` ${x.title}${x.convention ? ` — a defect only if the project uses ${x.convention}` : ""}`);
586
+ }
466
587
  if (c.ambiguous.length) {
467
588
  lines.push(``, `Ambiguous (${c.ambiguous.length}) — the key claims each twice; sharpen it:`);
468
589
  for (const a of c.ambiguous)
@@ -490,8 +611,10 @@ export function formatScorecard(c) {
490
611
  k.ambiguous && `${k.ambiguous} the key names ambiguously`,
491
612
  k.badConfidence && `${k.badConfidence} with an unusable confidence`,
492
613
  k.unsure && `${k.unsure} unsure`,
493
- k.outOfScope && `${k.outOfScope} dismissed as another lane's`,
614
+ k.outOfScope &&
615
+ `${k.outOfScope} dismissed as another lane's (${k.ownership === "routes" ? "by the lanes' routes" : k.ownership === "wording" ? "by wording: no lane routes archived" : "by the lanes' routes where archived, else by wording"})`,
494
616
  k.contextual && `${k.contextual} about things that are defects only under a convention the run cannot see`,
617
+ k.worthALook && `${k.worthALook} the lane marked worth a look`,
495
618
  ].filter(Boolean);
496
619
  lines.push(``, k.judged === 0
497
620
  ? `Lane calibration against the key: nothing the key could judge` + (skipped.length ? ` (${skipped.join(", ")})` : "")
@@ -499,6 +622,11 @@ export function formatScorecard(c) {
499
622
  (skipped.length ? ` — not scored: ${skipped.join(", ")}` : ""));
500
623
  for (const b of k.buckets)
501
624
  lines.push(` stated ${b.label}: ${b.decisions} decision(s), said ${b.stated.toFixed(2)}, right ${Math.round(b.correct * 100)}%`);
625
+ if (k.setAsideByRoute.length) {
626
+ lines.push(` Set aside as another lane's, by the lanes' routes (${k.setAsideByRoute.length}):`);
627
+ for (const r of k.setAsideByRoute)
628
+ lines.push(` ${r.lane} on ${r.id}, stated ${Number.isFinite(r.confidence) ? r.confidence.toFixed(2) : "?"}`);
629
+ }
502
630
  }
503
631
  return lines.join("\n");
504
632
  }
@@ -543,17 +671,22 @@ const LOCAL_PATH_RE = /(?:\/Users\/[^/\s"']+|\/home\/[^/\s"']+|\/private\/tmp|\/
543
671
  export function sanitize(text) {
544
672
  return text.replace(LOCAL_PATH_RE, "<path>");
545
673
  }
546
- export function toArchive(run, date, note, findings, decisions, app) {
674
+ export function toArchive(run, date, note, findings, decisions, app, laneRoutes = {}) {
675
+ // No query string or fragment reaches an archive: a lane copies routes from
676
+ // the address bar, and an address can carry a token.
677
+ const routes = Object.fromEntries(Object.entries(laneRoutes).map(([lane, list]) => [lane, list.map((r) => sanitize(stripRouteQuery(r)))]));
547
678
  return {
548
679
  app,
549
680
  run,
550
681
  date,
551
682
  note,
683
+ ...(Object.keys(routes).length ? { laneRoutes: routes } : {}),
552
684
  findings: findings.map((f) => ({
553
685
  title: sanitize(f.title),
554
686
  severity: f.severity,
555
687
  ...(f.category ? { category: f.category } : {}),
556
688
  ...(f.evidence ? { evidence: sanitize(f.evidence) } : {}),
689
+ ...(isWorthALook(f) ? { tier: "worth_a_look", ...(f.convention ? { convention: sanitize(f.convention) } : {}) } : {}),
557
690
  })),
558
691
  decisions: decisions.map((d) => ({ ...d, observation: sanitize(d.observation), evidence: d.evidence === null ? null : sanitize(d.evidence) })),
559
692
  };
@@ -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(() => { });