scenescout 3.13.1 → 3.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +35 -7
- package/dist/browsers.js +13 -0
- package/dist/check-run.js +2 -0
- package/dist/ci-run.js +134 -16
- package/dist/cli.js +97 -64
- package/dist/commands.js +104 -0
- package/dist/engine/authloss.js +149 -8
- package/dist/engine/brief.js +7 -1
- package/dist/engine/browser.js +615 -74
- package/dist/engine/capture.js +121 -0
- package/dist/engine/check.js +18 -0
- package/dist/engine/ci.js +117 -6
- package/dist/engine/dedup.js +50 -22
- package/dist/engine/expiry.js +254 -0
- package/dist/engine/fingerprint.js +2 -2
- package/dist/engine/flow.js +0 -2
- package/dist/engine/limits.js +144 -0
- package/dist/engine/live-page.js +57 -4
- package/dist/engine/oracles.js +8 -0
- package/dist/engine/png.js +238 -0
- package/dist/engine/postmessage.js +282 -0
- package/dist/engine/probes.js +4 -4
- package/dist/engine/profiles.js +392 -0
- package/dist/engine/refresh.js +383 -0
- package/dist/engine/scripted-login.js +400 -0
- package/dist/engine/teardown.js +41 -0
- package/dist/login-run.js +444 -0
- package/dist/mcp-server.js +136 -20
- package/package.json +7 -2
- package/skills/scenescout/SKILL.md +9 -6
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* scenescout doctor Check every piece of the setup and say how to fix what is missing
|
|
9
9
|
* scenescout check <url> Visit every route, measure it, and pass or fail (no model involved)
|
|
10
10
|
* scenescout ci <url> An exploratory run driven by a model's API, unattended, that reports
|
|
11
|
+
* scenescout login <url> --role r Sign in once in a visible browser and save it as a named role
|
|
11
12
|
*/
|
|
12
13
|
import { spawnSync } from "node:child_process";
|
|
13
14
|
import fs from "node:fs";
|
|
@@ -20,11 +21,15 @@ import { CLIENT_LABELS, firstMessageHint, manualFor, parseClients, registerWithC
|
|
|
20
21
|
import { CLI_NAME, diagnose, ensureCommand, findOnUserPath, installSkill, isEphemeralRoot, launchCommand, manualRegisterCommand, planCommand, registerMcp, resolveClaudeDir, spawnRunner, } from "./installer.js";
|
|
21
22
|
import { defaultCheckDir, readCheckInputs, runCheck } from "./check-run.js";
|
|
22
23
|
import { httpClient, runCi } from "./ci-run.js";
|
|
24
|
+
import { runLogin, runScriptedLogin, savedLine } from "./login-run.js";
|
|
25
|
+
import { credentialRedactor, LOGIN_ENV, readScriptedLogin } from "./engine/scripted-login.js";
|
|
26
|
+
import { parseLoginArgs } from "./engine/profiles.js";
|
|
23
27
|
import { detectProvider, EXIT_CI, KEY_ENV, parseCiArgs, redactKeys, secretValues } from "./engine/ci.js";
|
|
24
28
|
import { EXIT, exitCodeOf, formatCheck, parseCheckArgs, refusedFlowReason, toSarif, toSummaryJson, unmeasuredReason } from "./engine/check.js";
|
|
25
29
|
import { LEGACY_MEMORY_DIRNAME, MEMORY_DIRNAME, writeSelfIgnore } from "./engine/memory.js";
|
|
26
30
|
import { formatStatus, liveEngines, liveTokenFileName, localClock, LIVE_TOKEN_FILE, pidAlive, watchTarget, wholeSessions, } from "./engine/live.js";
|
|
27
31
|
import { formatScan, scanProject } from "./scan.js";
|
|
32
|
+
import { dispatch } from "./commands.js";
|
|
28
33
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
29
34
|
const packageRoot = path.resolve(here, "..");
|
|
30
35
|
/** This package's version, as published. */
|
|
@@ -57,6 +62,9 @@ Usage:
|
|
|
57
62
|
--ignore rule,rule; --storage-state file to check signed in;
|
|
58
63
|
--project dir (default: here); --out dir (default: .scenescout/check);
|
|
59
64
|
--browser chromium|firefox|webkit;
|
|
65
|
+
--action-timeout-ms N (default 5000), --nav-timeout-ms N (default 20000;
|
|
66
|
+
15000 per crawled route): raise on a loaded runner, or set
|
|
67
|
+
SCENESCOUT_ACTION_TIMEOUT_MS / SCENESCOUT_NAV_TIMEOUT_MS;
|
|
60
68
|
--flows dir|off: replay the flows saved there (default: .scenescout/flows);
|
|
61
69
|
--retest on|off: re-test open findings a page load reproduces (default on);
|
|
62
70
|
--flow-writes never|allow: never (default) replays flows under observe's
|
|
@@ -84,8 +92,28 @@ Usage:
|
|
|
84
92
|
destructive only with --allow-destructive as well); --level minimal|medium|
|
|
85
93
|
extensive (default medium); --focus "an area or flow";
|
|
86
94
|
--storage-state file; --browser chromium|firefox|webkit;
|
|
87
|
-
--
|
|
95
|
+
--action-timeout-ms N, --nav-timeout-ms N: as for check;
|
|
96
|
+
--project dir (default: here); --out dir (default: .scenescout/ci);
|
|
97
|
+
--show "the Save button": instead of exploring, capture that element as a PNG
|
|
98
|
+
under shots/; --compare-url https://…: with --show, capture it there too and
|
|
99
|
+
write a diff picture)
|
|
88
100
|
Exit code: 0 the run ran (findings never change it), 2 could not run.
|
|
101
|
+
scenescout login <url> --role <name>
|
|
102
|
+
Open a visible browser at the URL, sign in there (SSO, MFA, anything), then
|
|
103
|
+
press Enter in this terminal to save the session as that role's profile, in
|
|
104
|
+
.scenescout/auth/<name>.json (owner-only; never printed, never committed).
|
|
105
|
+
Closing the window or Ctrl+C saves nothing. Agents then attach with
|
|
106
|
+
scout_attach { role: "<name>" }, as many sessions as they like from one login.
|
|
107
|
+
(--project dir (default: here); --browser chromium|firefox|webkit)
|
|
108
|
+
scenescout login <url> --role <name> --script
|
|
109
|
+
For CI: sign in headless from SCENESCOUT_LOGIN_USERNAME, SCENESCOUT_LOGIN_PASSWORD
|
|
110
|
+
and, if the form asks for a code, SCENESCOUT_LOGIN_TOTP_SECRET (base32 or an
|
|
111
|
+
otpauth:// URI), then save the profile as above. A test user only. No value
|
|
112
|
+
is ever printed. Exit 0 signed in and saved, 1 not.
|
|
113
|
+
(--success-url text|url; --success-selector css; --username-selector,
|
|
114
|
+
--password-selector, --otp-selector, --submit-selector css; each of these also
|
|
115
|
+
from SCENESCOUT_LOGIN_<FLAG>, e.g. SCENESCOUT_LOGIN_SUCCESS_URL;
|
|
116
|
+
--timeout seconds (default 60))
|
|
89
117
|
scenescout status [projectPath] What is the engine doing right now? (every session + recent actions)
|
|
90
118
|
scenescout watch [projectPath] Open the live view in a browser: what each session is doing, a thumbnail
|
|
91
119
|
of its page, and a live stream you can switch on per session
|
|
@@ -268,13 +296,6 @@ function flagValue(flags, name) {
|
|
|
268
296
|
const next = flags[at + 1];
|
|
269
297
|
return next === undefined || next.startsWith("--") ? "" : next;
|
|
270
298
|
}
|
|
271
|
-
function browsersFlag(flags) {
|
|
272
|
-
// `--browser-only` is a different flag; a bare `--browser` is a slip that would otherwise be ignored and download Chromium.
|
|
273
|
-
const slip = flags.find((f) => f === "--browser" || f.startsWith("--browser="));
|
|
274
|
-
if (slip)
|
|
275
|
-
throw new Error(`unknown flag ${slip.split("=")[0]} — did you mean --browsers?`);
|
|
276
|
-
return flagValue(flags, "--browsers");
|
|
277
|
-
}
|
|
278
299
|
/** The `code` command on PATH, with its real path: the real path is what tells VS Code from a fork. */
|
|
279
300
|
function codeOnPath() {
|
|
280
301
|
const names = process.platform === "win32" ? ["code.cmd", "code.exe"] : ["code"];
|
|
@@ -322,7 +343,7 @@ async function install(flags) {
|
|
|
322
343
|
// only thing it cannot bring is the browser download.
|
|
323
344
|
const browserOnly = flags.includes("--browser-only");
|
|
324
345
|
// Read the choice before doing anything, so a typo costs nothing.
|
|
325
|
-
const selection = parseBrowserSelection(
|
|
346
|
+
const selection = parseBrowserSelection(flagValue(flags, "--browsers"));
|
|
326
347
|
if ("error" in selection)
|
|
327
348
|
throw new Error(selection.error);
|
|
328
349
|
const chosen = parseClients(flagValues(flags, ["--client", "--clients"]));
|
|
@@ -493,8 +514,6 @@ async function doctor(flags) {
|
|
|
493
514
|
}
|
|
494
515
|
/** `scenescout check`: exit 0 passed, 1 failed the gate, 2 could not run. */
|
|
495
516
|
async function check(args) {
|
|
496
|
-
if (args.includes("--help") || args.includes("-h"))
|
|
497
|
-
usage(0);
|
|
498
517
|
const parsed = parseCheckArgs(args, process.cwd());
|
|
499
518
|
if (!parsed.ok) {
|
|
500
519
|
console.error(`scenescout check: ${parsed.error}`);
|
|
@@ -557,8 +576,6 @@ async function check(args) {
|
|
|
557
576
|
}
|
|
558
577
|
/** `scenescout ci`: exit 0 when the run ran, 2 when it could not. Findings never change the exit code. */
|
|
559
578
|
async function ci(args) {
|
|
560
|
-
if (args.includes("--help") || args.includes("-h"))
|
|
561
|
-
usage(0);
|
|
562
579
|
const secrets = secretValues(process.env);
|
|
563
580
|
const say = (line) => console.log(redactKeys(line, secrets));
|
|
564
581
|
const fail = (message) => {
|
|
@@ -598,6 +615,44 @@ async function ci(args) {
|
|
|
598
615
|
}
|
|
599
616
|
process.exit(exitCode);
|
|
600
617
|
}
|
|
618
|
+
/** `scenescout login`: exit 0 saved, 1 nothing saved. */
|
|
619
|
+
async function login(args) {
|
|
620
|
+
const parsed = parseLoginArgs(args, process.cwd());
|
|
621
|
+
if (!parsed.ok) {
|
|
622
|
+
console.error(`scenescout login: ${parsed.error}`);
|
|
623
|
+
process.exit(1);
|
|
624
|
+
}
|
|
625
|
+
const options = parsed.options;
|
|
626
|
+
if (options.script) {
|
|
627
|
+
// Everything is checked before a browser launches; each problem names a variable, never a value.
|
|
628
|
+
const read = readScriptedLogin(options.script, process.env);
|
|
629
|
+
if (!read.ok) {
|
|
630
|
+
for (const e of read.errors)
|
|
631
|
+
console.error(`scenescout login: ${e}`);
|
|
632
|
+
process.exit(1);
|
|
633
|
+
}
|
|
634
|
+
const redactor = credentialRedactor(read.config, process.env[LOGIN_ENV.totpSecret]);
|
|
635
|
+
try {
|
|
636
|
+
const saved = await runScriptedLogin(options, read.config, redactor, (line) => console.log(line));
|
|
637
|
+
console.log(redactor.redact(savedLine(options, saved)));
|
|
638
|
+
}
|
|
639
|
+
catch (err) {
|
|
640
|
+
// Redacted again here: an error thrown by the browser itself has not been through the run's redaction.
|
|
641
|
+
console.error(redactor.redact(`scenescout login: ${err instanceof Error ? err.message : String(err)}`));
|
|
642
|
+
process.exit(1);
|
|
643
|
+
}
|
|
644
|
+
process.exit(0);
|
|
645
|
+
}
|
|
646
|
+
try {
|
|
647
|
+
const saved = await runLogin(options, (line) => console.log(line));
|
|
648
|
+
console.log(savedLine(options, saved));
|
|
649
|
+
}
|
|
650
|
+
catch (err) {
|
|
651
|
+
console.error(`scenescout login: ${err instanceof Error ? err.message : String(err)}`);
|
|
652
|
+
process.exit(1);
|
|
653
|
+
}
|
|
654
|
+
process.exit(0);
|
|
655
|
+
}
|
|
601
656
|
const [, , command, ...args] = process.argv;
|
|
602
657
|
// A CLI's failure mode should be a sentence, not a stack trace. `scan` on a
|
|
603
658
|
// path that does not exist and `status` on a half-written status.json both
|
|
@@ -605,57 +660,35 @@ const [, , command, ...args] = process.argv;
|
|
|
605
660
|
// one line the user needs. `serve` is deliberately outside this: it hands off
|
|
606
661
|
// to the MCP server, whose own transport owns error reporting from then on.
|
|
607
662
|
try {
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
break;
|
|
638
|
-
}
|
|
639
|
-
case "check": {
|
|
640
|
-
await check(args);
|
|
641
|
-
break;
|
|
642
|
-
}
|
|
643
|
-
case "ci": {
|
|
644
|
-
await ci(args);
|
|
645
|
-
break;
|
|
646
|
-
}
|
|
647
|
-
case "status": {
|
|
648
|
-
status(path.resolve(args[0] ?? process.cwd()));
|
|
649
|
-
break;
|
|
650
|
-
}
|
|
651
|
-
case "watch": {
|
|
652
|
-
const positional = args.filter((a) => !a.startsWith("--"));
|
|
653
|
-
watch(path.resolve(positional[0] ?? process.cwd()), !args.includes("--no-open"));
|
|
654
|
-
break;
|
|
655
|
-
}
|
|
656
|
-
default:
|
|
657
|
-
usage();
|
|
658
|
-
}
|
|
663
|
+
await dispatch(command, args, {
|
|
664
|
+
usage,
|
|
665
|
+
version: () => console.log(packageVersion()),
|
|
666
|
+
refuse: (message) => {
|
|
667
|
+
console.error(`scenescout ${command}: ${message}`);
|
|
668
|
+
console.error("Run `scenescout --help` for the options.");
|
|
669
|
+
process.exit(1);
|
|
670
|
+
},
|
|
671
|
+
commands: {
|
|
672
|
+
scan: (a) => {
|
|
673
|
+
if (!a[0])
|
|
674
|
+
usage();
|
|
675
|
+
console.log(formatScan(scanProject(a[0])));
|
|
676
|
+
},
|
|
677
|
+
serve: async () => {
|
|
678
|
+
await import("./mcp-server.js");
|
|
679
|
+
},
|
|
680
|
+
install,
|
|
681
|
+
doctor,
|
|
682
|
+
check,
|
|
683
|
+
ci,
|
|
684
|
+
login,
|
|
685
|
+
status: (a) => status(path.resolve(a[0] ?? process.cwd())),
|
|
686
|
+
watch: (a) => {
|
|
687
|
+
const positional = a.filter((x) => !x.startsWith("--"));
|
|
688
|
+
watch(path.resolve(positional[0] ?? process.cwd()), !a.includes("--no-open"));
|
|
689
|
+
},
|
|
690
|
+
},
|
|
691
|
+
});
|
|
659
692
|
}
|
|
660
693
|
catch (err) {
|
|
661
694
|
console.error(`scenescout ${command ?? ""}: ${err instanceof Error ? err.message : String(err)}`);
|
package/dist/commands.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the CLI decides what a command line asks for, before any command runs.
|
|
3
|
+
*
|
|
4
|
+
* It lives apart from cli.ts so it can be table-tested: `scenescout install
|
|
5
|
+
* --help` once ran a real install, because install read only the flags it knew
|
|
6
|
+
* and ignored the rest. Every subcommand now answers `--help` / `-h` with the
|
|
7
|
+
* usage text and exit 0 before it does anything, and a command that parses its
|
|
8
|
+
* own flags by hand refuses one it does not know.
|
|
9
|
+
*/
|
|
10
|
+
const HANDLERS_OF = {
|
|
11
|
+
scan: true,
|
|
12
|
+
serve: true,
|
|
13
|
+
install: true,
|
|
14
|
+
doctor: true,
|
|
15
|
+
check: true,
|
|
16
|
+
ci: true,
|
|
17
|
+
login: true,
|
|
18
|
+
status: true,
|
|
19
|
+
watch: true,
|
|
20
|
+
};
|
|
21
|
+
export const SUBCOMMANDS = Object.keys(HANDLERS_OF);
|
|
22
|
+
/**
|
|
23
|
+
* Commands that read their arguments by hand. `check`, `ci` and `login` are
|
|
24
|
+
* absent: their own parsers refuse unknown options. `serve` is absent on
|
|
25
|
+
* purpose: it is the line an MCP client launches, and a stray argument there
|
|
26
|
+
* should not stop the server from starting.
|
|
27
|
+
*/
|
|
28
|
+
export const HAND_PARSED = {
|
|
29
|
+
install: {
|
|
30
|
+
switches: ["--skip-browser", "--no-register", "--no-command", "--browser-only"],
|
|
31
|
+
valued: ["--browsers", "--client", "--clients"],
|
|
32
|
+
// `--browser` is what `check` and `login` call it; for install it would otherwise download Chromium regardless.
|
|
33
|
+
hints: { "--browser": "--browsers" },
|
|
34
|
+
positional: 0,
|
|
35
|
+
},
|
|
36
|
+
doctor: { switches: ["--engine"], valued: [], positional: 0 },
|
|
37
|
+
scan: { switches: [], valued: [], positional: 1 },
|
|
38
|
+
status: { switches: [], valued: [], positional: 1 },
|
|
39
|
+
watch: { switches: ["--no-open"], valued: [], positional: 1 },
|
|
40
|
+
};
|
|
41
|
+
/** True when the arguments ask for help. */
|
|
42
|
+
export function wantsHelp(args) {
|
|
43
|
+
return args.includes("--help") || args.includes("-h");
|
|
44
|
+
}
|
|
45
|
+
/** The first argument `spec` does not accept, as an error sentence; null when every one is accepted. */
|
|
46
|
+
export function unknownArgument(args, spec) {
|
|
47
|
+
let positional = 0;
|
|
48
|
+
for (let i = 0; i < args.length; i++) {
|
|
49
|
+
const arg = args[i];
|
|
50
|
+
if (!arg.startsWith("-") || arg === "-") {
|
|
51
|
+
if (++positional > spec.positional)
|
|
52
|
+
return `unexpected argument ${arg}`;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const name = arg.split("=")[0];
|
|
56
|
+
if (spec.valued.includes(name)) {
|
|
57
|
+
// The value after a valued flag is not itself a flag, even when it starts with a dash.
|
|
58
|
+
if (!arg.includes("="))
|
|
59
|
+
i++;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (spec.switches.includes(name) && !arg.includes("="))
|
|
63
|
+
continue;
|
|
64
|
+
const hint = spec.hints?.[name];
|
|
65
|
+
return `unknown option ${name}${hint ? ` — did you mean ${hint}?` : ""}`;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
/** Whether `command` names a subcommand. */
|
|
70
|
+
export function isSubcommand(command) {
|
|
71
|
+
return command !== undefined && Object.hasOwn(HANDLERS_OF, command);
|
|
72
|
+
}
|
|
73
|
+
/** Settle help and flag errors for a subcommand before its handler is reached. */
|
|
74
|
+
export function preflight(command, args) {
|
|
75
|
+
if (wantsHelp(args))
|
|
76
|
+
return { kind: "help" };
|
|
77
|
+
const spec = Object.hasOwn(HAND_PARSED, command) ? HAND_PARSED[command] : undefined;
|
|
78
|
+
const problem = spec ? unknownArgument(args, spec) : null;
|
|
79
|
+
return problem ? { kind: "error", message: problem } : { kind: "run" };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Run the command line: help and flag errors are settled here, so a subcommand's
|
|
83
|
+
* handler is reached only when it is actually meant to run.
|
|
84
|
+
*/
|
|
85
|
+
export async function dispatch(command, args, h) {
|
|
86
|
+
switch (command) {
|
|
87
|
+
// Asking for help is not an error; scripts and shells treat a non-zero exit as one.
|
|
88
|
+
case "--help":
|
|
89
|
+
case "-h":
|
|
90
|
+
case "help":
|
|
91
|
+
return h.usage(0);
|
|
92
|
+
case "--version":
|
|
93
|
+
case "-v":
|
|
94
|
+
return h.version();
|
|
95
|
+
}
|
|
96
|
+
if (!isSubcommand(command))
|
|
97
|
+
return h.usage(1);
|
|
98
|
+
const verdict = preflight(command, args);
|
|
99
|
+
if (verdict.kind === "help")
|
|
100
|
+
return h.usage(0);
|
|
101
|
+
if (verdict.kind === "error")
|
|
102
|
+
return h.refuse(verdict.message);
|
|
103
|
+
await h.commands[command](args);
|
|
104
|
+
}
|
package/dist/engine/authloss.js
CHANGED
|
@@ -14,6 +14,18 @@
|
|
|
14
14
|
export const LOGIN_ROUTE_RE = /\/(login|signin|sign-in|auth)(\/|$)/;
|
|
15
15
|
/** Consecutive login bounces before we stop assuming it is a permission wall. */
|
|
16
16
|
export const AUTH_LOSS_STREAK = 3;
|
|
17
|
+
/**
|
|
18
|
+
* Should this loss be answered by re-attaching from the role's saved profile?
|
|
19
|
+
*
|
|
20
|
+
* Only a session attached by role has a profile that `scenescout login` (or
|
|
21
|
+
* another process) may have refreshed since, so only it has something to
|
|
22
|
+
* re-read. A storage-state file or an anonymous session is reported as
|
|
23
|
+
* before. Once per session: a second loss means the refresh did not hold,
|
|
24
|
+
* and retrying on a loop would hide a dead profile behind endless recoveries.
|
|
25
|
+
*/
|
|
26
|
+
export function shouldReattach(opts) {
|
|
27
|
+
return opts.streak >= AUTH_LOSS_STREAK && opts.signIn === "role" && !opts.alreadyReattached;
|
|
28
|
+
}
|
|
17
29
|
export class AuthLossTracker {
|
|
18
30
|
/** Consecutive navigations that ended on a login page. */
|
|
19
31
|
streak = 0;
|
|
@@ -21,6 +33,37 @@ export class AuthLossTracker {
|
|
|
21
33
|
reported = false;
|
|
22
34
|
/** Notice for the in-flight call, consumed exactly once by take(). */
|
|
23
35
|
pending = "";
|
|
36
|
+
/** How the session signed in; set by beginSession. */
|
|
37
|
+
signIn = "none";
|
|
38
|
+
/** The role label of the session, for the notices. */
|
|
39
|
+
role = "";
|
|
40
|
+
/** What each navigation of the current streak asked for, in order. */
|
|
41
|
+
streakTargets = [];
|
|
42
|
+
/** A re-attach this navigation earned and the engine has not taken yet. */
|
|
43
|
+
due = null;
|
|
44
|
+
/** Taken by the engine: the next recorded navigation says whether it worked. */
|
|
45
|
+
awaiting = null;
|
|
46
|
+
/** What happened to this session's one re-attach. */
|
|
47
|
+
outcome = null;
|
|
48
|
+
/** Notices a batch (crawl) must still say at its end, since it discards the per-route ones. */
|
|
49
|
+
batchNotes = [];
|
|
50
|
+
/**
|
|
51
|
+
* Start tracking a newly attached session. An explicit attach is new
|
|
52
|
+
* credentials: the streak, the verdict and the one re-attach start afresh,
|
|
53
|
+
* so a new session neither inherits a dead one's bounces nor names its routes.
|
|
54
|
+
*/
|
|
55
|
+
beginSession(signIn, role) {
|
|
56
|
+
this.signIn = signIn;
|
|
57
|
+
this.role = role;
|
|
58
|
+
this.streak = 0;
|
|
59
|
+
this.streakTargets = [];
|
|
60
|
+
this.reported = false;
|
|
61
|
+
this.pending = "";
|
|
62
|
+
this.due = null;
|
|
63
|
+
this.awaiting = null;
|
|
64
|
+
this.outcome = null;
|
|
65
|
+
this.batchNotes = [];
|
|
66
|
+
}
|
|
24
67
|
/**
|
|
25
68
|
* Did a navigation to `requested` end up on a login screen?
|
|
26
69
|
*
|
|
@@ -46,14 +89,14 @@ export class AuthLossTracker {
|
|
|
46
89
|
*
|
|
47
90
|
* `bounced` decides both the streak and whether the route counts as covered;
|
|
48
91
|
* the caller owns the memory writes, because ownership of the store belongs
|
|
49
|
-
* to the engine, not to this tracker.
|
|
92
|
+
* to the engine, not to this tracker. `target` is what the caller navigated
|
|
93
|
+
* to (a path with its real ids, where `requestedRoute` is the normalized
|
|
94
|
+
* route), which a re-attach goes back to; it defaults to the route.
|
|
50
95
|
*/
|
|
51
96
|
record(opts) {
|
|
52
97
|
const { requestedRoute, landedRoute, bounced, role } = opts;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
else
|
|
56
|
-
this.streak = 0;
|
|
98
|
+
const target = opts.target ?? requestedRoute;
|
|
99
|
+
this.due = null;
|
|
57
100
|
const divergence = landedRoute === requestedRoute
|
|
58
101
|
? ""
|
|
59
102
|
: `⚠ REDIRECTED: asked for ${requestedRoute}, landed on ${landedRoute}` +
|
|
@@ -61,8 +104,101 @@ export class AuthLossTracker {
|
|
|
61
104
|
? ` — this is a login page, so the route is NOT counted as covered.`
|
|
62
105
|
: ` — the app redirected; the route counts as covered for role '${role}'.`) +
|
|
63
106
|
`\n`;
|
|
107
|
+
if (this.awaiting) {
|
|
108
|
+
this.settleReattach(requestedRoute, target, bounced, divergence);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (bounced) {
|
|
112
|
+
this.streak += 1;
|
|
113
|
+
this.streakTargets.push(target);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
this.streak = 0;
|
|
117
|
+
this.streakTargets = [];
|
|
118
|
+
}
|
|
119
|
+
if (shouldReattach({ streak: this.streak, signIn: this.signIn, alreadyReattached: this.outcome !== null })) {
|
|
120
|
+
// The verdict is replaced by the recovery's own notice once the engine
|
|
121
|
+
// has re-attached; if it never does (the call threw), the verdict stands.
|
|
122
|
+
this.due = { returnTo: target, retry: [...new Set(this.streakTargets)].filter((t) => t !== target) };
|
|
123
|
+
}
|
|
64
124
|
this.pending = this.banner() + divergence;
|
|
65
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* The re-attach this navigation earned, if any: consumed once, and from then
|
|
128
|
+
* on this session has used its one re-attach. `revisits` says which of the
|
|
129
|
+
* streak's other targets the caller will visit again itself (a crawl, its own
|
|
130
|
+
* paths); the plan it gets back holds only those, and the notice asks for the rest.
|
|
131
|
+
*/
|
|
132
|
+
takeReattach(opts = {}) {
|
|
133
|
+
const plan = this.due;
|
|
134
|
+
this.due = null;
|
|
135
|
+
if (!plan)
|
|
136
|
+
return null;
|
|
137
|
+
const revisits = opts.revisits;
|
|
138
|
+
this.awaiting = revisits
|
|
139
|
+
? { returnTo: plan.returnTo, retry: plan.retry.filter(revisits), left: plan.retry.filter((t) => !revisits(t)) }
|
|
140
|
+
: { returnTo: plan.returnTo, retry: [], left: plan.retry };
|
|
141
|
+
this.outcome = { kind: "failed", why: "it had not finished" };
|
|
142
|
+
return { returnTo: this.awaiting.returnTo, retry: this.awaiting.retry };
|
|
143
|
+
}
|
|
144
|
+
/** Whether a re-attach was taken and the navigation that decides it has not been recorded. */
|
|
145
|
+
get reattaching() {
|
|
146
|
+
return this.awaiting !== null;
|
|
147
|
+
}
|
|
148
|
+
/** The profile could not be applied, or the page to go back to never loaded: the loss is reported, with why. */
|
|
149
|
+
abortReattach(why) {
|
|
150
|
+
this.awaiting = null;
|
|
151
|
+
this.outcome = { kind: "failed", why };
|
|
152
|
+
const note = `The session tried to re-attach once from role '${this.role}''s saved profile, but ${why}.`;
|
|
153
|
+
this.pending = this.banner(this.lossTail(note));
|
|
154
|
+
this.batchNotes.push(`⚠ ${note}\n`);
|
|
155
|
+
}
|
|
156
|
+
/** The navigation after a re-attach decides it: signed in again, or not. */
|
|
157
|
+
settleReattach(requestedRoute, target, bounced, divergence) {
|
|
158
|
+
const plan = this.awaiting;
|
|
159
|
+
this.awaiting = null;
|
|
160
|
+
if (bounced) {
|
|
161
|
+
// Still on a login page with the latest profile: that profile is dead too.
|
|
162
|
+
this.streak = Math.max(this.streak, AUTH_LOSS_STREAK);
|
|
163
|
+
this.streakTargets.push(target);
|
|
164
|
+
const why = `its latest saved profile landed on a login page as well, so that profile has expired too`;
|
|
165
|
+
this.outcome = { kind: "failed", why };
|
|
166
|
+
const note = `The session re-attached once from role '${this.role}''s saved profile, but ${why}.`;
|
|
167
|
+
this.pending = this.banner(this.lossTail(note)) + divergence;
|
|
168
|
+
this.batchNotes.push(`⚠ ${note}\n`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
this.streak = 0;
|
|
172
|
+
this.streakTargets = [];
|
|
173
|
+
// The loss was answered: a later batch verdict must not claim this session is dead.
|
|
174
|
+
this.reported = false;
|
|
175
|
+
this.outcome = { kind: "recovered", route: requestedRoute };
|
|
176
|
+
const others = (plan.retry.length === 0 ? "" : ` The other routes that bounced (${plan.retry.join(", ")}) are visited again in this sweep.`) +
|
|
177
|
+
(plan.left.length === 0 ? "" : ` The other routes that bounced before it (${plan.left.join(", ")}) are still NOT covered: visit them again.`);
|
|
178
|
+
const note = `↻ SESSION RE-ATTACHED — its sign-in was lost, so it re-attached once from role '${this.role}''s latest saved profile and ` +
|
|
179
|
+
`is signed in again at ${requestedRoute}.${others} This happens once per session: a second loss is reported, not retried.\n`;
|
|
180
|
+
this.pending = note + divergence;
|
|
181
|
+
this.batchNotes.push(note);
|
|
182
|
+
}
|
|
183
|
+
/** The end of a loss once the one re-attach is spent: what happened, and that it is not retried. */
|
|
184
|
+
lossTail(note) {
|
|
185
|
+
return ` ${note} It is not retried: record the profile again with \`scenescout login\` for role '${this.role}' and re-attach.`;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* One line on what this session's re-attach did, for whoever hands its
|
|
189
|
+
* results on (a lane's report). Empty when it never re-attached.
|
|
190
|
+
*/
|
|
191
|
+
reattachSummary() {
|
|
192
|
+
if (!this.outcome)
|
|
193
|
+
return "";
|
|
194
|
+
if (this.outcome.kind === "recovered")
|
|
195
|
+
return `its sign-in was lost and it re-attached once from role '${this.role}''s saved profile, continuing from ${this.outcome.route}`;
|
|
196
|
+
return `its sign-in was lost and re-attaching from role '${this.role}''s saved profile did not recover it: ${this.outcome.why}`;
|
|
197
|
+
}
|
|
198
|
+
/** Start a batch (crawl): its end-of-batch notes cover only what happened during it. */
|
|
199
|
+
beginBatch() {
|
|
200
|
+
this.batchNotes = [];
|
|
201
|
+
}
|
|
66
202
|
/**
|
|
67
203
|
* The verdict, once the streak says the session is dead.
|
|
68
204
|
*
|
|
@@ -74,15 +210,18 @@ export class AuthLossTracker {
|
|
|
74
210
|
* collapses repeats to "nothing new" — the signal decayed exactly as the
|
|
75
211
|
* problem got worse.
|
|
76
212
|
*/
|
|
77
|
-
banner() {
|
|
213
|
+
banner(tail) {
|
|
78
214
|
if (this.streak < AUTH_LOSS_STREAK)
|
|
79
215
|
return "";
|
|
80
216
|
const first = !this.reported;
|
|
81
217
|
this.reported = true;
|
|
218
|
+
// After the session's one re-attach, a later loss says so rather than implying a retry is still to come.
|
|
219
|
+
const spent = tail ?? (this.outcome !== null ? this.lossTail(`This session already re-attached once from role '${this.role}''s saved profile.`) : "");
|
|
82
220
|
return (`⚠ SESSION AUTH LOST — ${this.streak} consecutive navigations were redirected to a login page. ` +
|
|
83
221
|
`The credentials this session attached with have almost certainly expired. ` +
|
|
84
222
|
`Nothing tested past this point is meaningful: re-attach with a fresh storage state before continuing.` +
|
|
85
223
|
(first ? ` Routes bounced this way are recorded as NOT covered, so the completion contract still sees them as gaps.` : "") +
|
|
224
|
+
spent +
|
|
86
225
|
`\n`);
|
|
87
226
|
}
|
|
88
227
|
/**
|
|
@@ -115,9 +254,11 @@ export class AuthLossTracker {
|
|
|
115
254
|
* whatever the final route did.
|
|
116
255
|
*/
|
|
117
256
|
batchVerdict() {
|
|
257
|
+
const notes = this.batchNotes.join("");
|
|
118
258
|
if (!this.reported)
|
|
119
|
-
return
|
|
120
|
-
return (
|
|
259
|
+
return notes;
|
|
260
|
+
return (notes +
|
|
261
|
+
`⚠ SESSION AUTH LOST during this sweep — navigations were redirected to a login page. ` +
|
|
121
262
|
`The credentials this session attached with have almost certainly expired, so routes crawled after that ` +
|
|
122
263
|
`point tested a logged-out app. They are recorded as NOT covered; re-attach with a fresh storage state ` +
|
|
123
264
|
`and crawl again.\n`);
|
package/dist/engine/brief.js
CHANGED
|
@@ -85,6 +85,12 @@ export function laneName(modules, index) {
|
|
|
85
85
|
const name = modules.length > 1 ? `${base}+${modules.length - 1}` : base || `lane-${index + 1}`;
|
|
86
86
|
return name.slice(0, LANE_NAME_MAX);
|
|
87
87
|
}
|
|
88
|
+
/** How a lane signs in, as scout_attach arguments: by saved role, by a storage-state file, or not at all. */
|
|
89
|
+
function signInArgument(opts) {
|
|
90
|
+
if (!opts.role || opts.role === "anonymous")
|
|
91
|
+
return "";
|
|
92
|
+
return opts.roleProfile ? `, role: "${opts.role}"` : `, storageStatePath: "<${opts.role}>"`;
|
|
93
|
+
}
|
|
88
94
|
/** The lanes to run, each with the objective to attach with. */
|
|
89
95
|
export function planLanes(routes, laneCount, opts = {}) {
|
|
90
96
|
return splitRoutes(routes, laneCount).map((lane, i) => ({
|
|
@@ -124,7 +130,7 @@ export function formatBriefs(briefs, opts = {}) {
|
|
|
124
130
|
`LANE PLAN — ${briefs.length} lane(s) over ${briefs.reduce((n, b) => n + b.routes.length, 0)} route(s).`,
|
|
125
131
|
``,
|
|
126
132
|
`Give each lane its own agent. Every lane attaches with its own session name, so the browsers run genuinely in parallel, and lands on its own first route rather than the home page:`,
|
|
127
|
-
` scout_attach { session: "<lane>", url: "<origin><landing>", projectPath, mode: "${mode}"${opts
|
|
133
|
+
` scout_attach { session: "<lane>", url: "<origin><landing>", projectPath, mode: "${mode}"${signInArgument(opts)}, objective: "<objective>" }`,
|
|
128
134
|
``,
|
|
129
135
|
`Rules to pass on, which a hand-written brief tends to drop:`,
|
|
130
136
|
` · A lane works ITS routes only. Two lanes auditing the same register while a third module is never opened is the failure this plan exists to prevent — and route coverage will look complete either way. A defect on a page it does not own is that page's lane's to file.`,
|