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.
Files changed (59) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +90 -18
  3. package/dist/browsers.js +28 -0
  4. package/dist/check-run.js +211 -24
  5. package/dist/ci-run.js +374 -62
  6. package/dist/cli.js +221 -47
  7. package/dist/commands.js +42 -4
  8. package/dist/engine/baseline.js +377 -0
  9. package/dist/engine/brief.js +16 -7
  10. package/dist/engine/browser.js +1660 -386
  11. package/dist/engine/calibration.js +61 -30
  12. package/dist/engine/capture.js +164 -0
  13. package/dist/engine/check.js +258 -68
  14. package/dist/engine/ci-lanes.js +215 -0
  15. package/dist/engine/ci.js +233 -18
  16. package/dist/engine/claims.js +234 -12
  17. package/dist/engine/collector.js +851 -78
  18. package/dist/engine/crawl.js +49 -0
  19. package/dist/engine/dedup.js +329 -9
  20. package/dist/engine/design.js +355 -43
  21. package/dist/engine/export.js +877 -0
  22. package/dist/engine/fingerprint.js +92 -4
  23. package/dist/engine/flow.js +18 -6
  24. package/dist/engine/forms.js +198 -18
  25. package/dist/engine/journey.js +29 -1
  26. package/dist/engine/lane.js +13 -3
  27. package/dist/engine/launch.js +45 -6
  28. package/dist/engine/limits.js +7 -0
  29. package/dist/engine/live-page.js +49 -2
  30. package/dist/engine/live.js +4 -1
  31. package/dist/engine/memory.js +668 -70
  32. package/dist/engine/open.js +118 -0
  33. package/dist/engine/oracles.js +167 -2
  34. package/dist/engine/plain.js +268 -0
  35. package/dist/engine/png.js +127 -0
  36. package/dist/engine/policy.js +379 -9
  37. package/dist/engine/probes.js +3 -2
  38. package/dist/engine/profiles.js +45 -9
  39. package/dist/engine/project-folder.js +191 -0
  40. package/dist/engine/provider.js +4 -3
  41. package/dist/engine/refresh.js +338 -25
  42. package/dist/engine/replay.js +63 -10
  43. package/dist/engine/report.js +248 -40
  44. package/dist/engine/request.js +317 -23
  45. package/dist/engine/sarif.js +120 -0
  46. package/dist/engine/scripted-login.js +342 -62
  47. package/dist/engine/settle.js +67 -0
  48. package/dist/engine/signed-in.js +256 -0
  49. package/dist/engine/tickets.js +671 -0
  50. package/dist/engine/unload.js +3 -2
  51. package/dist/export-run.js +633 -0
  52. package/dist/first-run.js +581 -0
  53. package/dist/installer.js +267 -3
  54. package/dist/intake.js +104 -0
  55. package/dist/login-run.js +386 -71
  56. package/dist/mcp-server.js +637 -68
  57. package/dist/playbook.js +5 -0
  58. package/package.json +6 -3
  59. package/skills/scenescout/SKILL.md +51 -18
@@ -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. No two sessions ever present one token.
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
- * The known refresh token a request carries — in its body, a header (a Cookie
110
- * header included) or its URL — or null. `known` is what the session last
111
- * loaded from the profile; a token the page got some other way is not the
112
- * role's and is left alone.
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 presentedToken(req, known) {
140
+ export function tokenPresence(req, known) {
115
141
  if (known.length === 0)
116
142
  return null;
117
- const haystacks = [req.body ?? "", req.url, ...Object.values(req.headers)];
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
- for (const form of wireForms(t.value)) {
120
- if (haystacks.some((h) => h.includes(form)))
121
- return t;
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
- * A profile that could not be read (null) leaves the page's state as it is.
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
- return withSessionStorage(pageState, splitProfile(profileOnDisk).sessionStorage);
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 line of setCookies) {
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
- if (err.code !== "EEXIST")
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;
@@ -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
- const plain = (text, fallback) => text
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 `<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>`;
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
- out.push(`<h${level}>${inline(m[2])}</h${level}>`);
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
- out.push(`<details><summary>${inline(m[1])}</summary>`);
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><a href="#report">Report</a><a href="#steps">Steps</a></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>