scenescout 3.14.1 → 3.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +26 -3
- package/dist/check-run.js +22 -11
- package/dist/ci-run.js +113 -17
- package/dist/cli.js +119 -6
- package/dist/commands.js +39 -2
- package/dist/engine/browser.js +546 -133
- package/dist/engine/check.js +77 -30
- package/dist/engine/ci.js +97 -0
- package/dist/engine/claims.js +81 -15
- package/dist/engine/collector.js +307 -65
- package/dist/engine/dedup.js +329 -9
- package/dist/engine/design.js +81 -12
- package/dist/engine/forms.js +17 -0
- package/dist/engine/memory.js +180 -36
- package/dist/engine/oracles.js +126 -1
- package/dist/engine/provider.js +4 -3
- package/dist/engine/refresh.js +270 -22
- package/dist/engine/report.js +8 -1
- package/dist/engine/scripted-login.js +342 -62
- package/dist/first-run.js +577 -0
- package/dist/login-run.js +136 -35
- package/dist/mcp-server.js +75 -16
- package/package.json +1 -1
- package/skills/scenescout/SKILL.md +3 -3
package/dist/engine/refresh.js
CHANGED
|
@@ -12,7 +12,17 @@
|
|
|
12
12
|
* has already rotated the token, it loads the rotated profile and sends the
|
|
13
13
|
* current token in place of the spent one. When the response arrives and the
|
|
14
14
|
* page has stored what it got back, the session writes its state over the
|
|
15
|
-
* profile and releases the lock
|
|
15
|
+
* profile and releases the lock, so two sessions do not present one token.
|
|
16
|
+
*
|
|
17
|
+
* Which requests count is decided by brokerDecision. A token in the body, the
|
|
18
|
+
* URL or a header the page set makes the request a refresh. A token that is
|
|
19
|
+
* only in the Cookie header does not by itself: a refresh cookie scoped to "/"
|
|
20
|
+
* rides on every request the page makes, scripts and images included. Such a
|
|
21
|
+
* request counts only when it is plausibly the refresh call, a POST to a path
|
|
22
|
+
* named for one or an endpoint the broker has seen rotate the cookie. Static
|
|
23
|
+
* assets never count, and a broker that cannot do its job lets the request
|
|
24
|
+
* through as the page sent it: an app left without its scripts is worse than
|
|
25
|
+
* the rare double refresh.
|
|
16
26
|
*
|
|
17
27
|
* Sessions may be separate processes (one MCP server per lane) sharing the
|
|
18
28
|
* profile on disk, so the lock is a file, created exclusively, owner-only,
|
|
@@ -37,8 +47,14 @@ export const REFRESH_NAME_RE = /refresh/i;
|
|
|
37
47
|
export const MIN_TOKEN_LENGTH = 16;
|
|
38
48
|
/** How deep inside a JSON-valued storage entry a refresh-token field is looked for. */
|
|
39
49
|
const MAX_JSON_DEPTH = 4;
|
|
50
|
+
/**
|
|
51
|
+
* Whether a value could be a token: long enough, no whitespace, and not an
|
|
52
|
+
* address. An app that keeps the URL it refreshes at under a refresh-named key
|
|
53
|
+
* ("/auth/refresh", "https://…/token") stores a path, which every request to
|
|
54
|
+
* that path would otherwise seem to carry.
|
|
55
|
+
*/
|
|
40
56
|
function looksLikeToken(value) {
|
|
41
|
-
return typeof value === "string" && value.length >= MIN_TOKEN_LENGTH && !/\s/.test(value);
|
|
57
|
+
return typeof value === "string" && value.length >= MIN_TOKEN_LENGTH && !/\s/.test(value) && !/^(\/|[a-z][a-z0-9+.-]*:\/\/)/i.test(value);
|
|
42
58
|
}
|
|
43
59
|
/** Refresh-token fields inside a JSON value, by dotted path. */
|
|
44
60
|
function jsonFields(value, at, depth, out) {
|
|
@@ -66,7 +82,7 @@ export function refreshTokenSlots(state) {
|
|
|
66
82
|
if (Array.isArray(s.cookies)) {
|
|
67
83
|
for (const c of s.cookies) {
|
|
68
84
|
if (typeof c?.name === "string" && REFRESH_NAME_RE.test(c.name) && looksLikeToken(c.value)) {
|
|
69
|
-
out.push({ slot: `cookie ${c.name} (${String(c.domain ?? "")}${String(c.path ?? "")})`, value: c.value });
|
|
85
|
+
out.push({ slot: `cookie ${c.name} (${String(c.domain ?? "")}${String(c.path ?? "")})`, value: c.value, cookie: c.name });
|
|
70
86
|
}
|
|
71
87
|
}
|
|
72
88
|
}
|
|
@@ -106,23 +122,142 @@ function wireForms(value) {
|
|
|
106
122
|
return encoded === value ? [value] : [value, encoded];
|
|
107
123
|
}
|
|
108
124
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
125
|
+
* Headers the browser adds to a request by itself. A token found in one of
|
|
126
|
+
* these was not sent by the page as an act of refreshing: a cookie scoped to
|
|
127
|
+
* "/" rides on every request the page makes, and a page whose own address
|
|
128
|
+
* carries a token passes it on as the Referer of everything it loads.
|
|
129
|
+
*/
|
|
130
|
+
function browserAddedHeader(name) {
|
|
131
|
+
const n = name.toLowerCase();
|
|
132
|
+
return n === "cookie" || n === "referer" || n === "origin" || n === "host" || n.startsWith("sec-") || n.startsWith(":");
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The known refresh token a request carries, and where, or null. `known` is
|
|
136
|
+
* what the session last loaded from the profile; a token the page got some
|
|
137
|
+
* other way is not the role's and is left alone. A token in the body, the URL
|
|
138
|
+
* or a header the page set wins over one that is only in the Cookie header.
|
|
113
139
|
*/
|
|
114
|
-
export function
|
|
140
|
+
export function tokenPresence(req, known) {
|
|
115
141
|
if (known.length === 0)
|
|
116
142
|
return null;
|
|
117
|
-
const
|
|
143
|
+
const carries = (haystack, t) => wireForms(t.value).some((form) => haystack.includes(form));
|
|
144
|
+
const explicit = Object.entries(req.headers)
|
|
145
|
+
.filter(([name]) => !browserAddedHeader(name))
|
|
146
|
+
.map(([, value]) => value);
|
|
118
147
|
for (const t of known) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
148
|
+
if (req.body && carries(req.body, t))
|
|
149
|
+
return { slot: t, via: "body" };
|
|
150
|
+
if (carries(req.url, t))
|
|
151
|
+
return { slot: t, via: "url" };
|
|
152
|
+
if (explicit.some((h) => carries(h, t)))
|
|
153
|
+
return { slot: t, via: "header" };
|
|
123
154
|
}
|
|
155
|
+
const cookie = Object.entries(req.headers).find(([name]) => name.toLowerCase() === "cookie")?.[1] ?? "";
|
|
156
|
+
for (const t of known)
|
|
157
|
+
if (cookie && carries(cookie, t))
|
|
158
|
+
return { slot: t, via: "cookie" };
|
|
124
159
|
return null;
|
|
125
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Requests for a page's static assets. They are never brokered, waited on or
|
|
163
|
+
* dropped, whatever cookie rides on them: holding a script back behind the
|
|
164
|
+
* refresh lock leaves the app under test without its code.
|
|
165
|
+
*/
|
|
166
|
+
export const STATIC_RESOURCE_TYPES = new Set(["script", "stylesheet", "image", "font", "media", "manifest", "texttrack"]);
|
|
167
|
+
export function isStaticAsset(method, resourceType) {
|
|
168
|
+
return (method === "GET" || method === "HEAD") && STATIC_RESOURCE_TYPES.has(resourceType);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Path segments that name a token refresh. Whole segments, as the write
|
|
172
|
+
* policy's auth segments are, never substrings: `/api/tokens` mints an API
|
|
173
|
+
* token and `/refreshments` is a menu.
|
|
174
|
+
*/
|
|
175
|
+
const REFRESH_SEGMENT_RE = /^(refresh|refresh[-_]?token|token|renew|reauth|reauthenticate)$/i;
|
|
176
|
+
/** A path whose last one or two segments name a token refresh: /auth/refresh, /oauth/token, /api/token/refresh. */
|
|
177
|
+
export function refreshLikePath(pathname) {
|
|
178
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
179
|
+
return segments.slice(-2).some((seg) => REFRESH_SEGMENT_RE.test(seg));
|
|
180
|
+
}
|
|
181
|
+
/** An endpoint as the broker remembers it: the method and the path, without the query. */
|
|
182
|
+
export function endpointKey(method, url) {
|
|
183
|
+
let pathname;
|
|
184
|
+
try {
|
|
185
|
+
pathname = new URL(url).pathname;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
pathname = url.split("?")[0];
|
|
189
|
+
}
|
|
190
|
+
return `${method.toUpperCase()} ${pathname}`;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Path segments of a sign-in, a sign-up or a sign-out. A cookie such a request
|
|
194
|
+
* sets is a new sign-in, perhaps as somebody else, not a refresh: the broker
|
|
195
|
+
* neither learns the endpoint nor saves the cookie over the role's profile.
|
|
196
|
+
*/
|
|
197
|
+
const SIGN_IN_SEGMENT_RE = /^(login|log-in|signin|sign-in|logout|log-out|signout|sign-out|signup|sign-up|register|verify|otp|magic-link|callback|authorize|sso)$/i;
|
|
198
|
+
/** Whether an endpoint seen to rotate the token may be learned as the refresh call: any but a sign-in, sign-up or sign-out. */
|
|
199
|
+
export function learnableEndpoint(url) {
|
|
200
|
+
let pathname;
|
|
201
|
+
try {
|
|
202
|
+
pathname = new URL(url).pathname;
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
return !pathname
|
|
208
|
+
.split("/")
|
|
209
|
+
.filter(Boolean)
|
|
210
|
+
.slice(-2)
|
|
211
|
+
.some((seg) => SIGN_IN_SEGMENT_RE.test(seg));
|
|
212
|
+
}
|
|
213
|
+
/** A body that asks for a grant other than a refresh (a password or code sign-in) is not a refresh, whatever cookie rides on it. */
|
|
214
|
+
function otherGrant(body) {
|
|
215
|
+
const m = /grant_type["']?\s*[=:]\s*["']?([A-Za-z_:.-]+)/.exec(body ?? "");
|
|
216
|
+
return m !== null && !/refresh/i.test(m[1]);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Whether a token that is only in the Cookie header could make this request a
|
|
220
|
+
* refresh, so that header is worth reading at all. A cookie alone never does,
|
|
221
|
+
* since a cookie scoped to "/" rides on every request. Only a request that is
|
|
222
|
+
* plausibly the refresh call counts: one the broker has learned rotates the
|
|
223
|
+
* token (`learned`, by endpointKey), or a POST, PUT or PATCH to a path whose
|
|
224
|
+
* last one or two segments name a refresh.
|
|
225
|
+
*/
|
|
226
|
+
export function cookieMayCount(method, url, learned) {
|
|
227
|
+
if (learned.has(endpointKey(method, url)))
|
|
228
|
+
return true;
|
|
229
|
+
if (method !== "POST" && method !== "PUT" && method !== "PATCH")
|
|
230
|
+
return false;
|
|
231
|
+
let pathname;
|
|
232
|
+
try {
|
|
233
|
+
pathname = new URL(url).pathname;
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
return refreshLikePath(pathname);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Whether a request goes through the refresh lock. A static asset never does.
|
|
242
|
+
* A request carrying a known token in its body, URL or a header the page set
|
|
243
|
+
* does. A token that is only in the Cookie header counts only for a request
|
|
244
|
+
* that is plausibly the refresh call (cookieMayCount), and not for one whose
|
|
245
|
+
* body asks for another grant.
|
|
246
|
+
*/
|
|
247
|
+
export function brokerDecision(req, known, learned) {
|
|
248
|
+
if (isStaticAsset(req.method, req.resourceType))
|
|
249
|
+
return { kind: "pass", why: "static asset" };
|
|
250
|
+
const found = tokenPresence(req, known);
|
|
251
|
+
if (!found)
|
|
252
|
+
return { kind: "pass", why: "no known token" };
|
|
253
|
+
if (found.via !== "cookie")
|
|
254
|
+
return { kind: "broker", sent: found.slot, via: found.via };
|
|
255
|
+
if (!cookieMayCount(req.method, req.url, learned))
|
|
256
|
+
return { kind: "pass", why: "only in a cookie" };
|
|
257
|
+
if (otherGrant(req.body))
|
|
258
|
+
return { kind: "pass", why: "another grant" };
|
|
259
|
+
return { kind: "broker", sent: found.slot, via: "cookie" };
|
|
260
|
+
}
|
|
126
261
|
export function planRefresh(sent, current) {
|
|
127
262
|
if (current.some((c) => c.value === sent.value))
|
|
128
263
|
return { kind: "send" };
|
|
@@ -180,6 +315,35 @@ export function profileAfterRotation(pageState, profileOnDisk) {
|
|
|
180
315
|
return pageState;
|
|
181
316
|
return withSessionStorage(pageState, splitProfile(profileOnDisk).sessionStorage);
|
|
182
317
|
}
|
|
318
|
+
/**
|
|
319
|
+
* The cookies a response's Set-Cookie headers set, by name. A header value may
|
|
320
|
+
* hold several cookies joined by newlines, as some browsers report them.
|
|
321
|
+
*/
|
|
322
|
+
function setCookiePairs(setCookies) {
|
|
323
|
+
const out = new Map();
|
|
324
|
+
for (const line of setCookies.flatMap((l) => l.split("\n"))) {
|
|
325
|
+
const pair = line.split(";")[0];
|
|
326
|
+
const eq = pair.indexOf("=");
|
|
327
|
+
if (eq <= 0)
|
|
328
|
+
continue;
|
|
329
|
+
out.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
|
|
330
|
+
}
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* The known cookie tokens a response's Set-Cookie headers replace with a new
|
|
335
|
+
* token: the request that got this response refreshed them. A cookie set to an
|
|
336
|
+
* empty or short value is being cleared (a sign-out), not rotated.
|
|
337
|
+
*/
|
|
338
|
+
export function rotatedCookies(setCookies, known) {
|
|
339
|
+
const set = setCookiePairs(setCookies);
|
|
340
|
+
return known.filter((t) => {
|
|
341
|
+
if (t.cookie === undefined)
|
|
342
|
+
return false;
|
|
343
|
+
const next = set.get(t.cookie);
|
|
344
|
+
return looksLikeToken(next) && next !== t.value;
|
|
345
|
+
});
|
|
346
|
+
}
|
|
183
347
|
/**
|
|
184
348
|
* The rotated refresh token a refresh response carries, for a page that did
|
|
185
349
|
* not store it in time: a refresh-named field in a JSON body, else a
|
|
@@ -198,16 +362,9 @@ export function rotatedFromResponse(body, setCookies) {
|
|
|
198
362
|
// Not a JSON body: the Set-Cookie headers are all there is to read.
|
|
199
363
|
}
|
|
200
364
|
if (found.size === 0) {
|
|
201
|
-
for (const
|
|
202
|
-
const pair = line.split(";")[0];
|
|
203
|
-
const eq = pair.indexOf("=");
|
|
204
|
-
if (eq <= 0)
|
|
205
|
-
continue;
|
|
206
|
-
const name = pair.slice(0, eq).trim();
|
|
207
|
-
const value = pair.slice(eq + 1).trim();
|
|
365
|
+
for (const [name, value] of setCookiePairs(setCookies))
|
|
208
366
|
if (REFRESH_NAME_RE.test(name) && looksLikeToken(value))
|
|
209
367
|
found.add(value);
|
|
210
|
-
}
|
|
211
368
|
}
|
|
212
369
|
return found.size === 1 ? [...found][0] : null;
|
|
213
370
|
}
|
|
@@ -224,6 +381,26 @@ export function swapProfileToken(state, from, to) {
|
|
|
224
381
|
}
|
|
225
382
|
return s;
|
|
226
383
|
}
|
|
384
|
+
/**
|
|
385
|
+
* The headers the broker sends a request with when it has to send the request
|
|
386
|
+
* itself, which it does only to put the current token in a Cookie header (a
|
|
387
|
+
* browser keeps the Cookie header it built, whatever a route override says).
|
|
388
|
+
* The page's own headers are kept; the ones the HTTP client sets for itself
|
|
389
|
+
* (host, content length, connection, the encodings it can decode) and
|
|
390
|
+
* pseudo-headers are left out, and `cookie` is the line to send.
|
|
391
|
+
*/
|
|
392
|
+
export function headersForResend(headers, cookie) {
|
|
393
|
+
const out = {};
|
|
394
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
395
|
+
const n = name.toLowerCase();
|
|
396
|
+
if (n.startsWith(":") || ["host", "content-length", "connection", "transfer-encoding", "accept-encoding", "cookie"].includes(n))
|
|
397
|
+
continue;
|
|
398
|
+
out[n] = value;
|
|
399
|
+
}
|
|
400
|
+
if (cookie)
|
|
401
|
+
out.cookie = cookie;
|
|
402
|
+
return out;
|
|
403
|
+
}
|
|
227
404
|
// ── Whether the broker runs ──────────────────────────────────────────────────
|
|
228
405
|
export const REFRESH_BROKER_ENV = "SCENESCOUT_REFRESH_BROKER";
|
|
229
406
|
/**
|
|
@@ -255,6 +432,60 @@ export const DEFAULT_WAIT_MS = 45_000;
|
|
|
255
432
|
export function lockPathFor(profileFile) {
|
|
256
433
|
return `${profileFile}.lock`;
|
|
257
434
|
}
|
|
435
|
+
// ── Endpoints learned to rotate the token ──────────────────────────────────
|
|
436
|
+
/**
|
|
437
|
+
* Where the broker keeps the endpoints it has seen rotate the role's refresh
|
|
438
|
+
* token, beside the profile: every session of the role reads it, so a session
|
|
439
|
+
* brokers an endpoint another session learned, even in another process.
|
|
440
|
+
*/
|
|
441
|
+
export function endpointsPathFor(profileFile) {
|
|
442
|
+
return `${profileFile}.endpoints`;
|
|
443
|
+
}
|
|
444
|
+
/** At most this many learned endpoints are kept, newest first. */
|
|
445
|
+
export const MAX_LEARNED_ENDPOINTS = 20;
|
|
446
|
+
const ENDPOINT_KEY_RE = /^(GET|POST|PUT|PATCH) \/\S{0,500}$/;
|
|
447
|
+
/** The learned endpoints on disk. None when there is no file yet; a file that is not the broker's teaches nothing. */
|
|
448
|
+
export function readLearnedEndpoints(file) {
|
|
449
|
+
let raw;
|
|
450
|
+
try {
|
|
451
|
+
raw = fs.readFileSync(file, "utf8");
|
|
452
|
+
}
|
|
453
|
+
catch (err) {
|
|
454
|
+
if (err.code === "ENOENT")
|
|
455
|
+
return [];
|
|
456
|
+
throw err;
|
|
457
|
+
}
|
|
458
|
+
let parsed;
|
|
459
|
+
try {
|
|
460
|
+
parsed = JSON.parse(raw);
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
return []; // written by rename, so never half-written: whatever this is, it is not a list of endpoints
|
|
464
|
+
}
|
|
465
|
+
const list = parsed?.endpoints;
|
|
466
|
+
if (!Array.isArray(list))
|
|
467
|
+
return [];
|
|
468
|
+
return list.filter((e) => typeof e === "string" && ENDPOINT_KEY_RE.test(e)).slice(0, MAX_LEARNED_ENDPOINTS);
|
|
469
|
+
}
|
|
470
|
+
/** The list with `key` added first, once, and the oldest dropped past the cap. A key that is not an endpoint changes nothing. */
|
|
471
|
+
export function withLearnedEndpoint(list, key) {
|
|
472
|
+
if (!ENDPOINT_KEY_RE.test(key))
|
|
473
|
+
return [...list];
|
|
474
|
+
return [key, ...list.filter((e) => e !== key)].slice(0, MAX_LEARNED_ENDPOINTS);
|
|
475
|
+
}
|
|
476
|
+
/** Write the learned endpoints owner-only, by rename, so a reader never sees half a file. Call it holding the lock. */
|
|
477
|
+
export function writeLearnedEndpoints(file, list) {
|
|
478
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
479
|
+
fs.rmSync(temp, { force: true });
|
|
480
|
+
const fd = fs.openSync(temp, "wx", LOCK_FILE_MODE);
|
|
481
|
+
try {
|
|
482
|
+
fs.writeFileSync(fd, JSON.stringify({ endpoints: list }));
|
|
483
|
+
}
|
|
484
|
+
finally {
|
|
485
|
+
fs.closeSync(fd);
|
|
486
|
+
}
|
|
487
|
+
fs.renameSync(temp, file);
|
|
488
|
+
}
|
|
258
489
|
/** Whether a lock last touched at `mtimeMs` is stale at `now`. */
|
|
259
490
|
export function isStale(mtimeMs, now, staleMs) {
|
|
260
491
|
return now - mtimeMs > staleMs;
|
|
@@ -275,6 +506,20 @@ function readNonce(file) {
|
|
|
275
506
|
}
|
|
276
507
|
}
|
|
277
508
|
/** One attempt: the lock, or null when another process holds a live one. */
|
|
509
|
+
/**
|
|
510
|
+
* What an error from creating the lock file means. EEXIST: another process
|
|
511
|
+
* holds it. On Windows, creating a file whose previous copy is still being
|
|
512
|
+
* deleted (a lock just released) fails with EPERM, EBUSY or EACCES instead;
|
|
513
|
+
* that is the lock changing hands, so it is waited out like a held lock, not
|
|
514
|
+
* reported as a failure. Anything else is a real error.
|
|
515
|
+
*/
|
|
516
|
+
export function lockCreateError(code, platform = process.platform) {
|
|
517
|
+
if (code === "EEXIST")
|
|
518
|
+
return "held";
|
|
519
|
+
if (platform === "win32" && (code === "EPERM" || code === "EBUSY" || code === "EACCES"))
|
|
520
|
+
return "busy";
|
|
521
|
+
return "error";
|
|
522
|
+
}
|
|
278
523
|
export function tryAcquireLock(file, opts = {}) {
|
|
279
524
|
const staleMs = opts.staleMs ?? DEFAULT_STALE_MS;
|
|
280
525
|
const now = opts.now ?? Date.now;
|
|
@@ -289,8 +534,11 @@ export function tryAcquireLock(file, opts = {}) {
|
|
|
289
534
|
}
|
|
290
535
|
}
|
|
291
536
|
catch (err) {
|
|
292
|
-
|
|
537
|
+
const meaning = lockCreateError(err.code, opts.platform);
|
|
538
|
+
if (meaning === "error")
|
|
293
539
|
throw err;
|
|
540
|
+
if (meaning === "busy")
|
|
541
|
+
return null; // being deleted as it changes hands: try again next poll
|
|
294
542
|
let mtimeMs;
|
|
295
543
|
try {
|
|
296
544
|
mtimeMs = fs.statSync(file).mtimeMs;
|
package/dist/engine/report.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { SHARED_CHROME_ROUTE, isEmbedKey, isWorthALook } from "./memory.js";
|
|
3
|
+
import { SHARED_CHROME_ROUTE, isEmbedKey, isWorthALook, judgedMergesOf } from "./memory.js";
|
|
4
4
|
import { sayVerification } from "./verify.js";
|
|
5
5
|
import { feedForSession } from "./live.js";
|
|
6
6
|
import { buildReplayHtml, evidenceFor } from "./replay.js";
|
|
@@ -503,6 +503,9 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
|
|
|
503
503
|
if (extras?.policyAttributed) {
|
|
504
504
|
lines.push(`| Errors caused by the tester's own write-policy blocks (not counted above) | ${extras.policyAttributed} |`);
|
|
505
505
|
}
|
|
506
|
+
const judged = memory.dedupJudge?.describe() ?? (memory.dedupOff ? `the rule alone: the dedup judge was asked for and is off (${memory.dedupOff})` : null);
|
|
507
|
+
if (judged)
|
|
508
|
+
lines.push(`| Finding dedup | ${judged.replace(/\|/g, "/")} |`);
|
|
506
509
|
lines.push(`| Elements exercised (informational — denominator grows with every state) | ${cov.elementsExercised}/${cov.elementsTotal} |`);
|
|
507
510
|
lines.push(``);
|
|
508
511
|
// ---- Page quality scores, worst first — the cross-page comparator. ----
|
|
@@ -601,6 +604,10 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
|
|
|
601
604
|
lines.push(`- **Evidence:** \`${f.evidence}\``);
|
|
602
605
|
lines.push(`- **Where:** \`${f.state}\` (${f.url})`);
|
|
603
606
|
lines.push(`- **Seen in runs:** ${f.runs}`);
|
|
607
|
+
// A merge the model made is shown with what was filed, so a wrong one can be seen, and refiled as its own defect (ADR 4).
|
|
608
|
+
for (const m of judgedMergesOf(f)) {
|
|
609
|
+
lines.push(`- **Merged by the dedup judge** (p_same ${m.pSame.toFixed(2)}, ${m.at.slice(0, 10)}): [${m.severity}] ${m.title}, filed as ${m.category}${m.evidence ? ` with evidence \`${m.evidence}\`` : ""}`);
|
|
610
|
+
}
|
|
604
611
|
// Only printed once somebody has re-tested it. A finding nobody has looked
|
|
605
612
|
// at again says nothing here, which is the honest thing for it to say.
|
|
606
613
|
if (f.verdict && f.verifiedAt) {
|