scenescout 3.13.1 → 3.14.1

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,444 @@
1
+ /**
2
+ * Runs `scenescout login <url> --role <name>`: opens a visible browser at the
3
+ * URL, lets the person sign in however the app asks (SSO, MFA, a password
4
+ * manager), and when they press Enter in the terminal saves the browser's
5
+ * storage state as that role's profile.
6
+ *
7
+ * Enter is the one way to save. Closing the window, Ctrl+C, or the terminal's
8
+ * input ending saves nothing: by the time a window is closed its state can no
9
+ * longer be read, and a profile saved by accident half-way through a sign-in
10
+ * would be attached later as if it worked.
11
+ *
12
+ * The rules (role names, where the file goes, its mode, what may be printed)
13
+ * are in engine/profiles.ts; this file only drives the browser and the terminal.
14
+ */
15
+ import readline from "node:readline";
16
+ import { chromium, firefox, webkit } from "playwright";
17
+ import { defaultEngine } from "./browsers.js";
18
+ import { explainLaunchFailure } from "./engine/launch.js";
19
+ import { explicitLimits, isTimeoutMessage, LIMIT_NAMES } from "./engine/limits.js";
20
+ import { redactRoute } from "./engine/check.js";
21
+ import { describeLifetime, readLifetime } from "./engine/expiry.js";
22
+ import { describeSaved, mergeSessionStorage, withSessionStorage, writeProfile } from "./engine/profiles.js";
23
+ import { chooseFields, chooseSubmit, describeStep, fieldIdentity, nextStep, secondsLeft, totp as totpCode, TOTP_MIN_SECONDS_LEFT, urlMatches, } from "./engine/scripted-login.js";
24
+ /**
25
+ * Read what the profile keeps from a signed-in context: cookies, localStorage
26
+ * and IndexedDB through Playwright's storage state, and sessionStorage, which
27
+ * the storage state has no field for, read from every frame of every open
28
+ * tab. One place on purpose: attach restores exactly what this captures (see
29
+ * splitProfile and sessionStorageInitScript in engine/profiles.ts).
30
+ */
31
+ export async function captureState(context) {
32
+ const state = await context.storageState({ indexedDB: true });
33
+ const frames = [];
34
+ for (const page of context.pages()) {
35
+ for (const frame of page.frames()) {
36
+ try {
37
+ frames.push(await frame.evaluate(() => {
38
+ // A sandboxed or opaque-origin frame throws on access; it has nothing to keep.
39
+ try {
40
+ const entries = [];
41
+ for (let i = 0; i < sessionStorage.length; i++) {
42
+ const name = sessionStorage.key(i);
43
+ if (name !== null)
44
+ entries.push([name, sessionStorage.getItem(name) ?? ""]);
45
+ }
46
+ return { origin: location.origin, entries };
47
+ }
48
+ catch {
49
+ return { origin: location.origin, entries: [] };
50
+ }
51
+ }));
52
+ }
53
+ catch (err) {
54
+ // A child frame that went away between listing and reading has nothing
55
+ // left to keep; the tab's own document failing to answer is a real error.
56
+ if (frame === page.mainFrame())
57
+ throw err;
58
+ }
59
+ }
60
+ }
61
+ return withSessionStorage(state, mergeSessionStorage(frames));
62
+ }
63
+ /** How long after the prompt input still counts as typed before it. */
64
+ const EARLY_INPUT_MS = 300;
65
+ /** Wait for Enter on the terminal, the window closing, the input ending or Ctrl+C, whichever comes first. */
66
+ function waitForEnd(browserClosed) {
67
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
68
+ // Lines typed before the prompt (while the browser launched or the page
69
+ // loaded) arrive at once, buffered; they are not an answer to it.
70
+ const listening = Date.now() + EARLY_INPUT_MS;
71
+ let onSigint;
72
+ const done = new Promise((resolve) => {
73
+ rl.on("line", () => {
74
+ if (Date.now() >= listening)
75
+ resolve("enter");
76
+ });
77
+ rl.once("close", () => resolve("input-ended"));
78
+ onSigint = () => resolve("interrupted");
79
+ process.once("SIGINT", onSigint);
80
+ void browserClosed.then(() => resolve("window-closed"));
81
+ });
82
+ return {
83
+ done,
84
+ dispose: () => {
85
+ if (onSigint)
86
+ process.removeListener("SIGINT", onSigint);
87
+ rl.removeAllListeners();
88
+ rl.close();
89
+ },
90
+ };
91
+ }
92
+ const NOT_SAVED = {
93
+ "window-closed": "The browser was closed before Enter was pressed, so nothing was saved. Run the command again and press Enter here once you are signed in.",
94
+ "input-ended": "The terminal's input ended before Enter was pressed, so nothing was saved. Run this command in an interactive terminal.",
95
+ interrupted: "Interrupted, so nothing was saved.",
96
+ };
97
+ export async function runLogin(options, log) {
98
+ const engine = options.browser ?? defaultEngine(process.env);
99
+ const types = { chromium, firefox, webkit };
100
+ // Checked before a window opens: a limit out of bounds is a sentence, not a browser left behind.
101
+ const navMs = explicitLimits({}, process.env).navMs ?? LOGIN_NAV_MS;
102
+ let browser;
103
+ try {
104
+ browser = await types[engine].launch({ headless: false });
105
+ }
106
+ catch (err) {
107
+ throw new Error(explainLaunchFailure(err instanceof Error ? err.message : String(err), 0, { engine, headed: true }));
108
+ }
109
+ const browserClosed = new Promise((resolve) => browser.once("disconnected", () => resolve()));
110
+ let end;
111
+ try {
112
+ const context = await browser.newContext();
113
+ const page = await context.newPage();
114
+ // Closing the only tab is how most people close the window; treat it the
115
+ // same. The browser may already be closing, and either way it ends up closed.
116
+ page.once("close", () => void browser.close().catch(() => { }));
117
+ try {
118
+ await page.goto(options.url, { waitUntil: "domcontentloaded", timeout: navMs });
119
+ }
120
+ catch (err) {
121
+ const explained = loginTimeout(err, "nav", navMs);
122
+ throw new Error(`could not open ${options.url}: ${explained instanceof Error ? explained.message.split("\n")[0] : String(explained)}`);
123
+ }
124
+ log(`A ${engine} window is open at ${options.url}.`);
125
+ log(`Sign in as "${options.role}" there — SSO, MFA, whatever the app asks — then come back here and press Enter to save.`);
126
+ log(`(Closing the window or pressing Ctrl+C saves nothing.)`);
127
+ // Listening starts only now, after the prompt: an Enter pressed while the
128
+ // page was still loading must not save a signed-out profile.
129
+ end = waitForEnd(browserClosed);
130
+ const how = await end.done;
131
+ if (how !== "enter")
132
+ throw new Error(NOT_SAVED[how]);
133
+ return await saveLogin(context, options);
134
+ }
135
+ finally {
136
+ end?.dispose();
137
+ // Closing a browser the person already closed fails; nothing is left to clean up then.
138
+ await browser.close().catch(() => { });
139
+ }
140
+ }
141
+ /**
142
+ * Save a signed-in context as the role's profile and read how long it lasts:
143
+ * what `scenescout login` does once Enter is pressed, apart so it can be run
144
+ * against a headless browser.
145
+ */
146
+ export async function saveLogin(context, options) {
147
+ const state = await captureState(context);
148
+ const saved = writeProfile(options.projectDir, options.role, state);
149
+ return { ...saved, lifetime: readLifetime(state, { url: options.url }) };
150
+ }
151
+ /** The lines printed on success: where, how much, how long it lasts. Never the profile's contents. */
152
+ export function savedLine(options, saved, now = Date.now()) {
153
+ return (`${describeSaved(saved.path, saved.summary)}\n` +
154
+ `${describeLifetime(saved.lifetime, now)}\n` +
155
+ `Attach as this role with scout_attach { role: "${options.role}" } — every session given it signs in from this one login.`);
156
+ }
157
+ // ── scenescout login --script ────────────────────────────────────────────────
158
+ /** The attribute the collector tags each visible control with, so a step can act on the one it chose. */
159
+ const TAG = "data-scenescout-login";
160
+ /** How often the page is read while waiting for the form to move on. */
161
+ const POLL_MS = 250;
162
+ /**
163
+ * The error with what to raise when it is a timeout. Signing in reads only the
164
+ * environment variable, so the hint names only that, or `--timeout` when the
165
+ * sign-in's own overall limit was the shorter one and ran out.
166
+ */
167
+ function loginTimeout(err, kind, ms, overallCapped = false) {
168
+ if (!(err instanceof Error) || !isTimeoutMessage(err.message))
169
+ return err;
170
+ const [first, ...rest] = err.message.split("\n");
171
+ const raise = overallCapped ? "raise --timeout, the sign-in's overall limit" : `raise it with ${LIMIT_NAMES[kind].env} in the environment`;
172
+ return new Error([`${first} — the ${LIMIT_NAMES[kind].what} (${ms} ms) ran out; if the machine is loaded rather than the app slow, ${raise}.`, ...rest].join("\n"));
173
+ }
174
+ /** How long a login page may take to load, unless SCENESCOUT_NAV_TIMEOUT_MS says otherwise (engine/limits.ts). */
175
+ const LOGIN_NAV_MS = 30_000;
176
+ /** How long filling a sign-in field or submitting may take, unless SCENESCOUT_ACTION_TIMEOUT_MS says otherwise. */
177
+ const LOGIN_ACTION_MS = 10_000;
178
+ /** How many times the form may be submitted before the run gives up: username, password and a code, with room for an interstitial. */
179
+ const MAX_SUBMITS = 5;
180
+ /**
181
+ * Read every visible form control on the page (the top document only), tag
182
+ * each with its index, and mark those matching a configured selector. Runs in
183
+ * the page, so it may use only what the page has.
184
+ */
185
+ function collectFields(selectors) {
186
+ const visible = (el) => {
187
+ const r = el.getBoundingClientRect();
188
+ const st = getComputedStyle(el);
189
+ return r.width > 0 && r.height > 0 && st.visibility !== "hidden" && st.display !== "none";
190
+ };
191
+ const forced = new Map();
192
+ for (const kind of ["username", "password", "otp", "submit"]) {
193
+ const sel = selectors[kind];
194
+ if (!sel)
195
+ continue;
196
+ let el;
197
+ try {
198
+ el = document.querySelector(sel);
199
+ }
200
+ catch {
201
+ return { badSelector: kind };
202
+ }
203
+ if (el)
204
+ forced.set(el, kind);
205
+ }
206
+ const labelOf = (el) => {
207
+ const parts = [];
208
+ const id = el.getAttribute("id");
209
+ if (id)
210
+ for (const l of Array.from(document.querySelectorAll(`label[for="${CSS.escape(id)}"]`)))
211
+ parts.push(l.textContent ?? "");
212
+ const wrapping = el.closest("label");
213
+ if (wrapping)
214
+ parts.push(wrapping.textContent ?? "");
215
+ parts.push(el.getAttribute("aria-label") ?? "", el.getAttribute("placeholder") ?? "");
216
+ const by = el.getAttribute("aria-labelledby");
217
+ if (by)
218
+ for (const ref of by.split(/\s+/))
219
+ parts.push(document.getElementById(ref)?.textContent ?? "");
220
+ return parts.join(" ").replace(/\s+/g, " ").trim();
221
+ };
222
+ // Tags from an earlier read name other elements now: one index, one element.
223
+ for (const old of Array.from(document.querySelectorAll("[data-scenescout-login]")))
224
+ old.removeAttribute("data-scenescout-login");
225
+ const out = [];
226
+ const all = Array.from(document.querySelectorAll("input, textarea, select, button"));
227
+ for (const el of all) {
228
+ const type = (el.getAttribute("type") ?? "").toLowerCase();
229
+ if (type === "hidden" || !visible(el))
230
+ continue;
231
+ const index = out.length;
232
+ el.setAttribute("data-scenescout-login", String(index));
233
+ const tag = el.tagName.toLowerCase();
234
+ const input = el;
235
+ out.push({
236
+ index,
237
+ tag,
238
+ type: tag === "button" ? (el.getAttribute("type") ?? "").toLowerCase() : type,
239
+ name: el.getAttribute("name") ?? "",
240
+ id: el.getAttribute("id") ?? "",
241
+ autocomplete: (el.getAttribute("autocomplete") ?? "").toLowerCase(),
242
+ label: labelOf(el),
243
+ text: tag === "button" ? (el.textContent ?? "").replace(/\s+/g, " ").trim() : type === "submit" || type === "button" ? input.value : "",
244
+ inputmode: (el.getAttribute("inputmode") ?? "").toLowerCase(),
245
+ maxLength: typeof input.maxLength === "number" ? input.maxLength : -1,
246
+ filled: tag !== "button" && typeof input.value === "string" && input.value.length > 0,
247
+ disabled: input.disabled === true,
248
+ ...(forced.has(el) ? { forced: forced.get(el) } : {}),
249
+ });
250
+ }
251
+ return out;
252
+ }
253
+ /** A page's error or alert text, if it shows one: the reason a refused sign-in gives. Redacted before it is printed. */
254
+ async function alertText(page) {
255
+ const text = await page
256
+ .evaluate(() => {
257
+ const el = document.querySelector('[role="alert"], [aria-live="assertive"], .error, .alert');
258
+ return (el?.textContent ?? "").replace(/\s+/g, " ").trim();
259
+ })
260
+ // The page's message only adds to the refusal being reported; a page navigating away as it is read leaves the refusal as it stands.
261
+ .catch(() => "");
262
+ return text.slice(0, 200);
263
+ }
264
+ /**
265
+ * Sign in with no one at the keyboard: headless, the credentials from the
266
+ * environment, the form found by engine/scripted-login.ts's rules. Every line
267
+ * it logs and every error it throws goes through the credential redaction
268
+ * first. Saves the profile exactly as the manual login does.
269
+ */
270
+ export async function runScriptedLogin(options, config, redactor, log, now = () => Date.now() / 1000) {
271
+ const redact = (text) => redactor.redact(text);
272
+ const say = (line) => log(redact(line));
273
+ /** Every error this run throws, redacted. */
274
+ const fail = (message) => new Error(redact(message));
275
+ const engine = options.browser ?? defaultEngine(process.env);
276
+ const types = { chromium, firefox, webkit };
277
+ // A limit set in the environment applies here too; unset, signing in keeps its own longer waits.
278
+ const set = explicitLimits({}, process.env);
279
+ const limits = { navMs: set.navMs ?? LOGIN_NAV_MS, actionMs: set.actionMs ?? LOGIN_ACTION_MS };
280
+ let browser;
281
+ try {
282
+ browser = await types[engine].launch({ headless: true });
283
+ }
284
+ catch (err) {
285
+ throw new Error(redact(explainLaunchFailure(err instanceof Error ? err.message : String(err), 0, { engine, headed: false })));
286
+ }
287
+ const deadline = Date.now() + config.timeoutMs;
288
+ try {
289
+ const context = await browser.newContext();
290
+ const page = await context.newPage();
291
+ try {
292
+ await page.goto(options.url, { waitUntil: "domcontentloaded", timeout: Math.min(limits.navMs, config.timeoutMs) });
293
+ }
294
+ catch (err) {
295
+ const explained = loginTimeout(err, "nav", Math.min(limits.navMs, config.timeoutMs), config.timeoutMs < limits.navMs);
296
+ throw fail(`could not open ${options.url}: ${explained instanceof Error ? explained.message.split("\n")[0] : String(explained)}`);
297
+ }
298
+ say(`Signing in as "${options.role}" at ${options.url} (${engine}, headless).`);
299
+ if (!config.success.url && !config.success.selector) {
300
+ say("No --success-url or --success-selector: signed in will mean no sign-in field is left on the page. Set one to be sure.");
301
+ }
302
+ const progress = { submitted: new Map(), submits: 0 };
303
+ const pageKey = () => {
304
+ const u = new URL(page.url());
305
+ return `${u.origin}${u.pathname}`;
306
+ };
307
+ /** A read the page's own navigation interrupted: nothing is known about the page yet, so the caller polls again. */
308
+ const midNavigation = (err) => /context was destroyed|navigat|detached/i.test(err.message);
309
+ const successMatched = async () => {
310
+ if (!config.success.url && !config.success.selector)
311
+ return false;
312
+ if (config.success.url && !urlMatches(page.url(), config.success.url))
313
+ return false;
314
+ if (config.success.selector) {
315
+ const visible = await page
316
+ .locator(config.success.selector)
317
+ .first()
318
+ .isVisible()
319
+ .catch((err) => {
320
+ if (midNavigation(err))
321
+ return false;
322
+ throw fail(`the success selector could not be used: ${err.message.split("\n")[0]}`);
323
+ });
324
+ if (!visible)
325
+ return false;
326
+ }
327
+ return true;
328
+ };
329
+ const read = async () => {
330
+ const got = await page.evaluate(collectFields, config.selectors).catch((err) => {
331
+ if (midNavigation(err))
332
+ return null;
333
+ throw err;
334
+ });
335
+ if (got === null)
336
+ return null;
337
+ if (!Array.isArray(got))
338
+ throw fail(`the ${got.badSelector} selector is not a valid CSS selector`);
339
+ return got;
340
+ };
341
+ /** The page's URL for a message, with any token in it redacted. */
342
+ const where = () => redactRoute(page.url());
343
+ const signature = (fields) => {
344
+ const chosen = chooseFields(fields);
345
+ return `${pageKey()}|${Object.keys(chosen).sort().join(",")}`;
346
+ };
347
+ const stepOpts = (successMatchedNow) => ({
348
+ hasTotp: config.totp !== undefined,
349
+ successConfigured: Boolean(config.success.url || config.success.selector),
350
+ successMatched: successMatchedNow,
351
+ });
352
+ let lastWait = "the sign-in form to appear";
353
+ for (;;) {
354
+ if (Date.now() > deadline)
355
+ throw fail(`timed out after ${config.timeoutMs / 1000}s waiting for ${lastWait} (at ${where()})`);
356
+ const fields = await read();
357
+ if (fields === null) {
358
+ await page.waitForTimeout(POLL_MS);
359
+ continue;
360
+ }
361
+ const chosen = chooseFields(fields);
362
+ const step = nextStep(chosen, progress, stepOpts(await successMatched()));
363
+ if (step.kind === "done") {
364
+ if (config.success.url || config.success.selector)
365
+ break;
366
+ // No sign-in field left is the signal only once the page has settled:
367
+ // a page between a redirect and its first render shows no fields either.
368
+ await page.waitForLoadState("load", { timeout: limits.actionMs }).catch((err) => {
369
+ if (!/timeout/i.test(err.message) && !midNavigation(err))
370
+ throw err;
371
+ });
372
+ await page.waitForTimeout(POLL_MS * 2);
373
+ const settled = await read();
374
+ if (settled !== null && nextStep(chooseFields(settled), progress, stepOpts(false)).kind === "done")
375
+ break;
376
+ continue;
377
+ }
378
+ if (step.kind === "refused") {
379
+ const says = await alertText(page);
380
+ throw fail(`sign-in refused: ${step.reason}.${says ? ` The page says: "${says}"` : ""}`);
381
+ }
382
+ if (step.kind === "stuck")
383
+ throw fail(`sign-in stuck: ${step.reason}.`);
384
+ if (step.kind === "wait") {
385
+ lastWait =
386
+ progress.submits === 0
387
+ ? "the sign-in form to appear (set the field selectors if the form is not found)"
388
+ : "the signed-in page (the success URL or selector, or the sign-in fields to go)";
389
+ await page.waitForTimeout(POLL_MS);
390
+ continue;
391
+ }
392
+ if (progress.submits >= MAX_SUBMITS)
393
+ throw fail(`gave up after ${MAX_SUBMITS} submits without reaching the signed-in page`);
394
+ let last = null;
395
+ for (const kind of step.fill) {
396
+ const field = chosen[kind];
397
+ let value;
398
+ if (kind === "otp") {
399
+ const totp = config.totp;
400
+ if (secondsLeft(totp, now()) < TOTP_MIN_SECONDS_LEFT)
401
+ await page.waitForTimeout(secondsLeft(totp, now()) * 1000 + 200);
402
+ value = totpCode(totp, now());
403
+ redactor.add(value);
404
+ }
405
+ else
406
+ value = kind === "username" ? config.username : config.password;
407
+ await page
408
+ .locator(`[${TAG}="${field.index}"]`)
409
+ .fill(value, { timeout: limits.actionMs })
410
+ .catch((err) => Promise.reject(loginTimeout(err, "action", limits.actionMs)));
411
+ progress.submitted.set(kind, fieldIdentity(field));
412
+ last = field;
413
+ }
414
+ const button = chooseSubmit(fields);
415
+ const before = signature(fields);
416
+ await (button
417
+ ? page.locator(`[${TAG}="${button.index}"]`).click({ timeout: limits.actionMs })
418
+ : page.locator(`[${TAG}="${last.index}"]`).press("Enter", { timeout: limits.actionMs })).catch((err) => Promise.reject(loginTimeout(err, "action", limits.actionMs)));
419
+ progress.submits += 1;
420
+ const did = describeStep(step.fill, button ? button.text || button.label || "the submit button" : null);
421
+ say(did);
422
+ lastWait = `the page to move on after that step (${did})`;
423
+ // Wait for the form to move on: another page, or other fields on this one.
424
+ while (Date.now() <= deadline) {
425
+ await page.waitForTimeout(POLL_MS);
426
+ const after = await read();
427
+ if (after === null)
428
+ continue;
429
+ if (signature(after) !== before || (await successMatched()))
430
+ break;
431
+ const again = chooseFields(after);
432
+ // The same fields, emptied: a refused submit re-rendered in place.
433
+ if (step.fill.some((k) => again[k] && !again[k].filled))
434
+ break;
435
+ }
436
+ }
437
+ say(`Signed in: now at ${where()}.`);
438
+ return await saveLogin(context, options);
439
+ }
440
+ finally {
441
+ // The result (a saved profile or the error being thrown) is already decided; a browser that fails to close changes neither.
442
+ await browser.close().catch(() => { });
443
+ }
444
+ }