scenescout 3.14.0 → 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.
package/dist/cli.js CHANGED
@@ -2,6 +2,7 @@
2
2
  /**
3
3
  * SceneScout CLI.
4
4
  *
5
+ * scenescout <url> A first look with no setup: observe mode, capped, never a gate
5
6
  * scenescout scan <projectPath> Print project discovery results
6
7
  * scenescout serve Run the MCP server on stdio
7
8
  * scenescout install Install the skill, download the browser, register the MCP server
@@ -20,15 +21,17 @@ import { APPROX_DISK_MB, BROWSER_ENGINES, browserPresence, defaultAttachNote, de
20
21
  import { CLIENT_LABELS, firstMessageHint, manualFor, parseClients, registerWithClient, vscodeBinary } from "./clients.js";
21
22
  import { CLI_NAME, diagnose, ensureCommand, findOnUserPath, installSkill, isEphemeralRoot, launchCommand, manualRegisterCommand, planCommand, registerMcp, resolveClaudeDir, spawnRunner, } from "./installer.js";
22
23
  import { defaultCheckDir, readCheckInputs, runCheck } from "./check-run.js";
23
- import { httpClient, runCi } from "./ci-run.js";
24
+ import { httpClient, httpJudgeAsk, runCi } from "./ci-run.js";
24
25
  import { runLogin, runScriptedLogin, savedLine } from "./login-run.js";
25
26
  import { credentialRedactor, LOGIN_ENV, readScriptedLogin } from "./engine/scripted-login.js";
26
27
  import { parseLoginArgs } from "./engine/profiles.js";
27
- import { detectProvider, EXIT_CI, KEY_ENV, parseCiArgs, redactKeys, secretValues } from "./engine/ci.js";
28
- import { EXIT, exitCodeOf, formatCheck, parseCheckArgs, refusedFlowReason, toSarif, toSummaryJson, unmeasuredReason } from "./engine/check.js";
28
+ import { detectProvider, EXIT_CI, judgeEffort, KEY_ENV, parseCiArgs, redactKeys, secretValues } from "./engine/ci.js";
29
+ import { EXIT, exitCodeOf, formatCheck, parseCheckArgs, refusedFlowReason, toSarif, toSummaryJson, unmeasuredReason, } from "./engine/check.js";
30
+ import { downloadLine, EXIT_FIRST_RUN, FIRST_RUN_DIRNAME, firstRunCheckOptions, firstRunDownloads, firstRunSummary, formatFirstRun, modeSentence, parseFirstRunArgs, reportFolderProblem, unreachableReason, writeFirstRunReport, } from "./first-run.js";
29
31
  import { LEGACY_MEMORY_DIRNAME, MEMORY_DIRNAME, writeSelfIgnore } from "./engine/memory.js";
30
32
  import { formatStatus, liveEngines, liveTokenFileName, localClock, LIVE_TOKEN_FILE, pidAlive, watchTarget, wholeSessions, } from "./engine/live.js";
31
33
  import { formatScan, scanProject } from "./scan.js";
34
+ import { dispatch } from "./commands.js";
32
35
  const here = path.dirname(fileURLToPath(import.meta.url));
33
36
  const packageRoot = path.resolve(here, "..");
34
37
  /** This package's version, as published. */
@@ -40,6 +43,18 @@ function usage(exitCode = 1) {
40
43
  console.log(`SceneScout ${packageVersion()} — AI exploratory UI testing engine (MCP)
41
44
 
42
45
  Usage:
46
+ scenescout <url> [options] A first look, with no setup: visits the app's pages and measures them (no
47
+ model, no API key), writes ${FIRST_RUN_DIRNAME}/ in this folder and prints
48
+ the three issues to look at first. Downloads Chromium if it is missing and
49
+ changes nothing else: no skill, no MCP registration, nothing on PATH.
50
+ The address comes first, its options after it.
51
+ (--max-routes N (default 20), --max-minutes N (default 3): no page is started
52
+ past either; --mode observe|read-only (default observe: nothing but reads
53
+ leaves the page, sign-in and token refresh apart; read-only lets a plain POST
54
+ through); --out dir: where the report goes. ${FIRST_RUN_DIRNAME}/ is written
55
+ only when it is new, empty or an earlier first look's)
56
+ Exit code: 0 it looked, whatever it found; 2 could not run (the URL could
57
+ not be reached, a bad argument, no browser) or could not write the report.
43
58
  scenescout scan <projectPath> Discover framework, routes, auth states
44
59
  scenescout serve Run the MCP server (stdio)
45
60
  scenescout install One-step setup: skill + Chromium + MCP registration
@@ -95,7 +110,11 @@ Usage:
95
110
  --project dir (default: here); --out dir (default: .scenescout/ci);
96
111
  --show "the Save button": instead of exploring, capture that element as a PNG
97
112
  under shots/; --compare-url https://…: with --show, capture it there too and
98
- write a diff picture)
113
+ write a diff picture;
114
+ --dedup judge|rule: judge (default) also asks the run's model, at its lowest
115
+ effort, whether a filed finding the rule keeps apart is one already on its
116
+ page (titles, categories, evidence and the page's path are sent);
117
+ rule asks nothing)
99
118
  Exit code: 0 the run ran (findings never change it), 2 could not run.
100
119
  scenescout login <url> --role <name>
101
120
  Open a visible browser at the URL, sign in there (SSO, MFA, anything), then
@@ -107,7 +126,9 @@ Usage:
107
126
  scenescout login <url> --role <name> --script
108
127
  For CI: sign in headless from SCENESCOUT_LOGIN_USERNAME, SCENESCOUT_LOGIN_PASSWORD
109
128
  and, if the form asks for a code, SCENESCOUT_LOGIN_TOTP_SECRET (base32 or an
110
- otpauth:// URI), then save the profile as above. A test user only. No value
129
+ otpauth:// URI) or SCENESCOUT_LOGIN_OTP_CODE (a fixed code a test environment
130
+ accepts), then save the profile as above. With a code and no password, a
131
+ passwordless sign-in (the username, then the code). A test user only. No value
111
132
  is ever printed. Exit 0 signed in and saved, 1 not.
112
133
  (--success-url text|url; --success-selector css; --username-selector,
113
134
  --password-selector, --otp-selector, --submit-selector css; each of these also
@@ -295,13 +316,6 @@ function flagValue(flags, name) {
295
316
  const next = flags[at + 1];
296
317
  return next === undefined || next.startsWith("--") ? "" : next;
297
318
  }
298
- function browsersFlag(flags) {
299
- // `--browser-only` is a different flag; a bare `--browser` is a slip that would otherwise be ignored and download Chromium.
300
- const slip = flags.find((f) => f === "--browser" || f.startsWith("--browser="));
301
- if (slip)
302
- throw new Error(`unknown flag ${slip.split("=")[0]} — did you mean --browsers?`);
303
- return flagValue(flags, "--browsers");
304
- }
305
319
  /** The `code` command on PATH, with its real path: the real path is what tells VS Code from a fork. */
306
320
  function codeOnPath() {
307
321
  const names = process.platform === "win32" ? ["code.cmd", "code.exe"] : ["code"];
@@ -349,7 +363,7 @@ async function install(flags) {
349
363
  // only thing it cannot bring is the browser download.
350
364
  const browserOnly = flags.includes("--browser-only");
351
365
  // Read the choice before doing anything, so a typo costs nothing.
352
- const selection = parseBrowserSelection(browsersFlag(flags));
366
+ const selection = parseBrowserSelection(flagValue(flags, "--browsers"));
353
367
  if ("error" in selection)
354
368
  throw new Error(selection.error);
355
369
  const chosen = parseClients(flagValues(flags, ["--client", "--clients"]));
@@ -520,8 +534,6 @@ async function doctor(flags) {
520
534
  }
521
535
  /** `scenescout check`: exit 0 passed, 1 failed the gate, 2 could not run. */
522
536
  async function check(args) {
523
- if (args.includes("--help") || args.includes("-h"))
524
- usage(0);
525
537
  const parsed = parseCheckArgs(args, process.cwd());
526
538
  if (!parsed.ok) {
527
539
  console.error(`scenescout check: ${parsed.error}`);
@@ -582,10 +594,92 @@ async function check(args) {
582
594
  console.error(`scenescout check: could not run a saved flow: ${refused}`);
583
595
  process.exit(exitCodeOf(result));
584
596
  }
597
+ /** `scenescout <url>`: a first look. Exit 0 once it has looked, whatever it found; 2 when it could not look or could not write its report. */
598
+ async function firstRun(args) {
599
+ const fail = (message) => {
600
+ console.error(`scenescout: ${message}`);
601
+ process.exit(EXIT_FIRST_RUN.couldNotRun);
602
+ };
603
+ const parsed = parseFirstRunArgs(args, process.cwd());
604
+ if (!parsed.ok)
605
+ return fail(parsed.error);
606
+ const options = parsed.options;
607
+ // Found out now, not after the look; and nothing is created until the look has something to write.
608
+ const folderProblem = reportFolderProblem(options.outDir ?? path.join(process.cwd(), FIRST_RUN_DIRNAME), options.outDir !== undefined);
609
+ if (folderProblem)
610
+ return fail(folderProblem);
611
+ console.log(`SceneScout ${packageVersion()} — a first look at ${options.url}`);
612
+ console.log(`It opens pages and measures them and submits no form: up to ${options.maxRoutes} pages, starting none after ${options.maxMinutes} minute(s). No model, no API key.`);
613
+ console.log(modeSentence(options.mode));
614
+ // Only the build a headless Chromium launch needs. Nothing else install does happens here: no skill, no registration, nothing on PATH.
615
+ const downloads = firstRunDownloads(await presentBrowsers());
616
+ if (downloads.length > 0) {
617
+ console.log(downloadLine(downloads));
618
+ const began = Date.now();
619
+ if (!downloadBrowsers(downloads)) {
620
+ return fail(`Chromium could not be downloaded. Check the network or proxy and run this again, or download it by hand: npx playwright install ${downloads.join(" ")}`);
621
+ }
622
+ // The installer's exit code is not the build: look again before saying it is there.
623
+ const still = firstRunDownloads(await presentBrowsers());
624
+ if (still.length > 0)
625
+ return fail(`the download finished, but ${still.join(", ")} is still not where Playwright looks for it. Run: npx playwright install ${still.join(" ")}`);
626
+ console.log(`✓ Chromium downloaded in ${Math.round((Date.now() - began) / 1000)} s.`);
627
+ }
628
+ // An empty project of its own: nothing is read from, or written to, the folder this runs in except the report.
629
+ const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "scenescout-first-run-"));
630
+ // Also on an exit the finally below never reaches, such as Ctrl+C, which the browser's driver answers with process.exit.
631
+ const removeProject = () => fs.rmSync(projectDir, { recursive: true, force: true });
632
+ process.once("exit", removeProject);
633
+ const began = Date.now();
634
+ let result;
635
+ let error = "";
636
+ try {
637
+ console.log(`\nLooking at ${options.url} …`);
638
+ result = await runCheck(firstRunCheckOptions(options, projectDir), (line) => console.log(line));
639
+ }
640
+ catch (err) {
641
+ error = err instanceof Error ? err.message : String(err);
642
+ }
643
+ finally {
644
+ process.off("exit", removeProject);
645
+ removeProject();
646
+ }
647
+ if (!result)
648
+ return fail(`could not run: ${error}`);
649
+ const unreachable = unreachableReason(result.routes);
650
+ if (unreachable)
651
+ return fail(`could not reach ${options.url}: ${unreachable}`);
652
+ const facts = { result, options, elapsedMs: Date.now() - began };
653
+ const files = {
654
+ "report.md": formatFirstRun(facts),
655
+ "check.json": JSON.stringify(toSummaryJson(result, packageVersion()), null, 2) + "\n",
656
+ };
657
+ // The look is done whatever happens to the files: its summary is printed either way.
658
+ let written;
659
+ let notWritten = "";
660
+ try {
661
+ written = writeFirstRunReport(files, { cwd: process.cwd(), tmpdir: os.tmpdir(), outDir: options.outDir });
662
+ }
663
+ catch (err) {
664
+ notWritten = err instanceof Error ? err.message : String(err);
665
+ }
666
+ if (written?.note)
667
+ console.log(written.note);
668
+ let where = `not written: ${notWritten}`;
669
+ if (written) {
670
+ const report = path.join(written.dir, "report.md");
671
+ const relative = path.relative(process.cwd(), report);
672
+ where = relative.startsWith("..") || path.isAbsolute(relative) ? report : relative;
673
+ }
674
+ console.log("");
675
+ for (const line of firstRunSummary(facts, where))
676
+ console.log(line);
677
+ if (!written)
678
+ return fail(`could not write the report: ${notWritten}`);
679
+ process.exit(EXIT_FIRST_RUN.ran);
680
+ }
585
681
  /** `scenescout ci`: exit 0 when the run ran, 2 when it could not. Findings never change the exit code. */
586
682
  async function ci(args) {
587
- if (args.includes("--help") || args.includes("-h"))
588
- usage(0);
589
683
  const secrets = secretValues(process.env);
590
684
  const say = (line) => console.log(redactKeys(line, secrets));
591
685
  const fail = (message) => {
@@ -601,11 +695,15 @@ async function ci(args) {
601
695
  return fail(provider.error);
602
696
  const resolved = provider.resolved;
603
697
  const key = (process.env[KEY_ENV[resolved.provider]] ?? "").trim();
604
- say(`Exploring ${options.url} with ${resolved.provider} ${resolved.model} (effort ${resolved.effort}), ${options.mode} mode, level ${options.level} …`);
698
+ // The dedup judge asks the run's model at the lowest effort its API takes; --dedup rule asks nothing.
699
+ const judge = options.dedup === "judge" && !options.show ? { ...resolved, effort: judgeEffort(resolved.provider) } : null;
700
+ say(`Exploring ${options.url} with ${resolved.provider} ${resolved.model} (effort ${resolved.effort}), ${options.mode} mode, level ${options.level}` +
701
+ `${judge ? `, duplicates judged by the model at effort ${judge.effort}` : ""} …`);
605
702
  let run;
606
703
  try {
607
704
  run = await runCi(options, resolved, {
608
705
  makeClient: (system, tools, kickoff) => httpClient(resolved, key, system, tools, kickoff),
706
+ ...(judge ? { judge: httpJudgeAsk(judge, key), judgeEffort: judge.effort } : {}),
609
707
  log: say,
610
708
  secrets,
611
709
  version: packageVersion(),
@@ -627,8 +725,6 @@ async function ci(args) {
627
725
  }
628
726
  /** `scenescout login`: exit 0 saved, 1 nothing saved. */
629
727
  async function login(args) {
630
- if (args.includes("--help") || args.includes("-h"))
631
- usage(0);
632
728
  const parsed = parseLoginArgs(args, process.cwd());
633
729
  if (!parsed.ok) {
634
730
  console.error(`scenescout login: ${parsed.error}`);
@@ -672,61 +768,40 @@ const [, , command, ...args] = process.argv;
672
768
  // one line the user needs. `serve` is deliberately outside this: it hands off
673
769
  // to the MCP server, whose own transport owns error reporting from then on.
674
770
  try {
675
- switch (command) {
676
- // Asking for help is not an error; scripts and shells treat a non-zero
677
- // exit as one.
678
- case "--help":
679
- case "-h":
680
- case "help":
681
- usage(0);
682
- case "--version":
683
- case "-v": {
684
- console.log(packageVersion());
685
- break;
686
- }
687
- case "scan": {
688
- const target = args[0];
689
- if (!target)
690
- usage();
691
- console.log(formatScan(scanProject(target)));
692
- break;
693
- }
694
- case "serve": {
695
- await import("./mcp-server.js");
696
- break;
697
- }
698
- case "install": {
699
- await install(args);
700
- break;
701
- }
702
- case "doctor": {
703
- await doctor(args);
704
- break;
705
- }
706
- case "check": {
707
- await check(args);
708
- break;
709
- }
710
- case "ci": {
711
- await ci(args);
712
- break;
713
- }
714
- case "login": {
715
- await login(args);
716
- break;
717
- }
718
- case "status": {
719
- status(path.resolve(args[0] ?? process.cwd()));
720
- break;
721
- }
722
- case "watch": {
723
- const positional = args.filter((a) => !a.startsWith("--"));
724
- watch(path.resolve(positional[0] ?? process.cwd()), !args.includes("--no-open"));
725
- break;
726
- }
727
- default:
728
- usage();
729
- }
771
+ await dispatch(command, args, {
772
+ usage,
773
+ version: () => console.log(packageVersion()),
774
+ refuse: (message) => {
775
+ console.error(`scenescout ${command}: ${message}`);
776
+ console.error("Run `scenescout --help` for the options.");
777
+ process.exit(1);
778
+ },
779
+ commands: {
780
+ scan: (a) => {
781
+ if (!a[0])
782
+ usage();
783
+ console.log(formatScan(scanProject(a[0])));
784
+ },
785
+ serve: async () => {
786
+ await import("./mcp-server.js");
787
+ },
788
+ install,
789
+ doctor,
790
+ check,
791
+ ci,
792
+ login,
793
+ status: (a) => status(path.resolve(a[0] ?? process.cwd())),
794
+ watch: (a) => {
795
+ const positional = a.filter((x) => !x.startsWith("--"));
796
+ watch(path.resolve(positional[0] ?? process.cwd()), !a.includes("--no-open"));
797
+ },
798
+ },
799
+ // Anything unforeseen is still "could not run" (2), not the exit 1 the other commands share below.
800
+ firstRun: (a) => firstRun(a).catch((err) => {
801
+ console.error(`scenescout: could not run: ${err instanceof Error ? err.message : String(err)}`);
802
+ process.exit(EXIT_FIRST_RUN.couldNotRun);
803
+ }),
804
+ });
730
805
  }
731
806
  catch (err) {
732
807
  console.error(`scenescout ${command ?? ""}: ${err instanceof Error ? err.message : String(err)}`);
@@ -0,0 +1,141 @@
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
+ * A first argument that is an address instead of a subcommand is a first run,
11
+ * `scenescout <url>` (first-run.ts), which parses its own options.
12
+ */
13
+ const HANDLERS_OF = {
14
+ scan: true,
15
+ serve: true,
16
+ install: true,
17
+ doctor: true,
18
+ check: true,
19
+ ci: true,
20
+ login: true,
21
+ status: true,
22
+ watch: true,
23
+ };
24
+ export const SUBCOMMANDS = Object.keys(HANDLERS_OF);
25
+ /**
26
+ * Commands that read their arguments by hand. `check`, `ci` and `login` are
27
+ * absent: their own parsers refuse unknown options. `serve` is absent on
28
+ * purpose: it is the line an MCP client launches, and a stray argument there
29
+ * should not stop the server from starting.
30
+ */
31
+ export const HAND_PARSED = {
32
+ install: {
33
+ switches: ["--skip-browser", "--no-register", "--no-command", "--browser-only"],
34
+ valued: ["--browsers", "--client", "--clients"],
35
+ // `--browser` is what `check` and `login` call it; for install it would otherwise download Chromium regardless.
36
+ hints: { "--browser": "--browsers" },
37
+ positional: 0,
38
+ },
39
+ doctor: { switches: ["--engine"], valued: [], positional: 0 },
40
+ scan: { switches: [], valued: [], positional: 1 },
41
+ status: { switches: [], valued: [], positional: 1 },
42
+ watch: { switches: ["--no-open"], valued: [], positional: 1 },
43
+ };
44
+ /** True when the arguments ask for help. */
45
+ export function wantsHelp(args) {
46
+ return args.includes("--help") || args.includes("-h");
47
+ }
48
+ /** The first argument `spec` does not accept, as an error sentence; null when every one is accepted. */
49
+ export function unknownArgument(args, spec) {
50
+ let positional = 0;
51
+ for (let i = 0; i < args.length; i++) {
52
+ const arg = args[i];
53
+ if (!arg.startsWith("-") || arg === "-") {
54
+ if (++positional > spec.positional)
55
+ return `unexpected argument ${arg}`;
56
+ continue;
57
+ }
58
+ const name = arg.split("=")[0];
59
+ if (spec.valued.includes(name)) {
60
+ // The value after a valued flag is not itself a flag, even when it starts with a dash.
61
+ if (!arg.includes("="))
62
+ i++;
63
+ continue;
64
+ }
65
+ if (spec.switches.includes(name) && !arg.includes("="))
66
+ continue;
67
+ const hint = spec.hints?.[name];
68
+ return `unknown option ${name}${hint ? ` — did you mean ${hint}?` : ""}`;
69
+ }
70
+ return null;
71
+ }
72
+ /** Whether `command` names a subcommand. */
73
+ export function isSubcommand(command) {
74
+ return command !== undefined && Object.hasOwn(HANDLERS_OF, command);
75
+ }
76
+ /** A host with no scheme: a name or address, an optional port, then optionally a path, query or fragment. */
77
+ const BARE_HOST = /^(?:\[[0-9a-f:.]+\]|[a-z0-9-]+(?:\.[a-z0-9-]+)*)(?::\d{1,5})?(?:[/?#].*)?$/i;
78
+ /** Whether an argument starts with a scheme, such as `http://`. */
79
+ export function hasScheme(arg) {
80
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(arg);
81
+ }
82
+ /** The host part of an address written without its scheme: everything before its path, query or fragment. */
83
+ export function hostOf(arg) {
84
+ return arg.split(/[/?#]/)[0];
85
+ }
86
+ /**
87
+ * Whether the first argument is an address, which makes the command line a
88
+ * first run (`scenescout <url>`): it has a scheme (`http://…`, and `ftp://…`,
89
+ * which the first run then refuses with a reason), or it reads as a host
90
+ * without one (`localhost:3000`, `example.com/app`), which the first run asks
91
+ * to be written in full. Subcommands are settled first, and a word that is
92
+ * neither (`instal`) still gets the usage.
93
+ */
94
+ export function looksLikeUrl(arg) {
95
+ if (!arg || arg.startsWith("-"))
96
+ return false;
97
+ if (hasScheme(arg))
98
+ return true;
99
+ if (!BARE_HOST.test(arg))
100
+ return false;
101
+ const host = hostOf(arg);
102
+ return host.includes(".") || host.includes(":") || host.toLowerCase() === "localhost";
103
+ }
104
+ /** Settle help and flag errors for a subcommand before its handler is reached. */
105
+ export function preflight(command, args) {
106
+ if (wantsHelp(args))
107
+ return { kind: "help" };
108
+ const spec = Object.hasOwn(HAND_PARSED, command) ? HAND_PARSED[command] : undefined;
109
+ const problem = spec ? unknownArgument(args, spec) : null;
110
+ return problem ? { kind: "error", message: problem } : { kind: "run" };
111
+ }
112
+ /**
113
+ * Run the command line: help and flag errors are settled here, so a subcommand's
114
+ * handler is reached only when it is actually meant to run.
115
+ */
116
+ export async function dispatch(command, args, h) {
117
+ switch (command) {
118
+ // Asking for help is not an error; scripts and shells treat a non-zero exit as one.
119
+ case "--help":
120
+ case "-h":
121
+ case "help":
122
+ return h.usage(0);
123
+ case "--version":
124
+ case "-v":
125
+ return h.version();
126
+ }
127
+ if (!isSubcommand(command)) {
128
+ if (!looksLikeUrl(command))
129
+ return h.usage(1);
130
+ // Its own parser refuses an option it does not know, with the first run's exit code, as check's does.
131
+ if (wantsHelp(args))
132
+ return h.usage(0);
133
+ return h.firstRun([command, ...args]);
134
+ }
135
+ const verdict = preflight(command, args);
136
+ if (verdict.kind === "help")
137
+ return h.usage(0);
138
+ if (verdict.kind === "error")
139
+ return h.refuse(verdict.message);
140
+ await h.commands[command](args);
141
+ }