scenescout 3.14.1 → 3.15.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.
@@ -1,16 +1,17 @@
1
1
  /**
2
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.
3
+ * with no person at the keyboard, for CI. The username, the password (none
4
+ * for a passwordless sign-in) and the one-time code, from a TOTP secret or a
5
+ * fixed code a test environment accepts, come from the environment; the
6
+ * browser fills the identity provider's form with them and the session is
7
+ * saved as the role's profile exactly as the manual login saves it.
7
8
  *
8
9
  * Everything here is Playwright-free so it can be table-tested: which
9
10
  * 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.
11
+ * code, which input on a page is the username, the password or the code
12
+ * (one field, or one box per character), which button moves the form on,
13
+ * what counts as signed in or refused, and the redaction every line of
14
+ * output goes through. The browser half is in login-run.ts.
14
15
  *
15
16
  * A credential value is never printed, logged or written. Each value, and
16
17
  * each of its URL-encoded forms, is replaced before any line leaves the
@@ -24,6 +25,7 @@ export const LOGIN_ENV = {
24
25
  username: "SCENESCOUT_LOGIN_USERNAME",
25
26
  password: "SCENESCOUT_LOGIN_PASSWORD",
26
27
  totpSecret: "SCENESCOUT_LOGIN_TOTP_SECRET",
28
+ otpCode: "SCENESCOUT_LOGIN_OTP_CODE",
27
29
  usernameSelector: "SCENESCOUT_LOGIN_USERNAME_SELECTOR",
28
30
  passwordSelector: "SCENESCOUT_LOGIN_PASSWORD_SELECTOR",
29
31
  otpSelector: "SCENESCOUT_LOGIN_OTP_SELECTOR",
@@ -53,20 +55,43 @@ export const MAX_TIMEOUT_S = 600;
53
55
  export function readScriptedLogin(flags, env) {
54
56
  const errors = [];
55
57
  const username = env[LOGIN_ENV.username] ?? "";
56
- const password = env[LOGIN_ENV.password] ?? "";
58
+ const rawPassword = env[LOGIN_ENV.password];
57
59
  if (username.trim() === "")
58
60
  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() !== "") {
61
+ const rawSecret = env[LOGIN_ENV.totpSecret] ?? "";
62
+ const rawCode = env[LOGIN_ENV.otpCode] ?? "";
63
+ const hasSecret = rawSecret.trim() !== "";
64
+ const hasCode = rawCode.trim() !== "";
65
+ const codeGiven = hasSecret || hasCode;
66
+ let code;
67
+ if (hasSecret && hasCode) {
68
+ errors.push(`${LOGIN_ENV.otpCode} and ${LOGIN_ENV.totpSecret} are both set: set the fixed code or the secret that generates codes, not both`);
69
+ }
70
+ else if (hasSecret) {
64
71
  const parsed = parseTotpSecret(rawSecret);
65
72
  if (parsed.ok)
66
- totp = parsed.params;
73
+ code = { kind: "totp", params: parsed.params };
67
74
  else
68
75
  errors.push(`${LOGIN_ENV.totpSecret} ${parsed.error}`);
69
76
  }
77
+ else if (hasCode) {
78
+ const parsed = parseOtpCode(rawCode);
79
+ if (parsed.ok)
80
+ code = { kind: "fixed", code: parsed.code };
81
+ else
82
+ errors.push(`${LOGIN_ENV.otpCode} ${parsed.error}`);
83
+ }
84
+ // Passwordless means the password is left unset. Set but empty is a secret that resolved to nothing (a CI secret that does not
85
+ // exist reads as ""), refused here rather than found out when the form asks for it.
86
+ if (rawPassword === "") {
87
+ errors.push(`${LOGIN_ENV.password} is set but empty: give the test user's password, or leave it unset for a passwordless sign-in`);
88
+ }
89
+ else if (rawPassword === undefined && !codeGiven) {
90
+ // A code variable that is set but blank is most likely a CI secret that does not exist: say so rather than "set it".
91
+ const blank = [LOGIN_ENV.otpCode, LOGIN_ENV.totpSecret].filter((name) => env[name] !== undefined);
92
+ errors.push(`${LOGIN_ENV.password} is not set: the test user's password (for a passwordless sign-in that asks only for a one-time code, set ${LOGIN_ENV.otpCode} or ${LOGIN_ENV.totpSecret} instead)` +
93
+ (blank.length > 0 ? `; ${blank.join(" and ")} ${blank.length > 1 ? "are" : "is"} set but empty` : ""));
94
+ }
70
95
  const pick = (flag, name) => {
71
96
  const v = flags.get(flag) ?? env[name];
72
97
  return v !== undefined && v.trim() !== "" ? v.trim() : undefined;
@@ -84,8 +109,11 @@ export function readScriptedLogin(flags, env) {
84
109
  selectors.otp = o;
85
110
  if (s)
86
111
  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`);
112
+ if (selectors.otp && !codeGiven) {
113
+ errors.push(`an OTP selector is set but neither ${LOGIN_ENV.otpCode} nor ${LOGIN_ENV.totpSecret} is: there is no code to type into it`);
114
+ }
115
+ if (selectors.password && rawPassword === undefined)
116
+ errors.push(`a password selector is set but ${LOGIN_ENV.password} is not: there is no password to type into it`);
89
117
  const success = {};
90
118
  const successUrl = pick("success-url", LOGIN_ENV.successUrl);
91
119
  const successSelector = pick("success-selector", LOGIN_ENV.successSelector);
@@ -104,7 +132,35 @@ export function readScriptedLogin(flags, env) {
104
132
  }
105
133
  if (errors.length > 0)
106
134
  return { ok: false, errors };
107
- return { ok: true, config: { username, password, ...(totp ? { totp } : {}), selectors, success, timeoutMs: timeoutS * 1000 } };
135
+ return {
136
+ ok: true,
137
+ config: {
138
+ username,
139
+ ...(rawPassword !== undefined ? { password: rawPassword } : {}),
140
+ ...(code ? { code } : {}),
141
+ selectors,
142
+ success,
143
+ timeoutMs: timeoutS * 1000,
144
+ },
145
+ };
146
+ }
147
+ /** The shortest and longest fixed one-time code accepted. */
148
+ const MIN_OTP_CODE = 4;
149
+ const MAX_OTP_CODE = 12;
150
+ /**
151
+ * Read a fixed one-time code: letters and digits only, as the code field
152
+ * takes it, with the whitespace a pasted secret often carries trimmed. The
153
+ * error never quotes the value.
154
+ */
155
+ export function parseOtpCode(raw) {
156
+ const code = raw.trim();
157
+ if (!new RegExp(`^[A-Za-z0-9]{${MIN_OTP_CODE},${MAX_OTP_CODE}}$`).test(code)) {
158
+ return {
159
+ ok: false,
160
+ error: `must be ${MIN_OTP_CODE} to ${MAX_OTP_CODE} letters or digits, with no spaces or dashes: the code exactly as the code field takes it`,
161
+ };
162
+ }
163
+ return { ok: true, code };
108
164
  }
109
165
  // ── redaction ───────────────────────────────────────────────────────────────
110
166
  export const REDACTED = "[redacted]";
@@ -112,6 +168,7 @@ export const REDACTED = "[redacted]";
112
168
  * Every form a credential could take in text: as typed, URL-encoded (a query
113
169
  * string, a form body), and with `+` for spaces. The TOTP secret as given,
114
170
  * and its base32 without spaces or padding, so a reformatted echo is caught.
171
+ * A fixed one-time code is a credential like the password.
115
172
  */
116
173
  export function credentialValues(config) {
117
174
  const out = new Set();
@@ -128,6 +185,7 @@ export function credentialValues(config) {
128
185
  };
129
186
  add(config.username);
130
187
  add(config.password);
188
+ add(config.otpCode);
131
189
  if (config.totpSecretRaw) {
132
190
  add(config.totpSecretRaw);
133
191
  add(config.totpSecretRaw.replace(/[\s=-]/g, "").toUpperCase());
@@ -137,7 +195,8 @@ export function credentialValues(config) {
137
195
  /**
138
196
  * Replace every credential value in a line, longest first so a value
139
197
  * containing another is not left half-printed. Case-sensitive except for the
140
- * username, which identity providers commonly echo lower-cased.
198
+ * values given as `caseInsensitive`: the username, which identity providers
199
+ * commonly echo lower-cased, and a fixed one-time code.
141
200
  */
142
201
  export function redactCredentials(text, values, caseInsensitive = []) {
143
202
  let out = text;
@@ -149,11 +208,17 @@ export function redactCredentials(text, values, caseInsensitive = []) {
149
208
  }
150
209
  return out;
151
210
  }
152
- /** The redactor for one scripted sign-in: every form of each credential, the username also without regard to case. */
211
+ /** The redactor for one scripted sign-in: every form of each credential, the username and a fixed code also without regard to case. */
153
212
  export function credentialRedactor(config, totpSecretRaw) {
154
- const values = credentialValues({ ...config, ...(totpSecretRaw ? { totpSecretRaw } : {}) });
213
+ const otpCode = config.code?.kind === "fixed" ? config.code.code : undefined;
214
+ const values = credentialValues({
215
+ username: config.username,
216
+ ...(config.password !== undefined ? { password: config.password } : {}),
217
+ ...(otpCode ? { otpCode } : {}),
218
+ ...(totpSecretRaw ? { totpSecretRaw } : {}),
219
+ });
155
220
  const username = config.username.trim();
156
- const anyCase = username ? [username, encodeURIComponent(username)] : [];
221
+ const anyCase = [...(username ? [username, encodeURIComponent(username)] : []), ...(otpCode ? [otpCode] : [])];
157
222
  return {
158
223
  redact: (text) => redactCredentials(text, values, anyCase),
159
224
  add: (value) => {
@@ -241,17 +306,88 @@ export function secondsLeft(params, unixSeconds) {
241
306
  }
242
307
  /** With fewer seconds than this left, wait for the next code rather than type one that expires in transit. */
243
308
  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;
309
+ 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
310
  const USER_WORDS = /(user(name)?|e-?mail|login|account|identifier|\bid\b)/i;
246
311
  const NOT_USER = /(search|query|coupon|promo)/i;
247
312
  const TEXTLIKE = new Set(["text", "email", "tel", "number", ""]);
313
+ /** Words that make a field's "code" some other code: a postal code, a country code, a promotion. */
314
+ const NOT_CODE = /(zip|postal|post\s?code|country|promo|coupon)/i;
315
+ /** A numeric field these words describe is a number of another kind, not a one-time code. */
316
+ const OTHER_NUMBER = /(phone|mobile|card|amount|quantity|year)/i;
248
317
  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));
318
+ const autocompletes = (f, token) => f.autocomplete.split(/\s+/).includes(token);
319
+ /**
320
+ * Why a field is a single one-time-code field, if it is. "named": its
321
+ * autocomplete is one-time-code, or else the words of its name, id and label
322
+ * say code, OTP or verification (a code field masked as a password field
323
+ * included, unless its words say password or passcode). "shape": only its
324
+ * shape says so, a numeric field (inputmode="numeric") 4 to 12 characters
325
+ * long with no autocomplete purpose of its own, whose words do not say phone,
326
+ * card, postal code or username. Short of autocomplete="one-time-code", a
327
+ * field that is the username by its type or autocomplete is never the code,
328
+ * whatever its label says ("Email: we will send you a code").
329
+ */
330
+ function otpEvidence(f) {
331
+ if (f.tag !== "input")
332
+ return null;
333
+ if (autocompletes(f, "one-time-code"))
334
+ return TEXTLIKE.has(f.type) || f.type === "password" ? "named" : null;
335
+ if (f.type === "email" || autocompletes(f, "username") || autocompletes(f, "email"))
336
+ return null;
337
+ const words = describe(f);
338
+ if (f.type === "password")
339
+ return OTP_WORDS.test(words) && !/pass(word|code)/i.test(words) ? "named" : null;
340
+ if (!TEXTLIKE.has(f.type) || NOT_CODE.test(words))
341
+ return null;
342
+ if (OTP_WORDS.test(words))
343
+ return "named";
344
+ const unnamedPurpose = f.autocomplete === "" || f.autocomplete === "off";
345
+ const sized = f.inputmode === "numeric" && f.maxLength >= MIN_OTP_CODE && f.maxLength <= MAX_OTP_CODE;
346
+ return sized && unnamedPurpose && !OTHER_NUMBER.test(words) && !USER_WORDS.test(words) ? "shape" : null;
347
+ }
348
+ /** A code field known only by its shape, not chosen by a selector: the sign-in's only in the steps signInFields allows. */
349
+ function shapeOnlyOtp(f) {
350
+ return f.forced !== "otp" && otpEvidence(f) === "shape";
351
+ }
352
+ /** The fewest and most boxes a one-time code split one character per box is laid out in. */
353
+ const MIN_CODE_BOXES = 4;
354
+ const MAX_CODE_BOXES = 10;
355
+ /**
356
+ * A one-time code split into one box per character: a run of 4 to 10 text
357
+ * inputs that each take one character (maxlength 1), side by side with no
358
+ * other control between them, the first of them enabled. Later boxes may be
359
+ * disabled until the earlier ones are filled. A split date, phone or card
360
+ * number has boxes of 2 to 4 characters, so it is never taken for one.
361
+ */
362
+ export function codeBoxes(fields) {
363
+ const isBox = (f) => f.tag === "input" && f.maxLength === 1 && ((TEXTLIKE.has(f.type) && f.type !== "email") || f.type === "password");
364
+ const runs = [];
365
+ for (const f of fields.filter(isBox).sort((a, b) => a.index - b.index)) {
366
+ const run = runs[runs.length - 1];
367
+ if (run && run[run.length - 1].index === f.index - 1)
368
+ run.push(f);
369
+ else
370
+ runs.push([f]);
371
+ }
372
+ return runs.find((r) => r.length >= MIN_CODE_BOXES && r.length <= MAX_CODE_BOXES && !r[0].disabled) ?? null;
373
+ }
374
+ /** The boxes of a split code that `otp` (the field chosen for the code) is the first of, or null when it is a single field. */
375
+ export function otpBoxes(fields, otp) {
376
+ const boxes = codeBoxes(fields);
377
+ return boxes && boxes[0].index === otp.index ? boxes : null;
378
+ }
379
+ /**
380
+ * One character per box, or why the code cannot fill these boxes: the number
381
+ * of boxes and where the code comes from, never the code itself.
382
+ */
383
+ export function splitCode(code, boxes, source) {
384
+ const chars = [...code];
385
+ if (chars.length === boxes)
386
+ return { ok: true, chars };
387
+ const from = source === "fixed"
388
+ ? `${LOGIN_ENV.otpCode} has a different number of characters`
389
+ : `the codes ${LOGIN_ENV.totpSecret} generates have ${chars.length} digits (an otpauth:// URI's digits parameter sets how many)`;
390
+ return { ok: false, error: `the page splits the code into ${boxes} boxes, one character each, and ${from}` };
255
391
  }
256
392
  function isUsername(f) {
257
393
  if (f.tag !== "input" || !TEXTLIKE.has(f.type) || f.type === "number")
@@ -278,7 +414,10 @@ function usernameRank(f) {
278
414
  * control that fits, by autocomplete, type, then name/id/label words. A
279
415
  * password field for a NEW password (autocomplete="new-password") is chosen
280
416
  * only when it is the only one: a sign-up form's confirm field is not the
281
- * sign-in password.
417
+ * sign-in password. A code field named as one wins over one known only by its
418
+ * shape. A code split one character per box is chosen by its first box
419
+ * (otpBoxes gives the rest), and none of its boxes is taken for the username
420
+ * or the password.
282
421
  */
283
422
  export function chooseFields(fields) {
284
423
  const usable = fields.filter((f) => !f.disabled && f.tag !== "button");
@@ -288,13 +427,20 @@ export function chooseFields(fields) {
288
427
  if (forced)
289
428
  out[kind] = forced;
290
429
  }
430
+ const boxes = codeBoxes(fields);
431
+ const inBoxes = new Set(boxes?.map((b) => b.index));
432
+ // A selector naming one box of a split code names the code.
433
+ if (boxes && (!out.otp || inBoxes.has(out.otp.index)))
434
+ out.otp = boxes[0];
291
435
  if (!out.password) {
292
- const pw = usable.filter((f) => f.tag === "input" && f.type === "password");
436
+ const pw = usable.filter((f) => f.tag === "input" && f.type === "password" && !inBoxes.has(f.index) && otpEvidence(f) === null);
293
437
  out.password = pw.find((f) => f.autocomplete.includes("current-password")) ?? pw.find((f) => !f.autocomplete.includes("new-password")) ?? pw[0];
294
438
  }
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));
439
+ const taken = new Set([...inBoxes, out.username?.index, out.password?.index, out.otp?.index].filter((i) => i !== undefined));
440
+ if (!out.otp) {
441
+ const free = usable.filter((f) => !taken.has(f.index));
442
+ out.otp = free.find((f) => otpEvidence(f) === "named") ?? free.find((f) => otpEvidence(f) === "shape");
443
+ }
298
444
  if (out.otp)
299
445
  taken.add(out.otp.index);
300
446
  if (!out.username) {
@@ -306,23 +452,112 @@ export function chooseFields(fields) {
306
452
  delete out[k];
307
453
  return out;
308
454
  }
455
+ /** Words on a button that signs in or moves the sign-in on. */
309
456
  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
457
  /**
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.
458
+ * Words on a button that sends a one-time code, or continues with an email, a
459
+ * phone or a code: the way on in a passwordless sign-in, and second to a
460
+ * button that signs in or verifies wherever both are on a page (a page asking
461
+ * for a code may also offer to text one).
462
+ */
463
+ const SEND_WORDS = /\b(send|(get|request)( me)?( a| the| my)?( [\w-]+)? code|(e-?mail|text) me|continue with)\b/i;
464
+ /**
465
+ * Buttons that lead away from this sign-in: another provider, another flow,
466
+ * another address, or a new code in place of the one being typed. "Continue
467
+ * with" leads to another provider, except with an email, a phone or a code.
468
+ */
469
+ const AWAY_WORDS = /\b(forgot|reset|sign[\s-]?up|register|create|continue with(?! (your |my |a |an |work |personal )?(e-?mail|phone|([\w-]+ )?code))|provider|google|microsoft|github|apple|facebook|sso|single sign|passkey|magic link|cancel|back|re-?send|remember|new code|another|(send|try)\b.*\bagain|different|change|edit|instead|didn['’]?t|did not|not you)\b/i;
470
+ /**
471
+ * Pick the button that moves the form on, enabled or not: a form often
472
+ * enables its button only once it is complete, and the caller waits for it.
473
+ * A configured selector wins. Otherwise, in this order, the first on the page
474
+ * of: a submit button whose text signs in or verifies (sign in, next,
475
+ * continue, verify), any button with such text, a submit button whose text
476
+ * sends a code or continues with an email, any button with such text, any
477
+ * other submit button; at the same rank an enabled button before a disabled
478
+ * one. A button that leads elsewhere (another provider, a reset, a new code,
479
+ * another address, or a password the run does not have) is never chosen.
480
+ * Null means press Enter in the field just filled, which submits a form with
481
+ * a single text field.
317
482
  */
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"))));
483
+ export function chooseSubmit(fields, opts = {}) {
484
+ const buttons = fields.filter((f) => f.tag === "button" || (f.tag === "input" && (f.type === "submit" || f.type === "button")));
320
485
  const forced = buttons.find((f) => f.forced === "submit");
321
486
  if (forced)
322
487
  return forced;
323
488
  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;
489
+ const rank = (f) => {
490
+ if (AWAY_WORDS.test(f.text) || (opts.passwordless && /\bpassword\b/i.test(f.text)))
491
+ return null;
492
+ const submit = isSubmit(f);
493
+ if (SEND_WORDS.test(f.text))
494
+ return submit ? 2 : 3;
495
+ if (GO_WORDS.test(f.text))
496
+ return submit ? 0 : 1;
497
+ return submit ? 4 : null;
498
+ };
499
+ let best = null;
500
+ let bestKey = Infinity;
501
+ for (const f of buttons) {
502
+ const r = rank(f);
503
+ const key = r === null ? Infinity : r * 2 + (f.disabled ? 1 : 0);
504
+ if (key < bestKey) {
505
+ best = f;
506
+ bestKey = key;
507
+ }
508
+ }
509
+ return best;
510
+ }
511
+ /**
512
+ * Decide how to submit what a step typed, from the page as it is now; the
513
+ * caller reads the page again and asks again while the answer is wait.
514
+ *
515
+ * - moved: the page left (another URL, or gone mid-navigation), or no field
516
+ * of a kind just typed is on it any more, or it has been emptied. The page
517
+ * took the step itself (a code that submits itself once complete), so
518
+ * nothing is submitted again.
519
+ * - fill-more: the same page now asks for a sign-in field it did not show
520
+ * before the typing (a password field enabled once the email is valid).
521
+ * Fill that before submitting.
522
+ * - click: the button chooseSubmit picks, enabled. After the username alone
523
+ * in a passwordless run, a disabled button that signs in is waiting for a
524
+ * code nobody has asked for yet: an enabled button that sends one is
525
+ * clicked instead.
526
+ * - wait: that button is disabled (the page is checking what was typed, or
527
+ * enables it only once the form is complete), or there is no button and
528
+ * the field just typed into is disabled.
529
+ * - enter: no button, so Enter in the field just typed into.
530
+ *
531
+ * A typed field is looked for again by its kind, disabled fields included,
532
+ * not by its label, which can change as it is typed into: a page that
533
+ * disables its code boxes once they are full, waiting for a click, has not
534
+ * moved on.
535
+ */
536
+ export function afterTyping(typed, page, shownBefore, opts = {}) {
537
+ if (page.left || typed.length === 0)
538
+ return { kind: "moved" };
539
+ const stillThere = chooseFields(page.fields.map((f) => ({ ...f, disabled: false })));
540
+ if (typed.some((k) => !stillThere[k]?.filled))
541
+ return { kind: "moved" };
542
+ const chosen = chooseFields(page.fields);
543
+ const asksMore = ["username", "password", "otp"].some((k) => {
544
+ const f = chosen[k];
545
+ if (!f || f.filled || typed.includes(k) || shownBefore.has(k))
546
+ return false;
547
+ return k !== "otp" || !shapeOnlyOtp(f);
548
+ });
549
+ if (asksMore)
550
+ return { kind: "fill-more" };
551
+ const button = chooseSubmit(page.fields, opts);
552
+ if (button?.disabled && opts.passwordless && typed.length === 1 && typed[0] === "username") {
553
+ const send = chooseSubmit(page.fields.filter((f) => !f.disabled), opts);
554
+ if (send && SEND_WORDS.test(send.text))
555
+ return { kind: "click", button: send };
556
+ }
557
+ if (button)
558
+ return button.disabled ? { kind: "wait", button } : { kind: "click", button };
559
+ const field = chosen[typed[typed.length - 1]];
560
+ return field ? { kind: "enter", field } : { kind: "wait", button: null };
326
561
  }
327
562
  /** What makes a field the same field when a form comes back: its name, id, type, autocomplete and label, never its value. */
328
563
  export function fieldIdentity(f) {
@@ -332,22 +567,24 @@ export function fieldIdentity(f) {
332
567
  * Decide the next step from what is on the page now.
333
568
  *
334
569
  * - 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
570
+ * with neither, a password or a code went through and no sign-in field is
336
571
  * left: no password or code field, and no username field unless it is a
337
572
  * different field from the one the username went into (an app's own email
338
- * field, say).
573
+ * field, say). After the username alone, a page with no field is still on
574
+ * its way to the next one (sending a code takes a moment), not signed in.
339
575
  * - Refused: a field already submitted is back — the password field after the
340
576
  * password went, the code field after the code went, or the same username
341
577
  * 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.
578
+ * - A password field with no password configured, or a code field with no
579
+ * code or TOTP secret configured, is stuck, with the variable to set.
344
580
  * - Otherwise fill what is showing and has not been submitted, or wait.
345
581
  */
346
- export function nextStep(chosen, progress, opts) {
582
+ export function nextStep(onPage, progress, opts) {
347
583
  // A success match before anything was submitted is the sign-in page itself matching (`/signin?next=/dashboard`).
348
584
  if (opts.successMatched && progress.submits > 0)
349
585
  return { kind: "done" };
350
586
  const { submitted } = progress;
587
+ const chosen = signInFields(onPage, submitted, opts);
351
588
  const sameUsername = chosen.username !== undefined && submitted.get("username") === fieldIdentity(chosen.username);
352
589
  if (submitted.has("password") && chosen.password) {
353
590
  return { kind: "refused", reason: "the password field came back after the password was submitted: the username or password was refused" };
@@ -356,23 +593,45 @@ export function nextStep(chosen, progress, opts) {
356
593
  return { kind: "refused", reason: "the sign-in form came back after the password was submitted: the username or password was refused" };
357
594
  }
358
595
  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
- };
596
+ const check = opts.code === "fixed" ? `check ${LOGIN_ENV.otpCode} is the code the app accepts` : "check the TOTP secret and the runner's clock";
597
+ return { kind: "refused", reason: `the one-time-code field came back after a code was submitted: the code was refused (${check})` };
363
598
  }
364
599
  const usernameIsSignIn = chosen.username !== undefined && (!submitted.has("username") || sameUsername);
365
600
  if (!chosen.password && !chosen.otp && !usernameIsSignIn) {
366
- if (progress.submits === 0 || opts.successConfigured)
601
+ const credentialSent = submitted.has("password") || submitted.has("otp");
602
+ if (progress.submits === 0 || opts.successConfigured || !credentialSent)
367
603
  return { kind: "wait" };
368
604
  return { kind: "done" };
369
605
  }
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` };
606
+ if (chosen.password && !opts.hasPassword) {
607
+ return { kind: "stuck", reason: `the page asks for a password and ${LOGIN_ENV.password} is not set` };
608
+ }
609
+ if (chosen.otp && !opts.code) {
610
+ return { kind: "stuck", reason: `the page asks for a one-time code and neither ${LOGIN_ENV.otpCode} nor ${LOGIN_ENV.totpSecret} is set` };
372
611
  }
373
612
  const fill = ["username", "password", "otp"].filter((k) => chosen[k] && !submitted.has(k));
374
613
  return fill.length > 0 ? { kind: "fill", fill } : { kind: "wait" };
375
614
  }
615
+ /**
616
+ * The chosen fields that belong to the sign-in. A code field known only by
617
+ * its shape (a numeric field sized for a code) does only while a code is
618
+ * configured and still to be typed after the username or the password has
619
+ * gone, or when it is the very field the code went into. Otherwise it is the app's own field
620
+ * (an order number on the signed-in page) and is left out, so it is neither
621
+ * filled nor read as the code coming back.
622
+ */
623
+ function signInFields(chosen, submitted, opts) {
624
+ const otp = chosen.otp;
625
+ if (!otp || !shapeOnlyOtp(otp))
626
+ return chosen;
627
+ // After the username or the password: a first page may ask for the password alone, the username carried in its link.
628
+ const stillToType = opts.code !== undefined && (submitted.has("username") || submitted.has("password")) && !submitted.has("otp");
629
+ if (stillToType || submitted.get("otp") === fieldIdentity(otp))
630
+ return chosen;
631
+ const rest = { ...chosen };
632
+ delete rest.otp;
633
+ return rest;
634
+ }
376
635
  /**
377
636
  * Whether a page URL is the configured success URL: an absolute URL matches
378
637
  * as a prefix, anything else as text contained in the path (`/dashboard`),
@@ -390,11 +649,32 @@ export function urlMatches(pageUrl, want) {
390
649
  }
391
650
  return pathname.includes(want);
392
651
  }
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" };
652
+ /** The fields a step filled, as words: "the username and the password", "the one-time code, one character in each of its 6 boxes". */
653
+ export function filledNames(fill, opts = {}) {
654
+ const names = {
655
+ username: "the username",
656
+ password: "the password",
657
+ otp: opts.codeBoxes ? `the one-time code, one character in each of its ${opts.codeBoxes} boxes` : "the one-time code",
658
+ };
396
659
  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}.`;
660
+ return list.length <= 1 ? list.join("") : `${list.slice(0, -1).join(", ")} and ${list[list.length - 1]}`;
661
+ }
662
+ /** One line for what a step did, naming the kinds of field and how it ended, never a value. */
663
+ export function describeStep(fill, by, opts = {}) {
664
+ const joined = filledNames(fill, opts);
665
+ if (by === "page")
666
+ return `Filled ${joined}; the page submitted it by itself.`;
667
+ if (by === "more")
668
+ return `Filled ${joined}; the page then asked for another field before submitting.`;
669
+ const how = by === "enter" ? "pressed Enter" : `clicked "${by.button.trim().slice(0, 40)}"`;
670
+ // The code's own clause ends in a comma before "and", as the code is always the last field named.
671
+ return `Filled ${joined}${opts.codeBoxes ? "," : ""} and ${how}.`;
672
+ }
673
+ /**
674
+ * Page text fit to quote: whitespace collapsed, every credential redacted,
675
+ * then cut to `max` characters. Redaction comes first: a credential cut in
676
+ * half would no longer match it and would be printed in part.
677
+ */
678
+ export function quotable(text, redact, max) {
679
+ return redact(text.replace(/\s+/g, " ").trim()).slice(0, max);
400
680
  }