scenescout 3.21.1 → 3.21.3

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # scenescout
2
2
 
3
+ ## 3.21.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 4c50ea3: `scenescout login --script` no longer reports a correct password or one-time code as refused when the app is slow to leave the page. A submitted field counts as refused only once the page has answered (emptied the field, drawn it again or moved on); while the very field it was typed into still holds it, disabled or not, the run keeps waiting, and at the timeout says the sign-in did not finish in time instead of calling it refused. An app that leaves a wrong password or code in its field, showing an error, used to be reported as refused at once; it is now reported when the timeout runs out, with the page's error quoted.
8
+
9
+ ## 3.21.2
10
+
11
+ ### Patch Changes
12
+
13
+ - 8e70289: `/scenescout qa check`: when GitHub refuses a dispatch for a reason the reply does not name, the job's log now carries GitHub's own words, with anything shaped like a token redacted, as the reply says it does. Reading the artifact's zip refuses a central directory over 4 MB before reading it. The guide's safety model now covers the check mode: the commenter starts the project's workflow, with its secrets, on a branch in the same repository, and the verdict is only as trustworthy as that branch.
14
+
3
15
  ## 3.21.1
4
16
 
5
17
  ### Patch Changes
@@ -563,6 +563,29 @@ export function afterTyping(typed, page, shownBefore, opts = {}) {
563
563
  export function fieldIdentity(f) {
564
564
  return [f.tag, f.type, f.name, f.id, f.autocomplete, f.label].join("|");
565
565
  }
566
+ /** The very element a value was typed into, still holding it: a submit the page has not answered yet. */
567
+ const unanswered = (f) => f !== undefined && f.typed === true && f.filled;
568
+ /**
569
+ * The submitted code or password whose very element is still on the page
570
+ * holding what was typed, disabled or not: the page has not answered that
571
+ * submit yet. A disabled field is not chosen for a step, so without this a
572
+ * page that disables the code field while it checks the code reads as having
573
+ * no sign-in field left, which is not a sign-in.
574
+ */
575
+ export function unansweredSubmit(fields, submitted) {
576
+ const all = chooseFields(fields.map((f) => ({ ...f, disabled: false })));
577
+ return ["otp", "password"].find((k) => submitted.has(k) && unanswered(all[k]));
578
+ }
579
+ /** Waiting on a submit the page has not answered, or, past the deadline, the timeout that says so. */
580
+ function awaitAnswer(pending, opts) {
581
+ if (opts.timedOutAfterS === undefined)
582
+ return { kind: "wait" };
583
+ const what = pending === "otp" ? "the one-time code" : "the password";
584
+ return {
585
+ kind: "timeout",
586
+ reason: `the sign-in did not finish within ${opts.timedOutAfterS}s: ${what} was submitted and the page still shows it in the same field`,
587
+ };
588
+ }
566
589
  /**
567
590
  * Decide the next step from what is on the page now.
568
591
  *
@@ -575,6 +598,16 @@ export function fieldIdentity(f) {
575
598
  * - Refused: a field already submitted is back — the password field after the
576
599
  * password went, the code field after the code went, or the same username
577
600
  * field, empty, after the password went: the provider sent the form back.
601
+ * The very element the password or code was typed into, still holding it,
602
+ * is not back: the page has not answered the submit yet (a slow check, or a
603
+ * redirect that has not started), so the run waits. Back means the page
604
+ * answered: it emptied that field, or drew it again (a new element, or
605
+ * another page).
606
+ * - Waiting, too, while that element is on the page disabled (checking): the
607
+ * page is checking the value, so no field showing is not yet a sign-in.
608
+ * - Timed out: the deadline passed (timedOutAfterS) with that element still
609
+ * holding what was typed. Nothing says the value was refused, so the reason
610
+ * says the sign-in did not finish in time.
578
611
  * - A password field with no password configured, or a code field with no
579
612
  * code or TOTP secret configured, is stuck, with the variable to set.
580
613
  * - Otherwise fill what is showing and has not been submitted, or wait.
@@ -586,18 +619,21 @@ export function nextStep(onPage, progress, opts) {
586
619
  const { submitted } = progress;
587
620
  const chosen = signInFields(onPage, submitted, opts);
588
621
  const sameUsername = chosen.username !== undefined && submitted.get("username") === fieldIdentity(chosen.username);
589
- if (submitted.has("password") && chosen.password) {
622
+ if (submitted.has("password") && chosen.password && !unanswered(chosen.password)) {
590
623
  return { kind: "refused", reason: "the password field came back after the password was submitted: the username or password was refused" };
591
624
  }
592
625
  if (submitted.has("password") && sameUsername && !chosen.username.filled) {
593
626
  return { kind: "refused", reason: "the sign-in form came back after the password was submitted: the username or password was refused" };
594
627
  }
595
- if (submitted.has("otp") && chosen.otp) {
628
+ if (submitted.has("otp") && chosen.otp && !unanswered(chosen.otp)) {
596
629
  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
630
  return { kind: "refused", reason: `the one-time-code field came back after a code was submitted: the code was refused (${check})` };
598
631
  }
632
+ const pending = ["otp", "password"].find((k) => submitted.has(k) && unanswered(chosen[k])) ?? opts.checking;
599
633
  const usernameIsSignIn = chosen.username !== undefined && (!submitted.has("username") || sameUsername);
600
634
  if (!chosen.password && !chosen.otp && !usernameIsSignIn) {
635
+ if (pending)
636
+ return awaitAnswer(pending, opts);
601
637
  const credentialSent = submitted.has("password") || submitted.has("otp");
602
638
  if (progress.submits === 0 || opts.successConfigured || !credentialSent)
603
639
  return { kind: "wait" };
@@ -610,7 +646,9 @@ export function nextStep(onPage, progress, opts) {
610
646
  return { kind: "stuck", reason: `the page asks for a one-time code and neither ${LOGIN_ENV.otpCode} nor ${LOGIN_ENV.totpSecret} is set` };
611
647
  }
612
648
  const fill = ["username", "password", "otp"].filter((k) => chosen[k] && !submitted.has(k));
613
- return fill.length > 0 ? { kind: "fill", fill } : { kind: "wait" };
649
+ if (fill.length > 0)
650
+ return { kind: "fill", fill };
651
+ return pending ? awaitAnswer(pending, opts) : { kind: "wait" };
614
652
  }
615
653
  /**
616
654
  * The chosen fields that belong to the sign-in. A code field known only by
package/dist/login-run.js CHANGED
@@ -22,7 +22,7 @@ import { explicitLimits, isTimeoutMessage, LIMIT_NAMES } from "./engine/limits.j
22
22
  import { redactRoute } from "./engine/check.js";
23
23
  import { describeLifetime, readLifetime } from "./engine/expiry.js";
24
24
  import { describeSaved, mergeSessionStorage, withSessionStorage, writeProfile } from "./engine/profiles.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";
25
+ import { afterTyping, chooseFields, describeStep, fieldIdentity, filledNames, nextStep, otpBoxes, quotable, secondsLeft, splitCode, totp as totpCode, TOTP_MIN_SECONDS_LEFT, urlMatches, unansweredSubmit, } from "./engine/scripted-login.js";
26
26
  import { DEFAULT_SAVE_MODE, judgeSignIn, LOOK_EVERY_MS, onApp, startWatch, } from "./engine/signed-in.js";
27
27
  /**
28
28
  * Read what the profile keeps from a signed-in context: cookies, localStorage
@@ -367,6 +367,12 @@ export function savedLine(options, saved, now = Date.now()) {
367
367
  // ── scenescout login --script ────────────────────────────────────────────────
368
368
  /** The attribute the collector tags each visible control with, so a step can act on the one it chose. */
369
369
  const TAG = "data-scenescout-login";
370
+ /**
371
+ * The attribute put on each element a value is typed into. The page drawing
372
+ * the field again (or another page) drops it, so a later read can tell a
373
+ * submit the page has not answered yet from the field coming back.
374
+ */
375
+ const TYPED = "data-scenescout-typed";
370
376
  /** How often the page is read while waiting for the form to move on. */
371
377
  const POLL_MS = 250;
372
378
  /**
@@ -459,6 +465,8 @@ function collectFields({ selectors, tag: tagging }) {
459
465
  maxLength: typeof input.maxLength === "number" ? input.maxLength : -1,
460
466
  filled: tag !== "button" && typeof input.value === "string" && input.value.length > 0,
461
467
  disabled: input.disabled === true,
468
+ // The literal, since this runs in the page: TYPED.
469
+ typed: el.hasAttribute("data-scenescout-typed"),
462
470
  ...(forced.has(el) ? { forced: forced.get(el) } : {}),
463
471
  });
464
472
  }
@@ -573,12 +581,16 @@ export async function runScriptedLogin(options, config, redactor, log, now = ()
573
581
  const chosen = chooseFields(fields);
574
582
  return `${pageKey()}|${Object.keys(chosen).sort().join(",")}`;
575
583
  };
576
- const stepOpts = (successMatchedNow) => ({
577
- hasPassword: config.password !== undefined,
578
- ...(config.code ? { code: config.code.kind } : {}),
579
- successConfigured: Boolean(config.success.url || config.success.selector),
580
- successMatched: successMatchedNow,
581
- });
584
+ const stepOpts = (successMatchedNow, fields) => {
585
+ const checking = unansweredSubmit(fields, progress.submitted);
586
+ return {
587
+ hasPassword: config.password !== undefined,
588
+ ...(config.code ? { code: config.code.kind } : {}),
589
+ successConfigured: Boolean(config.success.url || config.success.selector),
590
+ successMatched: successMatchedNow,
591
+ ...(checking ? { checking } : {}),
592
+ };
593
+ };
582
594
  /** 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
595
  const currentCode = async (source) => {
584
596
  if (source.kind === "fixed")
@@ -591,6 +603,8 @@ export async function runScriptedLogin(options, config, redactor, log, now = ()
591
603
  return code;
592
604
  };
593
605
  const at = (f) => page.locator(`[${TAG}="${f.index}"]`);
606
+ /** Mark the element about to be typed into: see TYPED. */
607
+ const markTyped = (f) => at(f).evaluate((el, attr) => el.setAttribute(attr, ""), TYPED, { timeout: limits.actionMs });
594
608
  /** A failed action as this run's error: the time limit explained, redacted like everything else it throws. */
595
609
  const actionError = (err) => {
596
610
  const explained = loginTimeout(err, "action", limits.actionMs);
@@ -609,16 +623,27 @@ export async function runScriptedLogin(options, config, redactor, log, now = ()
609
623
  const submitOpts = { passwordless: config.password === undefined };
610
624
  let lastWait = "the sign-in form to appear";
611
625
  for (;;) {
612
- if (Date.now() > deadline)
613
- throw await timedOut(lastWait);
626
+ // Past the deadline the page is read once more, so a submit it never answered is reported as that, not as a refusal.
627
+ const late = Date.now() > deadline;
614
628
  const fields = await read();
615
629
  if (fields === null) {
630
+ if (late)
631
+ throw await timedOut(lastWait);
616
632
  await page.waitForTimeout(POLL_MS);
617
633
  continue;
618
634
  }
619
635
  const keyAtRead = pageKey();
620
636
  const chosen = chooseFields(fields);
621
- const step = nextStep(chosen, progress, stepOpts(await successMatched()));
637
+ const step = nextStep(chosen, progress, {
638
+ ...stepOpts(await successMatched(), fields),
639
+ ...(late ? { timedOutAfterS: config.timeoutMs / 1000 } : {}),
640
+ });
641
+ if (step.kind === "timeout") {
642
+ const says = await pageSays();
643
+ throw fail(`${step.reason} (at ${where()})${says ? `. The page says: "${says}"` : ""}`);
644
+ }
645
+ if (late && step.kind !== "refused")
646
+ throw await timedOut(lastWait);
622
647
  if (step.kind === "done") {
623
648
  if (config.success.url || config.success.selector)
624
649
  break;
@@ -630,7 +655,7 @@ export async function runScriptedLogin(options, config, redactor, log, now = ()
630
655
  });
631
656
  await page.waitForTimeout(POLL_MS * 2);
632
657
  const settled = await read();
633
- if (settled !== null && nextStep(chooseFields(settled), progress, stepOpts(false)).kind === "done")
658
+ if (settled !== null && nextStep(chooseFields(settled), progress, stepOpts(false, settled)).kind === "done")
634
659
  break;
635
660
  continue;
636
661
  }
@@ -673,6 +698,7 @@ export async function runScriptedLogin(options, config, redactor, log, now = ()
673
698
  // Cleared only when it holds something: clearing sends Delete, which some boxes take as a step back to the box before.
674
699
  if ((await at(box).inputValue({ timeout: limits.actionMs })) !== "")
675
700
  await at(box).fill("", { timeout: limits.actionMs });
701
+ await markTyped(box);
676
702
  // 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
703
  await at(box).pressSequentially(split.chars[i], { timeout: limits.actionMs });
678
704
  }
@@ -687,6 +713,7 @@ export async function runScriptedLogin(options, config, redactor, log, now = ()
687
713
  }
688
714
  else {
689
715
  const value = kind === "otp" ? await currentCode(config.code) : kind === "username" ? config.username : config.password;
716
+ await markTyped(field).catch(actionFailed);
690
717
  await at(field).fill(value, { timeout: limits.actionMs }).catch(actionFailed);
691
718
  }
692
719
  typedIds.set(kind, fieldIdentity(field));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenescout",
3
- "version": "3.21.1",
3
+ "version": "3.21.3",
4
4
  "description": "SceneScout — exploratory UI testing for AI coding agents. An MCP server that gives any agent (Claude Code, Cursor, VS Code Copilot, Codex, Gemini CLI and others) a structured view of a running web app, always-on oracles, a network-level write policy, memory across runs and a gap-checked report.",
5
5
  "license": "MIT",
6
6
  "author": "brunoboto96",