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.
- package/CHANGELOG.md +87 -0
- package/README.md +70 -18
- package/dist/browsers.js +28 -0
- package/dist/check-run.js +191 -14
- package/dist/ci-run.js +268 -52
- package/dist/cli.js +107 -47
- package/dist/commands.js +3 -2
- package/dist/engine/baseline.js +377 -0
- package/dist/engine/brief.js +16 -7
- package/dist/engine/browser.js +1147 -286
- package/dist/engine/calibration.js +61 -30
- package/dist/engine/capture.js +164 -0
- package/dist/engine/check.js +244 -42
- package/dist/engine/ci-lanes.js +215 -0
- package/dist/engine/ci.js +136 -18
- package/dist/engine/claims.js +159 -3
- package/dist/engine/collector.js +561 -30
- package/dist/engine/crawl.js +49 -0
- package/dist/engine/design.js +281 -38
- 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 +181 -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 +501 -47
- package/dist/engine/open.js +118 -0
- package/dist/engine/oracles.js +41 -1
- 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/refresh.js +68 -3
- package/dist/engine/replay.js +63 -10
- package/dist/engine/report.js +241 -40
- package/dist/engine/request.js +317 -23
- package/dist/engine/sarif.js +120 -0
- package/dist/engine/settle.js +67 -0
- package/dist/engine/signed-in.js +256 -0
- package/dist/engine/status-pane-page.js +441 -0
- package/dist/engine/status-pane.js +128 -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 +5 -0
- package/dist/installer.js +378 -8
- package/dist/intake.js +104 -0
- package/dist/login-run.js +250 -36
- package/dist/mcp-server.js +660 -65
- package/dist/playbook.js +5 -0
- package/dist/prompts.js +106 -0
- package/package.json +8 -5
- 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,
|
|
4
|
-
* load can reproduce, and write
|
|
5
|
-
* engine/check.ts, engine/flow.ts
|
|
6
|
-
*
|
|
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
|
|
18
|
-
* sentence naming the file and the field on anything it
|
|
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
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
+
}
|