scenescout 3.15.0 → 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.
Files changed (56) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +64 -15
  3. package/dist/browsers.js +28 -0
  4. package/dist/check-run.js +190 -14
  5. package/dist/ci-run.js +268 -52
  6. package/dist/cli.js +104 -43
  7. package/dist/commands.js +3 -2
  8. package/dist/engine/baseline.js +377 -0
  9. package/dist/engine/brief.js +16 -7
  10. package/dist/engine/browser.js +1147 -286
  11. package/dist/engine/calibration.js +61 -30
  12. package/dist/engine/capture.js +164 -0
  13. package/dist/engine/check.js +182 -39
  14. package/dist/engine/ci-lanes.js +215 -0
  15. package/dist/engine/ci.js +136 -18
  16. package/dist/engine/claims.js +159 -3
  17. package/dist/engine/collector.js +561 -30
  18. package/dist/engine/crawl.js +49 -0
  19. package/dist/engine/design.js +281 -38
  20. package/dist/engine/export.js +877 -0
  21. package/dist/engine/fingerprint.js +92 -4
  22. package/dist/engine/flow.js +18 -6
  23. package/dist/engine/forms.js +181 -18
  24. package/dist/engine/journey.js +29 -1
  25. package/dist/engine/lane.js +13 -3
  26. package/dist/engine/launch.js +45 -6
  27. package/dist/engine/limits.js +7 -0
  28. package/dist/engine/live-page.js +49 -2
  29. package/dist/engine/live.js +4 -1
  30. package/dist/engine/memory.js +501 -47
  31. package/dist/engine/open.js +118 -0
  32. package/dist/engine/oracles.js +41 -1
  33. package/dist/engine/plain.js +268 -0
  34. package/dist/engine/png.js +127 -0
  35. package/dist/engine/policy.js +379 -9
  36. package/dist/engine/probes.js +3 -2
  37. package/dist/engine/profiles.js +45 -9
  38. package/dist/engine/project-folder.js +191 -0
  39. package/dist/engine/refresh.js +68 -3
  40. package/dist/engine/replay.js +63 -10
  41. package/dist/engine/report.js +241 -40
  42. package/dist/engine/request.js +317 -23
  43. package/dist/engine/sarif.js +120 -0
  44. package/dist/engine/settle.js +67 -0
  45. package/dist/engine/signed-in.js +256 -0
  46. package/dist/engine/tickets.js +671 -0
  47. package/dist/engine/unload.js +3 -2
  48. package/dist/export-run.js +633 -0
  49. package/dist/first-run.js +4 -0
  50. package/dist/installer.js +267 -3
  51. package/dist/intake.js +104 -0
  52. package/dist/login-run.js +250 -36
  53. package/dist/mcp-server.js +567 -57
  54. package/dist/playbook.js +5 -0
  55. package/package.json +6 -3
  56. package/skills/scenescout/SKILL.md +49 -16
package/dist/cli.js CHANGED
@@ -10,25 +10,30 @@
10
10
  * scenescout check <url> Visit every route, measure it, and pass or fail (no model involved)
11
11
  * scenescout ci <url> An exploratory run driven by a model's API, unattended, that reports
12
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
13
14
  */
14
15
  import { spawnSync } from "node:child_process";
15
16
  import fs from "node:fs";
16
- import { createRequire } from "node:module";
17
17
  import os from "node:os";
18
18
  import path from "node:path";
19
19
  import { fileURLToPath } from "node:url";
20
- import { APPROX_DISK_MB, BROWSER_ENGINES, browserPresence, defaultAttachNote, defaultEngine, launchTarget, parseBrowserSelection, playwrightInstallArgs, } from "./browsers.js";
20
+ import { APPROX_DISK_MB, defaultAttachNote, defaultEngine, launchTarget, parseBrowserSelection } from "./browsers.js";
21
21
  import { CLIENT_LABELS, firstMessageHint, manualFor, parseClients, registerWithClient, vscodeBinary } from "./clients.js";
22
- import { CLI_NAME, diagnose, ensureCommand, findOnUserPath, installSkill, isEphemeralRoot, launchCommand, manualRegisterCommand, planCommand, registerMcp, resolveClaudeDir, spawnRunner, } from "./installer.js";
23
- import { defaultCheckDir, readCheckInputs, runCheck } from "./check-run.js";
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";
24
25
  import { httpClient, httpJudgeAsk, runCi } from "./ci-run.js";
26
+ import { runExport } from "./export-run.js";
25
27
  import { runLogin, runScriptedLogin, savedLine } from "./login-run.js";
26
28
  import { credentialRedactor, LOGIN_ENV, readScriptedLogin } from "./engine/scripted-login.js";
27
29
  import { parseLoginArgs } from "./engine/profiles.js";
28
30
  import { detectProvider, EXIT_CI, judgeEffort, KEY_ENV, parseCiArgs, redactKeys, secretValues } from "./engine/ci.js";
31
+ import { VISUAL_DIRNAME } from "./engine/baseline.js";
29
32
  import { EXIT, exitCodeOf, formatCheck, parseCheckArgs, refusedFlowReason, toSarif, toSummaryJson, unmeasuredReason, } from "./engine/check.js";
30
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";
31
35
  import { LEGACY_MEMORY_DIRNAME, MEMORY_DIRNAME, writeSelfIgnore } from "./engine/memory.js";
36
+ import { sarifFilesFor } from "./engine/sarif.js";
32
37
  import { formatStatus, liveEngines, liveTokenFileName, localClock, LIVE_TOKEN_FILE, pidAlive, watchTarget, wholeSessions, } from "./engine/live.js";
33
38
  import { formatScan, scanProject } from "./scan.js";
34
39
  import { dispatch } from "./commands.js";
@@ -87,7 +92,19 @@ Usage:
87
92
  --on-refused-step report|stop: report (default) marks a flow whose step was
88
93
  refused "could not run", keeps every other verdict and exits 2; stop exits 2 there;
89
94
  --gate-retests never|high|all: which still-reproducing findings fail the gate
90
- (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))
91
108
  Exit code: 0 passed, 1 failed the gate, 2 could not run.
92
109
  scenescout ci <url> An exploratory run with no person present: a model reached through its API
93
110
  drives the tools by the SceneScout method and the run ends in the report.
@@ -100,6 +117,8 @@ Usage:
100
117
  --max-turns N (default 40); --max-tokens N (default 1500000);
101
118
  --max-minutes N (default 20): the run stops at the first cap reached and still
102
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;
103
122
  --price-in, --price-cached-in, --price-out: US dollars per million tokens,
104
123
  over the built-in prices, for the cost estimate of any model;
105
124
  --mode observe|read-only|safe-write|destructive (default read-only;
@@ -114,15 +133,22 @@ Usage:
114
133
  --dedup judge|rule: judge (default) also asks the run's model, at its lowest
115
134
  effort, whether a filed finding the rule keeps apart is one already on its
116
135
  page (titles, categories, evidence and the page's path are sent);
117
- rule asks nothing)
136
+ rule asks nothing;
137
+ --sarif-file-anchor path: the repository file each SARIF result points at,
138
+ as for check)
118
139
  Exit code: 0 the run ran (findings never change it), 2 could not run.
119
140
  scenescout login <url> --role <name>
120
- Open a visible browser at the URL, sign in there (SSO, MFA, anything), then
121
- press Enter in this terminal to save the session as that role's profile, in
122
- .scenescout/auth/<name>.json (owner-only; never printed, never committed).
123
- Closing the window or Ctrl+C saves nothing. Agents then attach with
124
- scout_attach { role: "<name>" }, as many sessions as they like from one login.
125
- (--project dir (default: here); --browser chromium|firefox|webkit)
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)
126
152
  scenescout login <url> --role <name> --script
127
153
  For CI: sign in headless from SCENESCOUT_LOGIN_USERNAME, SCENESCOUT_LOGIN_PASSWORD
128
154
  and, if the form asks for a code, SCENESCOUT_LOGIN_TOTP_SECRET (base32 or an
@@ -134,6 +160,30 @@ Usage:
134
160
  --password-selector, --otp-selector, --submit-selector css; each of these also
135
161
  from SCENESCOUT_LOGIN_<FLAG>, e.g. SCENESCOUT_LOGIN_SUCCESS_URL;
136
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.
137
187
  scenescout status [projectPath] What is the engine doing right now? (every session + recent actions)
138
188
  scenescout watch [projectPath] Open the live view in a browser: what each session is doing, a thumbnail
139
189
  of its page, and a live stream you can switch on per session
@@ -281,26 +331,6 @@ function browserOpener(url) {
281
331
  return { command: "xdg-open", args: [url] };
282
332
  }
283
333
  }
284
- /** Which browser builds are on disk, going by the paths Playwright reports for the version we depend on. */
285
- async function presentBrowsers() {
286
- const executables = { chromium: null, firefox: null, webkit: null };
287
- try {
288
- const playwright = await import("playwright");
289
- for (const name of BROWSER_ENGINES)
290
- executables[name] = playwright[name].executablePath() || null;
291
- }
292
- catch {
293
- // Playwright cannot be loaded: every build reads as absent, which is what doctor should say.
294
- }
295
- return browserPresence(executables);
296
- }
297
- /** Download browser builds through the playwright CLI that ships with our own dependency. */
298
- function downloadBrowsers(targets) {
299
- const require = createRequire(import.meta.url);
300
- const cli = path.join(path.dirname(require.resolve("playwright/package.json")), "cli.js");
301
- const r = spawnSync(process.execPath, [cli, ...playwrightInstallArgs(targets)], { stdio: "inherit" });
302
- return r.status === 0;
303
- }
304
334
  /**
305
335
  * The value of a flag written as `--name x` or `--name=x`. Undefined when the
306
336
  * flag is absent; empty when it was given no value, which includes being
@@ -392,7 +422,7 @@ async function install(flags) {
392
422
  if (missing.length > 0) {
393
423
  const size = missing.reduce((sum, t) => sum + APPROX_DISK_MB[t], 0);
394
424
  console.log(`· Downloading ${missing.join(", ")} (one-time, about ${size} MB on disk)…`);
395
- if (downloadBrowsers(missing))
425
+ if ((await downloadBrowsers(missing, "inherit")).ok)
396
426
  console.log(`✓ Downloaded: ${missing.join(", ")}.`);
397
427
  else {
398
428
  failed = true;
@@ -496,12 +526,11 @@ async function install(flags) {
496
526
  process.exitCode = 1;
497
527
  return;
498
528
  }
499
- if (browserOnly) {
500
- console.log("\nThe browser is ready — attach again.");
529
+ const closing = installClosing({ browserOnly, forClaude });
530
+ if (closing)
531
+ console.log(`\n${closing}`);
532
+ if (browserOnly)
501
533
  return;
502
- }
503
- if (forClaude)
504
- console.log("\nStart a FRESH Claude Code session, then in any project run: /scenescout");
505
534
  // Telling someone to restart a client nothing was registered with sends them looking for a server that is not there.
506
535
  if (others.length > 0 && !flags.includes("--no-register"))
507
536
  console.log(`\n${firstMessageHint(others)}`);
@@ -513,12 +542,15 @@ async function doctor(flags) {
513
542
  packageRoot,
514
543
  claudeDir: resolveClaudeDir(process.env, os.homedir()),
515
544
  nodeVersion: process.version,
545
+ version: packageVersion(),
516
546
  // What a default attach launches: the headless build of the default browser.
517
547
  defaultBrowser: await (async () => {
518
548
  const target = launchTarget(defaultEngine(process.env), false);
519
549
  const found = (await presentBrowsers())[target];
520
550
  return { target, path: found.installed ? found.path : null, expected: found.path };
521
551
  })(),
552
+ desktopExtension: findDesktopExtension(desktopExtensionRoots({ platform: process.platform, home: os.homedir(), env: process.env })),
553
+ headlessShellDir: (await presentBrowsers())["chromium-headless-shell"].path,
522
554
  run: spawnRunner,
523
555
  });
524
556
  for (const c of checks) {
@@ -528,9 +560,7 @@ async function doctor(flags) {
528
560
  }
529
561
  if (checks.some((c) => !c.ok))
530
562
  process.exit(1);
531
- console.log(flags.includes("--engine")
532
- ? "\nAll good. Ask your agent: Use SceneScout to test http://localhost:3000"
533
- : "\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) })}`);
534
564
  }
535
565
  /** `scenescout check`: exit 0 passed, 1 failed the gate, 2 could not run. */
536
566
  async function check(args) {
@@ -576,7 +606,16 @@ async function check(args) {
576
606
  writeSelfIgnore(path.dirname(outDir));
577
607
  fs.mkdirSync(outDir, { recursive: true });
578
608
  fs.writeFileSync(path.join(outDir, "report.md"), markdown);
579
- fs.writeFileSync(path.join(outDir, "check.sarif"), JSON.stringify(toSarif(result, version), null, 2) + "\n");
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");
580
619
  fs.writeFileSync(path.join(outDir, "check.json"), JSON.stringify(toSummaryJson(result, version), null, 2) + "\n");
581
620
  // On GitHub Actions the verdict also goes on the run's summary page.
582
621
  if (process.env.GITHUB_STEP_SUMMARY)
@@ -589,6 +628,12 @@ async function check(args) {
589
628
  }
590
629
  console.log("\n" + markdown);
591
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)}`);
592
637
  // --on-refused-step report: everything else has its verdict in the files, and the exit code still says the run was incomplete.
593
638
  if (refused)
594
639
  console.error(`scenescout check: could not run a saved flow: ${refused}`);
@@ -616,7 +661,7 @@ async function firstRun(args) {
616
661
  if (downloads.length > 0) {
617
662
  console.log(downloadLine(downloads));
618
663
  const began = Date.now();
619
- if (!downloadBrowsers(downloads)) {
664
+ if (!(await downloadBrowsers(downloads, "inherit")).ok) {
620
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(" ")}`);
621
666
  }
622
667
  // The installer's exit code is not the build: look again before saying it is there.
@@ -761,6 +806,21 @@ async function login(args) {
761
806
  }
762
807
  process.exit(0);
763
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
+ }
764
824
  const [, , command, ...args] = process.argv;
765
825
  // A CLI's failure mode should be a sentence, not a stack trace. `scan` on a
766
826
  // path that does not exist and `status` on a half-written status.json both
@@ -790,6 +850,7 @@ try {
790
850
  check,
791
851
  ci,
792
852
  login,
853
+ export: exportFindings,
793
854
  status: (a) => status(path.resolve(a[0] ?? process.cwd())),
794
855
  watch: (a) => {
795
856
  const positional = a.filter((x) => !x.startsWith("--"));
package/dist/commands.js CHANGED
@@ -18,13 +18,14 @@ const HANDLERS_OF = {
18
18
  check: true,
19
19
  ci: true,
20
20
  login: true,
21
+ export: true,
21
22
  status: true,
22
23
  watch: true,
23
24
  };
24
25
  export const SUBCOMMANDS = Object.keys(HANDLERS_OF);
25
26
  /**
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
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
28
29
  * purpose: it is the line an MCP client launches, and a stray argument there
29
30
  * should not stop the server from starting.
30
31
  */