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,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules of `scenescout login <url> --role <name> --script`: a sign-in
|
|
3
|
+
* with no person at the keyboard, for CI. The username, the password and an
|
|
4
|
+
* optional TOTP secret come from the environment; the browser fills the
|
|
5
|
+
* identity provider's form with them and the session is saved as the role's
|
|
6
|
+
* profile exactly as the manual login saves it.
|
|
7
|
+
*
|
|
8
|
+
* Everything here is Playwright-free so it can be table-tested: which
|
|
9
|
+
* environment variables and flags configure a run, the RFC 6238 one-time
|
|
10
|
+
* code, which input on a page is the username, the password or the code,
|
|
11
|
+
* which button moves the form on, what counts as signed in or refused, and
|
|
12
|
+
* the redaction every line of output goes through. The browser half is in
|
|
13
|
+
* login-run.ts.
|
|
14
|
+
*
|
|
15
|
+
* A credential value is never printed, logged or written. Each value, and
|
|
16
|
+
* each of its URL-encoded forms, is replaced before any line leaves the
|
|
17
|
+
* process, including text the page itself shows (a page that echoes what
|
|
18
|
+
* was typed).
|
|
19
|
+
*/
|
|
20
|
+
import { createHmac } from "node:crypto";
|
|
21
|
+
// ── configuration ───────────────────────────────────────────────────────────
|
|
22
|
+
/** The environment variables a scripted sign-in reads. */
|
|
23
|
+
export const LOGIN_ENV = {
|
|
24
|
+
username: "SCENESCOUT_LOGIN_USERNAME",
|
|
25
|
+
password: "SCENESCOUT_LOGIN_PASSWORD",
|
|
26
|
+
totpSecret: "SCENESCOUT_LOGIN_TOTP_SECRET",
|
|
27
|
+
usernameSelector: "SCENESCOUT_LOGIN_USERNAME_SELECTOR",
|
|
28
|
+
passwordSelector: "SCENESCOUT_LOGIN_PASSWORD_SELECTOR",
|
|
29
|
+
otpSelector: "SCENESCOUT_LOGIN_OTP_SELECTOR",
|
|
30
|
+
submitSelector: "SCENESCOUT_LOGIN_SUBMIT_SELECTOR",
|
|
31
|
+
successUrl: "SCENESCOUT_LOGIN_SUCCESS_URL",
|
|
32
|
+
successSelector: "SCENESCOUT_LOGIN_SUCCESS_SELECTOR",
|
|
33
|
+
};
|
|
34
|
+
/** Flags `--script` accepts, each overriding the environment variable of the same meaning. Credentials have no flag: a flag is visible in the process list and the shell history. */
|
|
35
|
+
export const SCRIPT_FLAGS = [
|
|
36
|
+
"username-selector",
|
|
37
|
+
"password-selector",
|
|
38
|
+
"otp-selector",
|
|
39
|
+
"submit-selector",
|
|
40
|
+
"success-url",
|
|
41
|
+
"success-selector",
|
|
42
|
+
"timeout",
|
|
43
|
+
];
|
|
44
|
+
/** How long a scripted sign-in may take in all, by default, and the bounds `--timeout` must stay within. */
|
|
45
|
+
export const DEFAULT_TIMEOUT_S = 60;
|
|
46
|
+
export const MIN_TIMEOUT_S = 5;
|
|
47
|
+
export const MAX_TIMEOUT_S = 600;
|
|
48
|
+
/**
|
|
49
|
+
* Read a scripted sign-in's configuration at startup, collecting every
|
|
50
|
+
* problem before anything launches. Each problem names the variable or flag,
|
|
51
|
+
* never its value.
|
|
52
|
+
*/
|
|
53
|
+
export function readScriptedLogin(flags, env) {
|
|
54
|
+
const errors = [];
|
|
55
|
+
const username = env[LOGIN_ENV.username] ?? "";
|
|
56
|
+
const password = env[LOGIN_ENV.password] ?? "";
|
|
57
|
+
if (username.trim() === "")
|
|
58
|
+
errors.push(`${LOGIN_ENV.username} is not set: the test user's username or email`);
|
|
59
|
+
if (password === "")
|
|
60
|
+
errors.push(`${LOGIN_ENV.password} is not set: the test user's password`);
|
|
61
|
+
let totp;
|
|
62
|
+
const rawSecret = env[LOGIN_ENV.totpSecret];
|
|
63
|
+
if (rawSecret !== undefined && rawSecret.trim() !== "") {
|
|
64
|
+
const parsed = parseTotpSecret(rawSecret);
|
|
65
|
+
if (parsed.ok)
|
|
66
|
+
totp = parsed.params;
|
|
67
|
+
else
|
|
68
|
+
errors.push(`${LOGIN_ENV.totpSecret} ${parsed.error}`);
|
|
69
|
+
}
|
|
70
|
+
const pick = (flag, name) => {
|
|
71
|
+
const v = flags.get(flag) ?? env[name];
|
|
72
|
+
return v !== undefined && v.trim() !== "" ? v.trim() : undefined;
|
|
73
|
+
};
|
|
74
|
+
const selectors = {};
|
|
75
|
+
const u = pick("username-selector", LOGIN_ENV.usernameSelector);
|
|
76
|
+
const p = pick("password-selector", LOGIN_ENV.passwordSelector);
|
|
77
|
+
const o = pick("otp-selector", LOGIN_ENV.otpSelector);
|
|
78
|
+
const s = pick("submit-selector", LOGIN_ENV.submitSelector);
|
|
79
|
+
if (u)
|
|
80
|
+
selectors.username = u;
|
|
81
|
+
if (p)
|
|
82
|
+
selectors.password = p;
|
|
83
|
+
if (o)
|
|
84
|
+
selectors.otp = o;
|
|
85
|
+
if (s)
|
|
86
|
+
selectors.submit = s;
|
|
87
|
+
if (selectors.otp && !totp)
|
|
88
|
+
errors.push(`an OTP selector is set but ${LOGIN_ENV.totpSecret} is not: there is no code to type into it`);
|
|
89
|
+
const success = {};
|
|
90
|
+
const successUrl = pick("success-url", LOGIN_ENV.successUrl);
|
|
91
|
+
const successSelector = pick("success-selector", LOGIN_ENV.successSelector);
|
|
92
|
+
if (successUrl)
|
|
93
|
+
success.url = successUrl;
|
|
94
|
+
if (successSelector)
|
|
95
|
+
success.selector = successSelector;
|
|
96
|
+
let timeoutS = DEFAULT_TIMEOUT_S;
|
|
97
|
+
const rawTimeout = flags.get("timeout");
|
|
98
|
+
if (rawTimeout !== undefined) {
|
|
99
|
+
const n = Number(rawTimeout);
|
|
100
|
+
if (!Number.isInteger(n) || n < MIN_TIMEOUT_S || n > MAX_TIMEOUT_S)
|
|
101
|
+
errors.push(`--timeout must be a whole number of seconds from ${MIN_TIMEOUT_S} to ${MAX_TIMEOUT_S}`);
|
|
102
|
+
else
|
|
103
|
+
timeoutS = n;
|
|
104
|
+
}
|
|
105
|
+
if (errors.length > 0)
|
|
106
|
+
return { ok: false, errors };
|
|
107
|
+
return { ok: true, config: { username, password, ...(totp ? { totp } : {}), selectors, success, timeoutMs: timeoutS * 1000 } };
|
|
108
|
+
}
|
|
109
|
+
// ── redaction ───────────────────────────────────────────────────────────────
|
|
110
|
+
export const REDACTED = "[redacted]";
|
|
111
|
+
/**
|
|
112
|
+
* Every form a credential could take in text: as typed, URL-encoded (a query
|
|
113
|
+
* string, a form body), and with `+` for spaces. The TOTP secret as given,
|
|
114
|
+
* and its base32 without spaces or padding, so a reformatted echo is caught.
|
|
115
|
+
*/
|
|
116
|
+
export function credentialValues(config) {
|
|
117
|
+
const out = new Set();
|
|
118
|
+
const add = (v) => {
|
|
119
|
+
if (!v)
|
|
120
|
+
return;
|
|
121
|
+
out.add(v);
|
|
122
|
+
const enc = encodeURIComponent(v);
|
|
123
|
+
out.add(enc);
|
|
124
|
+
out.add(enc.replace(/%20/g, "+"));
|
|
125
|
+
const trimmed = v.trim();
|
|
126
|
+
if (trimmed)
|
|
127
|
+
out.add(trimmed);
|
|
128
|
+
};
|
|
129
|
+
add(config.username);
|
|
130
|
+
add(config.password);
|
|
131
|
+
if (config.totpSecretRaw) {
|
|
132
|
+
add(config.totpSecretRaw);
|
|
133
|
+
add(config.totpSecretRaw.replace(/[\s=-]/g, "").toUpperCase());
|
|
134
|
+
}
|
|
135
|
+
return [...out].filter((v) => v.length > 0);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Replace every credential value in a line, longest first so a value
|
|
139
|
+
* containing another is not left half-printed. Case-sensitive except for the
|
|
140
|
+
* username, which identity providers commonly echo lower-cased.
|
|
141
|
+
*/
|
|
142
|
+
export function redactCredentials(text, values, caseInsensitive = []) {
|
|
143
|
+
let out = text;
|
|
144
|
+
for (const v of [...values].sort((a, b) => b.length - a.length))
|
|
145
|
+
out = out.split(v).join(REDACTED);
|
|
146
|
+
for (const v of [...caseInsensitive].sort((a, b) => b.length - a.length)) {
|
|
147
|
+
if (v)
|
|
148
|
+
out = out.replace(new RegExp(v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"), REDACTED);
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
/** The redactor for one scripted sign-in: every form of each credential, the username also without regard to case. */
|
|
153
|
+
export function credentialRedactor(config, totpSecretRaw) {
|
|
154
|
+
const values = credentialValues({ ...config, ...(totpSecretRaw ? { totpSecretRaw } : {}) });
|
|
155
|
+
const username = config.username.trim();
|
|
156
|
+
const anyCase = username ? [username, encodeURIComponent(username)] : [];
|
|
157
|
+
return {
|
|
158
|
+
redact: (text) => redactCredentials(text, values, anyCase),
|
|
159
|
+
add: (value) => {
|
|
160
|
+
if (value)
|
|
161
|
+
values.push(value);
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
166
|
+
/** Decode RFC 4648 base32, ignoring case, spaces, dashes and padding. Null for anything else. */
|
|
167
|
+
export function base32Decode(input) {
|
|
168
|
+
const clean = input.replace(/[\s-]/g, "").replace(/=+$/, "").toUpperCase();
|
|
169
|
+
if (clean.length === 0)
|
|
170
|
+
return null;
|
|
171
|
+
let bits = 0;
|
|
172
|
+
let value = 0;
|
|
173
|
+
const bytes = [];
|
|
174
|
+
for (const ch of clean) {
|
|
175
|
+
const idx = BASE32.indexOf(ch);
|
|
176
|
+
if (idx < 0)
|
|
177
|
+
return null;
|
|
178
|
+
value = (value << 5) | idx;
|
|
179
|
+
bits += 5;
|
|
180
|
+
if (bits >= 8) {
|
|
181
|
+
bytes.push((value >>> (bits - 8)) & 0xff);
|
|
182
|
+
bits -= 8;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return bytes.length > 0 ? Buffer.from(bytes) : null;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Read a TOTP secret: base32 as authenticator apps show it, or the
|
|
189
|
+
* `otpauth://totp/...` URI a QR code carries (its algorithm, digits and
|
|
190
|
+
* period are honoured). The error never quotes the value.
|
|
191
|
+
*/
|
|
192
|
+
export function parseTotpSecret(raw) {
|
|
193
|
+
const text = raw.trim();
|
|
194
|
+
let secret = text;
|
|
195
|
+
let algorithm = "SHA1";
|
|
196
|
+
let digits = 6;
|
|
197
|
+
let period = 30;
|
|
198
|
+
if (/^otpauth:/i.test(text)) {
|
|
199
|
+
let url;
|
|
200
|
+
try {
|
|
201
|
+
url = new URL(text);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return { ok: false, error: "is not a valid otpauth:// URI" };
|
|
205
|
+
}
|
|
206
|
+
if (url.host.toLowerCase() !== "totp")
|
|
207
|
+
return { ok: false, error: "is an otpauth URI but not a TOTP one (only time-based codes are supported)" };
|
|
208
|
+
secret = url.searchParams.get("secret") ?? "";
|
|
209
|
+
const alg = (url.searchParams.get("algorithm") ?? "SHA1").toUpperCase();
|
|
210
|
+
if (alg !== "SHA1" && alg !== "SHA256" && alg !== "SHA512")
|
|
211
|
+
return { ok: false, error: "names an algorithm other than SHA1, SHA256 or SHA512" };
|
|
212
|
+
algorithm = alg;
|
|
213
|
+
digits = Number(url.searchParams.get("digits") ?? "6");
|
|
214
|
+
period = Number(url.searchParams.get("period") ?? "30");
|
|
215
|
+
if (!Number.isInteger(digits) || digits < 6 || digits > 8)
|
|
216
|
+
return { ok: false, error: "asks for a code length other than 6 to 8 digits" };
|
|
217
|
+
if (!Number.isInteger(period) || period < 1 || period > 300)
|
|
218
|
+
return { ok: false, error: "asks for a period outside 1 to 300 seconds" };
|
|
219
|
+
}
|
|
220
|
+
const key = base32Decode(secret);
|
|
221
|
+
if (!key)
|
|
222
|
+
return { ok: false, error: "is not a base32 secret (letters A–Z and digits 2–7) or an otpauth:// URI" };
|
|
223
|
+
return { ok: true, params: { key, algorithm, digits, period } };
|
|
224
|
+
}
|
|
225
|
+
/** RFC 4226 HOTP for one counter value. */
|
|
226
|
+
export function hotp(key, counter, digits = 6, algorithm = "SHA1") {
|
|
227
|
+
const msg = Buffer.alloc(8);
|
|
228
|
+
msg.writeBigUInt64BE(BigInt(counter));
|
|
229
|
+
const mac = createHmac(algorithm.toLowerCase(), key).update(msg).digest();
|
|
230
|
+
const offset = mac[mac.length - 1] & 0x0f;
|
|
231
|
+
const bin = ((mac[offset] & 0x7f) << 24) | (mac[offset + 1] << 16) | (mac[offset + 2] << 8) | mac[offset + 3];
|
|
232
|
+
return String(bin % 10 ** digits).padStart(digits, "0");
|
|
233
|
+
}
|
|
234
|
+
/** RFC 6238 TOTP at a Unix time in seconds. */
|
|
235
|
+
export function totp(params, unixSeconds) {
|
|
236
|
+
return hotp(params.key, Math.floor(unixSeconds / params.period), params.digits, params.algorithm);
|
|
237
|
+
}
|
|
238
|
+
/** Seconds until the code at this time stops being current. */
|
|
239
|
+
export function secondsLeft(params, unixSeconds) {
|
|
240
|
+
return params.period - (Math.floor(unixSeconds) % params.period);
|
|
241
|
+
}
|
|
242
|
+
/** With fewer seconds than this left, wait for the next code rather than type one that expires in transit. */
|
|
243
|
+
export const TOTP_MIN_SECONDS_LEFT = 3;
|
|
244
|
+
const OTP_WORDS = /\b(otp|one[\s_-]?time|totp|mfa|2fa|two[\s_-]?factor|verification[\s_-]?code|auth(entication|enticator)?[\s_-]?code|security[\s_-]?code|passcode|code)\b/i;
|
|
245
|
+
const USER_WORDS = /(user(name)?|e-?mail|login|account|identifier|\bid\b)/i;
|
|
246
|
+
const NOT_USER = /(search|query|coupon|promo)/i;
|
|
247
|
+
const TEXTLIKE = new Set(["text", "email", "tel", "number", ""]);
|
|
248
|
+
const describe = (f) => `${f.name} ${f.id} ${f.label}`.replace(/[_-]+/g, " ");
|
|
249
|
+
function isOtp(f) {
|
|
250
|
+
if (f.tag !== "input" || !TEXTLIKE.has(f.type))
|
|
251
|
+
return false;
|
|
252
|
+
if (f.autocomplete.split(/\s+/).includes("one-time-code"))
|
|
253
|
+
return true;
|
|
254
|
+
return OTP_WORDS.test(describe(f)) && !/(zip|postal|country|promo|coupon)/i.test(describe(f));
|
|
255
|
+
}
|
|
256
|
+
function isUsername(f) {
|
|
257
|
+
if (f.tag !== "input" || !TEXTLIKE.has(f.type) || f.type === "number")
|
|
258
|
+
return false;
|
|
259
|
+
const ac = f.autocomplete.split(/\s+/);
|
|
260
|
+
if (ac.includes("username") || ac.includes("email"))
|
|
261
|
+
return true;
|
|
262
|
+
if (f.type === "email")
|
|
263
|
+
return true;
|
|
264
|
+
return USER_WORDS.test(describe(f)) && !NOT_USER.test(describe(f));
|
|
265
|
+
}
|
|
266
|
+
/** Rank so the strongest signal wins: autocomplete, then the type, then the words around it. */
|
|
267
|
+
function usernameRank(f) {
|
|
268
|
+
const ac = f.autocomplete.split(/\s+/);
|
|
269
|
+
if (ac.includes("username") || ac.includes("email"))
|
|
270
|
+
return 0;
|
|
271
|
+
if (f.type === "email")
|
|
272
|
+
return 1;
|
|
273
|
+
return 2;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Pick the username, password and one-time-code fields among the visible
|
|
277
|
+
* controls. A configured selector wins for its kind; otherwise the first
|
|
278
|
+
* control that fits, by autocomplete, type, then name/id/label words. A
|
|
279
|
+
* password field for a NEW password (autocomplete="new-password") is chosen
|
|
280
|
+
* only when it is the only one: a sign-up form's confirm field is not the
|
|
281
|
+
* sign-in password.
|
|
282
|
+
*/
|
|
283
|
+
export function chooseFields(fields) {
|
|
284
|
+
const usable = fields.filter((f) => !f.disabled && f.tag !== "button");
|
|
285
|
+
const out = {};
|
|
286
|
+
for (const kind of ["username", "password", "otp"]) {
|
|
287
|
+
const forced = usable.find((f) => f.forced === kind);
|
|
288
|
+
if (forced)
|
|
289
|
+
out[kind] = forced;
|
|
290
|
+
}
|
|
291
|
+
if (!out.password) {
|
|
292
|
+
const pw = usable.filter((f) => f.tag === "input" && f.type === "password");
|
|
293
|
+
out.password = pw.find((f) => f.autocomplete.includes("current-password")) ?? pw.find((f) => !f.autocomplete.includes("new-password")) ?? pw[0];
|
|
294
|
+
}
|
|
295
|
+
const taken = new Set([out.username?.index, out.password?.index, out.otp?.index].filter((i) => i !== undefined));
|
|
296
|
+
if (!out.otp)
|
|
297
|
+
out.otp = usable.find((f) => !taken.has(f.index) && isOtp(f));
|
|
298
|
+
if (out.otp)
|
|
299
|
+
taken.add(out.otp.index);
|
|
300
|
+
if (!out.username) {
|
|
301
|
+
const candidates = usable.filter((f) => !taken.has(f.index) && isUsername(f));
|
|
302
|
+
out.username = candidates.sort((a, b) => usernameRank(a) - usernameRank(b) || a.index - b.index)[0];
|
|
303
|
+
}
|
|
304
|
+
for (const k of Object.keys(out))
|
|
305
|
+
if (!out[k])
|
|
306
|
+
delete out[k];
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
const GO_WORDS = /\b(sign[\s-]?in|log[\s-]?in|login|continue|next|verify|submit|confirm|proceed)\b/i;
|
|
310
|
+
/** Buttons that lead away from a password sign-in: another provider, another flow. */
|
|
311
|
+
const AWAY_WORDS = /\b(forgot|reset|sign[\s-]?up|register|create|continue with|provider|google|microsoft|github|apple|facebook|sso|single sign|passkey|magic link|cancel|back|resend|remember)\b/i;
|
|
312
|
+
/**
|
|
313
|
+
* Pick the button that moves the form on. A configured selector wins; else a
|
|
314
|
+
* submit button whose text says go (sign in, next, continue, verify), else
|
|
315
|
+
* any submit button that does not lead elsewhere. Null means press Enter in
|
|
316
|
+
* the last field filled, which submits any form with a single text field.
|
|
317
|
+
*/
|
|
318
|
+
export function chooseSubmit(fields) {
|
|
319
|
+
const buttons = fields.filter((f) => !f.disabled && (f.tag === "button" || (f.tag === "input" && (f.type === "submit" || f.type === "button"))));
|
|
320
|
+
const forced = buttons.find((f) => f.forced === "submit");
|
|
321
|
+
if (forced)
|
|
322
|
+
return forced;
|
|
323
|
+
const isSubmit = (f) => f.type === "submit" || (f.tag === "button" && f.type === "");
|
|
324
|
+
const clean = buttons.filter((f) => !AWAY_WORDS.test(f.text));
|
|
325
|
+
return clean.find((f) => GO_WORDS.test(f.text) && isSubmit(f)) ?? clean.find((f) => GO_WORDS.test(f.text)) ?? clean.find(isSubmit) ?? null;
|
|
326
|
+
}
|
|
327
|
+
/** What makes a field the same field when a form comes back: its name, id, type, autocomplete and label, never its value. */
|
|
328
|
+
export function fieldIdentity(f) {
|
|
329
|
+
return [f.tag, f.type, f.name, f.id, f.autocomplete, f.label].join("|");
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Decide the next step from what is on the page now.
|
|
333
|
+
*
|
|
334
|
+
* - Signed in: the success URL or selector matched when one is configured;
|
|
335
|
+
* with neither, at least one submit went through and no sign-in field is
|
|
336
|
+
* left: no password or code field, and no username field unless it is a
|
|
337
|
+
* different field from the one the username went into (an app's own email
|
|
338
|
+
* field, say).
|
|
339
|
+
* - Refused: a field already submitted is back — the password field after the
|
|
340
|
+
* password went, the code field after the code went, or the same username
|
|
341
|
+
* field, empty, after the password went: the provider sent the form back.
|
|
342
|
+
* - A code field with no TOTP secret configured is stuck, with the variable
|
|
343
|
+
* to set.
|
|
344
|
+
* - Otherwise fill what is showing and has not been submitted, or wait.
|
|
345
|
+
*/
|
|
346
|
+
export function nextStep(chosen, progress, opts) {
|
|
347
|
+
// A success match before anything was submitted is the sign-in page itself matching (`/signin?next=/dashboard`).
|
|
348
|
+
if (opts.successMatched && progress.submits > 0)
|
|
349
|
+
return { kind: "done" };
|
|
350
|
+
const { submitted } = progress;
|
|
351
|
+
const sameUsername = chosen.username !== undefined && submitted.get("username") === fieldIdentity(chosen.username);
|
|
352
|
+
if (submitted.has("password") && chosen.password) {
|
|
353
|
+
return { kind: "refused", reason: "the password field came back after the password was submitted: the username or password was refused" };
|
|
354
|
+
}
|
|
355
|
+
if (submitted.has("password") && sameUsername && !chosen.username.filled) {
|
|
356
|
+
return { kind: "refused", reason: "the sign-in form came back after the password was submitted: the username or password was refused" };
|
|
357
|
+
}
|
|
358
|
+
if (submitted.has("otp") && chosen.otp) {
|
|
359
|
+
return {
|
|
360
|
+
kind: "refused",
|
|
361
|
+
reason: "the one-time-code field came back after a code was submitted: the code was refused (check the TOTP secret and the runner's clock)",
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
const usernameIsSignIn = chosen.username !== undefined && (!submitted.has("username") || sameUsername);
|
|
365
|
+
if (!chosen.password && !chosen.otp && !usernameIsSignIn) {
|
|
366
|
+
if (progress.submits === 0 || opts.successConfigured)
|
|
367
|
+
return { kind: "wait" };
|
|
368
|
+
return { kind: "done" };
|
|
369
|
+
}
|
|
370
|
+
if (chosen.otp && !opts.hasTotp) {
|
|
371
|
+
return { kind: "stuck", reason: `the page asks for a one-time code and ${LOGIN_ENV.totpSecret} is not set` };
|
|
372
|
+
}
|
|
373
|
+
const fill = ["username", "password", "otp"].filter((k) => chosen[k] && !submitted.has(k));
|
|
374
|
+
return fill.length > 0 ? { kind: "fill", fill } : { kind: "wait" };
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Whether a page URL is the configured success URL: an absolute URL matches
|
|
378
|
+
* as a prefix, anything else as text contained in the path (`/dashboard`),
|
|
379
|
+
* never the query, where a sign-in page carries where it will return to.
|
|
380
|
+
*/
|
|
381
|
+
export function urlMatches(pageUrl, want) {
|
|
382
|
+
if (/^https?:\/\//i.test(want))
|
|
383
|
+
return pageUrl.startsWith(want);
|
|
384
|
+
let pathname;
|
|
385
|
+
try {
|
|
386
|
+
pathname = new URL(pageUrl).pathname;
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
return false; // Not a URL (about:blank before the first load, say): nothing to match.
|
|
390
|
+
}
|
|
391
|
+
return pathname.includes(want);
|
|
392
|
+
}
|
|
393
|
+
/** One line for what a step did, naming the kinds of field and the button, never a value. */
|
|
394
|
+
export function describeStep(fill, button) {
|
|
395
|
+
const names = { username: "the username", password: "the password", otp: "the one-time code" };
|
|
396
|
+
const list = fill.map((k) => names[k]);
|
|
397
|
+
const joined = list.length <= 1 ? list.join("") : `${list.slice(0, -1).join(", ")} and ${list[list.length - 1]}`;
|
|
398
|
+
const how = button ? `clicked "${button.trim().slice(0, 40)}"` : "pressed Enter";
|
|
399
|
+
return `Filled ${joined} and ${how}.`;
|
|
400
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Closing a session's browser within a bound, without losing the browser.
|
|
3
|
+
*
|
|
4
|
+
* Kept out of browser.ts so the rule can be table-tested with stand-in objects.
|
|
5
|
+
* The cost of getting it wrong is a browser left running under the engine's
|
|
6
|
+
* process: it is not an orphan, so the reaper never takes it, and its pipe keeps
|
|
7
|
+
* node from exiting.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Close the context's pages, then the context, then the browser, and return
|
|
11
|
+
* once that is done or `capMs` has passed, whichever is first.
|
|
12
|
+
*
|
|
13
|
+
* The browser and context are passed in rather than read from the caller's
|
|
14
|
+
* fields while teardown runs. A caller that returns at the cap clears those
|
|
15
|
+
* fields, and a teardown still running that then read them would find nothing
|
|
16
|
+
* to close and leave the browser running. When the cap is reached, the browser
|
|
17
|
+
* is also closed directly, without waiting on a page or context that has not
|
|
18
|
+
* closed: closing the browser ends every context in it.
|
|
19
|
+
*/
|
|
20
|
+
export async function boundedTeardown(context, browser, capMs) {
|
|
21
|
+
let finished = false;
|
|
22
|
+
const ordered = (async () => {
|
|
23
|
+
for (const p of context?.pages() ?? [])
|
|
24
|
+
await p.close().catch(() => { });
|
|
25
|
+
await context?.close().catch(() => { });
|
|
26
|
+
await browser?.close().catch(() => { });
|
|
27
|
+
})()
|
|
28
|
+
.catch(() => { })
|
|
29
|
+
.finally(() => {
|
|
30
|
+
finished = true;
|
|
31
|
+
});
|
|
32
|
+
let timer;
|
|
33
|
+
await Promise.race([
|
|
34
|
+
ordered.finally(() => clearTimeout(timer)),
|
|
35
|
+
new Promise((resolve) => {
|
|
36
|
+
timer = setTimeout(resolve, capMs);
|
|
37
|
+
}),
|
|
38
|
+
]);
|
|
39
|
+
if (!finished)
|
|
40
|
+
void browser?.close().catch(() => { });
|
|
41
|
+
}
|