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.
Files changed (59) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +90 -18
  3. package/dist/browsers.js +28 -0
  4. package/dist/check-run.js +211 -24
  5. package/dist/ci-run.js +374 -62
  6. package/dist/cli.js +221 -47
  7. package/dist/commands.js +42 -4
  8. package/dist/engine/baseline.js +377 -0
  9. package/dist/engine/brief.js +16 -7
  10. package/dist/engine/browser.js +1660 -386
  11. package/dist/engine/calibration.js +61 -30
  12. package/dist/engine/capture.js +164 -0
  13. package/dist/engine/check.js +258 -68
  14. package/dist/engine/ci-lanes.js +215 -0
  15. package/dist/engine/ci.js +233 -18
  16. package/dist/engine/claims.js +234 -12
  17. package/dist/engine/collector.js +851 -78
  18. package/dist/engine/crawl.js +49 -0
  19. package/dist/engine/dedup.js +329 -9
  20. package/dist/engine/design.js +355 -43
  21. package/dist/engine/export.js +877 -0
  22. package/dist/engine/fingerprint.js +92 -4
  23. package/dist/engine/flow.js +18 -6
  24. package/dist/engine/forms.js +198 -18
  25. package/dist/engine/journey.js +29 -1
  26. package/dist/engine/lane.js +13 -3
  27. package/dist/engine/launch.js +45 -6
  28. package/dist/engine/limits.js +7 -0
  29. package/dist/engine/live-page.js +49 -2
  30. package/dist/engine/live.js +4 -1
  31. package/dist/engine/memory.js +668 -70
  32. package/dist/engine/open.js +118 -0
  33. package/dist/engine/oracles.js +167 -2
  34. package/dist/engine/plain.js +268 -0
  35. package/dist/engine/png.js +127 -0
  36. package/dist/engine/policy.js +379 -9
  37. package/dist/engine/probes.js +3 -2
  38. package/dist/engine/profiles.js +45 -9
  39. package/dist/engine/project-folder.js +191 -0
  40. package/dist/engine/provider.js +4 -3
  41. package/dist/engine/refresh.js +338 -25
  42. package/dist/engine/replay.js +63 -10
  43. package/dist/engine/report.js +248 -40
  44. package/dist/engine/request.js +317 -23
  45. package/dist/engine/sarif.js +120 -0
  46. package/dist/engine/scripted-login.js +342 -62
  47. package/dist/engine/settle.js +67 -0
  48. package/dist/engine/signed-in.js +256 -0
  49. package/dist/engine/tickets.js +671 -0
  50. package/dist/engine/unload.js +3 -2
  51. package/dist/export-run.js +633 -0
  52. package/dist/first-run.js +581 -0
  53. package/dist/installer.js +267 -3
  54. package/dist/intake.js +104 -0
  55. package/dist/login-run.js +386 -71
  56. package/dist/mcp-server.js +637 -68
  57. package/dist/playbook.js +5 -0
  58. package/package.json +6 -3
  59. package/skills/scenescout/SKILL.md +51 -18
package/dist/check-run.js CHANGED
@@ -1,36 +1,65 @@
1
1
  /**
2
2
  * Runs `scenescout check`: attach, crawl every route the engine can find,
3
- * measure each one, replay the saved flows, re-test the open findings a page
4
- * load can reproduce, and write the verdict. The rules live in
5
- * engine/check.ts, engine/flow.ts and engine/verify.ts; this file only drives
6
- * the browser and the files.
3
+ * measure each one, picture the targets of any visual baselines, replay the
4
+ * saved flows, re-test the open findings a page load can reproduce, and write
5
+ * the verdict. The rules live in engine/check.ts, engine/flow.ts,
6
+ * engine/verify.ts and engine/baseline.ts; this file only drives the browser
7
+ * and the files.
7
8
  */
8
9
  import fs from "node:fs";
9
10
  import os from "node:os";
10
11
  import path from "node:path";
12
+ import { BASELINE_CAPTURE, baselineFiles, baselineMeta, defaultBaselinesDir, elementTarget, isVisualPicture, judgeBaseline, missingTargetsMessage, parseBaselineTargets, readStoredBaseline, TARGETS_FILE, VISUAL_DIRNAME, visualFiles, } from "./engine/baseline.js";
11
13
  import { BrowserEngine } from "./engine/browser.js";
12
- import { checkFindings, redactFlowRuns, redactRoute, redactRoutes, settingsOf, withoutOwnResponse, } from "./engine/check.js";
14
+ import { checkFindings, redactBaselineRun, MAX_DISCOVERY_ROUNDS, redactFlowRuns, redactRoute, redactRoutes, settingsOf, unmeasuredReason, withoutOwnResponse, } from "./engine/check.js";
13
15
  import { loadFlows, resolveFlowsDir } from "./engine/flow.js";
14
- import { MemoryStore, MEMORY_DIRNAME } from "./engine/memory.js";
16
+ import { MemoryStore, MEMORY_DIRNAME, writeSelfIgnore } from "./engine/memory.js";
17
+ import { decodePng, encodePng } from "./engine/png.js";
18
+ import { firstLineOf } from "./engine/limits.js";
15
19
  import { checkRetestPlan, retestResults, wellFormedFindings } from "./engine/verify.js";
16
- /** Link discovery rounds: each crawl reveals the routes its pages link to. Past a few, a site is paginating rather than revealing. */
17
- const MAX_ROUNDS = 6;
20
+ /** The baselines folder a check uses: the one --baselines names, else the project's own. */
21
+ export function baselinesDirOf(options) {
22
+ return options.baselinesDir ?? defaultBaselinesDir(options.projectDir);
23
+ }
24
+ /** Read and validate targets.json. A missing or invalid one stops the check before it starts: baselines that silently compare nothing pass by never running. */
25
+ function readBaselineTargets(options) {
26
+ const file = path.join(baselinesDirOf(options), TARGETS_FILE);
27
+ let text;
28
+ try {
29
+ text = fs.readFileSync(file, "utf8");
30
+ }
31
+ catch (err) {
32
+ if (err.code === "ENOENT")
33
+ throw new Error(`--baseline ${options.baseline}: ${missingTargetsMessage(file)}`);
34
+ throw new Error(`could not read ${file}: ${err instanceof Error ? err.message : String(err)}`);
35
+ }
36
+ const parsed = parseBaselineTargets(text, file);
37
+ if (!parsed.ok)
38
+ throw new Error(`baseline targets: ${parsed.error}`);
39
+ return parsed.targets;
40
+ }
18
41
  /**
19
- * Read the flows and the findings, before any browser starts. Throws with a
20
- * sentence naming the file and the field on anything it cannot read: a flow
21
- * that is silently skipped is a flow that silently passes.
42
+ * Read the flows, the findings and the baseline targets, before any browser
43
+ * starts. Throws with a sentence naming the file and the field on anything it
44
+ * cannot read: a flow that is silently skipped is a flow that silently passes.
22
45
  */
23
46
  export function readCheckInputs(options) {
24
47
  const where = resolveFlowsDir(options.flows, options.projectDir, (p) => fs.existsSync(p) && fs.statSync(p).isDirectory());
25
48
  if ("error" in where)
26
49
  throw new Error(where.error);
27
- const { flows, skipped: skippedFlows } = where.dir ? loadFlows(where.dir) : { flows: [], skipped: [] };
50
+ const loaded = where.dir ? loadFlows(where.dir) : { flows: [], skipped: [] };
51
+ const read = {
52
+ flows: loaded.flows,
53
+ skippedFlows: loaded.skipped,
54
+ flowsDir: where.dir,
55
+ ...(options.baseline !== "off" ? { baselineTargets: readBaselineTargets(options) } : {}),
56
+ };
28
57
  if (!options.retest)
29
- return { flows, skippedFlows, findings: null };
58
+ return { ...read, findings: null };
30
59
  // Read, never written: a check leaves the project's memory as it found it.
31
60
  const memoryPath = path.join(options.projectDir, MEMORY_DIRNAME, "memory.json");
32
61
  if (!fs.existsSync(memoryPath))
33
- return { flows, skippedFlows, findings: null };
62
+ return { ...read, findings: null };
34
63
  let findings;
35
64
  try {
36
65
  findings = JSON.parse(fs.readFileSync(memoryPath, "utf8")).findings;
@@ -40,14 +69,22 @@ export function readCheckInputs(options) {
40
69
  }
41
70
  if (findings !== undefined && !Array.isArray(findings))
42
71
  throw new Error(`${memoryPath}: "findings" is not a list. Pass --retest off to check without it`);
43
- return { flows, skippedFlows, findings: wellFormedFindings(findings ?? []) };
72
+ return { ...read, findings: wellFormedFindings(findings ?? []) };
44
73
  }
45
74
  export async function runCheck(options, log = () => { }, inputs = { flows: [], findings: null }) {
75
+ if (options.paths && options.timeBudgetMs !== undefined)
76
+ throw new Error("a time budget applies to route discovery, not to a list of paths");
46
77
  // A throwaway memory: a check is one run, and a store shared with earlier
47
78
  // exploratory runs would count their visits as this check's and skip those routes.
48
79
  const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "scenescout-check-"));
80
+ // Also on an exit the finally below never reaches, such as Ctrl+C, which the browser's driver answers with process.exit.
81
+ const removeScratch = () => fs.rmSync(scratch, { recursive: true, force: true });
82
+ process.once("exit", removeScratch);
49
83
  const engine = new BrowserEngine();
50
84
  const start = new URL(options.url);
85
+ // From the start of the run, browser launch included: the budget is wall-clock time a person waits.
86
+ const deadline = options.timeBudgetMs !== undefined ? Date.now() + options.timeBudgetMs : undefined;
87
+ const pastDeadline = () => deadline !== undefined && Date.now() >= deadline;
51
88
  try {
52
89
  // Attached at the origin: the engine joins every crawled path onto the URL
53
90
  // it attached to, so attaching to a start page with a path would double it.
@@ -60,6 +97,8 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
60
97
  ...(options.browser ? { browser: options.browser } : {}),
61
98
  actionTimeoutMs: options.actionTimeoutMs,
62
99
  navTimeoutMs: options.navTimeoutMs,
100
+ // Named rather than left to attach's defaults: a baseline is only comparable with a picture taken in the same window, at the same scale.
101
+ ...(options.baseline !== "off" ? { viewport: BASELINE_CAPTURE.viewport, deviceScaleFactor: BASELINE_CAPTURE.deviceScaleFactor } : {}),
63
102
  objective: "Deterministic check: visit every route and measure it",
64
103
  task: "Checking every route",
65
104
  });
@@ -68,23 +107,26 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
68
107
  if (authFailed)
69
108
  throw new Error(authFailed.replace(/ Continuing now tests a logged-out app\.$/, "").replace(/re-attach/, "run the check again"));
70
109
  const routes = [];
71
- const crawl = async (paths, limit) => {
72
- await engine.crawl(paths, { inspect: true, limit });
110
+ const crawl = async (paths, limit, deadline) => {
111
+ await engine.crawl(paths, { inspect: true, limit, deadline });
73
112
  routes.push(...engine.lastCrawlHealth);
74
113
  };
75
114
  if (options.paths) {
76
115
  await crawl(options.paths.slice(0, options.maxRoutes), options.maxRoutes);
77
116
  }
78
117
  else {
79
- // The start page first, whatever else is known: it is the one route the user named.
118
+ // The start page first, whatever else is known: it is the one route the user named. The
119
+ // time budget does not apply to it, so a run always measures at least the page it was given.
80
120
  await crawl([`${start.pathname}${start.search}${start.hash}`], 1);
81
- for (let round = 0; round < MAX_ROUNDS && routes.length < options.maxRoutes; round++) {
82
- if (engine.crawlableRoutes().length === 0)
121
+ for (let round = 0; round < MAX_DISCOVERY_ROUNDS && routes.length < options.maxRoutes; round++) {
122
+ if (engine.crawlableRoutes().length === 0 || pastDeadline())
83
123
  break;
84
- await crawl(undefined, options.maxRoutes - routes.length);
124
+ await crawl(undefined, options.maxRoutes - routes.length, deadline);
85
125
  log(` ${routes.length} route(s) checked`);
86
126
  }
87
127
  }
128
+ // Out of time with routes still to visit; a cap of routes reached first is --max-routes's to report.
129
+ const timeLimitReached = pastDeadline() && routes.length < options.maxRoutes && engine.crawlableRoutes().length > 0;
88
130
  // Pages of open findings the crawl did not load exactly: loaded now, so each re-test has its own measurement.
89
131
  // Kept apart from `routes`: they are measured only to re-test, never checked against the page rules, and do not count
90
132
  // towards --max-routes. With --paths the check stays on the paths it was given.
@@ -98,6 +140,13 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
98
140
  log(` ${missing.length} page(s) of open findings loaded only to re-test them`);
99
141
  }
100
142
  }
143
+ // Before the flows, so a picture is of the page as a visit finds it, not as a flow left it. Not when the crawl
144
+ // reached only sign-in pages: the check has no verdict then, and an update would write sign-in pages as baselines.
145
+ if (options.baseline !== "off" && !inputs.baselineTargets)
146
+ throw new Error(`--baseline ${options.baseline} was given no targets: read them with readCheckInputs`);
147
+ const baselines = options.baseline !== "off" && inputs.baselineTargets && unmeasuredReason(routes, !options.paths) === null
148
+ ? await takeBaselines(engine, options, options.baseline, inputs.baselineTargets, log)
149
+ : null;
101
150
  const flowRuns = [];
102
151
  for (const flow of inputs.flows) {
103
152
  // --flow-writes never: observe's rule, whatever --mode lets the crawl do.
@@ -125,7 +174,8 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
125
174
  : null;
126
175
  const measured = redactRoutes(routes.map(withoutOwnResponse));
127
176
  const flows = redactFlowRuns(flowRuns);
128
- const { issues, worthALook } = checkFindings(measured, start.origin, options.ignore, flows);
177
+ const pictured = baselines ? redactBaselineRun(baselines) : null;
178
+ const { issues, worthALook } = checkFindings(measured, start.origin, options.ignore, flows, pictured);
129
179
  return {
130
180
  url: redactRoute(options.url),
131
181
  generatedAt: new Date().toISOString(),
@@ -134,21 +184,158 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
134
184
  routes: measured,
135
185
  issues,
136
186
  worthALook,
137
- // Routes that failed to load are issues already; "not visited" is only what --max-routes left out.
187
+ // Routes that failed to load are issues already; "not visited" is only what --max-routes (or the time budget) left out.
138
188
  unvisited: options.paths ? [] : engine.crawlableRoutes().map(redactRoute),
189
+ ...(options.timeBudgetMs !== undefined ? { timeBudget: { ms: options.timeBudgetMs, reached: timeLimitReached } } : {}),
139
190
  ignored: options.ignore,
140
191
  flows,
141
192
  skippedFlows: inputs.skippedFlows ?? [],
142
193
  retest,
143
194
  settings: settingsOf(options),
195
+ baselines: pictured,
144
196
  };
145
197
  }
146
198
  finally {
147
199
  await engine.close().catch((err) => log(`closing the browser failed: ${err instanceof Error ? err.message : String(err)}`));
148
- fs.rmSync(scratch, { recursive: true, force: true });
200
+ process.off("exit", removeScratch);
201
+ removeScratch();
149
202
  }
150
203
  }
151
204
  /** Where a check writes when not told: beside the project's other SceneScout output, which is already ignored by git. */
152
205
  export function defaultCheckDir(projectDir) {
153
206
  return path.join(projectDir, MEMORY_DIRNAME, "check");
154
207
  }
208
+ /** A file's bytes, or null when there is no such file. Any other failure to read it is thrown. */
209
+ function readIfThere(file) {
210
+ try {
211
+ return fs.readFileSync(file);
212
+ }
213
+ catch (err) {
214
+ if (err.code === "ENOENT")
215
+ return null;
216
+ throw err;
217
+ }
218
+ }
219
+ /**
220
+ * Remove the pictures an earlier run left under <out>/visual/, so none is
221
+ * kept or uploaded as this run's. Only files named as a check names them
222
+ * (isVisualPicture) are removed, then any folder that leaves empty: the
223
+ * output folder is one a project chose, and whatever else it holds stays.
224
+ */
225
+ function clearVisualPictures(outDir) {
226
+ const root = path.join(outDir, VISUAL_DIRNAME);
227
+ let folders;
228
+ try {
229
+ folders = fs.readdirSync(root, { withFileTypes: true });
230
+ }
231
+ catch (err) {
232
+ if (err.code === "ENOENT")
233
+ return;
234
+ throw err;
235
+ }
236
+ for (const folder of folders.filter((f) => f.isDirectory())) {
237
+ const dir = path.join(root, folder.name);
238
+ for (const file of fs.readdirSync(dir, { withFileTypes: true })) {
239
+ if (file.isFile() && isVisualPicture(folder.name, file.name))
240
+ fs.rmSync(path.join(dir, file.name));
241
+ }
242
+ if (fs.readdirSync(dir).length === 0)
243
+ fs.rmdirSync(dir);
244
+ }
245
+ if (fs.readdirSync(root).length === 0)
246
+ fs.rmdirSync(root);
247
+ }
248
+ /** The baselines folder as the report names it: relative to the project when inside it, with forward slashes. */
249
+ function shownDir(dir, projectDir) {
250
+ const rel = path.relative(projectDir, dir);
251
+ if (rel === "")
252
+ return ".";
253
+ return rel.startsWith("..") || path.isAbsolute(rel) ? dir : rel.split(path.sep).join("/");
254
+ }
255
+ /**
256
+ * --baseline compare|update: take each target's picture, judge it against its
257
+ * stored baseline (baseline.ts), and write what the judgement says to write: a
258
+ * new baseline under update, and under compare the baseline, the picture now
259
+ * and the changed pixels of a target that changed, beside the report. A page
260
+ * load per target, so no picture depends on the one taken before it.
261
+ */
262
+ async function takeBaselines(engine, options, mode, targets, log) {
263
+ const dir = baselinesDirOf(options);
264
+ const outDir = options.outDir ?? defaultCheckDir(options.projectDir);
265
+ const engineName = engine.browserEngine;
266
+ const at = (root, rel) => path.join(root, ...rel.split("/"));
267
+ // The default folders are inside .scenescout/, which ignores itself, and its .gitignore is written before anything goes
268
+ // there: a run that ends early must not leave pictures a `git add -A` would pick up. A folder the project named is its own.
269
+ if (!options.outDir || (!options.baselinesDir && mode === "update")) {
270
+ const scenescoutDir = path.join(options.projectDir, MEMORY_DIRNAME);
271
+ fs.mkdirSync(scenescoutDir, { recursive: true });
272
+ writeSelfIgnore(scenescoutDir);
273
+ }
274
+ clearVisualPictures(outDir);
275
+ const results = [];
276
+ try {
277
+ for (const target of targets) {
278
+ const files = baselineFiles(engineName, target);
279
+ const own = { path: target.path, element: target.element, baseline: files.png };
280
+ const shown = `${target.element} on ${redactRoute(target.path)}`;
281
+ const element = elementTarget(target.element);
282
+ // parseBaselineTargets refuses such an element; one reaching here must not be pictured as the whole page instead.
283
+ if (element === undefined)
284
+ throw new Error(`baseline target ${shown} names neither the page nor an element`);
285
+ let shot;
286
+ let image;
287
+ try {
288
+ shot = await engine.captureForBaseline(target.path, element, BASELINE_CAPTURE);
289
+ image = decodePng(shot.png);
290
+ }
291
+ catch (err) {
292
+ // A closed browser, or a mistake in this code, is the check's failure, never the app's: the check ends "could not run".
293
+ if (!engine.alive || err instanceof TypeError || err instanceof ReferenceError || err instanceof RangeError)
294
+ throw err;
295
+ const why = firstLineOf(err);
296
+ results.push({ ...own, status: "not-captured", detail: why });
297
+ log(` baseline ${shown}: not captured (${why})`);
298
+ continue;
299
+ }
300
+ const capture = { ...BASELINE_CAPTURE, viewport: shot.viewport, deviceScaleFactor: shot.deviceScaleFactor };
301
+ const storedPng = readIfThere(at(dir, files.png));
302
+ const storedJson = readIfThere(at(dir, files.json));
303
+ const stored = readStoredBaseline(target, engineName, storedPng, storedJson === null ? null : storedJson.toString("utf8"));
304
+ const { diffImage, ...verdict } = judgeBaseline({
305
+ mode,
306
+ threshold: options.baselineThreshold,
307
+ stored,
308
+ now: { capture, platform: process.platform, image },
309
+ });
310
+ const result = { ...own, ...verdict, ...(shot.cut ? { partial: shot.cut } : {}) };
311
+ if (verdict.status === "updated") {
312
+ fs.mkdirSync(path.dirname(at(dir, files.png)), { recursive: true });
313
+ fs.writeFileSync(at(dir, files.png), shot.png);
314
+ const meta = baselineMeta({
315
+ target,
316
+ engine: engineName,
317
+ platform: process.platform,
318
+ capture,
319
+ size: { width: image.width, height: image.height },
320
+ capturedAt: new Date().toISOString(),
321
+ });
322
+ fs.writeFileSync(at(dir, files.json), JSON.stringify(meta, null, 2) + "\n");
323
+ }
324
+ if (diffImage && storedPng) {
325
+ const pictures = visualFiles(target);
326
+ fs.mkdirSync(path.dirname(at(outDir, pictures.diff)), { recursive: true });
327
+ fs.writeFileSync(at(outDir, pictures.expected), storedPng);
328
+ fs.writeFileSync(at(outDir, pictures.actual), shot.png);
329
+ fs.writeFileSync(at(outDir, pictures.diff), encodePng(diffImage));
330
+ result.files = pictures;
331
+ }
332
+ results.push(result);
333
+ log(` baseline ${shown}: ${result.status}${result.diff ? ` (${result.diff.percent}% changed)` : ""}`);
334
+ }
335
+ }
336
+ finally {
337
+ // Reported, never thrown: an error from the loop above must not be replaced by this one.
338
+ await engine.endBaselineCaptures().catch((err) => log(` could not stop requesting reduced motion: ${firstLineOf(err)}`));
339
+ }
340
+ return { mode, engine: engineName, threshold: options.baselineThreshold, dir: shownDir(dir, options.projectDir), results };
341
+ }