scenescout 3.21.2 → 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 +6 -0
- package/dist/engine/scripted-login.js +41 -3
- package/dist/login-run.js +38 -11
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
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
|
+
|
|
3
9
|
## 3.21.2
|
|
4
10
|
|
|
5
11
|
### 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
|
-
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
613
|
-
|
|
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,
|
|
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.
|
|
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",
|