scenescout 3.14.1 → 3.16.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 +94 -0
- package/README.md +90 -18
- package/dist/browsers.js +28 -0
- package/dist/check-run.js +211 -24
- package/dist/ci-run.js +374 -62
- package/dist/cli.js +221 -47
- package/dist/commands.js +42 -4
- package/dist/engine/baseline.js +377 -0
- package/dist/engine/brief.js +16 -7
- package/dist/engine/browser.js +1660 -386
- package/dist/engine/calibration.js +61 -30
- package/dist/engine/capture.js +164 -0
- package/dist/engine/check.js +258 -68
- package/dist/engine/ci-lanes.js +215 -0
- package/dist/engine/ci.js +233 -18
- package/dist/engine/claims.js +234 -12
- package/dist/engine/collector.js +851 -78
- package/dist/engine/crawl.js +49 -0
- package/dist/engine/dedup.js +329 -9
- package/dist/engine/design.js +355 -43
- package/dist/engine/export.js +877 -0
- package/dist/engine/fingerprint.js +92 -4
- package/dist/engine/flow.js +18 -6
- package/dist/engine/forms.js +198 -18
- package/dist/engine/journey.js +29 -1
- package/dist/engine/lane.js +13 -3
- package/dist/engine/launch.js +45 -6
- package/dist/engine/limits.js +7 -0
- package/dist/engine/live-page.js +49 -2
- package/dist/engine/live.js +4 -1
- package/dist/engine/memory.js +668 -70
- package/dist/engine/open.js +118 -0
- package/dist/engine/oracles.js +167 -2
- package/dist/engine/plain.js +268 -0
- package/dist/engine/png.js +127 -0
- package/dist/engine/policy.js +379 -9
- package/dist/engine/probes.js +3 -2
- package/dist/engine/profiles.js +45 -9
- package/dist/engine/project-folder.js +191 -0
- package/dist/engine/provider.js +4 -3
- package/dist/engine/refresh.js +338 -25
- package/dist/engine/replay.js +63 -10
- package/dist/engine/report.js +248 -40
- package/dist/engine/request.js +317 -23
- package/dist/engine/sarif.js +120 -0
- package/dist/engine/scripted-login.js +342 -62
- package/dist/engine/settle.js +67 -0
- package/dist/engine/signed-in.js +256 -0
- package/dist/engine/tickets.js +671 -0
- package/dist/engine/unload.js +3 -2
- package/dist/export-run.js +633 -0
- package/dist/first-run.js +581 -0
- package/dist/installer.js +267 -3
- package/dist/intake.js +104 -0
- package/dist/login-run.js +386 -71
- package/dist/mcp-server.js +637 -68
- package/dist/playbook.js +5 -0
- package/package.json +6 -3
- package/skills/scenescout/SKILL.md +51 -18
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,29 +122,151 @@ 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" };
|
|
129
264
|
const same = current.find((c) => c.slot === sent.slot);
|
|
130
265
|
return same ? { kind: "swap", to: same } : { kind: "unknown" };
|
|
131
266
|
}
|
|
267
|
+
export function profileLoadWhileHeld(isNavigation) {
|
|
268
|
+
return isNavigation ? "cookies" : "storage";
|
|
269
|
+
}
|
|
132
270
|
/** Replace a spent token with the current one in a body, URL or header value, in whichever wire form it appears. */
|
|
133
271
|
export function swapToken(text, from, to) {
|
|
134
272
|
let out = text.split(from).join(to);
|
|
@@ -173,12 +311,103 @@ export function rotationStored(state, sent) {
|
|
|
173
311
|
* page's storage state, which has no sessionStorage, with the sessionStorage
|
|
174
312
|
* of the profile on disk kept beside it. Without it, an app whose sign-in also
|
|
175
313
|
* lives in sessionStorage would lose that half on every brokered refresh.
|
|
176
|
-
*
|
|
314
|
+
* The same goes for IndexedDB, which a storage state holds only when it was
|
|
315
|
+
* asked for: an origin the page's state has no IndexedDB entry for keeps the
|
|
316
|
+
* one on disk. An origin whose state does list IndexedDB, even an empty list,
|
|
317
|
+
* is the page's as it now is. With `storageFrom` "disk" the origins
|
|
318
|
+
* (localStorage and IndexedDB) are the profile's on disk and only the cookies
|
|
319
|
+
* are the page's (writeBackStorageFrom). A profile that could not be read
|
|
320
|
+
* (null) leaves the page's state as it is.
|
|
177
321
|
*/
|
|
178
|
-
export function profileAfterRotation(pageState, profileOnDisk) {
|
|
322
|
+
export function profileAfterRotation(pageState, profileOnDisk, storageFrom = "page") {
|
|
179
323
|
if (profileOnDisk === null)
|
|
180
324
|
return pageState;
|
|
181
|
-
|
|
325
|
+
const disk = splitProfile(profileOnDisk);
|
|
326
|
+
if (storageFrom === "disk" && pageState && typeof pageState === "object") {
|
|
327
|
+
return withSessionStorage({ ...pageState, origins: disk.storageState.origins ?? [] }, disk.sessionStorage);
|
|
328
|
+
}
|
|
329
|
+
return withSessionStorage(withIndexedDB(pageState, disk.storageState.origins), disk.sessionStorage);
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Where a write-back takes each origin's storage from (profileAfterRotation's
|
|
333
|
+
* `storageFrom`). A swap that loaded only the profile's cookies while it held
|
|
334
|
+
* a navigation (profileLoadWhileHeld) left the page's storage as it was
|
|
335
|
+
* before the other session's rotation, so writing that back would put spent
|
|
336
|
+
* tokens over the current ones on disk: the storage stays the profile's, and
|
|
337
|
+
* only the cookies, where the rotation it presented lives, are the page's.
|
|
338
|
+
* A token presented from storage is the page's to store, so its storage is
|
|
339
|
+
* taken from the page as it is now.
|
|
340
|
+
*/
|
|
341
|
+
export function writeBackStorageFrom(loaded, presented) {
|
|
342
|
+
return loaded === "cookies" && presented.cookie !== undefined ? "disk" : "page";
|
|
343
|
+
}
|
|
344
|
+
/** `state` with each disk origin's IndexedDB carried over where the state has none of its own (profileAfterRotation). */
|
|
345
|
+
function withIndexedDB(state, diskOrigins) {
|
|
346
|
+
const kept = diskOrigins.filter((o) => !!o && typeof o === "object" && typeof o.origin === "string" && Array.isArray(o.indexedDB));
|
|
347
|
+
if (kept.length === 0 || !state || typeof state !== "object")
|
|
348
|
+
return state;
|
|
349
|
+
const origins = Array.isArray(state.origins) ? [...state.origins] : [];
|
|
350
|
+
for (const disk of kept) {
|
|
351
|
+
const at = origins.findIndex((o) => !!o && typeof o === "object" && o.origin === disk.origin);
|
|
352
|
+
if (at === -1)
|
|
353
|
+
origins.push({ origin: disk.origin, localStorage: [], indexedDB: disk.indexedDB });
|
|
354
|
+
else if (origins[at].indexedDB === undefined)
|
|
355
|
+
origins[at] = { ...origins[at], indexedDB: disk.indexedDB };
|
|
356
|
+
}
|
|
357
|
+
return { ...state, origins };
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* The lines an action's result carries for what the refresh broker did since
|
|
361
|
+
* the last action result (a write-back can finish after the action that
|
|
362
|
+
* started it): one line per kind of event, counted, in a fixed order. Counts only: no
|
|
363
|
+
* token, slot value or endpoint is named. Empty when it did nothing.
|
|
364
|
+
*/
|
|
365
|
+
export function refreshNotice(events) {
|
|
366
|
+
const count = (kind) => events.filter((e) => e === kind).length;
|
|
367
|
+
const times = (n) => (n > 1 ? ` (${n} times)` : "");
|
|
368
|
+
const lines = [];
|
|
369
|
+
const refreshed = count("refreshed");
|
|
370
|
+
if (refreshed > 0)
|
|
371
|
+
lines.push(`↻ token refreshed under the role's lock and stored in its profile${times(refreshed)}`);
|
|
372
|
+
const swapped = count("swapped");
|
|
373
|
+
if (swapped > 0)
|
|
374
|
+
lines.push(`↻ another session had rotated the role's token; loaded its profile and sent the current one${times(swapped)}`);
|
|
375
|
+
const learned = count("learned");
|
|
376
|
+
if (learned > 0)
|
|
377
|
+
lines.push(`↻ learned ${learned} endpoint${learned === 1 ? "" : "s"} that rotate${learned === 1 ? "s" : ""} the role's token; brokered from now on`);
|
|
378
|
+
const failed = count("failed");
|
|
379
|
+
if (failed > 0)
|
|
380
|
+
lines.push(`⚠ ${failed === 1 ? "a refresh" : `${failed} refreshes`} could not be brokered (see the action log)`);
|
|
381
|
+
return lines.map((l) => `\n${l}`).join("");
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* The cookies a response's Set-Cookie headers set, by name. A header value may
|
|
385
|
+
* hold several cookies joined by newlines, as some browsers report them.
|
|
386
|
+
*/
|
|
387
|
+
function setCookiePairs(setCookies) {
|
|
388
|
+
const out = new Map();
|
|
389
|
+
for (const line of setCookies.flatMap((l) => l.split("\n"))) {
|
|
390
|
+
const pair = line.split(";")[0];
|
|
391
|
+
const eq = pair.indexOf("=");
|
|
392
|
+
if (eq <= 0)
|
|
393
|
+
continue;
|
|
394
|
+
out.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
|
|
395
|
+
}
|
|
396
|
+
return out;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* The known cookie tokens a response's Set-Cookie headers replace with a new
|
|
400
|
+
* token: the request that got this response refreshed them. A cookie set to an
|
|
401
|
+
* empty or short value is being cleared (a sign-out), not rotated.
|
|
402
|
+
*/
|
|
403
|
+
export function rotatedCookies(setCookies, known) {
|
|
404
|
+
const set = setCookiePairs(setCookies);
|
|
405
|
+
return known.filter((t) => {
|
|
406
|
+
if (t.cookie === undefined)
|
|
407
|
+
return false;
|
|
408
|
+
const next = set.get(t.cookie);
|
|
409
|
+
return looksLikeToken(next) && next !== t.value;
|
|
410
|
+
});
|
|
182
411
|
}
|
|
183
412
|
/**
|
|
184
413
|
* The rotated refresh token a refresh response carries, for a page that did
|
|
@@ -198,16 +427,9 @@ export function rotatedFromResponse(body, setCookies) {
|
|
|
198
427
|
// Not a JSON body: the Set-Cookie headers are all there is to read.
|
|
199
428
|
}
|
|
200
429
|
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();
|
|
430
|
+
for (const [name, value] of setCookiePairs(setCookies))
|
|
208
431
|
if (REFRESH_NAME_RE.test(name) && looksLikeToken(value))
|
|
209
432
|
found.add(value);
|
|
210
|
-
}
|
|
211
433
|
}
|
|
212
434
|
return found.size === 1 ? [...found][0] : null;
|
|
213
435
|
}
|
|
@@ -224,6 +446,26 @@ export function swapProfileToken(state, from, to) {
|
|
|
224
446
|
}
|
|
225
447
|
return s;
|
|
226
448
|
}
|
|
449
|
+
/**
|
|
450
|
+
* The headers the broker sends a request with when it has to send the request
|
|
451
|
+
* itself, which it does only to put the current token in a Cookie header (a
|
|
452
|
+
* browser keeps the Cookie header it built, whatever a route override says).
|
|
453
|
+
* The page's own headers are kept; the ones the HTTP client sets for itself
|
|
454
|
+
* (host, content length, connection, the encodings it can decode) and
|
|
455
|
+
* pseudo-headers are left out, and `cookie` is the line to send.
|
|
456
|
+
*/
|
|
457
|
+
export function headersForResend(headers, cookie) {
|
|
458
|
+
const out = {};
|
|
459
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
460
|
+
const n = name.toLowerCase();
|
|
461
|
+
if (n.startsWith(":") || ["host", "content-length", "connection", "transfer-encoding", "accept-encoding", "cookie"].includes(n))
|
|
462
|
+
continue;
|
|
463
|
+
out[n] = value;
|
|
464
|
+
}
|
|
465
|
+
if (cookie)
|
|
466
|
+
out.cookie = cookie;
|
|
467
|
+
return out;
|
|
468
|
+
}
|
|
227
469
|
// ── Whether the broker runs ──────────────────────────────────────────────────
|
|
228
470
|
export const REFRESH_BROKER_ENV = "SCENESCOUT_REFRESH_BROKER";
|
|
229
471
|
/**
|
|
@@ -255,6 +497,60 @@ export const DEFAULT_WAIT_MS = 45_000;
|
|
|
255
497
|
export function lockPathFor(profileFile) {
|
|
256
498
|
return `${profileFile}.lock`;
|
|
257
499
|
}
|
|
500
|
+
// ── Endpoints learned to rotate the token ──────────────────────────────────
|
|
501
|
+
/**
|
|
502
|
+
* Where the broker keeps the endpoints it has seen rotate the role's refresh
|
|
503
|
+
* token, beside the profile: every session of the role reads it, so a session
|
|
504
|
+
* brokers an endpoint another session learned, even in another process.
|
|
505
|
+
*/
|
|
506
|
+
export function endpointsPathFor(profileFile) {
|
|
507
|
+
return `${profileFile}.endpoints`;
|
|
508
|
+
}
|
|
509
|
+
/** At most this many learned endpoints are kept, newest first. */
|
|
510
|
+
export const MAX_LEARNED_ENDPOINTS = 20;
|
|
511
|
+
const ENDPOINT_KEY_RE = /^(GET|POST|PUT|PATCH) \/\S{0,500}$/;
|
|
512
|
+
/** The learned endpoints on disk. None when there is no file yet; a file that is not the broker's teaches nothing. */
|
|
513
|
+
export function readLearnedEndpoints(file) {
|
|
514
|
+
let raw;
|
|
515
|
+
try {
|
|
516
|
+
raw = fs.readFileSync(file, "utf8");
|
|
517
|
+
}
|
|
518
|
+
catch (err) {
|
|
519
|
+
if (err.code === "ENOENT")
|
|
520
|
+
return [];
|
|
521
|
+
throw err;
|
|
522
|
+
}
|
|
523
|
+
let parsed;
|
|
524
|
+
try {
|
|
525
|
+
parsed = JSON.parse(raw);
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
return []; // written by rename, so never half-written: whatever this is, it is not a list of endpoints
|
|
529
|
+
}
|
|
530
|
+
const list = parsed?.endpoints;
|
|
531
|
+
if (!Array.isArray(list))
|
|
532
|
+
return [];
|
|
533
|
+
return list.filter((e) => typeof e === "string" && ENDPOINT_KEY_RE.test(e)).slice(0, MAX_LEARNED_ENDPOINTS);
|
|
534
|
+
}
|
|
535
|
+
/** The list with `key` added first, once, and the oldest dropped past the cap. A key that is not an endpoint changes nothing. */
|
|
536
|
+
export function withLearnedEndpoint(list, key) {
|
|
537
|
+
if (!ENDPOINT_KEY_RE.test(key))
|
|
538
|
+
return [...list];
|
|
539
|
+
return [key, ...list.filter((e) => e !== key)].slice(0, MAX_LEARNED_ENDPOINTS);
|
|
540
|
+
}
|
|
541
|
+
/** Write the learned endpoints owner-only, by rename, so a reader never sees half a file. Call it holding the lock. */
|
|
542
|
+
export function writeLearnedEndpoints(file, list) {
|
|
543
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
544
|
+
fs.rmSync(temp, { force: true });
|
|
545
|
+
const fd = fs.openSync(temp, "wx", LOCK_FILE_MODE);
|
|
546
|
+
try {
|
|
547
|
+
fs.writeFileSync(fd, JSON.stringify({ endpoints: list }));
|
|
548
|
+
}
|
|
549
|
+
finally {
|
|
550
|
+
fs.closeSync(fd);
|
|
551
|
+
}
|
|
552
|
+
fs.renameSync(temp, file);
|
|
553
|
+
}
|
|
258
554
|
/** Whether a lock last touched at `mtimeMs` is stale at `now`. */
|
|
259
555
|
export function isStale(mtimeMs, now, staleMs) {
|
|
260
556
|
return now - mtimeMs > staleMs;
|
|
@@ -275,6 +571,20 @@ function readNonce(file) {
|
|
|
275
571
|
}
|
|
276
572
|
}
|
|
277
573
|
/** One attempt: the lock, or null when another process holds a live one. */
|
|
574
|
+
/**
|
|
575
|
+
* What an error from creating the lock file means. EEXIST: another process
|
|
576
|
+
* holds it. On Windows, creating a file whose previous copy is still being
|
|
577
|
+
* deleted (a lock just released) fails with EPERM, EBUSY or EACCES instead;
|
|
578
|
+
* that is the lock changing hands, so it is waited out like a held lock, not
|
|
579
|
+
* reported as a failure. Anything else is a real error.
|
|
580
|
+
*/
|
|
581
|
+
export function lockCreateError(code, platform = process.platform) {
|
|
582
|
+
if (code === "EEXIST")
|
|
583
|
+
return "held";
|
|
584
|
+
if (platform === "win32" && (code === "EPERM" || code === "EBUSY" || code === "EACCES"))
|
|
585
|
+
return "busy";
|
|
586
|
+
return "error";
|
|
587
|
+
}
|
|
278
588
|
export function tryAcquireLock(file, opts = {}) {
|
|
279
589
|
const staleMs = opts.staleMs ?? DEFAULT_STALE_MS;
|
|
280
590
|
const now = opts.now ?? Date.now;
|
|
@@ -289,8 +599,11 @@ export function tryAcquireLock(file, opts = {}) {
|
|
|
289
599
|
}
|
|
290
600
|
}
|
|
291
601
|
catch (err) {
|
|
292
|
-
|
|
602
|
+
const meaning = lockCreateError(err.code, opts.platform);
|
|
603
|
+
if (meaning === "error")
|
|
293
604
|
throw err;
|
|
605
|
+
if (meaning === "busy")
|
|
606
|
+
return null; // being deleted as it changes hands: try again next poll
|
|
294
607
|
let mtimeMs;
|
|
295
608
|
try {
|
|
296
609
|
mtimeMs = fs.statSync(file).mtimeMs;
|
package/dist/engine/replay.js
CHANGED
|
@@ -19,8 +19,16 @@
|
|
|
19
19
|
* escaping and the grouping are table-tested.
|
|
20
20
|
*/
|
|
21
21
|
import path from "node:path";
|
|
22
|
+
import { isSafeRelativePath } from "./plain.js";
|
|
22
23
|
/** Most frames one recorded session keeps. A long run is thousands of actions, and a project folder is not a video store. */
|
|
23
24
|
export const RECORD_MAX_FRAMES = 600;
|
|
25
|
+
/** A name reduced to one plain path segment: letters, digits, dot, dash and underscore, never leading with a dot or dash. */
|
|
26
|
+
export function plainSegment(text, fallback) {
|
|
27
|
+
return (text
|
|
28
|
+
.replace(/[^a-z0-9._-]+/gi, "-")
|
|
29
|
+
.replace(/^[.-]+/, "")
|
|
30
|
+
.slice(0, 60) || fallback);
|
|
31
|
+
}
|
|
24
32
|
/**
|
|
25
33
|
* Where a recorded frame is stored, relative to the memory directory — and
|
|
26
34
|
* the path the live view serves it at, so it is always written with forward
|
|
@@ -29,11 +37,7 @@ export const RECORD_MAX_FRAMES = 600;
|
|
|
29
37
|
* nothing about where the engine writes.
|
|
30
38
|
*/
|
|
31
39
|
export function framePath(session, index, action) {
|
|
32
|
-
|
|
33
|
-
.replace(/[^a-z0-9._-]+/gi, "-")
|
|
34
|
-
.replace(/^[.-]+/, "")
|
|
35
|
-
.slice(0, 60) || fallback;
|
|
36
|
-
return `recordings/${plain(session, "session")}/${String(index).padStart(4, "0")}-${plain(action, "step")}.jpg`;
|
|
40
|
+
return `recordings/${plainSegment(session, "session")}/${String(index).padStart(4, "0")}-${plainSegment(action, "step")}.jpg`;
|
|
37
41
|
}
|
|
38
42
|
/**
|
|
39
43
|
* The file a viewer's frame request names, or null when it is not one of this
|
|
@@ -136,9 +140,18 @@ export function evidenceFor(steps, foundAt, most = 4) {
|
|
|
136
140
|
frame: x.frame,
|
|
137
141
|
}));
|
|
138
142
|
}
|
|
143
|
+
/** A finding's own picture, shown open under it: the evidence a reader looks for first. */
|
|
144
|
+
function renderPicture(p, id, framePrefix = "", savedAt = "") {
|
|
145
|
+
const src = escapeHtml(framePrefix + p.file);
|
|
146
|
+
return (`<figure class="picture"><a class="frame" href="${src}" target="_blank" rel="noreferrer" data-testid="finding-picture-open" data-finding="${escapeHtml(id)}">` +
|
|
147
|
+
`<img loading="lazy" ${GONE} src="${src}"${p.width && p.height ? ` width="${p.width}" height="${p.height}"` : ""} alt="What the finding is about, when it was filed">` +
|
|
148
|
+
`<p class="gone-note">This picture is not beside this file. Pictures live in the run's <code>recordings/</code> folder, which travels with it.</p></a>` +
|
|
149
|
+
`<figcaption>${escapeHtml(p.caption)}${savedAt ? `<span class="onDisk">${escapeHtml(savedAt + "/" + p.file)}</span>` : ""}</figcaption></figure>`);
|
|
150
|
+
}
|
|
139
151
|
function renderEvidence(e, framePrefix = "", savedAt = "") {
|
|
152
|
+
const picture = e.picture ? renderPicture(e.picture, e.id, framePrefix, savedAt) : "";
|
|
140
153
|
if (e.frames.length === 0)
|
|
141
|
-
return
|
|
154
|
+
return picture;
|
|
142
155
|
const shots = e.frames
|
|
143
156
|
.map((f) => `<figure><img loading="lazy" ${GONE} src="${escapeHtml(framePrefix + f.frame)}" alt="The page when this step ran">` +
|
|
144
157
|
`<p class="gone-note">This frame is not beside this file. Frames live in the run's <code>recordings/</code> folder, which travels with it.</p>` +
|
|
@@ -146,8 +159,10 @@ function renderEvidence(e, framePrefix = "", savedAt = "") {
|
|
|
146
159
|
(savedAt ? `<span class="onDisk">${escapeHtml(savedAt + "/" + f.frame)}</span>` : "") +
|
|
147
160
|
`</figcaption></figure>`)
|
|
148
161
|
.join("");
|
|
149
|
-
return
|
|
162
|
+
return `${picture}<details class="evidence"><summary>Evidence — the ${e.frames.length} step${e.frames.length === 1 ? "" : "s"} on screen before this was filed</summary><div class="shots">${shots}</div></details>`;
|
|
150
163
|
}
|
|
164
|
+
/** The report's sections the header links to, by heading. */
|
|
165
|
+
const SECTION_ANCHORS = { "In plain words": "plain", "Technical detail": "technical" };
|
|
151
166
|
/**
|
|
152
167
|
* The report's markdown as elements. A deliberately small subset — headings,
|
|
153
168
|
* tables, lists, code fences, the report's own <details> repro blocks — built
|
|
@@ -184,12 +199,28 @@ export function renderMarkdown(md, evidence = [], framePrefix = "", savedAt = ""
|
|
|
184
199
|
else if ((m = /^(#{1,6}) (.*)$/.exec(line))) {
|
|
185
200
|
flush();
|
|
186
201
|
const level = Math.min(6, m[1].length + 1);
|
|
187
|
-
|
|
202
|
+
// The two parts of the report are what the header links to.
|
|
203
|
+
const anchor = m[1] === "##" ? SECTION_ANCHORS[m[2]] : undefined;
|
|
204
|
+
out.push(`<h${level}${anchor ? ` id="${anchor}"` : ""}>${inline(m[2])}</h${level}>`);
|
|
205
|
+
i += 1;
|
|
206
|
+
}
|
|
207
|
+
else if ((m = /^!\[([^\]]*)\]\(([^)\s]+)\)$/.exec(line))) {
|
|
208
|
+
// A finding's picture. The path is the report's own, relative to the run's
|
|
209
|
+
// folder, and anything else (a scheme, an absolute path, `..`) is dropped.
|
|
210
|
+
flush();
|
|
211
|
+
if (isSafeRelativePath(m[2])) {
|
|
212
|
+
const src = (m[2].startsWith("recordings/") ? framePrefix : "") + m[2];
|
|
213
|
+
out.push(`<figure class="picture"><a href="${escapeHtml(src)}" target="_blank" rel="noreferrer" data-testid="report-picture-open"><img loading="lazy" ${GONE} src="${escapeHtml(src)}" alt="${escapeHtml(m[1])}"></a>` +
|
|
214
|
+
`<p class="gone-note">This picture is not beside this file. It lives in the run's folder, which travels with it.</p>` +
|
|
215
|
+
(savedAt ? `<span class="onDisk">${escapeHtml(savedAt + "/" + m[2])}</span>` : "") +
|
|
216
|
+
`</figure>`);
|
|
217
|
+
}
|
|
188
218
|
i += 1;
|
|
189
219
|
}
|
|
190
220
|
else if ((m = /^<details><summary>(.*)<\/summary>$/.exec(line))) {
|
|
191
221
|
flush();
|
|
192
|
-
|
|
222
|
+
const testid = m[1] === "Technical detail" ? "report-technical-toggle" : "report-details-toggle";
|
|
223
|
+
out.push(`<details><summary data-testid="${testid}">${inline(m[1])}</summary>`);
|
|
193
224
|
i += 1;
|
|
194
225
|
}
|
|
195
226
|
else if (/^<\/details>$/.test(line)) {
|
|
@@ -298,6 +329,10 @@ ol.steps { list-style:none; margin:0; padding:0; }
|
|
|
298
329
|
.step .d { color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
299
330
|
.step .frame { display:block; margin:4px 0 10px; }
|
|
300
331
|
.step .frame img { max-width:min(100%,720px); max-height:360px; object-fit:cover; object-position:top; border:1px solid var(--line); border-radius:6px; display:block; }
|
|
332
|
+
figure.picture { margin:6px 0 14px; max-width:min(100%,720px); }
|
|
333
|
+
figure.picture img { max-width:100%; height:auto; border:1px solid var(--line); border-radius:6px; display:block; background:var(--panel); }
|
|
334
|
+
figure.picture figcaption { margin-top:4px; color:var(--muted); font-size:12px; }
|
|
335
|
+
figure.picture figcaption .onDisk { display:block; font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; overflow-wrap:anywhere; }
|
|
301
336
|
details.evidence { margin:6px 0 18px; padding:8px 12px; background:var(--panel); border:1px solid var(--line); border-radius:8px; }
|
|
302
337
|
details.evidence > summary { cursor:pointer; color:var(--muted); font-size:13px; }
|
|
303
338
|
details.evidence .shots { display:flex; flex-wrap:wrap; gap:14px; margin-top:12px; }
|
|
@@ -317,6 +352,11 @@ header .served code { font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,m
|
|
|
317
352
|
figure.gone .gone-note, a.gone .gone-note { display:block; }
|
|
318
353
|
figure.gone img, a.gone img { display:none; }
|
|
319
354
|
a.frame.gone { display:block; max-width:min(100%,720px); }
|
|
355
|
+
figure.picture { margin:8px 0 14px; max-width:min(100%,720px); }
|
|
356
|
+
figure.picture img { width:auto; max-width:100%; max-height:420px; border:1px solid var(--line); border-radius:6px; display:block; background:var(--panel); }
|
|
357
|
+
details > summary { cursor:pointer; }
|
|
358
|
+
details:not(.session):not(.evidence) { margin:4px 0 18px; padding:6px 12px; border:1px solid var(--line); border-radius:8px; background:var(--panel); }
|
|
359
|
+
details:not(.session):not(.evidence) > summary { color:var(--muted); font-size:13px; }
|
|
320
360
|
details.evidence figcaption { margin-top:4px; color:var(--muted); font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; overflow-wrap:anywhere; }
|
|
321
361
|
`;
|
|
322
362
|
/**
|
|
@@ -366,6 +406,19 @@ function exitWatch(savedFile) {
|
|
|
366
406
|
" });\n" +
|
|
367
407
|
"})();</script>");
|
|
368
408
|
}
|
|
409
|
+
/** The header's links: the report's parts it holds, then the steps. */
|
|
410
|
+
function navLinks(markdown) {
|
|
411
|
+
const has = (heading) => markdown.split("\n").includes(`## ${heading}`);
|
|
412
|
+
return [
|
|
413
|
+
has("In plain words")
|
|
414
|
+
? `<a href="#plain" data-testid="report-plain-link">In plain words</a>`
|
|
415
|
+
: `<a href="#report" data-testid="report-top-link">Report</a>`,
|
|
416
|
+
has("Technical detail") ? `<a href="#technical" data-testid="report-technical-link">Technical detail</a>` : "",
|
|
417
|
+
`<a href="#steps" data-testid="report-steps-link">Steps</a>`,
|
|
418
|
+
]
|
|
419
|
+
.filter(Boolean)
|
|
420
|
+
.join("");
|
|
421
|
+
}
|
|
369
422
|
/** The whole document: one file, no external assets, opens from the file system. */
|
|
370
423
|
export function buildReplayHtml(input) {
|
|
371
424
|
const framed = input.sessions.some((s) => s.steps.some((x) => x.frame));
|
|
@@ -385,7 +438,7 @@ export function buildReplayHtml(input) {
|
|
|
385
438
|
<h1>SceneScout run</h1>
|
|
386
439
|
<span class="meta">${escapeHtml(input.project)} · written ${escapeHtml(stamp(input.at))}${input.version ? ` · v${escapeHtml(input.version)}` : ""}</span>
|
|
387
440
|
${savedAt ? `<p class="served">This page is served by the engine and goes when it does. The copy that stays is <code>${escapeHtml(savedAt)}/report.html</code>, beside the frames it shows.</p>` : ""}
|
|
388
|
-
<nav
|
|
441
|
+
<nav>${navLinks(input.markdown)}</nav>
|
|
389
442
|
</header>
|
|
390
443
|
${savedAt
|
|
391
444
|
? `<div id="gone-bar" role="alert" data-testid="run-engine-gone" hidden>
|