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.
- package/CHANGELOG.md +29 -0
- package/README.md +34 -6
- package/dist/browsers.js +13 -0
- package/dist/check-run.js +2 -0
- package/dist/ci-run.js +134 -16
- package/dist/cli.js +72 -1
- package/dist/engine/authloss.js +149 -8
- package/dist/engine/brief.js +7 -1
- package/dist/engine/browser.js +615 -74
- package/dist/engine/capture.js +121 -0
- package/dist/engine/check.js +18 -0
- package/dist/engine/ci.js +117 -6
- package/dist/engine/dedup.js +50 -22
- package/dist/engine/expiry.js +254 -0
- package/dist/engine/fingerprint.js +2 -2
- package/dist/engine/flow.js +0 -2
- package/dist/engine/limits.js +144 -0
- package/dist/engine/live-page.js +57 -4
- package/dist/engine/oracles.js +8 -0
- package/dist/engine/png.js +238 -0
- package/dist/engine/postmessage.js +282 -0
- package/dist/engine/probes.js +4 -4
- package/dist/engine/profiles.js +392 -0
- package/dist/engine/refresh.js +383 -0
- package/dist/engine/scripted-login.js +400 -0
- package/dist/engine/teardown.js +41 -0
- package/dist/login-run.js +444 -0
- package/dist/mcp-server.js +136 -20
- package/package.json +7 -2
- package/skills/scenescout/SKILL.md +9 -6
|
@@ -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("
|
|
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("
|
|
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
|
package/dist/engine/flow.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/engine/live-page.js
CHANGED
|
@@ -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)
|
|
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)
|
|
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 () {
|
|
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.
|
package/dist/engine/oracles.js
CHANGED
|
@@ -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;
|