scenescout 3.15.0 → 3.17.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 +87 -0
  2. package/README.md +70 -18
  3. package/dist/browsers.js +28 -0
  4. package/dist/check-run.js +191 -14
  5. package/dist/ci-run.js +268 -52
  6. package/dist/cli.js +107 -47
  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 +244 -42
  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/status-pane-page.js +441 -0
  47. package/dist/engine/status-pane.js +128 -0
  48. package/dist/engine/tickets.js +671 -0
  49. package/dist/engine/unload.js +3 -2
  50. package/dist/export-run.js +633 -0
  51. package/dist/first-run.js +5 -0
  52. package/dist/installer.js +378 -8
  53. package/dist/intake.js +104 -0
  54. package/dist/login-run.js +250 -36
  55. package/dist/mcp-server.js +660 -65
  56. package/dist/playbook.js +5 -0
  57. package/dist/prompts.js +106 -0
  58. package/package.json +8 -5
  59. package/skills/scenescout/SKILL.md +49 -16
package/dist/check-run.js CHANGED
@@ -1,34 +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, MAX_DISCOVERY_ROUNDS, 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";
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
+ }
16
41
  /**
17
- * Read the flows and the findings, before any browser starts. Throws with a
18
- * sentence naming the file and the field on anything it cannot read: a flow
19
- * 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.
20
45
  */
21
46
  export function readCheckInputs(options) {
22
47
  const where = resolveFlowsDir(options.flows, options.projectDir, (p) => fs.existsSync(p) && fs.statSync(p).isDirectory());
23
48
  if ("error" in where)
24
49
  throw new Error(where.error);
25
- 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
+ };
26
57
  if (!options.retest)
27
- return { flows, skippedFlows, findings: null };
58
+ return { ...read, findings: null };
28
59
  // Read, never written: a check leaves the project's memory as it found it.
29
60
  const memoryPath = path.join(options.projectDir, MEMORY_DIRNAME, "memory.json");
30
61
  if (!fs.existsSync(memoryPath))
31
- return { flows, skippedFlows, findings: null };
62
+ return { ...read, findings: null };
32
63
  let findings;
33
64
  try {
34
65
  findings = JSON.parse(fs.readFileSync(memoryPath, "utf8")).findings;
@@ -38,7 +69,7 @@ export function readCheckInputs(options) {
38
69
  }
39
70
  if (findings !== undefined && !Array.isArray(findings))
40
71
  throw new Error(`${memoryPath}: "findings" is not a list. Pass --retest off to check without it`);
41
- return { flows, skippedFlows, findings: wellFormedFindings(findings ?? []) };
72
+ return { ...read, findings: wellFormedFindings(findings ?? []) };
42
73
  }
43
74
  export async function runCheck(options, log = () => { }, inputs = { flows: [], findings: null }) {
44
75
  if (options.paths && options.timeBudgetMs !== undefined)
@@ -66,6 +97,8 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
66
97
  ...(options.browser ? { browser: options.browser } : {}),
67
98
  actionTimeoutMs: options.actionTimeoutMs,
68
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 } : {}),
69
102
  objective: "Deterministic check: visit every route and measure it",
70
103
  task: "Checking every route",
71
104
  });
@@ -107,6 +140,13 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
107
140
  log(` ${missing.length} page(s) of open findings loaded only to re-test them`);
108
141
  }
109
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;
110
150
  const flowRuns = [];
111
151
  for (const flow of inputs.flows) {
112
152
  // --flow-writes never: observe's rule, whatever --mode lets the crawl do.
@@ -134,7 +174,8 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
134
174
  : null;
135
175
  const measured = redactRoutes(routes.map(withoutOwnResponse));
136
176
  const flows = redactFlowRuns(flowRuns);
137
- 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, options.ignorePaths);
138
179
  return {
139
180
  url: redactRoute(options.url),
140
181
  generatedAt: new Date().toISOString(),
@@ -147,10 +188,12 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
147
188
  unvisited: options.paths ? [] : engine.crawlableRoutes().map(redactRoute),
148
189
  ...(options.timeBudgetMs !== undefined ? { timeBudget: { ms: options.timeBudgetMs, reached: timeLimitReached } } : {}),
149
190
  ignored: options.ignore,
191
+ ignoredPaths: options.ignorePaths,
150
192
  flows,
151
193
  skippedFlows: inputs.skippedFlows ?? [],
152
194
  retest,
153
195
  settings: settingsOf(options),
196
+ baselines: pictured,
154
197
  };
155
198
  }
156
199
  finally {
@@ -163,3 +206,137 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
163
206
  export function defaultCheckDir(projectDir) {
164
207
  return path.join(projectDir, MEMORY_DIRNAME, "check");
165
208
  }
209
+ /** A file's bytes, or null when there is no such file. Any other failure to read it is thrown. */
210
+ function readIfThere(file) {
211
+ try {
212
+ return fs.readFileSync(file);
213
+ }
214
+ catch (err) {
215
+ if (err.code === "ENOENT")
216
+ return null;
217
+ throw err;
218
+ }
219
+ }
220
+ /**
221
+ * Remove the pictures an earlier run left under <out>/visual/, so none is
222
+ * kept or uploaded as this run's. Only files named as a check names them
223
+ * (isVisualPicture) are removed, then any folder that leaves empty: the
224
+ * output folder is one a project chose, and whatever else it holds stays.
225
+ */
226
+ function clearVisualPictures(outDir) {
227
+ const root = path.join(outDir, VISUAL_DIRNAME);
228
+ let folders;
229
+ try {
230
+ folders = fs.readdirSync(root, { withFileTypes: true });
231
+ }
232
+ catch (err) {
233
+ if (err.code === "ENOENT")
234
+ return;
235
+ throw err;
236
+ }
237
+ for (const folder of folders.filter((f) => f.isDirectory())) {
238
+ const dir = path.join(root, folder.name);
239
+ for (const file of fs.readdirSync(dir, { withFileTypes: true })) {
240
+ if (file.isFile() && isVisualPicture(folder.name, file.name))
241
+ fs.rmSync(path.join(dir, file.name));
242
+ }
243
+ if (fs.readdirSync(dir).length === 0)
244
+ fs.rmdirSync(dir);
245
+ }
246
+ if (fs.readdirSync(root).length === 0)
247
+ fs.rmdirSync(root);
248
+ }
249
+ /** The baselines folder as the report names it: relative to the project when inside it, with forward slashes. */
250
+ function shownDir(dir, projectDir) {
251
+ const rel = path.relative(projectDir, dir);
252
+ if (rel === "")
253
+ return ".";
254
+ return rel.startsWith("..") || path.isAbsolute(rel) ? dir : rel.split(path.sep).join("/");
255
+ }
256
+ /**
257
+ * --baseline compare|update: take each target's picture, judge it against its
258
+ * stored baseline (baseline.ts), and write what the judgement says to write: a
259
+ * new baseline under update, and under compare the baseline, the picture now
260
+ * and the changed pixels of a target that changed, beside the report. A page
261
+ * load per target, so no picture depends on the one taken before it.
262
+ */
263
+ async function takeBaselines(engine, options, mode, targets, log) {
264
+ const dir = baselinesDirOf(options);
265
+ const outDir = options.outDir ?? defaultCheckDir(options.projectDir);
266
+ const engineName = engine.browserEngine;
267
+ const at = (root, rel) => path.join(root, ...rel.split("/"));
268
+ // The default folders are inside .scenescout/, which ignores itself, and its .gitignore is written before anything goes
269
+ // 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.
270
+ if (!options.outDir || (!options.baselinesDir && mode === "update")) {
271
+ const scenescoutDir = path.join(options.projectDir, MEMORY_DIRNAME);
272
+ fs.mkdirSync(scenescoutDir, { recursive: true });
273
+ writeSelfIgnore(scenescoutDir);
274
+ }
275
+ clearVisualPictures(outDir);
276
+ const results = [];
277
+ try {
278
+ for (const target of targets) {
279
+ const files = baselineFiles(engineName, target);
280
+ const own = { path: target.path, element: target.element, baseline: files.png };
281
+ const shown = `${target.element} on ${redactRoute(target.path)}`;
282
+ const element = elementTarget(target.element);
283
+ // parseBaselineTargets refuses such an element; one reaching here must not be pictured as the whole page instead.
284
+ if (element === undefined)
285
+ throw new Error(`baseline target ${shown} names neither the page nor an element`);
286
+ let shot;
287
+ let image;
288
+ try {
289
+ shot = await engine.captureForBaseline(target.path, element, BASELINE_CAPTURE);
290
+ image = decodePng(shot.png);
291
+ }
292
+ catch (err) {
293
+ // A closed browser, or a mistake in this code, is the check's failure, never the app's: the check ends "could not run".
294
+ if (!engine.alive || err instanceof TypeError || err instanceof ReferenceError || err instanceof RangeError)
295
+ throw err;
296
+ const why = firstLineOf(err);
297
+ results.push({ ...own, status: "not-captured", detail: why });
298
+ log(` baseline ${shown}: not captured (${why})`);
299
+ continue;
300
+ }
301
+ const capture = { ...BASELINE_CAPTURE, viewport: shot.viewport, deviceScaleFactor: shot.deviceScaleFactor };
302
+ const storedPng = readIfThere(at(dir, files.png));
303
+ const storedJson = readIfThere(at(dir, files.json));
304
+ const stored = readStoredBaseline(target, engineName, storedPng, storedJson === null ? null : storedJson.toString("utf8"));
305
+ const { diffImage, ...verdict } = judgeBaseline({
306
+ mode,
307
+ threshold: options.baselineThreshold,
308
+ stored,
309
+ now: { capture, platform: process.platform, image },
310
+ });
311
+ const result = { ...own, ...verdict, ...(shot.cut ? { partial: shot.cut } : {}) };
312
+ if (verdict.status === "updated") {
313
+ fs.mkdirSync(path.dirname(at(dir, files.png)), { recursive: true });
314
+ fs.writeFileSync(at(dir, files.png), shot.png);
315
+ const meta = baselineMeta({
316
+ target,
317
+ engine: engineName,
318
+ platform: process.platform,
319
+ capture,
320
+ size: { width: image.width, height: image.height },
321
+ capturedAt: new Date().toISOString(),
322
+ });
323
+ fs.writeFileSync(at(dir, files.json), JSON.stringify(meta, null, 2) + "\n");
324
+ }
325
+ if (diffImage && storedPng) {
326
+ const pictures = visualFiles(target);
327
+ fs.mkdirSync(path.dirname(at(outDir, pictures.diff)), { recursive: true });
328
+ fs.writeFileSync(at(outDir, pictures.expected), storedPng);
329
+ fs.writeFileSync(at(outDir, pictures.actual), shot.png);
330
+ fs.writeFileSync(at(outDir, pictures.diff), encodePng(diffImage));
331
+ result.files = pictures;
332
+ }
333
+ results.push(result);
334
+ log(` baseline ${shown}: ${result.status}${result.diff ? ` (${result.diff.percent}% changed)` : ""}`);
335
+ }
336
+ }
337
+ finally {
338
+ // Reported, never thrown: an error from the loop above must not be replaced by this one.
339
+ await engine.endBaselineCaptures().catch((err) => log(` could not stop requesting reduced motion: ${firstLineOf(err)}`));
340
+ }
341
+ return { mode, engine: engineName, threshold: options.baselineThreshold, dir: shownDir(dir, options.projectDir), results };
342
+ }