scenescout 3.14.1 → 3.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +90 -18
  3. package/dist/browsers.js +28 -0
  4. package/dist/check-run.js +211 -24
  5. package/dist/ci-run.js +374 -62
  6. package/dist/cli.js +221 -47
  7. package/dist/commands.js +42 -4
  8. package/dist/engine/baseline.js +377 -0
  9. package/dist/engine/brief.js +16 -7
  10. package/dist/engine/browser.js +1660 -386
  11. package/dist/engine/calibration.js +61 -30
  12. package/dist/engine/capture.js +164 -0
  13. package/dist/engine/check.js +258 -68
  14. package/dist/engine/ci-lanes.js +215 -0
  15. package/dist/engine/ci.js +233 -18
  16. package/dist/engine/claims.js +234 -12
  17. package/dist/engine/collector.js +851 -78
  18. package/dist/engine/crawl.js +49 -0
  19. package/dist/engine/dedup.js +329 -9
  20. package/dist/engine/design.js +355 -43
  21. package/dist/engine/export.js +877 -0
  22. package/dist/engine/fingerprint.js +92 -4
  23. package/dist/engine/flow.js +18 -6
  24. package/dist/engine/forms.js +198 -18
  25. package/dist/engine/journey.js +29 -1
  26. package/dist/engine/lane.js +13 -3
  27. package/dist/engine/launch.js +45 -6
  28. package/dist/engine/limits.js +7 -0
  29. package/dist/engine/live-page.js +49 -2
  30. package/dist/engine/live.js +4 -1
  31. package/dist/engine/memory.js +668 -70
  32. package/dist/engine/open.js +118 -0
  33. package/dist/engine/oracles.js +167 -2
  34. package/dist/engine/plain.js +268 -0
  35. package/dist/engine/png.js +127 -0
  36. package/dist/engine/policy.js +379 -9
  37. package/dist/engine/probes.js +3 -2
  38. package/dist/engine/profiles.js +45 -9
  39. package/dist/engine/project-folder.js +191 -0
  40. package/dist/engine/provider.js +4 -3
  41. package/dist/engine/refresh.js +338 -25
  42. package/dist/engine/replay.js +63 -10
  43. package/dist/engine/report.js +248 -40
  44. package/dist/engine/request.js +317 -23
  45. package/dist/engine/sarif.js +120 -0
  46. package/dist/engine/scripted-login.js +342 -62
  47. package/dist/engine/settle.js +67 -0
  48. package/dist/engine/signed-in.js +256 -0
  49. package/dist/engine/tickets.js +671 -0
  50. package/dist/engine/unload.js +3 -2
  51. package/dist/export-run.js +633 -0
  52. package/dist/first-run.js +581 -0
  53. package/dist/installer.js +267 -3
  54. package/dist/intake.js +104 -0
  55. package/dist/login-run.js +386 -71
  56. package/dist/mcp-server.js +637 -68
  57. package/dist/playbook.js +5 -0
  58. package/package.json +6 -3
  59. package/skills/scenescout/SKILL.md +51 -18
package/dist/login-run.js CHANGED
@@ -1,13 +1,15 @@
1
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.
2
+ * Runs `scenescout login <url> --role <name>` and scout_login: opens a
3
+ * visible browser at the URL, lets the person sign in however the app asks
4
+ * (SSO, MFA, a password manager), and once they are signed in saves the
5
+ * browser's storage state as that role's profile.
6
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.
7
+ * The window saves by itself once engine/signed-in.ts says the sign-in has
8
+ * finished; Enter in the terminal saves at once, and `--save enter` makes it
9
+ * the only way, as before. Closing the window or Ctrl+C saves nothing: by the
10
+ * time a window is closed its state can no longer be read, and a profile saved
11
+ * by accident half-way through a sign-in would be attached later as if it
12
+ * worked.
11
13
  *
12
14
  * The rules (role names, where the file goes, its mode, what may be printed)
13
15
  * are in engine/profiles.ts; this file only drives the browser and the terminal.
@@ -20,7 +22,8 @@ import { explicitLimits, isTimeoutMessage, LIMIT_NAMES } from "./engine/limits.j
20
22
  import { redactRoute } from "./engine/check.js";
21
23
  import { describeLifetime, readLifetime } from "./engine/expiry.js";
22
24
  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";
25
+ import { afterTyping, chooseFields, describeStep, fieldIdentity, filledNames, nextStep, otpBoxes, quotable, secondsLeft, splitCode, totp as totpCode, TOTP_MIN_SECONDS_LEFT, urlMatches, } from "./engine/scripted-login.js";
26
+ import { DEFAULT_SAVE_MODE, judgeSignIn, LOOK_EVERY_MS, onApp, startWatch, } from "./engine/signed-in.js";
24
27
  /**
25
28
  * Read what the profile keeps from a signed-in context: cookies, localStorage
26
29
  * and IndexedDB through Playwright's storage state, and sessionStorage, which
@@ -62,8 +65,13 @@ export async function captureState(context) {
62
65
  }
63
66
  /** How long after the prompt input still counts as typed before it. */
64
67
  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) {
68
+ /**
69
+ * Wait for Enter on the terminal, the input ending or Ctrl+C. With `keepOnEnd`
70
+ * (the window saves by itself), input that ends is not an answer: a client
71
+ * that starts the command with no terminal closes it at once, and the window
72
+ * is still the way to finish.
73
+ */
74
+ function waitForEnter(keepOnEnd) {
67
75
  const rl = readline.createInterface({ input: process.stdin, terminal: false });
68
76
  // Lines typed before the prompt (while the browser launched or the page
69
77
  // loaded) arrive at once, buffered; they are not an answer to it.
@@ -74,10 +82,10 @@ function waitForEnd(browserClosed) {
74
82
  if (Date.now() >= listening)
75
83
  resolve("enter");
76
84
  });
77
- rl.once("close", () => resolve("input-ended"));
85
+ if (!keepOnEnd)
86
+ rl.once("close", () => resolve("input-ended"));
78
87
  onSigint = () => resolve("interrupted");
79
88
  process.once("SIGINT", onSigint);
80
- void browserClosed.then(() => resolve("window-closed"));
81
89
  });
82
90
  return {
83
91
  done,
@@ -90,28 +98,30 @@ function waitForEnd(browserClosed) {
90
98
  };
91
99
  }
92
100
  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.",
101
+ "window-closed": "The browser was closed before the sign-in finished, so nothing was saved. Run the command again and sign in in the window it opens.",
102
+ "input-ended": "The terminal's input ended before Enter was pressed, so nothing was saved. Run this command in an interactive terminal, or leave out --save enter so the window saves by itself once you are signed in.",
95
103
  interrupted: "Interrupted, so nothing was saved.",
104
+ "timed-out": "The sign-in did not finish in time, so the window was closed and nothing was saved.",
96
105
  };
97
- export async function runLogin(options, log) {
106
+ /** Open the window a person signs in in. Throws a sentence when the browser cannot start or the page cannot load. */
107
+ export async function openLoginWindow(options, opts = {}) {
98
108
  const engine = options.browser ?? defaultEngine(process.env);
99
109
  const types = { chromium, firefox, webkit };
100
110
  // Checked before a window opens: a limit out of bounds is a sentence, not a browser left behind.
101
111
  const navMs = explicitLimits({}, process.env).navMs ?? LOGIN_NAV_MS;
112
+ const headless = opts.headless ?? false;
102
113
  let browser;
103
114
  try {
104
- browser = await types[engine].launch({ headless: false });
115
+ browser = await types[engine].launch({ headless });
105
116
  }
106
117
  catch (err) {
107
- throw new Error(explainLaunchFailure(err instanceof Error ? err.message : String(err), 0, { engine, headed: true }));
118
+ throw new Error(explainLaunchFailure(err instanceof Error ? err.message : String(err), 0, { engine, headed: !headless }));
108
119
  }
109
- const browserClosed = new Promise((resolve) => browser.once("disconnected", () => resolve()));
110
- let end;
120
+ const closed = new Promise((resolve) => browser.once("disconnected", () => resolve()));
111
121
  try {
112
122
  const context = await browser.newContext();
113
123
  const page = await context.newPage();
114
- // Closing the only tab is how most people close the window; treat it the
124
+ // Closing the sign-in tab is how most people close the window; treat it the
115
125
  // same. The browser may already be closing, and either way it ends up closed.
116
126
  page.once("close", () => void browser.close().catch(() => { }));
117
127
  try {
@@ -121,23 +131,223 @@ export async function runLogin(options, log) {
121
131
  const explained = loginTimeout(err, "nav", navMs);
122
132
  throw new Error(`could not open ${options.url}: ${explained instanceof Error ? explained.message.split("\n")[0] : String(explained)}`);
123
133
  }
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.)`);
134
+ return { browser, context, page, engine, closed };
135
+ }
136
+ catch (err) {
137
+ // The error being thrown is the one to report; a browser that fails to close as well adds nothing to it.
138
+ await browser.close().catch(() => { });
139
+ throw err;
140
+ }
141
+ }
142
+ /** Longest storage value read for a look; a longer one is compared by its start. */
143
+ const MAX_HELD_VALUE = 64 * 1024;
144
+ /** The cookies the browser would send to `url` and the top document's storage: what a look compares with the baseline. */
145
+ async function readHeld(context, page, url) {
146
+ const held = (await context.cookies([url])).map((c) => ({
147
+ kind: "cookie",
148
+ key: `cookie|${c.domain}|${c.path}|${c.name}`,
149
+ name: c.name,
150
+ value: c.value,
151
+ }));
152
+ const storage = await page.evaluate((max) => {
153
+ const out = [];
154
+ for (const [kind, store] of [
155
+ ["local", localStorage],
156
+ ["session", sessionStorage],
157
+ ]) {
158
+ for (let i = 0; i < store.length; i++) {
159
+ const name = store.key(i);
160
+ if (name !== null)
161
+ out.push({ kind, name, value: (store.getItem(name) ?? "").slice(0, max) });
162
+ }
163
+ }
164
+ return { origin: location.origin, out };
165
+ }, MAX_HELD_VALUE);
166
+ for (const e of storage.out)
167
+ held.push({ kind: e.kind, key: `${e.kind}|${storage.origin}|${e.name}`, name: e.name, value: e.value });
168
+ return held;
169
+ }
170
+ /** A read the page's own navigation interrupted: the look is skipped and taken again. */
171
+ const betweenPages = (err) => /context was destroyed|navigat|detached|target closed|has been closed/i.test(err.message);
172
+ /** One look at the window, or null when the page was between documents and the look should be taken again. */
173
+ async function lookAt(win, watch) {
174
+ const url = win.page.url();
175
+ const popupAway = win.context.pages().some((p) => p !== win.page && !p.isClosed() && /^https?:/i.test(p.url()) && !onApp(watch, p.url()));
176
+ // Off the app nothing is read, except at an absolute success URL, where a sign-in field still says it is not done.
177
+ const atSuccess = watch.successUrl !== undefined && /^https?:\/\//i.test(watch.successUrl) && urlMatches(url, watch.successUrl);
178
+ if (!onApp(watch, url) && !atSuccess)
179
+ return { url, signInField: false, held: [], popupAway };
180
+ try {
181
+ const fields = await win.page.evaluate(collectFields, { selectors: {}, tag: false });
182
+ if (!Array.isArray(fields))
183
+ return null;
184
+ const chosen = chooseFields(fields);
185
+ const held = await readHeld(win.context, win.page, url);
186
+ // The page moved while it was read: what was read belongs to two pages.
187
+ if (win.page.url() !== url)
188
+ return null;
189
+ return { url, signInField: Boolean(chosen.password || chosen.otp), held, popupAway };
190
+ }
191
+ catch (err) {
192
+ if (err instanceof Error && betweenPages(err))
193
+ return null;
194
+ throw err;
195
+ }
196
+ }
197
+ /**
198
+ * Watch the window until the person is signed in (engine/signed-in.ts says
199
+ * when), the window closes, or `signal` aborts. Resolves with how it ended;
200
+ * the caller saves and closes.
201
+ */
202
+ export async function watchForSignIn(win, options, opts = {}) {
203
+ let closed = false;
204
+ void win.closed.then(() => {
205
+ closed = true;
206
+ });
207
+ // The baseline is what the window holds once the first page is there: whatever the app set before anyone signed in.
208
+ const firstUrl = win.page.url();
209
+ let watch = startWatch(options.url, [], options.successUrl, firstUrl);
210
+ const baseline = [];
211
+ try {
212
+ for (const c of await win.context.cookies())
213
+ baseline.push({ kind: "cookie", key: `cookie|${c.domain}|${c.path}|${c.name}`, name: c.name, value: c.value });
214
+ if (onApp(watch, firstUrl)) {
215
+ const first = await readHeld(win.context, win.page, firstUrl).catch((err) => {
216
+ if (betweenPages(err))
217
+ return [];
218
+ throw err;
219
+ });
220
+ baseline.push(...first.filter((h) => h.kind !== "cookie"));
221
+ }
222
+ }
223
+ catch (err) {
224
+ if (opts.signal?.aborted)
225
+ return { kind: "aborted" };
226
+ if (closed || win.page.isClosed())
227
+ return { kind: "closed" };
228
+ throw err;
229
+ }
230
+ watch = startWatch(options.url, baseline, options.successUrl, firstUrl);
231
+ opts.progress?.({ reason: "starting", url: firstUrl });
232
+ for (;;) {
233
+ if (closed || win.page.isClosed())
234
+ return { kind: "closed" };
235
+ if (opts.signal?.aborted)
236
+ return { kind: "aborted" };
237
+ let look;
238
+ try {
239
+ look = await lookAt(win, watch);
240
+ }
241
+ catch (err) {
242
+ // The window closing under a look is the window closing, not an error; nor is a look the caller already stopped
243
+ // (Enter was pressed and the window is being closed to save).
244
+ if (opts.signal?.aborted)
245
+ return { kind: "aborted" };
246
+ if (closed || win.page.isClosed())
247
+ return { kind: "closed" };
248
+ throw err;
249
+ }
250
+ if (look !== null) {
251
+ const judged = judgeSignIn(watch, look);
252
+ watch = judged.watch;
253
+ if (judged.verdict.kind === "signed-in")
254
+ return { kind: "signed-in", verdict: judged.verdict, url: look.url };
255
+ opts.progress?.({ reason: judged.verdict.reason, url: look.url });
256
+ }
257
+ await new Promise((resolve) => setTimeout(resolve, LOOK_EVERY_MS));
258
+ }
259
+ }
260
+ /** What the window says once the sign-in is seen: where, and by what. */
261
+ function sayDetected(verdict, url) {
262
+ const how = verdict.via === "success-url" ? "reached the success URL" : `the app holds a new session (${verdict.credential})`;
263
+ return `Signed in: back on ${redactRoute(url)} and ${how}. Saving and closing the window.`;
264
+ }
265
+ export async function runLogin(options, log) {
266
+ const mode = options.save ?? DEFAULT_SAVE_MODE;
267
+ const win = await openLoginWindow(options);
268
+ let enter;
269
+ const stop = new AbortController();
270
+ try {
271
+ log(`A ${win.engine} window is open at ${options.url}.`);
272
+ if (mode === "auto") {
273
+ log(`Sign in as "${options.role}" there — SSO, MFA, whatever the app asks. The window saves and closes by itself once you are signed in.`);
274
+ log(`(To save sooner, press Enter here. Closing the window or pressing Ctrl+C saves nothing.)`);
275
+ }
276
+ else {
277
+ log(`Sign in as "${options.role}" there — SSO, MFA, whatever the app asks — then come back here and press Enter to save.`);
278
+ log(`(Closing the window or pressing Ctrl+C saves nothing.)`);
279
+ }
127
280
  // Listening starts only now, after the prompt: an Enter pressed while the
128
281
  // 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")
282
+ enter = waitForEnter(mode === "auto");
283
+ const racers = [enter.done, win.closed.then(() => "window-closed")];
284
+ if (mode === "auto") {
285
+ racers.push(watchForSignIn(win, options, { signal: stop.signal }).then((r) => {
286
+ if (r.kind === "signed-in") {
287
+ log(sayDetected(r.verdict, r.url));
288
+ return "signed-in";
289
+ }
290
+ return r.kind === "closed" ? "window-closed" : "interrupted";
291
+ }));
292
+ }
293
+ const how = await Promise.race(racers);
294
+ stop.abort();
295
+ if (how !== "enter" && how !== "signed-in")
132
296
  throw new Error(NOT_SAVED[how]);
133
- return await saveLogin(context, options);
297
+ return await saveLogin(win.context, options);
134
298
  }
135
299
  finally {
136
- end?.dispose();
300
+ stop.abort();
301
+ enter?.dispose();
137
302
  // Closing a browser the person already closed fails; nothing is left to clean up then.
138
- await browser.close().catch(() => { });
303
+ await win.browser.close().catch(() => { });
139
304
  }
140
305
  }
306
+ /** How long a sign-in window opened from the conversation stays open, at most, before it closes saving nothing. */
307
+ export const LOGIN_WINDOW_MAX_MS = 15 * 60_000;
308
+ /**
309
+ * Open a window for a person to sign in, watch it, and save the profile once
310
+ * they are signed in: what scout_login does. Never waits for Enter; the
311
+ * window closing, `cancel`, or LOGIN_WINDOW_MAX_MS passing saves nothing.
312
+ */
313
+ export async function startLoginWindow(options, opts = {}) {
314
+ const win = await openLoginWindow(options, opts);
315
+ const stop = new AbortController();
316
+ let last = { reason: "starting", url: options.url };
317
+ const timer = setTimeout(() => stop.abort(), opts.maxMs ?? LOGIN_WINDOW_MAX_MS);
318
+ const done = (async () => {
319
+ try {
320
+ const r = await watchForSignIn(win, options, { signal: stop.signal, progress: (p) => (last = p) });
321
+ if (r.kind === "closed")
322
+ return { ok: false, error: NOT_SAVED["window-closed"] };
323
+ if (r.kind === "aborted")
324
+ return { ok: false, error: NOT_SAVED["timed-out"] };
325
+ const saved = await saveLogin(win.context, options);
326
+ return { ok: true, saved, detected: sayDetected(r.verdict, r.url).replace(/ Saving and closing the window\.$/, "") };
327
+ }
328
+ catch (err) {
329
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
330
+ }
331
+ finally {
332
+ clearTimeout(timer);
333
+ // The outcome is decided; a window the person already closed fails to close again, and nothing is left then.
334
+ await win.browser.close().catch(() => { });
335
+ }
336
+ })();
337
+ return {
338
+ role: options.role,
339
+ url: options.url,
340
+ window: win,
341
+ done,
342
+ progress: () => last,
343
+ cancel: async () => {
344
+ stop.abort();
345
+ // Closed either way: a window already gone fails to close again. `done` settles once the watch sees it.
346
+ await win.browser.close().catch(() => { });
347
+ await done;
348
+ },
349
+ };
350
+ }
141
351
  /**
142
352
  * Save a signed-in context as the role's profile and read how long it lasts:
143
353
  * what `scenescout login` does once Enter is pressed, apart so it can be run
@@ -179,10 +389,12 @@ const LOGIN_ACTION_MS = 10_000;
179
389
  const MAX_SUBMITS = 5;
180
390
  /**
181
391
  * 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.
392
+ * each with its index when asked (a scripted sign-in acts on them; watching a
393
+ * person sign in only reads them, so leaves the page as it is), and mark those
394
+ * matching a configured selector. Runs in the page, so it may use only what
395
+ * the page has.
184
396
  */
185
- function collectFields(selectors) {
397
+ function collectFields({ selectors, tag: tagging }) {
186
398
  const visible = (el) => {
187
399
  const r = el.getBoundingClientRect();
188
400
  const st = getComputedStyle(el);
@@ -220,8 +432,9 @@ function collectFields(selectors) {
220
432
  return parts.join(" ").replace(/\s+/g, " ").trim();
221
433
  };
222
434
  // 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");
435
+ if (tagging)
436
+ for (const old of Array.from(document.querySelectorAll("[data-scenescout-login]")))
437
+ old.removeAttribute("data-scenescout-login");
225
438
  const out = [];
226
439
  const all = Array.from(document.querySelectorAll("input, textarea, select, button"));
227
440
  for (const el of all) {
@@ -229,7 +442,8 @@ function collectFields(selectors) {
229
442
  if (type === "hidden" || !visible(el))
230
443
  continue;
231
444
  const index = out.length;
232
- el.setAttribute("data-scenescout-login", String(index));
445
+ if (tagging)
446
+ el.setAttribute("data-scenescout-login", String(index));
233
447
  const tag = el.tagName.toLowerCase();
234
448
  const input = el;
235
449
  out.push({
@@ -250,22 +464,37 @@ function collectFields(selectors) {
250
464
  }
251
465
  return out;
252
466
  }
253
- /** A page's error or alert text, if it shows one: the reason a refused sign-in gives. Redacted before it is printed. */
467
+ /**
468
+ * The error or alert a page shows, if any: the first one on screen with text
469
+ * in it (a hidden, pre-rendered alert says nothing about this run), whole.
470
+ * The caller redacts it before shortening it (quotable).
471
+ */
254
472
  async function alertText(page) {
255
- const text = await page
473
+ return (page
256
474
  .evaluate(() => {
257
- const el = document.querySelector('[role="alert"], [aria-live="assertive"], .error, .alert');
258
- return (el?.textContent ?? "").replace(/\s+/g, " ").trim();
475
+ for (const el of Array.from(document.querySelectorAll('[role="alert"], [aria-live="assertive"], .error, .alert'))) {
476
+ const r = el.getBoundingClientRect();
477
+ const st = getComputedStyle(el);
478
+ const text = (el.textContent ?? "").trim();
479
+ if (text && r.width > 0 && r.height > 0 && st.visibility !== "hidden" && st.display !== "none")
480
+ return text;
481
+ }
482
+ return "";
259
483
  })
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);
484
+ // The page's message only adds to the error being reported; a page navigating away as it is read leaves that error as it stands.
485
+ .catch(() => ""));
263
486
  }
264
487
  /**
265
488
  * Sign in with no one at the keyboard: headless, the credentials from the
266
489
  * environment, the form found by engine/scripted-login.ts's rules. Every line
267
490
  * it logs and every error it throws goes through the credential redaction
268
491
  * first. Saves the profile exactly as the manual login does.
492
+ *
493
+ * Each step fills what the page asks for, then reads the page again until it
494
+ * is clear how to submit (engine/scripted-login.ts's afterTyping): a page that
495
+ * submitted the step itself is not submitted again, a button the page enables
496
+ * only once the form is complete is waited for, and a field the typing
497
+ * revealed is filled first.
269
498
  */
270
499
  export async function runScriptedLogin(options, config, redactor, log, now = () => Date.now() / 1000) {
271
500
  const redact = (text) => redactor.redact(text);
@@ -327,10 +556,10 @@ export async function runScriptedLogin(options, config, redactor, log, now = ()
327
556
  return true;
328
557
  };
329
558
  const read = async () => {
330
- const got = await page.evaluate(collectFields, config.selectors).catch((err) => {
559
+ const got = await page.evaluate(collectFields, { selectors: config.selectors, tag: true }).catch((err) => {
331
560
  if (midNavigation(err))
332
561
  return null;
333
- throw err;
562
+ throw fail(err.message);
334
563
  });
335
564
  if (got === null)
336
565
  return null;
@@ -345,19 +574,49 @@ export async function runScriptedLogin(options, config, redactor, log, now = ()
345
574
  return `${pageKey()}|${Object.keys(chosen).sort().join(",")}`;
346
575
  };
347
576
  const stepOpts = (successMatchedNow) => ({
348
- hasTotp: config.totp !== undefined,
577
+ hasPassword: config.password !== undefined,
578
+ ...(config.code ? { code: config.code.kind } : {}),
349
579
  successConfigured: Boolean(config.success.url || config.success.selector),
350
580
  successMatched: successMatchedNow,
351
581
  });
582
+ /** The code to type now: the fixed one, or the TOTP code, waiting for the next one when this one is about to expire. */
583
+ const currentCode = async (source) => {
584
+ if (source.kind === "fixed")
585
+ return source.code;
586
+ const params = source.params;
587
+ if (secondsLeft(params, now()) < TOTP_MIN_SECONDS_LEFT)
588
+ await page.waitForTimeout(secondsLeft(params, now()) * 1000 + 200);
589
+ const code = totpCode(params, now());
590
+ redactor.add(code);
591
+ return code;
592
+ };
593
+ const at = (f) => page.locator(`[${TAG}="${f.index}"]`);
594
+ /** A failed action as this run's error: the time limit explained, redacted like everything else it throws. */
595
+ const actionError = (err) => {
596
+ const explained = loginTimeout(err, "action", limits.actionMs);
597
+ return fail(explained instanceof Error ? explained.message : String(explained));
598
+ };
599
+ const actionFailed = (err) => Promise.reject(actionError(err));
600
+ /** What the page says, fit to quote. */
601
+ const pageSays = async () => quotable(await alertText(page), redact, 200);
602
+ /** A button's text fit to quote, redacted before it is shortened. */
603
+ const buttonText = (f) => quotable(f.text || f.label || "the submit button", redact, 40);
604
+ /** The run's deadline passed: say what it was waiting for, and what the page says, if anything. */
605
+ const timedOut = async (waitingFor) => {
606
+ const says = await pageSays();
607
+ return fail(`timed out after ${config.timeoutMs / 1000}s waiting for ${waitingFor} (at ${where()})${says ? `. The page says: "${says}"` : ""}`);
608
+ };
609
+ const submitOpts = { passwordless: config.password === undefined };
352
610
  let lastWait = "the sign-in form to appear";
353
611
  for (;;) {
354
612
  if (Date.now() > deadline)
355
- throw fail(`timed out after ${config.timeoutMs / 1000}s waiting for ${lastWait} (at ${where()})`);
613
+ throw await timedOut(lastWait);
356
614
  const fields = await read();
357
615
  if (fields === null) {
358
616
  await page.waitForTimeout(POLL_MS);
359
617
  continue;
360
618
  }
619
+ const keyAtRead = pageKey();
361
620
  const chosen = chooseFields(fields);
362
621
  const step = nextStep(chosen, progress, stepOpts(await successMatched()));
363
622
  if (step.kind === "done") {
@@ -367,7 +626,7 @@ export async function runScriptedLogin(options, config, redactor, log, now = ()
367
626
  // a page between a redirect and its first render shows no fields either.
368
627
  await page.waitForLoadState("load", { timeout: limits.actionMs }).catch((err) => {
369
628
  if (!/timeout/i.test(err.message) && !midNavigation(err))
370
- throw err;
629
+ throw fail(err.message);
371
630
  });
372
631
  await page.waitForTimeout(POLL_MS * 2);
373
632
  const settled = await read();
@@ -376,48 +635,104 @@ export async function runScriptedLogin(options, config, redactor, log, now = ()
376
635
  continue;
377
636
  }
378
637
  if (step.kind === "refused") {
379
- const says = await alertText(page);
638
+ const says = await pageSays();
380
639
  throw fail(`sign-in refused: ${step.reason}.${says ? ` The page says: "${says}"` : ""}`);
381
640
  }
382
641
  if (step.kind === "stuck")
383
642
  throw fail(`sign-in stuck: ${step.reason}.`);
384
643
  if (step.kind === "wait") {
644
+ const credentialSent = progress.submitted.has("password") || progress.submitted.has("otp");
385
645
  lastWait =
386
646
  progress.submits === 0
387
647
  ? "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)";
648
+ : !credentialSent
649
+ ? "the page to ask for the password or the one-time code (set --password-selector or --otp-selector if the field is there but not found)"
650
+ : "the signed-in page (the success URL or selector, or the sign-in fields to go)";
389
651
  await page.waitForTimeout(POLL_MS);
390
652
  continue;
391
653
  }
392
654
  if (progress.submits >= MAX_SUBMITS)
393
655
  throw fail(`gave up after ${MAX_SUBMITS} submits without reaching the signed-in page`);
394
- let last = null;
656
+ let boxOpts = {};
657
+ // What this step typed, recorded as submitted only once the step is: a step the page holds back (fill-more) is typed again.
658
+ const typedIds = new Map();
395
659
  for (const kind of step.fill) {
396
660
  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);
661
+ const boxes = kind === "otp" ? otpBoxes(fields, field) : null;
662
+ if (boxes) {
663
+ const split = splitCode(await currentCode(config.code), boxes.length, config.code.kind);
664
+ if (!split.ok)
665
+ throw fail(`sign-in stuck: ${split.error}.`);
666
+ for (const [i, box] of boxes.entries()) {
667
+ try {
668
+ // A box is often enabled only once the one before it is filled.
669
+ await page.waitForFunction(([attr, index]) => {
670
+ const el = document.querySelector(`[${attr}="${index}"]`);
671
+ return el !== null && !el.disabled;
672
+ }, [TAG, String(box.index)], { timeout: limits.actionMs, polling: 50 });
673
+ // Cleared only when it holds something: clearing sends Delete, which some boxes take as a step back to the box before.
674
+ if ((await at(box).inputValue({ timeout: limits.actionMs })) !== "")
675
+ await at(box).fill("", { timeout: limits.actionMs });
676
+ // Typed as keys: a box that moves on to the next by itself, or reads keys rather than its value, still gets its character.
677
+ await at(box).pressSequentially(split.chars[i], { timeout: limits.actionMs });
678
+ }
679
+ catch (err) {
680
+ // The first line only: the call log after it names the character being typed, which no redaction of the whole code catches.
681
+ const explained = loginTimeout(err, "action", limits.actionMs);
682
+ const first = (explained instanceof Error ? explained.message : String(explained)).split("\n")[0];
683
+ throw fail(`could not type into box ${i + 1} of ${boxes.length} of the one-time code: ${first}`);
684
+ }
685
+ }
686
+ boxOpts = { codeBoxes: boxes.length };
687
+ }
688
+ else {
689
+ const value = kind === "otp" ? await currentCode(config.code) : kind === "username" ? config.username : config.password;
690
+ await at(field).fill(value, { timeout: limits.actionMs }).catch(actionFailed);
404
691
  }
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;
692
+ typedIds.set(kind, fieldIdentity(field));
413
693
  }
414
- const button = chooseSubmit(fields);
415
694
  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)));
695
+ const shownBefore = new Set(Object.keys(chosen));
696
+ const readNow = async () => {
697
+ const now = await read();
698
+ return afterTyping(step.fill, { left: now === null || pageKey() !== keyAtRead, fields: now ?? [] }, shownBefore, submitOpts);
699
+ };
700
+ let next;
701
+ for (;;) {
702
+ await page.waitForTimeout(POLL_MS);
703
+ next = await readNow();
704
+ if (next.kind !== "wait")
705
+ break;
706
+ if (Date.now() > deadline) {
707
+ const control = next.button ? `the "${buttonText(next.button)}" button` : "the field just filled";
708
+ throw await timedOut(`${control} to be enabled, or the page to move on, after filling ${filledNames(step.fill, boxOpts)}`);
709
+ }
710
+ }
711
+ if (next.kind === "fill-more") {
712
+ say(describeStep(step.fill, "more", boxOpts));
713
+ continue;
714
+ }
715
+ let by = "page";
716
+ if (next.kind === "click" || next.kind === "enter") {
717
+ try {
718
+ if (next.kind === "click")
719
+ await at(next.button).click({ timeout: limits.actionMs });
720
+ else
721
+ await at(next.field).press("Enter", { timeout: limits.actionMs });
722
+ }
723
+ catch (err) {
724
+ // The page moved on while the click waited (it took the step itself, or the click went through as it left): the step is
725
+ // done. Otherwise the click's own error is the one to report, also when reading the page to tell fails.
726
+ const moved = await readNow().then((after) => after.kind === "moved", () => false);
727
+ if (!moved)
728
+ throw actionError(err);
729
+ }
730
+ by = next.kind === "click" ? { button: buttonText(next.button) } : "enter";
731
+ }
732
+ for (const [kind, identity] of typedIds)
733
+ progress.submitted.set(kind, identity);
419
734
  progress.submits += 1;
420
- const did = describeStep(step.fill, button ? button.text || button.label || "the submit button" : null);
735
+ const did = describeStep(step.fill, by, boxOpts);
421
736
  say(did);
422
737
  lastWait = `the page to move on after that step (${did})`;
423
738
  // Wait for the form to move on: another page, or other fields on this one.