scenescout 3.11.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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # scenescout
2
2
 
3
+ ## 3.11.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 847c149: Two different defects on one element now stay two findings. Finding dedup compares what is wrong as well as where: identical evidence, a shared quoted label or a reworded title merges two findings only when they are one kind of defect (the same category, or neighbouring categories of one family such as `page-error` and `console-error`). `visual`, `ux-polish`, `a11y` and `missing-testid` are each their own kind, so a link clipped out of view and the same link styled like body text are no longer merged. When `scout_finding` merges a filing it now names the category of the finding it joined and which categories the filing could have merged with.
8
+ - aca7a28: When a lane report is folded, a judged defect on a failing request now counts as filed when a finding names that request with its path written as a template: `GET /api/things/{id} 500` (or `:id`, or `*`) covers a lane's `GET /api/things/7 500`. A template stands only for one id segment (a number, a UUID or a long hex id), never for a word such as `me` or `export`. A defect naming several failing requests counts as filed only when every one of them is. On a request that did not fail, the lane's evidence, once the paths are aligned, must be the finding's, a restatement of part of it, or the finding's evidence followed only by the status the call should have returned, as in `… 200 as role=viewer (expected 403)`.
9
+ - 2b8ac9a: The write policy now judges writes a page sends as it is being left. In Chromium, a `navigator.sendBeacon` or `fetch(..., { keepalive: true })` sent on `pagehide`, `visibilitychange` or `unload` was never intercepted and reached the server in every mode, observe included; the engine now catches it at the browser level and applies the same rules, so under observe (and under read-only, for a destructive write) it is refused and reported with the other refused writes, and a mode that allows it still sends it. Such a write is recognised by its headers, since the page that sent it is gone: one out of the app whose Origin is another site's, or `null`, is treated as an embed's and refused outside destructive, and the exception for a captcha on the app's sign-in page is decided on the Referer, the page it was sent from. Browsers send only the origin as the Referer of a request to another site by default, so that exception seldom applies and such a captcha write is refused; a sign-in the tester completes while the page is open is not affected. In Chromium, a write a 307 or 308 redirect carries on to a new address is now judged there as well. In every browser, the pages the engine closes (at the end of a session, on a re-attach, and a popup of another site) are left for `about:blank` first, so what they send on the way out meets the policy too: in Firefox and WebKit such a write had also gone out unseen.
10
+
3
11
  ## 3.11.0
4
12
 
5
13
  ### Minor Changes
package/dist/browsers.js CHANGED
@@ -182,14 +182,58 @@ export function beaconResourceType(engine) {
182
182
  return engine === "chromium" ? "ping" : "beacon";
183
183
  }
184
184
  /**
185
- * Whether a beacon a page sends as it is being left (sendBeacon, or a
186
- * keepalive fetch, on `pagehide`) escapes interception. In Chromium it is
187
- * never routed, so the write policy does not see it and it reaches the server
188
- * in every mode; Firefox and WebKit route it, and the policy refuses it like
189
- * any other. A limit of the network-layer policy (ADR 2), reported in ADR 12;
190
- * the check smoke suite asserts both directions per engine.
191
- */
192
- export function unloadBeaconsEscapePolicy(engine) {
185
+ * Where the write policy meets a write a page sends as it is being left (a
186
+ * sendBeacon or keepalive fetch on `pagehide`, `visibilitychange` or
187
+ * `unload`, or one still leaving as a page is closed).
188
+ *
189
+ * - `route`: Firefox and WebKit hand it to the context's route handler like
190
+ * any other request.
191
+ * - `browser-fetch`: Chromium never routes it (it arrives with no network id,
192
+ * or after the page's session has gone, and the driver sends it on), so on
193
+ * its own it reached the server in every mode. The engine also pauses
194
+ * requests at the browser level with the DevTools Fetch domain and judges
195
+ * there, by the same rules, every write the route handler did not
196
+ * (unload.ts). Only in modes where the policy can refuse: destructive
197
+ * installs neither.
198
+ *
199
+ * Either way the write is judged and a refusal is reported like any other
200
+ * (ADR 2); the unload smoke suite asserts it per engine.
201
+ */
202
+ export function unloadWriteInterception(engine) {
203
+ return engine === "chromium" ? "browser-fetch" : "route";
204
+ }
205
+ /**
206
+ * Whether a write a page sends as it is being left may be lost after the
207
+ * write policy lets it through. WebKit cancels such a request when the route
208
+ * handler answers after the page has gone, so a mode that allows the write
209
+ * does not promise it arrives; nothing is sent that the policy refused. Firefox
210
+ * sends it on, and so does Chromium's browser-level interception. In
211
+ * destructive mode nothing is intercepted and every engine sends it.
212
+ */
213
+ export function allowedUnloadWritesMayBeLost(engine) {
214
+ return engine === "webkit";
215
+ }
216
+ /**
217
+ * Whether the writes another site's frame sends on `pagehide` may never be
218
+ * issued when the top page is left. Firefox can tear such a frame down before
219
+ * its requests reach the route handler, so there is nothing to refuse or to
220
+ * report, and nothing is sent. It depends on timing: seen in CI and under load
221
+ * locally, not on an idle machine. Chromium's browser-level interception and
222
+ * WebKit's route handler met them every time in the same runs. The writes the
223
+ * top page itself sends are not affected.
224
+ */
225
+ export function frameUnloadWritesMayGoUnissued(engine) {
226
+ return engine === "firefox";
227
+ }
228
+ /**
229
+ * Whether a write carried on by a redirect (a 307 or 308 keeps the method and
230
+ * the body) is judged at its new address. The route handler sees only the
231
+ * first request of a redirect in every engine. In Chromium the browser-level
232
+ * interception (`unloadWriteInterception`) sees each hop and judges it like any
233
+ * write; Firefox and WebKit send the later hops on unjudged, a known limit of
234
+ * the policy there (ADR 2).
235
+ */
236
+ export function writeRedirectHopsJudged(engine) {
193
237
  return engine === "chromium";
194
238
  }
195
239
  /**
@@ -15,7 +15,7 @@ import { describeStep, FLOW_AFTER_LAST_STEP_MS, FLOW_STEP_TIMEOUT_MS, isAction,
15
15
  import { framePath, RECORD_MAX_FRAMES } from "./replay.js";
16
16
  import { keepWatchingUrl, normalizePace, SETTLE_TICK_MS, shouldKeepWaiting } from "./settle.js";
17
17
  import { buildRequestScript, formatReplay, replaySignature, requestHeaders, resolveMethod, resolveRequestUrl, toReplayResult } from "./request.js";
18
- 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";
19
19
  import { revealedLines } from "./hover.js";
20
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";
21
21
  import { explainLaunchFailure, isMissingBrowser } from "./launch.js";
@@ -26,6 +26,7 @@ import { answersWithRefusal, destructiveRefusal, isDestructive, isDestructiveWir
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";
29
+ import { bodyDigest, headerOf, isReadMethod, judgeUnseenWrite, pausedRequestBytes, RoutedWrites, UnseenRefusals, unseenWriteSource, } from "./unload.js";
29
30
  const SETTLE_MS = 400;
30
31
  /**
31
32
  * Request URL → pathname, falling back to the raw string for anything
@@ -504,6 +505,19 @@ export class BrowserEngine {
504
505
  engineName = "chromium";
505
506
  /** Non-GET requests fired since the last action — surfaces silent state mutation in read-only runs (timestamped for attribution). */
506
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
+ }
507
521
  /**
508
522
  * Every request answered since the last action, with its status. The HTTP
509
523
  * oracle sees each 4xx as it happens and reports it on its own; this ledger
@@ -580,7 +594,7 @@ export class BrowserEngine {
580
594
  url: req.url(),
581
595
  status,
582
596
  resourceType: req.resourceType(),
583
- blockedByPolicy: this.refusedByPolicy.has(req),
597
+ blockedByPolicy: this.refusedByAnyPolicy(req),
584
598
  });
585
599
  }
586
600
  /**
@@ -700,7 +714,7 @@ export class BrowserEngine {
700
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)`;
701
715
  }
702
716
  this.oracles = new OracleMonitor();
703
- this.oracles.setPolicyRefusalCheck((req) => this.refusedByPolicy.has(req));
717
+ this.oracles.setPolicyRefusalCheck((req) => this.refusedByAnyPolicy(req));
704
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.
705
719
  this.oracles.setEmbedAttribution((req) => {
706
720
  let site = null;
@@ -934,8 +948,10 @@ export class BrowserEngine {
934
948
  return refuse(`sent from a page of ${offApp}, outside the app`);
935
949
  // Auth/session flows must work in every mode — but never a destructive
936
950
  // one, and in observe only the requests a login itself needs.
937
- 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()));
938
953
  return route.continue();
954
+ }
939
955
  let owned = this.isOwnedResource(pathname);
940
956
  // A single UI action commonly fires create-then-immediately-save
941
957
  // (POST gets an id, PUT saves content under it) faster than the
@@ -969,10 +985,22 @@ export class BrowserEngine {
969
985
  .finally(() => this.pendingCreations.delete(task));
970
986
  this.pendingCreations.add(task);
971
987
  }
988
+ this.routedWrites.note(this.mode, method, url, bodyDigest(req.postDataBuffer()));
972
989
  return route.continue();
973
990
  }
974
991
  return refuse();
975
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
+ }
976
1004
  }
977
1005
  // Popups / target=_blank: adopt same-origin pages as the active page (with
978
1006
  // oracles attached); close foreign-origin popups so exploration cannot
@@ -999,7 +1027,7 @@ export class BrowserEngine {
999
1027
  this.lastSnap = null;
1000
1028
  }
1001
1029
  else {
1002
- void newPage.close().catch(() => { });
1030
+ void BrowserEngine.leaveAndClose(newPage);
1003
1031
  }
1004
1032
  })
1005
1033
  .catch(() => { });
@@ -1818,6 +1846,121 @@ export class BrowserEngine {
1818
1846
  return null;
1819
1847
  return foreign;
1820
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
+ }
1821
1964
  /** Report (and clear) write-policy blocks since the last action. */
1822
1965
  drainBlocked() {
1823
1966
  this.lastActionBlocked = this.blockedRequests.length;
@@ -1868,7 +2011,7 @@ export class BrowserEngine {
1868
2011
  // to see the DUPLICATES that the reporting dedup below intentionally hides.
1869
2012
  this.lastActionMutationSigs = this.mutationRequests.map((e) => e.sig);
1870
2013
  const fresh = this.mutationRequests
1871
- .filter((entry) => !this.refusedByPolicy.has(entry.req))
2014
+ .filter((entry) => !entry.req || !this.refusedByAnyPolicy(entry.req))
1872
2015
  .filter((entry) => {
1873
2016
  const key = entry.sig.split("?")[0];
1874
2017
  if (this.reportedMutationSigs.has(key))
@@ -3614,6 +3757,23 @@ export class BrowserEngine {
3614
3757
  }
3615
3758
  }
3616
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
+ }
3617
3777
  async close() {
3618
3778
  // Marks the end of the time this session held a browser, so the pace
3619
3779
  // section can say how long it was held with nothing happening. Only when a
@@ -3621,6 +3781,10 @@ export class BrowserEngine {
3621
3781
  // is not an event.
3622
3782
  if (this.page)
3623
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);
3624
3788
  // Pending debounced coverage writes must land before the process can exit.
3625
3789
  try {
3626
3790
  this.memory?.flush();
@@ -3636,13 +3800,16 @@ export class BrowserEngine {
3636
3800
  // If teardown overruns the cap, the leftover process is reaped by the
3637
3801
  // orphan cleaner on the next attach (or server start).
3638
3802
  await BrowserEngine.settleWithin((async () => {
3639
- await this.page?.close().catch(() => { });
3803
+ for (const p of this.context?.pages() ?? [])
3804
+ await p.close().catch(() => { });
3640
3805
  await this.context?.close().catch(() => { });
3641
3806
  await this.browser?.close().catch(() => { });
3642
3807
  })(), 8000);
3643
3808
  this.page = null;
3644
3809
  this.context = null;
3645
3810
  this.browser = null;
3811
+ this.routedWrites.clear();
3812
+ this.unseenRefusals.clear();
3646
3813
  this.refs.clear();
3647
3814
  this.snapshotUrl = "";
3648
3815
  this.currentFingerprint = "";
@@ -20,6 +20,7 @@
20
20
  *
21
21
  * Pure, so every rule here is table-tested.
22
22
  */
23
+ import { requestsPair, statedRequests, templatedPathsMatch } from "./lane.js";
23
24
  import { failingSignatures } from "./memory.js";
24
25
  /**
25
26
  * Upper edge of each confidence bucket. Five is enough to see a shape and few
@@ -226,45 +227,129 @@ export const MAX_UNFILED_NAMED = 10;
226
227
  * nobody — and it is cheapest to catch at the moment the planner folds the
227
228
  * report, while the lane's session is still open.
228
229
  *
229
- * Matched on the store's failing-endpoint signature where the evidence has
230
- * one, and on the evidence text otherwise. The text match is what the
231
- * calibration join refuses, because there a miss is scored against the lane;
232
- * here a miss only asks someone to check, which costs a look and nothing more.
230
+ * The check exists to catch unfiled defects, so a false "filed" costs more
231
+ * than a false alarm, which only asks someone to look. A decision counts as
232
+ * filed by one of three rules, tried in order:
233
+ *
234
+ * 1. Failing signatures. When the decision's evidence names one or more
235
+ * failing requests (the store's `failingSignatures`), it is filed only when
236
+ * EVERY one of them is covered by a finding's signature: same method, same
237
+ * status, same route, where a path-template segment (`{id}`, `:id`, `*`) on
238
+ * either side stands for one id segment (see `templatedPathsMatch`). So a
239
+ * finding filed as `GET /api/things/{id} 500` covers `GET /api/things/7 500`,
240
+ * and a decision naming two failing requests is not filed by a finding for
241
+ * one of them.
242
+ * 2. A request that did not fail. When the decision names a request with a
243
+ * 2xx or 3xx status, or none, and a finding names the same request
244
+ * (template-aware, same status), the decision is filed only when its
245
+ * evidence, with its path written as the finding's, is the finding's
246
+ * evidence exactly, restates part of it, or is the finding's evidence
247
+ * followed only by that request's other status (`… 200 as role=viewer
248
+ * (expected 403)` against `… 200 as role=viewer`). Word overlap is not enough
249
+ * there: a 200 names a resource, not a defect, and two different defects
250
+ * on one endpoint read alike. A failing signature in the decision does not
251
+ * block this when it names the same request (`POST /api/things/7/archive
252
+ * 200 (expected 403)` reads as a 403 to the store); one naming any other
253
+ * request does, and sends the decision to rule 1's verdict.
254
+ * 3. Text. Evidence naming no failing request is matched on its text: the
255
+ * same evidence, a restatement of part of it, or shared identifiers and
256
+ * near-identical wording. The calibration join refuses a text match,
257
+ * because there a miss is scored against the lane.
233
258
  */
234
259
  export function unfiledDefects(decisions, findings) {
235
- const keys = new Set();
260
+ const keys = [];
236
261
  const filed = [];
237
262
  for (const f of findings) {
238
263
  const text = `${f.evidence ?? ""} ${f.title}`;
264
+ const requests = statedRequests(f.evidence ?? "");
265
+ // The store's signatures, and the finding's own failing requests as
266
+ // written: the store reads no `*` in a path, so `GET /api/things/* 500`
267
+ // has no signature of its own.
239
268
  if (f.evidence)
240
269
  for (const key of joinKeys(f.evidence))
241
- keys.add(key);
242
- filed.push({ ids: identifiers(text), words: words(text), evidenceWords: words(f.evidence ?? ""), evidence: squash(f.evidence ?? "") });
270
+ keys.push(...statedRequests(key));
271
+ keys.push(...requests.filter((q) => q.status !== null && /^[45]/.test(q.status)));
272
+ filed.push({
273
+ ids: identifiers(text),
274
+ words: words(text),
275
+ evidenceWords: words(f.evidence ?? ""),
276
+ evidence: squash(f.evidence ?? ""),
277
+ raw: f.evidence ?? "",
278
+ requests,
279
+ });
243
280
  }
244
281
  const out = [];
245
282
  for (const d of decisions) {
246
283
  if (d.verdict !== "defect")
247
284
  continue;
248
285
  if (d.evidence) {
249
- const joined = [...joinKeys(d.evidence)];
250
- if (joined.some((k) => keys.has(k)))
286
+ const evidence = d.evidence;
287
+ const joined = [...joinKeys(evidence)];
288
+ const failing = joined.flatMap((sig) => statedRequests(sig));
289
+ if (failing.length > 0 && failing.every((sig) => keys.some((k) => requestsPair(sig, k))))
290
+ continue;
291
+ if (filed.some((f) => restatesRequest(evidence, failing, f)))
251
292
  continue;
252
293
  // A failing-endpoint signature is the store's own identity for a bug. When
253
- // the decision has one and no finding shares it, nothing else is a match.
294
+ // the decision has one and no finding covers it, nothing else is a match.
254
295
  if (joined.length > 0) {
255
296
  out.push(`${d.observation} — ${d.evidence}`);
256
297
  continue;
257
298
  }
258
- const ids = identifiers(d.evidence);
259
- const w = words(d.evidence);
260
- const text = squash(d.evidence);
261
- if (filed.some((f) => (text !== "" && f.evidence === text) || restates(text, f) || covers(ids, w, f)))
299
+ if (filed.some((f) => matchesText(evidence, f)))
262
300
  continue;
263
301
  }
264
302
  out.push(d.evidence ? `${d.observation} — ${d.evidence}` : d.observation);
265
303
  }
266
304
  return out;
267
305
  }
306
+ /** Rule 3: the same evidence, a restatement of part of it, or shared identifiers and wording. */
307
+ function matchesText(reported, f) {
308
+ const text = squash(reported);
309
+ return (text !== "" && f.evidence === text) || restates(text, f) || covers(identifiers(reported), words(reported), f);
310
+ }
311
+ /**
312
+ * Rule 2 of unfiledDefects: the decision names a request that did not fail,
313
+ * the finding names the same request, and the decision's evidence — its path
314
+ * written as the finding's, so a template and the id it stands for are not a
315
+ * difference — is the finding's evidence exactly, restates part of it, or
316
+ * adds only the status the request should have returned.
317
+ * `failing` is the decision's failing signatures; each must name this same
318
+ * request, or the pair is refused.
319
+ */
320
+ function restatesRequest(evidence, failing, f) {
321
+ for (const r of statedRequests(evidence)) {
322
+ if (r.status !== null && /^[45]/.test(r.status))
323
+ continue;
324
+ const paired = f.requests.find((q) => requestsPair(r, q));
325
+ if (!paired)
326
+ continue;
327
+ if (!failing.every((sig) => sig.method === r.method && templatedPathsMatch(sig.segments, r.segments)))
328
+ continue;
329
+ const aligned = squash(evidence.slice(0, r.start) + f.raw.slice(paired.start, paired.end) + evidence.slice(r.end));
330
+ if (aligned !== "" && (f.evidence === aligned || restates(aligned, f) || addsOnlyExpectedStatus(aligned, f.evidence, r.status)))
331
+ return true;
332
+ }
333
+ return false;
334
+ }
335
+ /**
336
+ * The status a lane adds after the call it quotes, to say what the call
337
+ * should have returned: "(expected 403)", "(reject 403)", "reject 403". One
338
+ * status, an optional verb, optional brackets, and nothing else.
339
+ */
340
+ const EXPECTED_STATUS_RE = /^[\s,;]*\(?\s*(?:(?:expected|expect|rejected|reject)\s*:?\s*)?([1-5]\d{2})\s*\)?[\s.,;]*$/;
341
+ /**
342
+ * Whether the reported evidence is the filed evidence followed by nothing but
343
+ * the status the request should have returned, which differs from the one it
344
+ * did. Anything else after it — another request, another role, another
345
+ * claim — is something the finding does not say, so it is not covered.
346
+ */
347
+ function addsOnlyExpectedStatus(reported, filed, status) {
348
+ if (filed === "" || !reported.startsWith(filed))
349
+ return false;
350
+ const expected = EXPECTED_STATUS_RE.exec(reported.slice(filed.length))?.[1];
351
+ return expected !== undefined && expected !== status;
352
+ }
268
353
  /**
269
354
  * Shortest reported evidence that counts as restating a filing it is part of.
270
355
  */
@@ -4,6 +4,17 @@ const HEX_RE = /^[0-9a-f]{12,}$/i;
4
4
  const NUM_RE = /^\d+$/;
5
5
  /** Query params that name a UI state (a different screen), not transient data. */
6
6
  const UI_STATE_PARAM_RE = /^(tab|view|mode|step|panel|section)$/i;
7
+ /**
8
+ * Whether a path segment is an id that route identity collapses to `:id`: a
9
+ * UUID or long hex string anywhere, a number from the second real segment on.
10
+ * `index` counts as `path.split("/")` does, so the first real segment of a
11
+ * leading-slash path is 1 (see the note on numeric first segments below).
12
+ */
13
+ export function isIdSegment(seg, index) {
14
+ if (UUID_RE.test(seg) || HEX_RE.test(seg))
15
+ return true;
16
+ return NUM_RE.test(seg) && index > 1;
17
+ }
7
18
  /**
8
19
  * Normalize a URL into a route-class identity:
9
20
  * - path ids collapse (/orders/123 → /orders/:id)
@@ -38,13 +49,7 @@ export function normalizePath(rawUrl) {
38
49
  }
39
50
  // segments[0] is always "" for a leading-slash path, so the first REAL
40
51
  // segment is index 1 — that is the one a bare numeric page occupies.
41
- const segments = path.split("/").map((seg, i) => {
42
- if (UUID_RE.test(seg) || HEX_RE.test(seg))
43
- return ":id";
44
- if (NUM_RE.test(seg) && i > 1)
45
- return ":id";
46
- return seg;
47
- });
52
+ const segments = path.split("/").map((seg, i) => (isIdSegment(seg, i) ? ":id" : seg));
48
53
  let normalized = segments.join("/") || "/";
49
54
  if (normalized === "")
50
55
  normalized = "/";
@@ -23,6 +23,7 @@
23
23
  * Pure logic, no browser, so every rule here is table-tested.
24
24
  */
25
25
  import { z } from "zod";
26
+ import { isIdSegment } from "./fingerprint.js";
26
27
  import { FINDING_CATEGORIES } from "./memory.js";
27
28
  /** Upper bound on the decisions and routes arrays, so a report is always small enough to hold whole. */
28
29
  export const LANE_MAX_ITEMS = 255;
@@ -271,6 +272,86 @@ export function summarizeLaneReport(r) {
271
272
  parts.push(`blocked by ${r.blocked_by}`);
272
273
  return `${r.lane}: ${parts.join(", ")}`;
273
274
  }
275
+ /**
276
+ * Method, optional origin, path (template characters included), optional query
277
+ * and fragment, then a status only when it comes next: after an arrow or a
278
+ * colon, or after spaces, and nothing else. A status further along
279
+ * ("... (expected 403)") is a different claim about the request and is not
280
+ * read as its status.
281
+ *
282
+ * The separator is two alternatives rather than `\s*(?:->|:)?\s*`: with the
283
+ * arrow optional, two adjacent `\s*` can split a run of spaces every way
284
+ * there is, and evidence ending in a long run of whitespace took quadratic
285
+ * time to reject.
286
+ */
287
+ const STATED_REQUEST_RE = /\b(GET|POST|PUT|PATCH|DELETE)\s+(?:https?:\/\/[^/\s]+)?(\/[A-Za-z0-9/_.:{}$*~%-]*)(?:\?[^\s#]*)?(?:#\S*)?(?:(?:\s*(?:->|→|=>|:)\s*|\s+)([1-5]\d{2})\b)?/gi;
288
+ /** The requests a piece of evidence names, in order. */
289
+ export function statedRequests(text) {
290
+ const out = [];
291
+ for (const m of text.matchAll(STATED_REQUEST_RE)) {
292
+ const raw = m[2].replace(/[.:,]+$/, "");
293
+ const start = (m.index ?? 0) + m[0].indexOf(m[2]);
294
+ out.push({ method: m[1].toUpperCase(), segments: pathSegments(raw), status: m[3] ?? null, start, end: start + raw.length });
295
+ }
296
+ return out;
297
+ }
298
+ function pathSegments(path) {
299
+ const trimmed = path.replace(/\/+$/, "");
300
+ if (trimmed === "")
301
+ return [];
302
+ return trimmed
303
+ .slice(1)
304
+ .split("/")
305
+ .map((seg) => {
306
+ try {
307
+ return decodeURIComponent(seg).toLowerCase();
308
+ }
309
+ catch {
310
+ return seg.toLowerCase();
311
+ }
312
+ });
313
+ }
314
+ /**
315
+ * A path-template segment: `{name}`, `:name` or `*`. Whole segments only —
316
+ * `order-{id}` is a literal, and so is `**`, which some routers read as "any
317
+ * number of segments": a template here stands for exactly one.
318
+ */
319
+ export function isTemplateSegment(seg) {
320
+ return seg === "*" || /^\{[^/{}]+\}$/.test(seg) || /^:[a-z_][a-z0-9_-]*$/i.test(seg);
321
+ }
322
+ /**
323
+ * Whether two paths name the same route. A template segment on either side
324
+ * stands for ONE segment that is an id by route identity's own rule
325
+ * (`isIdSegment`: a number after the first segment, a UUID, a long hex
326
+ * string), or for another template. Never for a word: `/users/{id}` is not
327
+ * `/users/me`, `/things/:id` is not `/things/export`, and a path of three stars does
328
+ * not stand for every three-segment path. Same number of segments, every literal
329
+ * equal (case-insensitive), so a template never absorbs a slash or an empty
330
+ * segment.
331
+ */
332
+ export function templatedPathsMatch(a, b) {
333
+ if (a.length !== b.length)
334
+ return false;
335
+ // +1: segments start at the first real one; isIdSegment counts as split("/") does.
336
+ const standsFor = (seg, i) => isTemplateSegment(seg) || isIdSegment(seg, i + 1);
337
+ return a.every((x, i) => {
338
+ const y = b[i];
339
+ if (isTemplateSegment(x))
340
+ return standsFor(y, i);
341
+ if (isTemplateSegment(y))
342
+ return standsFor(x, i);
343
+ return x === y;
344
+ });
345
+ }
346
+ /**
347
+ * Whether two stated requests are the same call: same method, same route with
348
+ * a template read as an id (see templatedPathsMatch), and the same status — both stated and equal, or
349
+ * both unstated. A different status is a different outcome of the call, and
350
+ * pairing a 200 with a 403 would file one claim under its opposite.
351
+ */
352
+ export function requestsPair(a, b) {
353
+ return a.method === b.method && a.status === b.status && templatedPathsMatch(a.segments, b.segments);
354
+ }
274
355
  /**
275
356
  * Which sessions are lanes of a parallel run, and whether each one's report
276
357
  * has been folded, so a lane's session is not closed before its decisions
@@ -451,6 +451,14 @@ export function requestsDisagree(a, b) {
451
451
  * refused save is data-loss to one and data-inconsistency to another. Across
452
452
  * families — a layout defect and a data defect — a shared quoted string names
453
453
  * a place on the page, not a bug.
454
+ *
455
+ * The presentation kinds are each a family of their own. They are not
456
+ * alternative labels for one observation: one element is routinely wrong in
457
+ * several independent ways at once — clipped out of view (visual), styled like
458
+ * body text (ux-polish), unnamed to a screen reader (a11y), missing its test id
459
+ * — and the findings about it quote the same control name and name the same
460
+ * test id. As one family, a link "styled like body text" was merged into the
461
+ * finding that the same link was clipped, and the second finding was lost.
454
462
  */
455
463
  const CATEGORY_FAMILY = {
456
464
  "data-inconsistency": "data",
@@ -460,10 +468,10 @@ const CATEGORY_FAMILY = {
460
468
  network: "failure",
461
469
  "console-error": "failure",
462
470
  "page-error": "failure",
463
- visual: "presentation",
464
- "ux-polish": "presentation",
465
- a11y: "presentation",
466
- "missing-testid": "presentation",
471
+ visual: "visual",
472
+ "ux-polish": "ux-polish",
473
+ a11y: "a11y",
474
+ "missing-testid": "missing-testid",
467
475
  "ux-confusing": "flow",
468
476
  "dead-end": "flow",
469
477
  security: "security",
@@ -485,7 +493,30 @@ export function sameFamily(a, b) {
485
493
  const fa = CATEGORY_FAMILY[a];
486
494
  return fa !== undefined && fa === CATEGORY_FAMILY[b];
487
495
  }
496
+ /**
497
+ * Whether two findings may be one defect by KIND — what is wrong, as opposed
498
+ * to where. The same category, or two categories of one family. Every
499
+ * route-scoped rule below asks this first: evidence, a quoted literal and a
500
+ * title all say where a defect is as often as what it is, so none of them may
501
+ * merge a finding of one kind into a finding of another.
502
+ */
503
+ function sameKind(a, b) {
504
+ return (!!a && a === b) || sameFamily(a, b);
505
+ }
506
+ /**
507
+ * The categories a finding of `category` can merge with, in list order —
508
+ * so scout_finding can tell a lane whose filing was merged which kinds would
509
+ * have been kept apart. An unknown category merges only with itself.
510
+ */
511
+ export function mergeableCategories(category) {
512
+ const known = FINDING_CATEGORIES.filter((c) => sameKind(category, c));
513
+ return known.length > 0 ? known : [category];
514
+ }
488
515
  function sameFinding(a, b) {
516
+ // What is wrong before where: two kinds of defect are two findings, however
517
+ // alike their evidence, quoted text or titles.
518
+ if (!sameKind(a.category, b.category))
519
+ return false;
489
520
  const aEv = a.evidence?.toLowerCase().replace(/\s+/g, " ").trim();
490
521
  const bEv = b.evidence?.toLowerCase().replace(/\s+/g, " ").trim();
491
522
  if (aEv && bEv && aEv === bEv)
@@ -506,7 +537,9 @@ function sameFinding(a, b) {
506
537
  // literal with the data defect describing what the button does — in its
507
538
  // detail or its own title. Merged, the layout defect was filed and then lost
508
539
  // from the report on most runs of a benchmark. Within a family, one bug
509
- // filed twice under neighbouring categories still merges.
540
+ // filed twice under neighbouring categories still merges. Stricter than the
541
+ // kind check above in one way: an unknown category read from an older file
542
+ // is never merged on a quoted string, even with itself.
510
543
  //
511
544
  // And never when both findings' evidence names requests with no endpoint in
512
545
  // common. A quoted control name bridges two findings about that control
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Writes the context's route handler never sees, and how the engine judges
3
+ * them anyway.
4
+ *
5
+ * In Chromium, a request a page sends while it is being left (a
6
+ * `navigator.sendBeacon` or a `fetch(..., { keepalive: true })` on
7
+ * `pagehide`, `visibilitychange` or `unload`, or one still leaving as a page
8
+ * is closed) is never handed to the context's route handler: it arrives with
9
+ * no network id, or after the page's own DevTools session has gone, and the
10
+ * driver lets it through unseen. Left alone it reached the server in every
11
+ * mode, observe included. The engine therefore also intercepts at the
12
+ * browser level (the DevTools Fetch domain on the browser target, which does
13
+ * pause these requests) and judges there every write the route handler did
14
+ * not already judge, by the same rules (ADR 2). Which engines need this is
15
+ * `unloadWriteInterception` in `browsers.ts`.
16
+ *
17
+ * Everything here is pure, so the rules are table-tested in policy-test; the
18
+ * wiring to the browser lives in `browser.ts`.
19
+ */
20
+ import { createHash } from "node:crypto";
21
+ import { allowsForeignWriteOnSignIn, allowsWrite, foreignWrite, isAuthExempt, offAppPageWrite, trustsForeignWrite } from "./policy.js";
22
+ /** Whether a method sends nothing the write policy judges. */
23
+ export function isReadMethod(method) {
24
+ return method === "GET" || method === "HEAD" || method === "OPTIONS";
25
+ }
26
+ /**
27
+ * Who sent a write the route handler never saw, as far as its headers tell:
28
+ * the embed it is charged to (`foreign`) and the off-app page it came from
29
+ * (`offApp`), the two inputs `judgeUnseenWrite` takes from the sender.
30
+ *
31
+ * The sending frame may be gone, so its URL is not known. The page it was
32
+ * sent from is its Referer; where there is none, the page the session drives
33
+ * stands in for `offApp` only.
34
+ *
35
+ * - `foreign`: policy.ts `foreignWrite` with no frame chain, so a write out of
36
+ * the app whose Origin header names another site is that site's embed's. A
37
+ * sign-in provider's page reached as the whole page, beaconing to itself as
38
+ * it is left, is therefore refused outside destructive; the route handler,
39
+ * which knows the frame, would let it through. The cautious direction. An
40
+ * `Origin: null` write out of the app is taken to be an embed's too, whether
41
+ * or not the page embeds anything, since the page can no longer be asked.
42
+ * - The sign-in exception (a captcha on the app's own sign-in page) is judged
43
+ * against the Referer, the page the request was sent from, never against
44
+ * whatever page the session has moved to by the time the request is seen.
45
+ * With no Referer it does not apply.
46
+ * - Trusted embeds (safe-write only) are judged by the Origin header alone.
47
+ */
48
+ export function unseenWriteSource(input) {
49
+ const { appUrl, mode, url, originHeader, referer } = input;
50
+ let foreign = foreignWrite(appUrl, { url, frameChain: [], frameUrl: null, originHeader, pageHasForeignFrame: true });
51
+ if (foreign && referer && allowsForeignWriteOnSignIn(mode, referer, appUrl))
52
+ foreign = null;
53
+ if (foreign && trustsForeignWrite(mode, input.trustedEmbeds, appUrl, { frameChain: [], originHeader, unadoptedPageUrl: null }))
54
+ foreign = null;
55
+ const offApp = offAppPageWrite(appUrl, referer ?? input.currentPageUrl, url, input.movedByEmbed);
56
+ return { foreign, offApp };
57
+ }
58
+ /**
59
+ * The route handler's verdict on a write, from what a browser-level
60
+ * interception can know about it. Same rules in the same order: a write
61
+ * another site's frame sends outside the app is refused; so is one sent from
62
+ * a page an embed moved off the app, a sign-in excepted; a sign-in passes;
63
+ * then the mode decides. `foreign` and `offApp` come from
64
+ * `unseenWriteSource`, since the sending frame may no longer exist.
65
+ * The one step it does not take is safe-write's short wait for a creation
66
+ * still in flight: a page being left has nothing more to wait for.
67
+ */
68
+ export function judgeUnseenWrite(input) {
69
+ const { mode, method, pathname, destructiveWire } = input;
70
+ if (mode === "destructive" || isReadMethod(method))
71
+ return { allow: true };
72
+ if (input.foreign)
73
+ return { allow: false, why: `sent from a frame of ${input.foreign}` };
74
+ const authExempt = isAuthExempt(mode, method, pathname, destructiveWire);
75
+ if (input.offApp && !authExempt)
76
+ return { allow: false, why: `sent from a page of ${input.offApp}, outside the app` };
77
+ if (authExempt)
78
+ return { allow: true };
79
+ return allowsWrite(mode, method, destructiveWire, input.owned) ? { allow: true } : { allow: false };
80
+ }
81
+ /**
82
+ * How long a write the route handler let through stays on the ledger. The
83
+ * browser-level pause follows the route handler's `continue` within
84
+ * milliseconds; an entry that outlives this is a request that never reached
85
+ * the network (answered by a service worker, cancelled by the page).
86
+ */
87
+ export const ROUTED_WRITE_TTL_MS = 5000;
88
+ /** Entries past this many are dropped oldest first: a page that posts in a loop cannot grow the ledger without bound. */
89
+ const MAX_ROUTED_WRITES = 200;
90
+ /** A request body's identity for the ledger: the SHA-256 of its bytes, or "" for no body. */
91
+ export function bodyDigest(body) {
92
+ return body && body.length > 0 ? createHash("sha256").update(body).digest("hex") : "";
93
+ }
94
+ /**
95
+ * The writes the route handler has already judged and let through, so the
96
+ * browser-level interception, which sees them again, lets them pass without
97
+ * judging them twice. The key is the mode, the method, the URL and the body's
98
+ * digest (`bodyDigest`), so a write let through can only excuse the very same
99
+ * request: never one with another body to the same URL, such as a delete
100
+ * command sent where a harmless save was allowed a moment before, and never
101
+ * one sent after a flow switched to a stricter rule. A write is claimed once:
102
+ * two identical requests, one routed and one not, leave the second to be
103
+ * judged. Requests the route handler refused never reach the browser level,
104
+ * so they are not recorded.
105
+ */
106
+ export class RoutedWrites {
107
+ ttlMs;
108
+ entries = [];
109
+ constructor(ttlMs = ROUTED_WRITE_TTL_MS) {
110
+ this.ttlMs = ttlMs;
111
+ }
112
+ static key(mode, method, url, digest) {
113
+ return `${mode} ${method} ${url.split("#")[0]} ${digest}`;
114
+ }
115
+ prune(now) {
116
+ this.entries = this.entries.filter((e) => now - e.at <= this.ttlMs);
117
+ if (this.entries.length > MAX_ROUTED_WRITES)
118
+ this.entries = this.entries.slice(-MAX_ROUTED_WRITES);
119
+ }
120
+ note(mode, method, url, digest, now = Date.now()) {
121
+ this.prune(now);
122
+ this.entries.push({ key: RoutedWrites.key(mode, method, url, digest), at: now });
123
+ }
124
+ /** Whether the route handler let this write through a moment ago; each note is claimed at most once. */
125
+ claim(mode, method, url, digest, now = Date.now()) {
126
+ this.prune(now);
127
+ const key = RoutedWrites.key(mode, method, url, digest);
128
+ const i = this.entries.findIndex((e) => e.key === key);
129
+ if (i < 0)
130
+ return false;
131
+ this.entries.splice(i, 1);
132
+ return true;
133
+ }
134
+ clear() {
135
+ this.entries = [];
136
+ }
137
+ }
138
+ /** How long a write refused at the browser level stays recognisable to the driver's request events. */
139
+ export const UNSEEN_REFUSAL_TTL_MS = 10000;
140
+ /**
141
+ * Writes refused at the browser level, which the driver may still report as
142
+ * requests of its own: a 307 or 308 hop is a request Playwright knows, and it
143
+ * then sees it fail (ERR_BLOCKED_BY_CLIENT). Without this the refusal was also
144
+ * filed as the app's failed request and listed as a possible mutation. Keyed
145
+ * by method and URL (fragment dropped), since the driver's request object is
146
+ * not the paused one; kept briefly, and not consumed, since several listeners
147
+ * ask about the same request.
148
+ */
149
+ export class UnseenRefusals {
150
+ ttlMs;
151
+ entries = [];
152
+ constructor(ttlMs = UNSEEN_REFUSAL_TTL_MS) {
153
+ this.ttlMs = ttlMs;
154
+ }
155
+ static key(method, url) {
156
+ return `${method} ${url.split("#")[0]}`;
157
+ }
158
+ prune(now) {
159
+ this.entries = this.entries.filter((e) => now - e.at <= this.ttlMs);
160
+ if (this.entries.length > MAX_ROUTED_WRITES)
161
+ this.entries = this.entries.slice(-MAX_ROUTED_WRITES);
162
+ }
163
+ note(method, url, now = Date.now()) {
164
+ this.prune(now);
165
+ this.entries.push({ key: UnseenRefusals.key(method, url), at: now });
166
+ }
167
+ has(method, url, now = Date.now()) {
168
+ this.prune(now);
169
+ const key = UnseenRefusals.key(method, url);
170
+ return this.entries.some((e) => e.key === key);
171
+ }
172
+ clear() {
173
+ this.entries = [];
174
+ }
175
+ }
176
+ /**
177
+ * The body of a request paused by the DevTools Fetch domain, as bytes: the
178
+ * base64 entries decoded and joined, else the plain `postData`. Undefined when
179
+ * the protocol sent neither (a body too large to include). Its digest then
180
+ * matches no routed write, so the request is judged, on its URL alone.
181
+ */
182
+ export function pausedRequestBytes(request) {
183
+ const entries = request.postDataEntries?.filter((e) => typeof e.bytes === "string");
184
+ if (entries && entries.length > 0)
185
+ return Buffer.concat(entries.map((e) => Buffer.from(e.bytes, "base64")));
186
+ return request.postData === undefined ? undefined : Buffer.from(request.postData, "utf8");
187
+ }
188
+ /** The same body as text, for the policy's body rules. */
189
+ export function pausedRequestBody(request) {
190
+ return pausedRequestBytes(request)?.toString("utf8");
191
+ }
192
+ /** A header's value from a DevTools header map, whose names keep the case they were sent with. */
193
+ export function headerOf(headers, name) {
194
+ if (!headers)
195
+ return undefined;
196
+ const wanted = name.toLowerCase();
197
+ for (const [k, v] of Object.entries(headers))
198
+ if (k.toLowerCase() === wanted)
199
+ return v;
200
+ return undefined;
201
+ }
@@ -34,7 +34,7 @@ import { ErrorCode, GetPromptRequestSchema, ListPromptsRequestSchema, McpError }
34
34
  import { z } from "zod";
35
35
  import { BrowserEngine } from "./engine/browser.js";
36
36
  import { reapOrphanBrowsers } from "./engine/reaper.js";
37
- import { FINDING_CATEGORIES, MemoryStore, redactSecrets } from "./engine/memory.js";
37
+ import { FINDING_CATEGORIES, MemoryStore, mergeableCategories, redactSecrets } from "./engine/memory.js";
38
38
  import { decodedEntitiesNote, LANE_NAME_MAX, LaneLedger, laneCloseGuard, laneReportInstruction, parseLaneReport, summarizeLaneReport } from "./engine/lane.js";
39
39
  import { MAX_UNFILED_NAMED, unfiledDefects } from "./engine/calibration.js";
40
40
  import { SessionQueue, withWatchdog } from "./engine/dispatch.js";
@@ -1168,7 +1168,7 @@ server.registerTool("scout_finding", {
1168
1168
  ? `Finding recorded: [${finding.severity}] ${finding.title} (id ${finding.id})`
1169
1169
  : finding.regressedAt
1170
1170
  ? `⟳ REOPENED as a REGRESSION: finding ${finding.id} was previously resolved but the evidence reproduces again (seen in ${finding.runs} runs). Worth calling out to the user.`
1171
- : `Not recorded as new: merged into existing finding ${finding.id} — [${finding.severity}] ${finding.title}${finding.evidence ? ` (evidence: ${finding.evidence.slice(0, 160)})` : " (no evidence)"}, seen in ${finding.runs} runs. If yours is a different bug, file it again with evidence naming the request that failed for you (method and path): two findings are kept apart when both name requests and none is shared.`, session);
1171
+ : `Not recorded as new: merged into existing finding ${finding.id} — [${finding.severity}] ${finding.title}${finding.evidence ? ` (evidence: ${finding.evidence.slice(0, 160)})` : " (no evidence)"}, filed as ${finding.category}, seen in ${finding.runs} runs. If yours is a different bug, file it again: under the category that says what is wrong if it is another kind of defect (a finding filed as ${category} merges only with one filed as ${mergeableCategories(category).join(" or ")}), or with evidence naming the request that failed for you (method and path) — two findings are kept apart when both name requests and none is shared.`, session);
1172
1172
  }
1173
1173
  catch (err) {
1174
1174
  return errorText(err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenescout",
3
- "version": "3.11.0",
3
+ "version": "3.11.1",
4
4
  "description": "SceneScout — exploratory UI testing for AI coding agents. An MCP server that gives any agent (Claude Code, Cursor, VS Code Copilot, Codex, Gemini CLI and others) a structured view of a running web app, always-on oracles, a network-level write policy, memory across runs and a gap-checked report.",
5
5
  "license": "MIT",
6
6
  "author": "brunoboto96",