scenescout 3.14.1 → 3.16.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/CHANGELOG.md +94 -0
- package/README.md +90 -18
- package/dist/browsers.js +28 -0
- package/dist/check-run.js +211 -24
- package/dist/ci-run.js +374 -62
- package/dist/cli.js +221 -47
- package/dist/commands.js +42 -4
- package/dist/engine/baseline.js +377 -0
- package/dist/engine/brief.js +16 -7
- package/dist/engine/browser.js +1660 -386
- package/dist/engine/calibration.js +61 -30
- package/dist/engine/capture.js +164 -0
- package/dist/engine/check.js +258 -68
- package/dist/engine/ci-lanes.js +215 -0
- package/dist/engine/ci.js +233 -18
- package/dist/engine/claims.js +234 -12
- package/dist/engine/collector.js +851 -78
- package/dist/engine/crawl.js +49 -0
- package/dist/engine/dedup.js +329 -9
- package/dist/engine/design.js +355 -43
- package/dist/engine/export.js +877 -0
- package/dist/engine/fingerprint.js +92 -4
- package/dist/engine/flow.js +18 -6
- package/dist/engine/forms.js +198 -18
- package/dist/engine/journey.js +29 -1
- package/dist/engine/lane.js +13 -3
- package/dist/engine/launch.js +45 -6
- package/dist/engine/limits.js +7 -0
- package/dist/engine/live-page.js +49 -2
- package/dist/engine/live.js +4 -1
- package/dist/engine/memory.js +668 -70
- package/dist/engine/open.js +118 -0
- package/dist/engine/oracles.js +167 -2
- package/dist/engine/plain.js +268 -0
- package/dist/engine/png.js +127 -0
- package/dist/engine/policy.js +379 -9
- package/dist/engine/probes.js +3 -2
- package/dist/engine/profiles.js +45 -9
- package/dist/engine/project-folder.js +191 -0
- package/dist/engine/provider.js +4 -3
- package/dist/engine/refresh.js +338 -25
- package/dist/engine/replay.js +63 -10
- package/dist/engine/report.js +248 -40
- package/dist/engine/request.js +317 -23
- package/dist/engine/sarif.js +120 -0
- package/dist/engine/scripted-login.js +342 -62
- package/dist/engine/settle.js +67 -0
- package/dist/engine/signed-in.js +256 -0
- package/dist/engine/tickets.js +671 -0
- package/dist/engine/unload.js +3 -2
- package/dist/export-run.js +633 -0
- package/dist/first-run.js +581 -0
- package/dist/installer.js +267 -3
- package/dist/intake.js +104 -0
- package/dist/login-run.js +386 -71
- package/dist/mcp-server.js +637 -68
- package/dist/playbook.js +5 -0
- package/package.json +6 -3
- package/skills/scenescout/SKILL.md +51 -18
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
|
|
@@ -9,24 +10,30 @@
|
|
|
9
10
|
* scenescout check <url> Visit every route, measure it, and pass or fail (no model involved)
|
|
10
11
|
* scenescout ci <url> An exploratory run driven by a model's API, unattended, that reports
|
|
11
12
|
* scenescout login <url> --role r Sign in once in a visible browser and save it as a named role
|
|
13
|
+
* scenescout export --to github File the project's findings as GitHub or Jira issues, each once
|
|
12
14
|
*/
|
|
13
15
|
import { spawnSync } from "node:child_process";
|
|
14
16
|
import fs from "node:fs";
|
|
15
|
-
import { createRequire } from "node:module";
|
|
16
17
|
import os from "node:os";
|
|
17
18
|
import path from "node:path";
|
|
18
19
|
import { fileURLToPath } from "node:url";
|
|
19
|
-
import { APPROX_DISK_MB,
|
|
20
|
+
import { APPROX_DISK_MB, defaultAttachNote, defaultEngine, launchTarget, parseBrowserSelection } from "./browsers.js";
|
|
20
21
|
import { CLIENT_LABELS, firstMessageHint, manualFor, parseClients, registerWithClient, vscodeBinary } from "./clients.js";
|
|
21
|
-
import { CLI_NAME, diagnose, ensureCommand, findOnUserPath, installSkill, isEphemeralRoot, launchCommand, manualRegisterCommand, planCommand, registerMcp, resolveClaudeDir, spawnRunner, } from "./installer.js";
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
22
|
+
import { CLAUDE_CODE_NOT_NEEDED, CLI_NAME, desktopExtensionRoots, diagnose, doctorAllGood, findDesktopExtension, installClosing, ensureCommand, findOnUserPath, installSkill, isEphemeralRoot, launchCommand, manualRegisterCommand, planCommand, registerMcp, resolveClaudeDir, spawnRunner, } from "./installer.js";
|
|
23
|
+
import { downloadBrowsers, presentBrowsers } from "./installer.js";
|
|
24
|
+
import { baselinesDirOf, defaultCheckDir, readCheckInputs, runCheck } from "./check-run.js";
|
|
25
|
+
import { httpClient, httpJudgeAsk, runCi } from "./ci-run.js";
|
|
26
|
+
import { runExport } from "./export-run.js";
|
|
24
27
|
import { runLogin, runScriptedLogin, savedLine } from "./login-run.js";
|
|
25
28
|
import { credentialRedactor, LOGIN_ENV, readScriptedLogin } from "./engine/scripted-login.js";
|
|
26
29
|
import { parseLoginArgs } from "./engine/profiles.js";
|
|
27
|
-
import { detectProvider, EXIT_CI, KEY_ENV, parseCiArgs, redactKeys, secretValues } from "./engine/ci.js";
|
|
28
|
-
import {
|
|
30
|
+
import { detectProvider, EXIT_CI, judgeEffort, KEY_ENV, parseCiArgs, redactKeys, secretValues } from "./engine/ci.js";
|
|
31
|
+
import { VISUAL_DIRNAME } from "./engine/baseline.js";
|
|
32
|
+
import { EXIT, exitCodeOf, formatCheck, parseCheckArgs, refusedFlowReason, toSarif, toSummaryJson, unmeasuredReason, } from "./engine/check.js";
|
|
33
|
+
import { downloadLine, EXIT_FIRST_RUN, FIRST_RUN_DIRNAME, firstRunCheckOptions, firstRunDownloads, firstRunSummary, formatFirstRun, modeSentence, parseFirstRunArgs, reportFolderProblem, unreachableReason, writeFirstRunReport, } from "./first-run.js";
|
|
34
|
+
import { credentialSecrets, EXIT_EXPORT, parseExportArgs } from "./engine/export.js";
|
|
29
35
|
import { LEGACY_MEMORY_DIRNAME, MEMORY_DIRNAME, writeSelfIgnore } from "./engine/memory.js";
|
|
36
|
+
import { sarifFilesFor } from "./engine/sarif.js";
|
|
30
37
|
import { formatStatus, liveEngines, liveTokenFileName, localClock, LIVE_TOKEN_FILE, pidAlive, watchTarget, wholeSessions, } from "./engine/live.js";
|
|
31
38
|
import { formatScan, scanProject } from "./scan.js";
|
|
32
39
|
import { dispatch } from "./commands.js";
|
|
@@ -41,6 +48,18 @@ function usage(exitCode = 1) {
|
|
|
41
48
|
console.log(`SceneScout ${packageVersion()} — AI exploratory UI testing engine (MCP)
|
|
42
49
|
|
|
43
50
|
Usage:
|
|
51
|
+
scenescout <url> [options] A first look, with no setup: visits the app's pages and measures them (no
|
|
52
|
+
model, no API key), writes ${FIRST_RUN_DIRNAME}/ in this folder and prints
|
|
53
|
+
the three issues to look at first. Downloads Chromium if it is missing and
|
|
54
|
+
changes nothing else: no skill, no MCP registration, nothing on PATH.
|
|
55
|
+
The address comes first, its options after it.
|
|
56
|
+
(--max-routes N (default 20), --max-minutes N (default 3): no page is started
|
|
57
|
+
past either; --mode observe|read-only (default observe: nothing but reads
|
|
58
|
+
leaves the page, sign-in and token refresh apart; read-only lets a plain POST
|
|
59
|
+
through); --out dir: where the report goes. ${FIRST_RUN_DIRNAME}/ is written
|
|
60
|
+
only when it is new, empty or an earlier first look's)
|
|
61
|
+
Exit code: 0 it looked, whatever it found; 2 could not run (the URL could
|
|
62
|
+
not be reached, a bad argument, no browser) or could not write the report.
|
|
44
63
|
scenescout scan <projectPath> Discover framework, routes, auth states
|
|
45
64
|
scenescout serve Run the MCP server (stdio)
|
|
46
65
|
scenescout install One-step setup: skill + Chromium + MCP registration
|
|
@@ -73,7 +92,19 @@ Usage:
|
|
|
73
92
|
--on-refused-step report|stop: report (default) marks a flow whose step was
|
|
74
93
|
refused "could not run", keeps every other verdict and exits 2; stop exits 2 there;
|
|
75
94
|
--gate-retests never|high|all: which still-reproducing findings fail the gate
|
|
76
|
-
(default high: those filed high)
|
|
95
|
+
(default high: those filed high);
|
|
96
|
+
--baseline off|compare|update: compare each page or element listed in the
|
|
97
|
+
baselines' targets.json with its baseline picture, or write new baselines
|
|
98
|
+
(update; only when asked) (default off);
|
|
99
|
+
--baselines dir: where targets.json and the baselines are (default
|
|
100
|
+
.scenescout/baselines, which git ignores; name a folder you commit to share
|
|
101
|
+
them); --baseline-threshold N: the % of a picture's pixels that may change
|
|
102
|
+
before its baseline is not met; update rewrites those past it, and any taken
|
|
103
|
+
on another OS (default 0.1, so small anti-aliasing noise between machines
|
|
104
|
+
passes; 0 counts every changed pixel);
|
|
105
|
+
--sarif-file-anchor path: the repository file a SARIF result points at when
|
|
106
|
+
no saved flow raised it (default: the running workflow's file on GitHub
|
|
107
|
+
Actions, else package.json, else README.md))
|
|
77
108
|
Exit code: 0 passed, 1 failed the gate, 2 could not run.
|
|
78
109
|
scenescout ci <url> An exploratory run with no person present: a model reached through its API
|
|
79
110
|
drives the tools by the SceneScout method and the run ends in the report.
|
|
@@ -86,6 +117,8 @@ Usage:
|
|
|
86
117
|
--max-turns N (default 40); --max-tokens N (default 1500000);
|
|
87
118
|
--max-minutes N (default 20): the run stops at the first cap reached and still
|
|
88
119
|
writes the report;
|
|
120
|
+
--lanes N (default 1, at most 8): split the app between N model loops that
|
|
121
|
+
explore at once, each in its own browser, sharing those caps;
|
|
89
122
|
--price-in, --price-cached-in, --price-out: US dollars per million tokens,
|
|
90
123
|
over the built-in prices, for the cost estimate of any model;
|
|
91
124
|
--mode observe|read-only|safe-write|destructive (default read-only;
|
|
@@ -96,24 +129,61 @@ Usage:
|
|
|
96
129
|
--project dir (default: here); --out dir (default: .scenescout/ci);
|
|
97
130
|
--show "the Save button": instead of exploring, capture that element as a PNG
|
|
98
131
|
under shots/; --compare-url https://…: with --show, capture it there too and
|
|
99
|
-
write a diff picture
|
|
132
|
+
write a diff picture;
|
|
133
|
+
--dedup judge|rule: judge (default) also asks the run's model, at its lowest
|
|
134
|
+
effort, whether a filed finding the rule keeps apart is one already on its
|
|
135
|
+
page (titles, categories, evidence and the page's path are sent);
|
|
136
|
+
rule asks nothing;
|
|
137
|
+
--sarif-file-anchor path: the repository file each SARIF result points at,
|
|
138
|
+
as for check)
|
|
100
139
|
Exit code: 0 the run ran (findings never change it), 2 could not run.
|
|
101
140
|
scenescout login <url> --role <name>
|
|
102
|
-
Open a visible browser at the URL
|
|
103
|
-
|
|
104
|
-
.scenescout/auth/<name>.json (owner-only; never printed, never
|
|
105
|
-
|
|
106
|
-
scout_attach { role: "<name>" },
|
|
107
|
-
|
|
141
|
+
Open a visible browser at the URL and sign in there (SSO, MFA, anything). Once
|
|
142
|
+
you are back on the app with a new session, the window saves it as that role's
|
|
143
|
+
profile, in .scenescout/auth/<name>.json (owner-only; never printed, never
|
|
144
|
+
committed), and closes. Enter in this terminal saves at once. Closing the window
|
|
145
|
+
or Ctrl+C saves nothing. Agents then attach with scout_attach { role: "<name>" },
|
|
146
|
+
as many sessions as they like from one login. From a conversation, scout_login
|
|
147
|
+
opens the same window.
|
|
148
|
+
(--project dir (default: here); --browser chromium|firefox|webkit;
|
|
149
|
+
--save auto|enter (default auto; enter: save on Enter only, as before);
|
|
150
|
+
--success-url text|url: signed in once the URL's path contains this, or the URL
|
|
151
|
+
starts with it, instead of when a new session appears)
|
|
108
152
|
scenescout login <url> --role <name> --script
|
|
109
153
|
For CI: sign in headless from SCENESCOUT_LOGIN_USERNAME, SCENESCOUT_LOGIN_PASSWORD
|
|
110
154
|
and, if the form asks for a code, SCENESCOUT_LOGIN_TOTP_SECRET (base32 or an
|
|
111
|
-
otpauth:// URI)
|
|
155
|
+
otpauth:// URI) or SCENESCOUT_LOGIN_OTP_CODE (a fixed code a test environment
|
|
156
|
+
accepts), then save the profile as above. With a code and no password, a
|
|
157
|
+
passwordless sign-in (the username, then the code). A test user only. No value
|
|
112
158
|
is ever printed. Exit 0 signed in and saved, 1 not.
|
|
113
159
|
(--success-url text|url; --success-selector css; --username-selector,
|
|
114
160
|
--password-selector, --otp-selector, --submit-selector css; each of these also
|
|
115
161
|
from SCENESCOUT_LOGIN_<FLAG>, e.g. SCENESCOUT_LOGIN_SUCCESS_URL;
|
|
116
162
|
--timeout seconds (default 60))
|
|
163
|
+
scenescout export --to github|jira
|
|
164
|
+
File the project's open findings (from .scenescout/memory.json) as issues,
|
|
165
|
+
each once: a finding whose marker is already on an issue is skipped. A dry run
|
|
166
|
+
that lists what it would file unless --yes is given. Credentials come from the
|
|
167
|
+
environment only: GH_TOKEN or GITHUB_TOKEN; JIRA_EMAIL and JIRA_API_TOKEN.
|
|
168
|
+
(--repo owner/name for GitHub (GITHUB_API_URL for GitHub Enterprise Server);
|
|
169
|
+
--jira-url https://…, --jira-project KEY, --jira-issue-type name (default Bug),
|
|
170
|
+
--jira-link-type name|none (default Relates), or JIRA_BASE_URL,
|
|
171
|
+
JIRA_PROJECT_KEY, JIRA_ISSUE_TYPE, JIRA_LINK_TYPE, for Jira Cloud: an issue
|
|
172
|
+
is linked to each ticket whose criterion its finding fails;
|
|
173
|
+
--jira-update on|off (default on): update an open Jira issue filed earlier,
|
|
174
|
+
leaving a summary or description edited in Jira as it is;
|
|
175
|
+
--min-severity high|medium|low (default low); --only id,id;
|
|
176
|
+
--max-issues N (default 20, at most 100): the most one export files;
|
|
177
|
+
--refile-closed: file a finding again when its issue was closed (by default
|
|
178
|
+
an issue open or closed counts as filed); --include-worth-a-look;
|
|
179
|
+
--severity-map high=…,medium=…,low=… or none: a label on GitHub, a priority
|
|
180
|
+
in Jira (default severity: high… / High, Medium, Low); --labels a,b;
|
|
181
|
+
--screenshots on|off (default on: the finding's picture and the run's frames,
|
|
182
|
+
attached in Jira, named on GitHub);
|
|
183
|
+
--project dir (default: here); --dry-run; --yes)
|
|
184
|
+
Exit code: 0 done (findings over the cap wait for the next export), 2 could not
|
|
185
|
+
export (it lists what it filed before it stopped), or a screenshot was not
|
|
186
|
+
attached or a ticket not linked.
|
|
117
187
|
scenescout status [projectPath] What is the engine doing right now? (every session + recent actions)
|
|
118
188
|
scenescout watch [projectPath] Open the live view in a browser: what each session is doing, a thumbnail
|
|
119
189
|
of its page, and a live stream you can switch on per session
|
|
@@ -261,26 +331,6 @@ function browserOpener(url) {
|
|
|
261
331
|
return { command: "xdg-open", args: [url] };
|
|
262
332
|
}
|
|
263
333
|
}
|
|
264
|
-
/** Which browser builds are on disk, going by the paths Playwright reports for the version we depend on. */
|
|
265
|
-
async function presentBrowsers() {
|
|
266
|
-
const executables = { chromium: null, firefox: null, webkit: null };
|
|
267
|
-
try {
|
|
268
|
-
const playwright = await import("playwright");
|
|
269
|
-
for (const name of BROWSER_ENGINES)
|
|
270
|
-
executables[name] = playwright[name].executablePath() || null;
|
|
271
|
-
}
|
|
272
|
-
catch {
|
|
273
|
-
// Playwright cannot be loaded: every build reads as absent, which is what doctor should say.
|
|
274
|
-
}
|
|
275
|
-
return browserPresence(executables);
|
|
276
|
-
}
|
|
277
|
-
/** Download browser builds through the playwright CLI that ships with our own dependency. */
|
|
278
|
-
function downloadBrowsers(targets) {
|
|
279
|
-
const require = createRequire(import.meta.url);
|
|
280
|
-
const cli = path.join(path.dirname(require.resolve("playwright/package.json")), "cli.js");
|
|
281
|
-
const r = spawnSync(process.execPath, [cli, ...playwrightInstallArgs(targets)], { stdio: "inherit" });
|
|
282
|
-
return r.status === 0;
|
|
283
|
-
}
|
|
284
334
|
/**
|
|
285
335
|
* The value of a flag written as `--name x` or `--name=x`. Undefined when the
|
|
286
336
|
* flag is absent; empty when it was given no value, which includes being
|
|
@@ -372,7 +422,7 @@ async function install(flags) {
|
|
|
372
422
|
if (missing.length > 0) {
|
|
373
423
|
const size = missing.reduce((sum, t) => sum + APPROX_DISK_MB[t], 0);
|
|
374
424
|
console.log(`· Downloading ${missing.join(", ")} (one-time, about ${size} MB on disk)…`);
|
|
375
|
-
if (downloadBrowsers(missing))
|
|
425
|
+
if ((await downloadBrowsers(missing, "inherit")).ok)
|
|
376
426
|
console.log(`✓ Downloaded: ${missing.join(", ")}.`);
|
|
377
427
|
else {
|
|
378
428
|
failed = true;
|
|
@@ -476,12 +526,11 @@ async function install(flags) {
|
|
|
476
526
|
process.exitCode = 1;
|
|
477
527
|
return;
|
|
478
528
|
}
|
|
479
|
-
|
|
480
|
-
|
|
529
|
+
const closing = installClosing({ browserOnly, forClaude });
|
|
530
|
+
if (closing)
|
|
531
|
+
console.log(`\n${closing}`);
|
|
532
|
+
if (browserOnly)
|
|
481
533
|
return;
|
|
482
|
-
}
|
|
483
|
-
if (forClaude)
|
|
484
|
-
console.log("\nStart a FRESH Claude Code session, then in any project run: /scenescout");
|
|
485
534
|
// Telling someone to restart a client nothing was registered with sends them looking for a server that is not there.
|
|
486
535
|
if (others.length > 0 && !flags.includes("--no-register"))
|
|
487
536
|
console.log(`\n${firstMessageHint(others)}`);
|
|
@@ -493,12 +542,15 @@ async function doctor(flags) {
|
|
|
493
542
|
packageRoot,
|
|
494
543
|
claudeDir: resolveClaudeDir(process.env, os.homedir()),
|
|
495
544
|
nodeVersion: process.version,
|
|
545
|
+
version: packageVersion(),
|
|
496
546
|
// What a default attach launches: the headless build of the default browser.
|
|
497
547
|
defaultBrowser: await (async () => {
|
|
498
548
|
const target = launchTarget(defaultEngine(process.env), false);
|
|
499
549
|
const found = (await presentBrowsers())[target];
|
|
500
550
|
return { target, path: found.installed ? found.path : null, expected: found.path };
|
|
501
551
|
})(),
|
|
552
|
+
desktopExtension: findDesktopExtension(desktopExtensionRoots({ platform: process.platform, home: os.homedir(), env: process.env })),
|
|
553
|
+
headlessShellDir: (await presentBrowsers())["chromium-headless-shell"].path,
|
|
502
554
|
run: spawnRunner,
|
|
503
555
|
});
|
|
504
556
|
for (const c of checks) {
|
|
@@ -508,9 +560,7 @@ async function doctor(flags) {
|
|
|
508
560
|
}
|
|
509
561
|
if (checks.some((c) => !c.ok))
|
|
510
562
|
process.exit(1);
|
|
511
|
-
console.log(flags.includes("--engine")
|
|
512
|
-
? "\nAll good. Ask your agent: Use SceneScout to test http://localhost:3000"
|
|
513
|
-
: "\nAll good. In any project, run: /scenescout (or ask: Use SceneScout to test http://localhost:3000)");
|
|
563
|
+
console.log(`\n${doctorAllGood({ engineOnly: flags.includes("--engine"), desktopOnly: checks.some((c) => c.name === CLAUDE_CODE_NOT_NEEDED) })}`);
|
|
514
564
|
}
|
|
515
565
|
/** `scenescout check`: exit 0 passed, 1 failed the gate, 2 could not run. */
|
|
516
566
|
async function check(args) {
|
|
@@ -556,7 +606,16 @@ async function check(args) {
|
|
|
556
606
|
writeSelfIgnore(path.dirname(outDir));
|
|
557
607
|
fs.mkdirSync(outDir, { recursive: true });
|
|
558
608
|
fs.writeFileSync(path.join(outDir, "report.md"), markdown);
|
|
559
|
-
|
|
609
|
+
const sarifFiles = sarifFilesFor({
|
|
610
|
+
option: options.sarifFileAnchor,
|
|
611
|
+
env: process.env,
|
|
612
|
+
projectDir: options.projectDir,
|
|
613
|
+
flowsDir: inputs.flowsDir,
|
|
614
|
+
exists: (p) => fs.existsSync(p),
|
|
615
|
+
});
|
|
616
|
+
if (sarifFiles.warning)
|
|
617
|
+
console.error(`scenescout check: ${sarifFiles.warning}`);
|
|
618
|
+
fs.writeFileSync(path.join(outDir, "check.sarif"), JSON.stringify(toSarif(result, version, sarifFiles), null, 2) + "\n");
|
|
560
619
|
fs.writeFileSync(path.join(outDir, "check.json"), JSON.stringify(toSummaryJson(result, version), null, 2) + "\n");
|
|
561
620
|
// On GitHub Actions the verdict also goes on the run's summary page.
|
|
562
621
|
if (process.env.GITHUB_STEP_SUMMARY)
|
|
@@ -569,11 +628,101 @@ async function check(args) {
|
|
|
569
628
|
}
|
|
570
629
|
console.log("\n" + markdown);
|
|
571
630
|
console.log(`Wrote report.md, check.sarif and check.json to ${outDir}`);
|
|
631
|
+
const pictured = result.baselines?.results.filter((r) => r.files).length ?? 0;
|
|
632
|
+
if (pictured > 0)
|
|
633
|
+
console.log(`Wrote the pictures of ${pictured} changed baseline(s) under ${path.join(outDir, VISUAL_DIRNAME)}`);
|
|
634
|
+
const written = result.baselines?.results.filter((r) => r.status === "updated").length ?? 0;
|
|
635
|
+
if (written > 0)
|
|
636
|
+
console.log(`Wrote ${written} baseline(s) to ${baselinesDirOf(options)}`);
|
|
572
637
|
// --on-refused-step report: everything else has its verdict in the files, and the exit code still says the run was incomplete.
|
|
573
638
|
if (refused)
|
|
574
639
|
console.error(`scenescout check: could not run a saved flow: ${refused}`);
|
|
575
640
|
process.exit(exitCodeOf(result));
|
|
576
641
|
}
|
|
642
|
+
/** `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. */
|
|
643
|
+
async function firstRun(args) {
|
|
644
|
+
const fail = (message) => {
|
|
645
|
+
console.error(`scenescout: ${message}`);
|
|
646
|
+
process.exit(EXIT_FIRST_RUN.couldNotRun);
|
|
647
|
+
};
|
|
648
|
+
const parsed = parseFirstRunArgs(args, process.cwd());
|
|
649
|
+
if (!parsed.ok)
|
|
650
|
+
return fail(parsed.error);
|
|
651
|
+
const options = parsed.options;
|
|
652
|
+
// Found out now, not after the look; and nothing is created until the look has something to write.
|
|
653
|
+
const folderProblem = reportFolderProblem(options.outDir ?? path.join(process.cwd(), FIRST_RUN_DIRNAME), options.outDir !== undefined);
|
|
654
|
+
if (folderProblem)
|
|
655
|
+
return fail(folderProblem);
|
|
656
|
+
console.log(`SceneScout ${packageVersion()} — a first look at ${options.url}`);
|
|
657
|
+
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.`);
|
|
658
|
+
console.log(modeSentence(options.mode));
|
|
659
|
+
// Only the build a headless Chromium launch needs. Nothing else install does happens here: no skill, no registration, nothing on PATH.
|
|
660
|
+
const downloads = firstRunDownloads(await presentBrowsers());
|
|
661
|
+
if (downloads.length > 0) {
|
|
662
|
+
console.log(downloadLine(downloads));
|
|
663
|
+
const began = Date.now();
|
|
664
|
+
if (!(await downloadBrowsers(downloads, "inherit")).ok) {
|
|
665
|
+
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(" ")}`);
|
|
666
|
+
}
|
|
667
|
+
// The installer's exit code is not the build: look again before saying it is there.
|
|
668
|
+
const still = firstRunDownloads(await presentBrowsers());
|
|
669
|
+
if (still.length > 0)
|
|
670
|
+
return fail(`the download finished, but ${still.join(", ")} is still not where Playwright looks for it. Run: npx playwright install ${still.join(" ")}`);
|
|
671
|
+
console.log(`✓ Chromium downloaded in ${Math.round((Date.now() - began) / 1000)} s.`);
|
|
672
|
+
}
|
|
673
|
+
// An empty project of its own: nothing is read from, or written to, the folder this runs in except the report.
|
|
674
|
+
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "scenescout-first-run-"));
|
|
675
|
+
// Also on an exit the finally below never reaches, such as Ctrl+C, which the browser's driver answers with process.exit.
|
|
676
|
+
const removeProject = () => fs.rmSync(projectDir, { recursive: true, force: true });
|
|
677
|
+
process.once("exit", removeProject);
|
|
678
|
+
const began = Date.now();
|
|
679
|
+
let result;
|
|
680
|
+
let error = "";
|
|
681
|
+
try {
|
|
682
|
+
console.log(`\nLooking at ${options.url} …`);
|
|
683
|
+
result = await runCheck(firstRunCheckOptions(options, projectDir), (line) => console.log(line));
|
|
684
|
+
}
|
|
685
|
+
catch (err) {
|
|
686
|
+
error = err instanceof Error ? err.message : String(err);
|
|
687
|
+
}
|
|
688
|
+
finally {
|
|
689
|
+
process.off("exit", removeProject);
|
|
690
|
+
removeProject();
|
|
691
|
+
}
|
|
692
|
+
if (!result)
|
|
693
|
+
return fail(`could not run: ${error}`);
|
|
694
|
+
const unreachable = unreachableReason(result.routes);
|
|
695
|
+
if (unreachable)
|
|
696
|
+
return fail(`could not reach ${options.url}: ${unreachable}`);
|
|
697
|
+
const facts = { result, options, elapsedMs: Date.now() - began };
|
|
698
|
+
const files = {
|
|
699
|
+
"report.md": formatFirstRun(facts),
|
|
700
|
+
"check.json": JSON.stringify(toSummaryJson(result, packageVersion()), null, 2) + "\n",
|
|
701
|
+
};
|
|
702
|
+
// The look is done whatever happens to the files: its summary is printed either way.
|
|
703
|
+
let written;
|
|
704
|
+
let notWritten = "";
|
|
705
|
+
try {
|
|
706
|
+
written = writeFirstRunReport(files, { cwd: process.cwd(), tmpdir: os.tmpdir(), outDir: options.outDir });
|
|
707
|
+
}
|
|
708
|
+
catch (err) {
|
|
709
|
+
notWritten = err instanceof Error ? err.message : String(err);
|
|
710
|
+
}
|
|
711
|
+
if (written?.note)
|
|
712
|
+
console.log(written.note);
|
|
713
|
+
let where = `not written: ${notWritten}`;
|
|
714
|
+
if (written) {
|
|
715
|
+
const report = path.join(written.dir, "report.md");
|
|
716
|
+
const relative = path.relative(process.cwd(), report);
|
|
717
|
+
where = relative.startsWith("..") || path.isAbsolute(relative) ? report : relative;
|
|
718
|
+
}
|
|
719
|
+
console.log("");
|
|
720
|
+
for (const line of firstRunSummary(facts, where))
|
|
721
|
+
console.log(line);
|
|
722
|
+
if (!written)
|
|
723
|
+
return fail(`could not write the report: ${notWritten}`);
|
|
724
|
+
process.exit(EXIT_FIRST_RUN.ran);
|
|
725
|
+
}
|
|
577
726
|
/** `scenescout ci`: exit 0 when the run ran, 2 when it could not. Findings never change the exit code. */
|
|
578
727
|
async function ci(args) {
|
|
579
728
|
const secrets = secretValues(process.env);
|
|
@@ -591,11 +740,15 @@ async function ci(args) {
|
|
|
591
740
|
return fail(provider.error);
|
|
592
741
|
const resolved = provider.resolved;
|
|
593
742
|
const key = (process.env[KEY_ENV[resolved.provider]] ?? "").trim();
|
|
594
|
-
|
|
743
|
+
// The dedup judge asks the run's model at the lowest effort its API takes; --dedup rule asks nothing.
|
|
744
|
+
const judge = options.dedup === "judge" && !options.show ? { ...resolved, effort: judgeEffort(resolved.provider) } : null;
|
|
745
|
+
say(`Exploring ${options.url} with ${resolved.provider} ${resolved.model} (effort ${resolved.effort}), ${options.mode} mode, level ${options.level}` +
|
|
746
|
+
`${judge ? `, duplicates judged by the model at effort ${judge.effort}` : ""} …`);
|
|
595
747
|
let run;
|
|
596
748
|
try {
|
|
597
749
|
run = await runCi(options, resolved, {
|
|
598
750
|
makeClient: (system, tools, kickoff) => httpClient(resolved, key, system, tools, kickoff),
|
|
751
|
+
...(judge ? { judge: httpJudgeAsk(judge, key), judgeEffort: judge.effort } : {}),
|
|
599
752
|
log: say,
|
|
600
753
|
secrets,
|
|
601
754
|
version: packageVersion(),
|
|
@@ -653,6 +806,21 @@ async function login(args) {
|
|
|
653
806
|
}
|
|
654
807
|
process.exit(0);
|
|
655
808
|
}
|
|
809
|
+
/** `scenescout export`: exits as EXIT_EXPORT says. */
|
|
810
|
+
async function exportFindings(args) {
|
|
811
|
+
const parsed = parseExportArgs(args, process.cwd(), process.env);
|
|
812
|
+
if (!parsed.ok) {
|
|
813
|
+
// A refusal can quote the value it refused, and a credential pasted into an option is still a credential.
|
|
814
|
+
console.error(redactKeys(`scenescout export: ${parsed.error}`, credentialSecrets(process.env)));
|
|
815
|
+
process.exit(EXIT_EXPORT.couldNotExport);
|
|
816
|
+
}
|
|
817
|
+
const outcome = await runExport(parsed.options, {
|
|
818
|
+
env: process.env,
|
|
819
|
+
log: (line) => console.log(line),
|
|
820
|
+
error: (line) => console.error(line),
|
|
821
|
+
});
|
|
822
|
+
process.exit(outcome.exitCode);
|
|
823
|
+
}
|
|
656
824
|
const [, , command, ...args] = process.argv;
|
|
657
825
|
// A CLI's failure mode should be a sentence, not a stack trace. `scan` on a
|
|
658
826
|
// path that does not exist and `status` on a half-written status.json both
|
|
@@ -682,12 +850,18 @@ try {
|
|
|
682
850
|
check,
|
|
683
851
|
ci,
|
|
684
852
|
login,
|
|
853
|
+
export: exportFindings,
|
|
685
854
|
status: (a) => status(path.resolve(a[0] ?? process.cwd())),
|
|
686
855
|
watch: (a) => {
|
|
687
856
|
const positional = a.filter((x) => !x.startsWith("--"));
|
|
688
857
|
watch(path.resolve(positional[0] ?? process.cwd()), !a.includes("--no-open"));
|
|
689
858
|
},
|
|
690
859
|
},
|
|
860
|
+
// Anything unforeseen is still "could not run" (2), not the exit 1 the other commands share below.
|
|
861
|
+
firstRun: (a) => firstRun(a).catch((err) => {
|
|
862
|
+
console.error(`scenescout: could not run: ${err instanceof Error ? err.message : String(err)}`);
|
|
863
|
+
process.exit(EXIT_FIRST_RUN.couldNotRun);
|
|
864
|
+
}),
|
|
691
865
|
});
|
|
692
866
|
}
|
|
693
867
|
catch (err) {
|
package/dist/commands.js
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
* and ignored the rest. Every subcommand now answers `--help` / `-h` with the
|
|
7
7
|
* usage text and exit 0 before it does anything, and a command that parses its
|
|
8
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.
|
|
9
12
|
*/
|
|
10
13
|
const HANDLERS_OF = {
|
|
11
14
|
scan: true,
|
|
@@ -15,13 +18,14 @@ const HANDLERS_OF = {
|
|
|
15
18
|
check: true,
|
|
16
19
|
ci: true,
|
|
17
20
|
login: true,
|
|
21
|
+
export: true,
|
|
18
22
|
status: true,
|
|
19
23
|
watch: true,
|
|
20
24
|
};
|
|
21
25
|
export const SUBCOMMANDS = Object.keys(HANDLERS_OF);
|
|
22
26
|
/**
|
|
23
|
-
* Commands that read their arguments by hand. `check`, `ci
|
|
24
|
-
* absent: their own parsers refuse unknown options. `serve` is absent on
|
|
27
|
+
* Commands that read their arguments by hand. `check`, `ci`, `login` and
|
|
28
|
+
* `export` are absent: their own parsers refuse unknown options. `serve` is absent on
|
|
25
29
|
* purpose: it is the line an MCP client launches, and a stray argument there
|
|
26
30
|
* should not stop the server from starting.
|
|
27
31
|
*/
|
|
@@ -70,6 +74,34 @@ export function unknownArgument(args, spec) {
|
|
|
70
74
|
export function isSubcommand(command) {
|
|
71
75
|
return command !== undefined && Object.hasOwn(HANDLERS_OF, command);
|
|
72
76
|
}
|
|
77
|
+
/** A host with no scheme: a name or address, an optional port, then optionally a path, query or fragment. */
|
|
78
|
+
const BARE_HOST = /^(?:\[[0-9a-f:.]+\]|[a-z0-9-]+(?:\.[a-z0-9-]+)*)(?::\d{1,5})?(?:[/?#].*)?$/i;
|
|
79
|
+
/** Whether an argument starts with a scheme, such as `http://`. */
|
|
80
|
+
export function hasScheme(arg) {
|
|
81
|
+
return /^[a-z][a-z0-9+.-]*:\/\//i.test(arg);
|
|
82
|
+
}
|
|
83
|
+
/** The host part of an address written without its scheme: everything before its path, query or fragment. */
|
|
84
|
+
export function hostOf(arg) {
|
|
85
|
+
return arg.split(/[/?#]/)[0];
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Whether the first argument is an address, which makes the command line a
|
|
89
|
+
* first run (`scenescout <url>`): it has a scheme (`http://…`, and `ftp://…`,
|
|
90
|
+
* which the first run then refuses with a reason), or it reads as a host
|
|
91
|
+
* without one (`localhost:3000`, `example.com/app`), which the first run asks
|
|
92
|
+
* to be written in full. Subcommands are settled first, and a word that is
|
|
93
|
+
* neither (`instal`) still gets the usage.
|
|
94
|
+
*/
|
|
95
|
+
export function looksLikeUrl(arg) {
|
|
96
|
+
if (!arg || arg.startsWith("-"))
|
|
97
|
+
return false;
|
|
98
|
+
if (hasScheme(arg))
|
|
99
|
+
return true;
|
|
100
|
+
if (!BARE_HOST.test(arg))
|
|
101
|
+
return false;
|
|
102
|
+
const host = hostOf(arg);
|
|
103
|
+
return host.includes(".") || host.includes(":") || host.toLowerCase() === "localhost";
|
|
104
|
+
}
|
|
73
105
|
/** Settle help and flag errors for a subcommand before its handler is reached. */
|
|
74
106
|
export function preflight(command, args) {
|
|
75
107
|
if (wantsHelp(args))
|
|
@@ -93,8 +125,14 @@ export async function dispatch(command, args, h) {
|
|
|
93
125
|
case "-v":
|
|
94
126
|
return h.version();
|
|
95
127
|
}
|
|
96
|
-
if (!isSubcommand(command))
|
|
97
|
-
|
|
128
|
+
if (!isSubcommand(command)) {
|
|
129
|
+
if (!looksLikeUrl(command))
|
|
130
|
+
return h.usage(1);
|
|
131
|
+
// Its own parser refuses an option it does not know, with the first run's exit code, as check's does.
|
|
132
|
+
if (wantsHelp(args))
|
|
133
|
+
return h.usage(0);
|
|
134
|
+
return h.firstRun([command, ...args]);
|
|
135
|
+
}
|
|
98
136
|
const verdict = preflight(command, args);
|
|
99
137
|
if (verdict.kind === "help")
|
|
100
138
|
return h.usage(0);
|