scenescout 3.13.0 → 3.14.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.
@@ -0,0 +1,254 @@
1
+ /**
2
+ * How long a saved sign-in profile will last, read from the profile itself:
3
+ * each cookie's `expires`, and the `exp` claim of any JWT found in a cookie
4
+ * value or a localStorage value. A JWT's payload is decoded to read `exp` and
5
+ * nothing else; its signature is never checked (there is no key to check it
6
+ * with, and nothing here trusts it), and no token value is ever returned,
7
+ * printed or logged — only the name of the cookie or storage key it was in.
8
+ *
9
+ * Used twice: `scenescout login` says how long the profile it just saved will
10
+ * last, and scout_lane_brief refuses to hand out lanes that attach by a role
11
+ * whose profile will not outlast the run.
12
+ *
13
+ * The verdict refuses only when it is certain. A profile holds more than the
14
+ * sign-in: analytics cookies that expire in a minute, preference cookies that
15
+ * last a year. So the profile counts as lasting until its LAST dated
16
+ * credential expires, and a session cookie with no date (it lives until the
17
+ * browser closes, and the server decides when it stops working) means the
18
+ * end is unknown. The first credential to expire is still reported, as a
19
+ * warning when it falls inside the run.
20
+ *
21
+ * Playwright-free and table-tested in profiles-test.
22
+ */
23
+ import fs from "node:fs";
24
+ import { sayDuration } from "./pace.js";
25
+ /** Default run length the lane check assumes when the planner names none. */
26
+ export const DEFAULT_RUN_MINUTES = 60;
27
+ /** Default slack past the run's end a profile must still last, so a lane is not signed out while writing its report. */
28
+ export const DEFAULT_EXPIRY_MARGIN_MINUTES = 10;
29
+ /** A JWT: three base64url parts, the first two starting with `{"` (eyJ). */
30
+ const JWT_RE = /eyJ[A-Za-z0-9_-]{2,}\.eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*/g;
31
+ /** Longest value scanned for tokens; a larger one is skipped rather than searched. */
32
+ const MAX_SCAN_CHARS = 64 * 1024;
33
+ /** Most tokens read out of one value. */
34
+ const MAX_TOKENS_PER_VALUE = 8;
35
+ /** The `exp` of a JWT in epoch ms, or null when the payload cannot be read or has none. Never verifies the signature. */
36
+ export function jwtExpiry(token) {
37
+ const parts = token.split(".");
38
+ if (parts.length !== 3)
39
+ return null;
40
+ let payload;
41
+ try {
42
+ payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ if (!payload || typeof payload !== "object")
48
+ return null;
49
+ const exp = payload.exp;
50
+ if (typeof exp !== "number" || !Number.isFinite(exp) || exp <= 0)
51
+ return null;
52
+ return exp * 1000;
53
+ }
54
+ /** Every JWT expiry in a value, and how many JWT-shaped strings in it had none readable. URL-encoded values are decoded first. */
55
+ function tokenExpiries(raw) {
56
+ if (raw.length > MAX_SCAN_CHARS)
57
+ return { expiries: [], unreadable: 0 };
58
+ let value = raw;
59
+ if (value.includes("%")) {
60
+ try {
61
+ value = decodeURIComponent(value);
62
+ }
63
+ catch {
64
+ // Not valid percent-encoding: scan it as it is.
65
+ }
66
+ }
67
+ const found = (value.match(JWT_RE) ?? []).slice(0, MAX_TOKENS_PER_VALUE);
68
+ const expiries = [];
69
+ let unreadable = 0;
70
+ for (const token of found) {
71
+ const at = jwtExpiry(token);
72
+ if (at === null)
73
+ unreadable += 1;
74
+ else
75
+ expiries.push(at);
76
+ }
77
+ return { expiries, unreadable };
78
+ }
79
+ /** Whether a cookie set for `domain` is sent to `host`, per the cookie domain-match rule. */
80
+ export function cookieMatchesHost(domain, host) {
81
+ const d = domain.replace(/^\./, "").toLowerCase();
82
+ const h = host.toLowerCase();
83
+ return h === d || h.endsWith(`.${d}`);
84
+ }
85
+ /**
86
+ * Read every credential a storage state holds. With `url`, only cookies sent
87
+ * to its host and localStorage of its origin count: the rest belong to other
88
+ * sites the sign-in passed through (an identity provider, say) and do not
89
+ * sign the app in.
90
+ */
91
+ export function readLifetime(state, opts = {}) {
92
+ const s = (state && typeof state === "object" ? state : {});
93
+ let target;
94
+ if (opts.url) {
95
+ try {
96
+ target = new URL(opts.url);
97
+ }
98
+ catch {
99
+ target = undefined;
100
+ }
101
+ }
102
+ const credentials = [];
103
+ let unreadableTokens = 0;
104
+ let elsewhere = 0;
105
+ for (const c of Array.isArray(s.cookies) ? s.cookies : []) {
106
+ if (!c || typeof c !== "object")
107
+ continue;
108
+ const cookie = c;
109
+ if (typeof cookie.name !== "string")
110
+ continue;
111
+ if (target && typeof cookie.domain === "string" && !cookieMatchesHost(cookie.domain, target.hostname)) {
112
+ elsewhere += 1;
113
+ continue;
114
+ }
115
+ const dated = typeof cookie.expires === "number" && Number.isFinite(cookie.expires) && cookie.expires > 0 ? cookie.expires * 1000 : null;
116
+ const tokens = typeof cookie.value === "string" ? tokenExpiries(cookie.value) : { expiries: [], unreadable: 0 };
117
+ unreadableTokens += tokens.unreadable;
118
+ if (tokens.expiries.length > 0) {
119
+ // The cookie stops working at whichever comes first: the browser
120
+ // dropping it, or the server refusing the token inside it.
121
+ const jwtAt = Math.min(...tokens.expiries);
122
+ const at = dated === null ? jwtAt : Math.min(dated, jwtAt);
123
+ credentials.push({ source: at === dated ? "cookie" : "cookie-jwt", name: cookie.name, expiresAt: at });
124
+ }
125
+ else {
126
+ credentials.push({ source: "cookie", name: cookie.name, expiresAt: dated });
127
+ }
128
+ }
129
+ for (const o of Array.isArray(s.origins) ? s.origins : []) {
130
+ if (!o || typeof o !== "object")
131
+ continue;
132
+ const origin = o;
133
+ if (target && origin.origin !== target.origin)
134
+ continue;
135
+ for (const item of Array.isArray(origin.localStorage) ? origin.localStorage : []) {
136
+ if (!item || typeof item !== "object")
137
+ continue;
138
+ const entry = item;
139
+ if (typeof entry.name !== "string" || typeof entry.value !== "string")
140
+ continue;
141
+ // A localStorage entry with no token in it is not a credential this can
142
+ // date (most are preferences), so it is not counted at all.
143
+ const tokens = tokenExpiries(entry.value);
144
+ unreadableTokens += tokens.unreadable;
145
+ if (tokens.expiries.length > 0)
146
+ credentials.push({ source: "local-storage-jwt", name: entry.name, expiresAt: Math.min(...tokens.expiries) });
147
+ // A token with no readable expiry (a refresh token often has none) may
148
+ // keep the sign-in alive past every dated one: undated, not ignored.
149
+ else if (tokens.unreadable > 0)
150
+ credentials.push({ source: "local-storage-jwt", name: entry.name, expiresAt: null });
151
+ }
152
+ }
153
+ const dated = credentials.filter((c) => c.expiresAt !== null).sort((a, b) => a.expiresAt - b.expiresAt);
154
+ return { credentials, dated, undated: credentials.length - dated.length, elsewhere, unreadableTokens };
155
+ }
156
+ /** A span as a person says it; days past two, since "73h00m" reads badly. */
157
+ export function sayLifetime(ms) {
158
+ const days = Math.floor(ms / 86_400_000);
159
+ return days >= 2 ? `${days} days` : sayDuration(ms);
160
+ }
161
+ function describeCredential(c) {
162
+ const where = c.source === "cookie" ? "cookie" : c.source === "cookie-jwt" ? "token in cookie" : "token in localStorage";
163
+ return `${where} "${c.name.slice(0, 60)}"`;
164
+ }
165
+ /**
166
+ * The line `scenescout login` prints after saving: how long the profile will
167
+ * last, and what that is read from. Names only, never values.
168
+ */
169
+ export function describeLifetime(life, now) {
170
+ const live = life.dated.filter((c) => c.expiresAt > now);
171
+ const last = live.at(-1);
172
+ const first = live[0];
173
+ const undatedNote = life.undated > 0
174
+ ? `${life.undated} credential${life.undated === 1 ? "" : "s"} with no date (a session cookie, or a token with no expiry; the server decides when ${life.undated === 1 ? "it stops" : "they stop"} working)`
175
+ : "";
176
+ if (!last) {
177
+ if (life.dated.length > 0 && life.undated === 0)
178
+ return "Lasts: every dated credential in it has already expired, so it is unlikely to sign anyone in. Sign in again.";
179
+ if (life.undated > 0)
180
+ return `Lasts: unknown — ${life.dated.length > 0 ? "its dated credentials have already expired, leaving" : "no expiry found, only"} ${undatedNote}.`;
181
+ return "Lasts: unknown — no cookie or token with an expiry was found in it.";
182
+ }
183
+ const lastIn = sayLifetime(last.expiresAt - now);
184
+ let line = life.undated > 0
185
+ ? `Lasts: unknown — the last dated credential, ${describeCredential(last)}, ends in ${lastIn}, but it also holds ${undatedNote}.`
186
+ : `Lasts: about ${lastIn} (the last dated credential, ${describeCredential(last)}).`;
187
+ if (first && first !== last)
188
+ line += ` The first to expire is ${describeCredential(first)}, in ${sayLifetime(first.expiresAt - now)}.`;
189
+ return line;
190
+ }
191
+ /**
192
+ * Judge a profile against a run of `runMs` plus `marginMs`. Refused only when
193
+ * every credential it holds is dated, no cookie for another host was left
194
+ * out, and the last of them ends before the run does; see the header for why the last and not the first. `rerun` is the
195
+ * command that records the profile again.
196
+ */
197
+ export function judgeLifetime(life, opts) {
198
+ const needUntil = opts.now + opts.runMs + opts.marginMs;
199
+ const needs = `a run of ${sayLifetime(opts.runMs)} plus ${sayLifetime(opts.marginMs)} margin`;
200
+ const redo = `Run \`${opts.rerun}\` again, sign in, then plan the lanes again.`;
201
+ if (life.dated.length === 0) {
202
+ return {
203
+ kind: "unknown",
204
+ message: `No expiry was found in the saved sign-in for role "${opts.role}"${life.undated > 0 ? " (only credentials with no date)" : ""}, so whether it lasts the run is unknown. If lanes start landing on the sign-in page, run \`${opts.rerun}\` again.`,
205
+ };
206
+ }
207
+ const last = life.dated.at(-1);
208
+ const first = life.dated[0];
209
+ const lastAt = last.expiresAt;
210
+ // Certain only when every credential the app is sent is dated and nothing
211
+ // was left out that could still be keeping the sign-in alive.
212
+ const certain = life.undated === 0 && life.elsewhere === 0;
213
+ if (certain && lastAt <= opts.now) {
214
+ return {
215
+ kind: "refuse",
216
+ message: `The saved sign-in for role "${opts.role}" has expired: every dated credential in it ended, the last ${sayLifetime(opts.now - lastAt)} ago. ${redo}`,
217
+ };
218
+ }
219
+ if (certain && lastAt < needUntil) {
220
+ return {
221
+ kind: "refuse",
222
+ message: `The saved sign-in for role "${opts.role}" will not last the run: it ends in ${sayLifetime(lastAt - opts.now)} (${describeCredential(last)}), and the lanes need ${needs}. ${redo} (A shorter run or a smaller margin can be passed as runMinutes / expiryMarginMinutes.)`,
223
+ };
224
+ }
225
+ if (first.expiresAt < needUntil) {
226
+ const firstAt = first.expiresAt;
227
+ const when = firstAt <= opts.now ? `expired ${sayLifetime(opts.now - firstAt)} ago` : `expires in ${sayLifetime(firstAt - opts.now)}`;
228
+ return {
229
+ kind: "warn",
230
+ message: `In the saved sign-in for role "${opts.role}", ${describeCredential(first)} ${when}, inside ${needs}. It is not the only credential, so the sign-in may outlast it; if lanes start landing on the sign-in page, run \`${opts.rerun}\` again.`,
231
+ };
232
+ }
233
+ return { kind: "ok" };
234
+ }
235
+ /** Read a profile file and judge it. A file that cannot be read or parsed is thrown: attach would fail on it too. */
236
+ export function judgeProfileFile(file, opts, read = (p) => fs.readFileSync(p, "utf8")) {
237
+ let text;
238
+ try {
239
+ text = read(file);
240
+ }
241
+ catch (err) {
242
+ const code = err.code ?? "read error";
243
+ throw new Error(`the saved sign-in for role "${opts.role}" could not be read (${code}); run \`${opts.rerun}\` again`);
244
+ }
245
+ let state;
246
+ try {
247
+ state = JSON.parse(text);
248
+ }
249
+ catch {
250
+ // The parser's message quotes the text around the fault, and the text is a live session: say only that it is not JSON.
251
+ throw new Error(`the saved sign-in for role "${opts.role}" is not valid JSON; run \`${opts.rerun}\` again`);
252
+ }
253
+ return judgeLifetime(readLifetime(state, { url: opts.url }), opts);
254
+ }
@@ -97,11 +97,11 @@ export function elementKey(el) {
97
97
  export function fingerprintState(url, elements) {
98
98
  const route = normalizePath(url);
99
99
  const keys = [...new Set(elements.map(elementKey))].sort();
100
- const hash = createHash("sha1").update(keys.join("|")).digest("hex").slice(0, 8);
100
+ const hash = createHash("sha256").update(keys.join("|")).digest("hex").slice(0, 8);
101
101
  return `${route}#${hash}`;
102
102
  }
103
103
  export function shortHash(input) {
104
- return createHash("sha1").update(input).digest("hex").slice(0, 10);
104
+ return createHash("sha256").update(input).digest("hex").slice(0, 10);
105
105
  }
106
106
  /**
107
107
  * A route as a lane wrote it, with every query string and every fragment that
@@ -24,8 +24,6 @@ import { MEMORY_DIRNAME, SELF_IGNORE_KEEP } from "./memory.js";
24
24
  export const MAX_FLOW_STEPS = 50;
25
25
  /** Most flows one check replays. */
26
26
  export const MAX_FLOWS = 50;
27
- /** How long a step waits for its target, text, URL or request before it counts as broken. */
28
- export const FLOW_STEP_TIMEOUT_MS = 5000;
29
27
  /**
30
28
  * How long a flow waits after its last step for a write that step set off
31
29
  * late (a save on a timer, a debounced autosave), before it counts as passed.
@@ -0,0 +1,144 @@
1
+ /**
2
+ * How long the engine waits for one action on the page (a click, typing, a
3
+ * hover, a pick from a list) and for one page to load, before it calls the
4
+ * wait a failure.
5
+ *
6
+ * On a loaded machine — a shared CI runner, a laptop building something else —
7
+ * the defaults can run out while the app is fine, and the run then reports a
8
+ * timeout that belongs to the machine. So both are settings: a scout_attach
9
+ * option (or a `scenescout check` / `ci` flag) wins over an environment
10
+ * variable, which wins over the default. The defaults are the values the
11
+ * engine has always used.
12
+ *
13
+ * It lives apart from browser.ts so the precedence, the bounds and the
14
+ * parsing are table-tested (scripts/limits-test.ts) without a browser.
15
+ */
16
+ export const DEFAULT_ACTION_TIMEOUT_MS = 5000;
17
+ export const DEFAULT_NAV_TIMEOUT_MS = 20000;
18
+ /** The crawl has always given each route less than a deliberate navigation. */
19
+ export const DEFAULT_CRAWL_NAV_TIMEOUT_MS = 15000;
20
+ /** Going back and a popup's load have always had the shortest wait. */
21
+ export const DEFAULT_BACK_NAV_TIMEOUT_MS = 10000;
22
+ export const DEFAULT_TIME_LIMITS = {
23
+ actionMs: DEFAULT_ACTION_TIMEOUT_MS,
24
+ navMs: DEFAULT_NAV_TIMEOUT_MS,
25
+ crawlNavMs: DEFAULT_CRAWL_NAV_TIMEOUT_MS,
26
+ backNavMs: DEFAULT_BACK_NAV_TIMEOUT_MS,
27
+ };
28
+ export const ACTION_TIMEOUT_ENV = "SCENESCOUT_ACTION_TIMEOUT_MS";
29
+ export const NAV_TIMEOUT_ENV = "SCENESCOUT_NAV_TIMEOUT_MS";
30
+ /** Inclusive bounds. Under a second is no wait at all; past the maximum a stuck page holds the run for minutes per step. */
31
+ export const LIMIT_BOUNDS = {
32
+ action: { min: 1000, max: 120_000 },
33
+ nav: { min: 1000, max: 300_000 },
34
+ };
35
+ /** How each limit is set, by name, for errors and for the hint a timeout carries. */
36
+ export const LIMIT_NAMES = {
37
+ action: { what: "action limit", option: "actionTimeoutMs", flag: "--action-timeout-ms", env: ACTION_TIMEOUT_ENV },
38
+ nav: { what: "page-load limit", option: "navTimeoutMs", flag: "--nav-timeout-ms", env: NAV_TIMEOUT_ENV },
39
+ };
40
+ function boundsText(kind) {
41
+ const { min, max } = LIMIT_BOUNDS[kind];
42
+ return `a whole number of milliseconds from ${min} to ${max}`;
43
+ }
44
+ /** Check one value against its bounds. `source` names where it came from, so the error says what to fix. */
45
+ export function checkLimit(kind, value, source) {
46
+ const { min, max } = LIMIT_BOUNDS[kind];
47
+ if (!Number.isInteger(value) || value < min || value > max) {
48
+ throw new Error(`${source} must be ${boundsText(kind)} (got ${value}).`);
49
+ }
50
+ return value;
51
+ }
52
+ /**
53
+ * Read one limit from text (an environment variable, a CLI flag). Digits only:
54
+ * "5s", "5000ms", "1e4" and "-1" are refused rather than guessed at.
55
+ */
56
+ export function parseLimit(kind, raw, source) {
57
+ const text = raw.trim();
58
+ if (!/^\d+$/.test(text))
59
+ throw new Error(`${source} must be ${boundsText(kind)} (got "${raw}").`);
60
+ return checkLimit(kind, Number(text), source);
61
+ }
62
+ /** A CLI flag's value, as the argument parsers want it: a number or a sentence. */
63
+ export function parseLimitFlag(kind, raw) {
64
+ if (raw === undefined)
65
+ return { ok: true, value: undefined };
66
+ try {
67
+ return { ok: true, value: parseLimit(kind, raw, LIMIT_NAMES[kind].flag) };
68
+ }
69
+ catch (err) {
70
+ return { ok: false, error: err.message.replace(/\.$/, "") };
71
+ }
72
+ }
73
+ function pick(kind, option, env) {
74
+ const names = LIMIT_NAMES[kind];
75
+ if (option !== undefined)
76
+ return checkLimit(kind, option, names.option);
77
+ const raw = env[names.env];
78
+ if (raw === undefined || raw.trim() === "")
79
+ return undefined;
80
+ return parseLimit(kind, raw, names.env);
81
+ }
82
+ /**
83
+ * Only the limits that were set, by option or environment, each checked. For
84
+ * a command with defaults of its own (signing in to save a login profile
85
+ * waits longer than an exploring session), which applies a set limit and
86
+ * otherwise keeps its own.
87
+ */
88
+ export function explicitLimits(options, env) {
89
+ const action = pick("action", options.actionTimeoutMs, env);
90
+ const nav = pick("nav", options.navTimeoutMs, env);
91
+ return { ...(action !== undefined ? { actionMs: action } : {}), ...(nav !== undefined ? { navMs: nav } : {}) };
92
+ }
93
+ /**
94
+ * The limits a session runs with: the option when given, else the
95
+ * environment variable when set, else the default. A value out of bounds
96
+ * throws, naming where it came from — an attach never starts on a limit it
97
+ * would silently replace.
98
+ */
99
+ export function resolveTimeLimits(options, env) {
100
+ const { actionMs: action, navMs: nav } = explicitLimits(options, env);
101
+ return {
102
+ actionMs: action ?? DEFAULT_ACTION_TIMEOUT_MS,
103
+ navMs: nav ?? DEFAULT_NAV_TIMEOUT_MS,
104
+ crawlNavMs: nav ?? DEFAULT_CRAWL_NAV_TIMEOUT_MS,
105
+ backNavMs: nav ?? DEFAULT_BACK_NAV_TIMEOUT_MS,
106
+ };
107
+ }
108
+ /**
109
+ * A tool call's watchdog, given the session's limits. The watchdogs are sized
110
+ * for the default limits; a session that raised them gets the watchdog
111
+ * lengthened by as much, so a raised limit is what ends a slow call, with its
112
+ * hint, rather than the watchdog. An action is counted twice: a click that
113
+ * timed out is retried once. Never shorter than the base.
114
+ */
115
+ export function watchdogFor(baseMs, limits) {
116
+ const moreAction = Math.max(0, limits.actionMs - DEFAULT_ACTION_TIMEOUT_MS);
117
+ const moreNav = Math.max(0, limits.navMs - DEFAULT_NAV_TIMEOUT_MS, limits.crawlNavMs - DEFAULT_CRAWL_NAV_TIMEOUT_MS);
118
+ return baseMs + 2 * moreAction + moreNav;
119
+ }
120
+ /** True for a Playwright timeout ("Timeout 5000ms exceeded", "page.goto: Timeout 20000ms exceeded."). */
121
+ export function isTimeoutMessage(message) {
122
+ return /\bTimeout \d+ms exceeded/i.test(message);
123
+ }
124
+ const HINT_MARK = "ran out. If the machine is loaded";
125
+ /** What to say when a limit ran out: which one, how long it was, and how to raise it. */
126
+ export function limitHint(kind, ms) {
127
+ const n = LIMIT_NAMES[kind];
128
+ return (`the ${n.what} (${ms} ms) ${HINT_MARK} rather than the app slow, raise it: ` +
129
+ `${n.option} on scout_attach, ${n.flag} on scenescout check or ci, or ${n.env} in the environment`);
130
+ }
131
+ /**
132
+ * The same error with the hint added when it is a timeout, unchanged otherwise.
133
+ * The hint goes on line 1, since callers cut a Playwright error to its first
134
+ * line; the call log below it is kept. An error that already carries a hint is
135
+ * returned as it is.
136
+ */
137
+ export function explainTimeout(err, kind, ms) {
138
+ if (!(err instanceof Error) || !isTimeoutMessage(err.message) || err.message.includes(HINT_MARK))
139
+ return err;
140
+ const [first, ...rest] = err.message.split("\n");
141
+ const out = new Error([`${first} — ${limitHint(kind, ms)}.`, ...rest].join("\n"));
142
+ out.name = err.name;
143
+ return out;
144
+ }
@@ -259,6 +259,11 @@ export const LIVE_PAGE = `<!doctype html>
259
259
  // was, unless the viewer used the close-up's own Stream button, whose choice
260
260
  // stands.
261
261
  var focusStartedStream = false;
262
+ // The close-up's still, loaded off-screen and swapped in once it has
263
+ // arrived: one request at a time, and a single failed capture leaves the
264
+ // picture up rather than blanking the stage until the next poll.
265
+ var focusStill = null;
266
+ var focusStillFailures = 0;
262
267
  var skew = 0;
263
268
  var latest = {};
264
269
  var focusTick = 0;
@@ -358,7 +363,11 @@ export const LIVE_PAGE = `<!doctype html>
358
363
  // Frames for a live card arrive over the shared connection; the thumbnail poll takes over again when it is switched off.
359
364
  if (on && frames[card.name]) card.img.src = frames[card.name];
360
365
  if (!on) card.img.src = shotUrl(card.name);
361
- if (focused === card.name) paintFocusStream();
366
+ if (focused === card.name) {
367
+ // A failure counted before streaming began says nothing about the stills after it.
368
+ focusStillFailures = 0;
369
+ paintFocusStream();
370
+ }
362
371
  syncEvents();
363
372
  }
364
373
  // The close-up's Stream button is the card's, shown where the viewer is looking.
@@ -370,7 +379,32 @@ export const LIVE_PAGE = `<!doctype html>
370
379
  button.setAttribute('aria-pressed', card.live ? 'true' : 'false');
371
380
  button.textContent = card.live ? 'Streaming' : 'Stream';
372
381
  // Off, the picture is a still that the status poll refreshes.
373
- if (!card.live && !scrubbed) document.getElementById('focus-img').src = shotUrl(card.name);
382
+ if (!card.live && !scrubbed) refreshFocusStill(card.name);
383
+ }
384
+ function refreshFocusStill(name) {
385
+ if (focusStill) return;
386
+ var next = new Image();
387
+ focusStill = next;
388
+ function current() {
389
+ var card = cards[name];
390
+ return focusStill === next && focused === name && card && !card.live && !scrubbed;
391
+ }
392
+ next.onload = function () {
393
+ if (current()) {
394
+ focusStillFailures = 0;
395
+ document.getElementById('focus-img').src = next.src;
396
+ }
397
+ if (focusStill === next) focusStill = null;
398
+ };
399
+ next.onerror = function () {
400
+ if (current()) {
401
+ focusStillFailures += 1;
402
+ // One failure can be a capture that missed; two in a row is a session with nothing to show.
403
+ if (focusStillFailures >= 2) document.getElementById('focus-stage').classList.add('empty');
404
+ }
405
+ if (focusStill === next) focusStill = null;
406
+ };
407
+ next.src = shotUrl(name);
374
408
  }
375
409
  function refreshThumb(card) {
376
410
  if (card.live || document.hidden) return;
@@ -426,7 +460,12 @@ export const LIVE_PAGE = `<!doctype html>
426
460
  root.appendChild(top); root.appendChild(task); root.appendChild(doing); root.appendChild(pace); root.appendChild(tool); root.appendChild(url); root.appendChild(shot); root.appendChild(feed); root.appendChild(foot);
427
461
 
428
462
  var card = { name: name, root: root, role: role, badge: badge, task: task, doing: doing, pace: pace, tool: tool, url: url, shot: shot, img: img, feed: feed, toggle: toggle, spec: spec, live: false };
429
- toggle.addEventListener('click', function () { setLive(card, !card.live); });
463
+ toggle.addEventListener('click', function () {
464
+ // Reachable from the keyboard behind an open close-up: a choice made on
465
+ // the card stands after the close-up closes, as one made in it does.
466
+ if (focused === name) focusStartedStream = false;
467
+ setLive(card, !card.live);
468
+ });
430
469
  shot.addEventListener('click', function () { openFocus(name); });
431
470
  img.src = shotUrl(name);
432
471
  if (streamAll) setLive(card, true);
@@ -798,7 +837,14 @@ export const LIVE_PAGE = `<!doctype html>
798
837
  }
799
838
 
800
839
  function openFocus(name) {
840
+ // Reachable from the keyboard with a close-up already open: hand the first
841
+ // session back as it was before showing the next, or a stream it started
842
+ // is left running with nothing to switch it off.
843
+ if (focused === name) return;
844
+ if (focused) closeFocus();
801
845
  focused = name;
846
+ focusStill = null;
847
+ focusStillFailures = 0;
802
848
  scrubbed = null;
803
849
  // The ticks belong to the session just left; showing them under this one's
804
850
  // name, and playing its frames when one is clicked, is worse than none.
@@ -829,6 +875,7 @@ export const LIVE_PAGE = `<!doctype html>
829
875
  document.getElementById('focus-img').removeAttribute('src');
830
876
  if (focusStartedStream && was && cards[was]) setLive(cards[was], false);
831
877
  focusStartedStream = false;
878
+ focusStill = null;
832
879
  syncEvents();
833
880
  if (was && cards[was]) cards[was].shot.focus();
834
881
  }
@@ -885,10 +932,12 @@ export const LIVE_PAGE = `<!doctype html>
885
932
  var s = focused && latest[focused];
886
933
  var line = document.getElementById('focus-line');
887
934
  paintBrief();
935
+ // Before the early return: a session that has closed has no card, and its
936
+ // Stream button must go with it rather than stay up doing nothing.
937
+ paintFocusStream();
888
938
  if (!s) { line.textContent = focused ? 'This session has closed.' : ''; return; }
889
939
  var d = describe(s);
890
940
  line.textContent = d.badge + ' · ' + d.tool + ' · ' + (s.url || '');
891
- paintFocusStream();
892
941
  if (focusTick % 3 === 0) loadFullFeed(focused);
893
942
  focusTick += 1;
894
943
  }
@@ -991,6 +1040,10 @@ export const LIVE_PAGE = `<!doctype html>
991
1040
  streamAll = !streamAll;
992
1041
  this.setAttribute('aria-pressed', streamAll ? 'true' : 'false');
993
1042
  this.textContent = streamAll ? 'Streaming all' : 'Stream all';
1043
+ // A choice for every card, the close-up's included: closing it afterwards
1044
+ // must not undo it for that one card. Reachable from the keyboard while a
1045
+ // close-up is open, since the overlay only covers the header for a pointer.
1046
+ focusStartedStream = false;
994
1047
  Object.keys(cards).forEach(function (name) { setLive(cards[name], streamAll); });
995
1048
  });
996
1049
  // A session with nothing to show answers 503; say so rather than leaving a broken-image icon.
@@ -243,6 +243,14 @@ export class OracleMonitor {
243
243
  noteContradiction(c, url) {
244
244
  this.record({ kind: c.kind, severity: "high", detail: c.detail, url });
245
245
  }
246
+ /**
247
+ * The page posted a token-shaped value with targetOrigin "*" (postmessage.ts).
248
+ * Reported through the capture script's binding, not a page event. `embed`
249
+ * is the receiving frame's site when that frame is another site's.
250
+ */
251
+ noteTokenPost(detail, url, embed) {
252
+ this.record({ kind: "postmessage_token", severity: "high", detail, url, embed: embed ?? undefined });
253
+ }
246
254
  record(v) {
247
255
  if (isPolicyInduced(v, this.lastPolicyBlockAt === null ? null : Date.now() - this.lastPolicyBlockAt)) {
248
256
  this.policyAttributed += 1;