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 +8 -0
- package/dist/browsers.js +52 -8
- package/dist/engine/browser.js +174 -7
- package/dist/engine/calibration.js +99 -14
- package/dist/engine/fingerprint.js +12 -7
- package/dist/engine/lane.js +81 -0
- package/dist/engine/memory.js +38 -5
- package/dist/engine/unload.js +201 -0
- package/dist/mcp-server.js +2 -2
- package/package.json +1 -1
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
|
-
*
|
|
186
|
-
* keepalive fetch
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
|
|
192
|
-
|
|
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
|
/**
|
package/dist/engine/browser.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
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 =
|
|
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.
|
|
242
|
-
|
|
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
|
|
250
|
-
|
|
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
|
|
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
|
-
|
|
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 = "/";
|
package/dist/engine/lane.js
CHANGED
|
@@ -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
|
package/dist/engine/memory.js
CHANGED
|
@@ -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: "
|
|
464
|
-
"ux-polish": "
|
|
465
|
-
a11y: "
|
|
466
|
-
"missing-testid": "
|
|
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
|
+
}
|
package/dist/mcp-server.js
CHANGED
|
@@ -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)
|
|
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.
|
|
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",
|