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
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visual baselines for `scenescout check --baseline compare|update`, as data.
|
|
3
|
+
*
|
|
4
|
+
* A baseline is an approved picture of a page (its viewport, from the top) or
|
|
5
|
+
* of one element on it, kept per route and per browser engine, beside a small
|
|
6
|
+
* JSON saying how it was taken. `compare` takes the same picture again and
|
|
7
|
+
* diffs it with the baseline (png.ts); `update` writes new baselines, and only
|
|
8
|
+
* when it is asked to. What has a baseline is the project's choice, listed in
|
|
9
|
+
* the baselines folder's targets.json; nothing is baselined by default.
|
|
10
|
+
*
|
|
11
|
+
* Everything here is pure: the targets file and its errors, the file layout,
|
|
12
|
+
* what a baseline records, whether two pictures were taken alike, the verdict,
|
|
13
|
+
* and the evidence an unmet baseline is filed with. The browser half is
|
|
14
|
+
* BrowserEngine.captureForBaseline, which only takes the picture; check-run.ts
|
|
15
|
+
* reads and writes the files. Table-tested in check-test.
|
|
16
|
+
*/
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
import { CAPTURE_MARGIN } from "./capture.js";
|
|
21
|
+
import { ARIA_ROLES, fieldPath, issueMessage, parseJsonFile, parseTarget, TARGET_HELP } from "./flow.js";
|
|
22
|
+
import { MEMORY_DIRNAME, redactRoute } from "./memory.js";
|
|
23
|
+
import { decodePng, diffImages } from "./png.js";
|
|
24
|
+
export const BASELINE_MODES = ["off", "compare", "update"];
|
|
25
|
+
/** The targets file, at the top of the baselines folder. */
|
|
26
|
+
export const TARGETS_FILE = "targets.json";
|
|
27
|
+
/** The most targets one check takes pictures of: each one is a page load. */
|
|
28
|
+
export const MAX_BASELINE_TARGETS = 100;
|
|
29
|
+
/**
|
|
30
|
+
* The share of pixels, in percent, that may change before a baseline counts
|
|
31
|
+
* as not met. Not 0: two pictures of an unchanged page taken by one browser
|
|
32
|
+
* build on one machine compare at 0%, but a run on another machine, or after
|
|
33
|
+
* a browser or font update, can anti-alias text and curved edges a pixel
|
|
34
|
+
* differently, and a gate that fails on that noise teaches a team to ignore
|
|
35
|
+
* it. 0.1% is 1,152 pixels of a 1280×900 page and 64 of a 320×200 picture, so
|
|
36
|
+
* a smaller change passes unless the project lowers the threshold. A change of
|
|
37
|
+
* size always counts.
|
|
38
|
+
*/
|
|
39
|
+
export const DEFAULT_BASELINE_THRESHOLD = 0.1;
|
|
40
|
+
/** The element a target names when it names none: the page's viewport, from the top. */
|
|
41
|
+
const PAGE_ELEMENT = "page";
|
|
42
|
+
/** Where the pictures of an unmet baseline go, beside report.md. */
|
|
43
|
+
export const VISUAL_DIRNAME = "visual";
|
|
44
|
+
/** The baselines folder when --baselines names none: inside .scenescout/, which git ignores, so nothing is committed unless the project chooses a folder of its own. */
|
|
45
|
+
export function defaultBaselinesDir(projectDir) {
|
|
46
|
+
return path.join(projectDir, MEMORY_DIRNAME, "baselines");
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The settings every baseline is taken with, in one copy: the check attaches
|
|
50
|
+
* with its window and scale, the browser takes the picture by the rest, and
|
|
51
|
+
* the JSON records them all, the window and scale as the browser reports them.
|
|
52
|
+
*/
|
|
53
|
+
export const BASELINE_CAPTURE = {
|
|
54
|
+
viewport: { width: 1280, height: 900 },
|
|
55
|
+
deviceScaleFactor: 1,
|
|
56
|
+
margin: CAPTURE_MARGIN,
|
|
57
|
+
reducedMotion: "reduce",
|
|
58
|
+
animations: "disabled",
|
|
59
|
+
caret: "hide",
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Page-side source that stops every animation and transition before an
|
|
63
|
+
* element is measured, not only while the screenshot is taken: a target that
|
|
64
|
+
* itself moves would otherwise be cropped wherever it was mid-movement. Finite
|
|
65
|
+
* ones are finished, infinite ones cancelled, as the screenshot's own setting
|
|
66
|
+
* does. A string, like the collector, so no build step can wrap it in a
|
|
67
|
+
* helper the page does not have.
|
|
68
|
+
*/
|
|
69
|
+
export const STOP_ANIMATIONS_SCRIPT = `(() => {
|
|
70
|
+
for (const a of document.getAnimations()) {
|
|
71
|
+
const end = a.effect ? a.effect.getComputedTiming().endTime : Infinity;
|
|
72
|
+
if (end === Infinity) a.cancel();
|
|
73
|
+
else a.finish();
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
})()`;
|
|
77
|
+
const ELEMENT_HELP = `${PAGE_ELEMENT} or ${TARGET_HELP}`;
|
|
78
|
+
/** What an element names: null for the page itself, else the target a saved flow would use. Undefined when it is neither. */
|
|
79
|
+
export function elementTarget(element) {
|
|
80
|
+
if (element === PAGE_ELEMENT)
|
|
81
|
+
return null;
|
|
82
|
+
return parseTarget(element) ?? undefined;
|
|
83
|
+
}
|
|
84
|
+
const elementSchema = z.string().superRefine((e, ctx) => {
|
|
85
|
+
const target = elementTarget(e);
|
|
86
|
+
if (target === undefined)
|
|
87
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `must be ${ELEMENT_HELP}` });
|
|
88
|
+
else if (target?.by === "role" && !ARIA_ROLES.has(target.role)) {
|
|
89
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `names the role "${target.role}", which is not an ARIA role (e.g. button, link, region, img)` });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
const targetsSchema = z
|
|
93
|
+
.object({
|
|
94
|
+
targets: z
|
|
95
|
+
.array(z
|
|
96
|
+
.object({
|
|
97
|
+
path: z.string().regex(/^\/\S*$/, "must be a path on the app, starting with / and with no spaces"),
|
|
98
|
+
element: elementSchema.optional(),
|
|
99
|
+
})
|
|
100
|
+
.strict())
|
|
101
|
+
.min(1, "lists nothing to keep a baseline of")
|
|
102
|
+
.max(MAX_BASELINE_TARGETS, `holds at most ${MAX_BASELINE_TARGETS} targets`),
|
|
103
|
+
})
|
|
104
|
+
.strict()
|
|
105
|
+
.superRefine((file, ctx) => {
|
|
106
|
+
// Two entries for one picture would write one file twice and compare it twice.
|
|
107
|
+
const seen = new Map();
|
|
108
|
+
file.targets.forEach((t, i) => {
|
|
109
|
+
const key = `${t.path}\u0000${t.element ?? PAGE_ELEMENT}`;
|
|
110
|
+
const first = seen.get(key);
|
|
111
|
+
if (first === undefined)
|
|
112
|
+
seen.set(key, i);
|
|
113
|
+
else
|
|
114
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["targets", i], message: `repeats targets[${first}]` });
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
/** Validate a targets file's text. Every mistake names the file and the field, as a flow's do. */
|
|
118
|
+
export function parseBaselineTargets(text, file) {
|
|
119
|
+
const parsed = parseJsonFile(text, file, targetsSchema);
|
|
120
|
+
if (!parsed.ok)
|
|
121
|
+
return parsed;
|
|
122
|
+
return { ok: true, targets: parsed.data.targets.map((t) => ({ path: t.path, element: t.element ?? PAGE_ELEMENT })) };
|
|
123
|
+
}
|
|
124
|
+
/** What to tell someone who asked for baselines in a folder with no targets file. */
|
|
125
|
+
export function missingTargetsMessage(file) {
|
|
126
|
+
return (`there is no ${file}: list the pages and elements to keep baselines of there, e.g. ` +
|
|
127
|
+
`{"targets": [{"path": "/", "element": "page"}, {"path": "/settings", "element": "testid=profile-card"}]}`);
|
|
128
|
+
}
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Where the files are
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
/**
|
|
133
|
+
* Text as a file name: lower-case letters, digits and dashes, then a hash of
|
|
134
|
+
* the exact text, so two that read alike never share a file. The readable part
|
|
135
|
+
* is taken from the text redacted as a route is, so a token in a target's path
|
|
136
|
+
* never becomes part of a file name, the report or the JSON.
|
|
137
|
+
*/
|
|
138
|
+
function fileKey(text, fallback) {
|
|
139
|
+
const slug = redactRoute(text)
|
|
140
|
+
.toLowerCase()
|
|
141
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
142
|
+
.replace(/^-+|-+$/g, "")
|
|
143
|
+
.slice(0, 48)
|
|
144
|
+
.replace(/-+$/, "") || fallback;
|
|
145
|
+
return `${slug}-${createHash("sha256").update(text).digest("hex").slice(0, 8)}`;
|
|
146
|
+
}
|
|
147
|
+
/** The folder of one route's baselines: `/settings/profile` → `settings-profile-<hash>`, `/` → `index-<hash>`. */
|
|
148
|
+
export function routeFolder(routePath) {
|
|
149
|
+
return fileKey(routePath, "index");
|
|
150
|
+
}
|
|
151
|
+
/** The file name of one element's baseline, without its extension: `page`, or e.g. `testid-profile-card-<hash>`. */
|
|
152
|
+
export function elementFile(element) {
|
|
153
|
+
return element === PAGE_ELEMENT ? PAGE_ELEMENT : fileKey(element, "element");
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* A target's baseline, relative to the baselines folder and with forward
|
|
157
|
+
* slashes: one folder per engine (a picture from one browser is not another's
|
|
158
|
+
* baseline), one per route, then the PNG and the JSON beside it.
|
|
159
|
+
*/
|
|
160
|
+
export function baselineFiles(engine, target) {
|
|
161
|
+
const stem = `${engine}/${routeFolder(target.path)}/${elementFile(target.element)}`;
|
|
162
|
+
return { png: `${stem}.png`, json: `${stem}.json` };
|
|
163
|
+
}
|
|
164
|
+
/** The pictures of an unmet baseline, relative to the check's output folder: what was expected, what it is now, and the changed pixels. */
|
|
165
|
+
export function visualFiles(target) {
|
|
166
|
+
const stem = `${VISUAL_DIRNAME}/${routeFolder(target.path)}/${elementFile(target.element)}`;
|
|
167
|
+
return { expected: `${stem}.expected.png`, actual: `${stem}.actual.png`, diff: `${stem}.diff.png` };
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Whether a file under the output folder's visual/ is one visualFiles names:
|
|
171
|
+
* a route folder and a picture named exactly as a check names them. Only
|
|
172
|
+
* these are cleared before a run writes its own, so nothing else that happens
|
|
173
|
+
* to sit there is ever deleted.
|
|
174
|
+
*/
|
|
175
|
+
export function isVisualPicture(folder, file) {
|
|
176
|
+
return /^[a-z0-9-]+-[0-9a-f]{8}$/.test(folder) && /^(page|[a-z0-9-]+-[0-9a-f]{8})\.(expected|actual|diff)\.png$/.test(file);
|
|
177
|
+
}
|
|
178
|
+
const size = z.object({ width: z.number().int().positive(), height: z.number().int().positive() });
|
|
179
|
+
const metaSchema = z.object({
|
|
180
|
+
path: z.string(),
|
|
181
|
+
element: z.string(),
|
|
182
|
+
engine: z.string(),
|
|
183
|
+
platform: z.string(),
|
|
184
|
+
// Read as strings, not the values this version writes, so a baseline taken another way is reported as different, not as unreadable.
|
|
185
|
+
capture: z.object({
|
|
186
|
+
viewport: size,
|
|
187
|
+
deviceScaleFactor: z.number().positive(),
|
|
188
|
+
margin: z.number().int().min(0),
|
|
189
|
+
reducedMotion: z.string(),
|
|
190
|
+
animations: z.string(),
|
|
191
|
+
caret: z.string(),
|
|
192
|
+
}),
|
|
193
|
+
size,
|
|
194
|
+
capturedAt: z.string(),
|
|
195
|
+
});
|
|
196
|
+
export function baselineMeta(o) {
|
|
197
|
+
return {
|
|
198
|
+
path: o.target.path,
|
|
199
|
+
element: o.target.element,
|
|
200
|
+
engine: o.engine,
|
|
201
|
+
platform: o.platform,
|
|
202
|
+
capture: o.capture,
|
|
203
|
+
size: o.size,
|
|
204
|
+
capturedAt: o.capturedAt,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/** Read a baseline's JSON. An error says why it cannot be used, for the report. */
|
|
208
|
+
export function parseBaselineMeta(text) {
|
|
209
|
+
let json;
|
|
210
|
+
try {
|
|
211
|
+
json = JSON.parse(text);
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
return { ok: false, error: `its JSON is not valid (${err instanceof Error ? err.message : String(err)})` };
|
|
215
|
+
}
|
|
216
|
+
const parsed = metaSchema.safeParse(json);
|
|
217
|
+
if (!parsed.success) {
|
|
218
|
+
const first = parsed.error.issues[0];
|
|
219
|
+
return { ok: false, error: `its JSON is not a baseline's: ${fieldPath(first.path)} ${issueMessage(first)}` };
|
|
220
|
+
}
|
|
221
|
+
return { ok: true, meta: parsed.data };
|
|
222
|
+
}
|
|
223
|
+
/** How each setting is named and shown. Typed by CaptureSettings, so a setting added there must be added here, and so compared. */
|
|
224
|
+
const SETTING_SHOWN = {
|
|
225
|
+
viewport: { label: "viewport", value: (v) => `${v.width}×${v.height}` },
|
|
226
|
+
deviceScaleFactor: { label: "device scale", value: (v) => String(v) },
|
|
227
|
+
margin: { label: "margin", value: (v) => `${v}px` },
|
|
228
|
+
reducedMotion: { label: "reducedMotion", value: (v) => v },
|
|
229
|
+
animations: { label: "animations", value: (v) => v },
|
|
230
|
+
caret: { label: "caret", value: (v) => v },
|
|
231
|
+
};
|
|
232
|
+
/** How two pictures' settings differ, one phrase each; empty when they were taken alike. */
|
|
233
|
+
export function captureDifferences(stored, now) {
|
|
234
|
+
return Object.keys(SETTING_SHOWN).flatMap((k) => {
|
|
235
|
+
const { label, value } = SETTING_SHOWN[k];
|
|
236
|
+
const was = value(stored[k]);
|
|
237
|
+
const is = value(now[k]);
|
|
238
|
+
return was === is ? [] : [`${label} ${was} (now ${is})`];
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Make sense of a target's two baseline files as found on disk (null for one
|
|
243
|
+
* that is not there). Half a baseline, a JSON that describes another target or
|
|
244
|
+
* browser, and a PNG that cannot be read or is not the size its JSON says are
|
|
245
|
+
* each unusable, with the reason: never silently compared, never silently
|
|
246
|
+
* treated as absent.
|
|
247
|
+
*/
|
|
248
|
+
export function readStoredBaseline(target, engine, png, json) {
|
|
249
|
+
if (png === null && json === null)
|
|
250
|
+
return null;
|
|
251
|
+
if (png === null)
|
|
252
|
+
return { problem: "its PNG is missing; only the JSON beside it is there" };
|
|
253
|
+
if (json === null)
|
|
254
|
+
return { problem: "the JSON beside its PNG is missing" };
|
|
255
|
+
const parsed = parseBaselineMeta(json);
|
|
256
|
+
if (!parsed.ok)
|
|
257
|
+
return { problem: parsed.error };
|
|
258
|
+
const m = parsed.meta;
|
|
259
|
+
if (m.path !== target.path || m.element !== target.element || m.engine !== engine) {
|
|
260
|
+
return { problem: `its JSON describes ${m.element} on ${m.path} in ${m.engine}, not this target` };
|
|
261
|
+
}
|
|
262
|
+
let image;
|
|
263
|
+
try {
|
|
264
|
+
image = decodePng(png);
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
return { problem: `its PNG cannot be read: ${err instanceof Error ? err.message : String(err)}` };
|
|
268
|
+
}
|
|
269
|
+
if (image.width !== m.size.width || image.height !== m.size.height) {
|
|
270
|
+
return { problem: `its PNG is ${image.width}×${image.height} where its JSON says ${m.size.width}×${m.size.height}` };
|
|
271
|
+
}
|
|
272
|
+
return { meta: m, image };
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Far below the smallest step a share of pixels can take (100 / MAX_PIXELS),
|
|
276
|
+
* so a share exactly at the threshold is never read as past it because of
|
|
277
|
+
* floating point: 7 of 100 is 7.000000000000001%.
|
|
278
|
+
*/
|
|
279
|
+
const SHARE_EPSILON = 1e-9;
|
|
280
|
+
/** Whether a comparison is a change: any change of size, or more of the pixels than the threshold allows. */
|
|
281
|
+
export function isChange(diff, threshold) {
|
|
282
|
+
if (diff.sizeChanged)
|
|
283
|
+
return true;
|
|
284
|
+
return diff.total > 0 && (diff.changed / diff.total) * 100 > threshold + SHARE_EPSILON;
|
|
285
|
+
}
|
|
286
|
+
function diffSummary(d) {
|
|
287
|
+
return { percent: d.percent, changedPixels: d.changed, totalPixels: d.total, sizeChanged: d.sizeChanged, baseline: d.before, now: d.after };
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Decide what becomes of one captured target, given its stored baseline and
|
|
291
|
+
* the picture just taken. A status of `updated` is the one that writes the new
|
|
292
|
+
* picture as its baseline; `diffImage` comes with `changed`, for the pictures
|
|
293
|
+
* kept beside the report. update rewrites what compare would not accept and
|
|
294
|
+
* leaves alone a baseline compare would (none of its pixels changed, or no
|
|
295
|
+
* more than the threshold allows), so an update that changes nothing changes
|
|
296
|
+
* no file and noise under the threshold does not churn the folder. A baseline
|
|
297
|
+
* taken on another operating system is rewritten however close it came, so
|
|
298
|
+
* retaking baselines where the check runs replaces every one of them.
|
|
299
|
+
*/
|
|
300
|
+
export function judgeBaseline(o) {
|
|
301
|
+
const { mode, stored, now } = o;
|
|
302
|
+
if (stored === null)
|
|
303
|
+
return mode === "update" ? { status: "updated", detail: "it had no baseline" } : { status: "no-baseline" };
|
|
304
|
+
const cannotUse = (why) => mode === "update" ? { status: "updated", detail: `replaced one that could not be used: ${why}` } : { status: "unusable", detail: why };
|
|
305
|
+
if ("problem" in stored)
|
|
306
|
+
return cannotUse(stored.problem);
|
|
307
|
+
const differs = captureDifferences(stored.meta.capture, now.capture);
|
|
308
|
+
if (differs.length > 0)
|
|
309
|
+
return cannotUse(`it was taken with other settings: ${differs.join(", ")}`);
|
|
310
|
+
const { platform } = stored.meta;
|
|
311
|
+
const otherSystem = platform !== now.platform;
|
|
312
|
+
const d = diffImages(stored.image, now.image);
|
|
313
|
+
const diff = diffSummary(d);
|
|
314
|
+
if (mode === "update") {
|
|
315
|
+
if (otherSystem)
|
|
316
|
+
return { status: "updated", detail: `replaced one taken on ${platform}`, diff };
|
|
317
|
+
if (!isChange(d, o.threshold))
|
|
318
|
+
return { status: "matches", diff };
|
|
319
|
+
// With the count, as describeChange gives it.
|
|
320
|
+
return { status: "updated", detail: `it was ${d.percent}% different (${d.changed} of ${d.total} pixels)`, diff };
|
|
321
|
+
}
|
|
322
|
+
const note = otherSystem ? { platformNote: `its baseline was taken on ${platform} and this check ran on ${now.platform}` } : {};
|
|
323
|
+
if (!isChange(d, o.threshold))
|
|
324
|
+
return { status: "matches", diff, ...note };
|
|
325
|
+
return { status: "changed", diff, diffImage: d.image, ...note };
|
|
326
|
+
}
|
|
327
|
+
/** The issue rule an unmet baseline is filed under (check.ts). */
|
|
328
|
+
export const VISUAL_RULE = "visual-change";
|
|
329
|
+
/**
|
|
330
|
+
* The evidence an unmet baseline is filed with, or null when the result is
|
|
331
|
+
* not an issue. A change and an unusable baseline (compare), and a target that
|
|
332
|
+
* could not be pictured at all (either mode), are issues: targets.json says
|
|
333
|
+
* the project expects to see it, and a comparison that compared nothing must
|
|
334
|
+
* not pass. No baseline yet is never one.
|
|
335
|
+
*/
|
|
336
|
+
export function baselineEvidence(r, run) {
|
|
337
|
+
const what = `${r.element} on ${r.path}`;
|
|
338
|
+
switch (r.status) {
|
|
339
|
+
case "not-captured":
|
|
340
|
+
return `${what} could not be captured: ${r.detail ?? "no reason given"}${run.mode === "update" ? ", so its baseline was not written" : ""}`;
|
|
341
|
+
case "unusable":
|
|
342
|
+
return `${what}: its baseline cannot be used (${r.detail ?? "no reason given"}); run the check with --baseline update to take it again`;
|
|
343
|
+
case "changed":
|
|
344
|
+
return `${what}: ${r.diff ? describeChange(r.diff, run.threshold) : "it no longer matches its baseline"}${r.files ? ` — diff: ${r.files.diff}` : ""}`;
|
|
345
|
+
default:
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/** A change in words. A change of size always counts, whatever the threshold, so it is named first. */
|
|
350
|
+
export function describeChange(d, threshold) {
|
|
351
|
+
if (d.sizeChanged) {
|
|
352
|
+
return `its size changed from ${d.baseline.width}×${d.baseline.height} to ${d.now.width}×${d.now.height}, which always counts (${d.percent}% of its pixels differ)`;
|
|
353
|
+
}
|
|
354
|
+
// The count beside the rounded share, so 0.504% past a 0.5% threshold does not read as 0.5% (allowed: 0.5%).
|
|
355
|
+
return `${d.percent}% of its pixels changed (${d.changedPixels} of ${d.totalPixels}; allowed: ${threshold}%)`;
|
|
356
|
+
}
|
|
357
|
+
/** The fingerprint of an unmet baseline: the same target in the same browser is the same alert, whatever its percentage this time. */
|
|
358
|
+
export function baselineFingerprint(engine, r) {
|
|
359
|
+
return createHash("sha256").update(`${VISUAL_RULE}\u0000${engine}\u0000${r.path}\u0000${r.element}`).digest("hex").slice(0, 32);
|
|
360
|
+
}
|
|
361
|
+
/** How many results there are of each status, for the report's summary line. */
|
|
362
|
+
export function countStatuses(results) {
|
|
363
|
+
const counts = { matches: 0, changed: 0, "no-baseline": 0, unusable: 0, "not-captured": 0, updated: 0 };
|
|
364
|
+
for (const r of results)
|
|
365
|
+
counts[r.status] += 1;
|
|
366
|
+
return counts;
|
|
367
|
+
}
|
|
368
|
+
/** Parse --baseline-threshold: a percentage from 0 to 100. */
|
|
369
|
+
export function parseThreshold(raw) {
|
|
370
|
+
if (raw === undefined)
|
|
371
|
+
return { ok: true, value: DEFAULT_BASELINE_THRESHOLD };
|
|
372
|
+
const value = Number(raw.trim());
|
|
373
|
+
if (raw.trim() === "" || !Number.isFinite(value) || value < 0 || value > 100) {
|
|
374
|
+
return { ok: false, error: "--baseline-threshold must be a percentage from 0 to 100, e.g. 0.5" };
|
|
375
|
+
}
|
|
376
|
+
return { ok: true, value };
|
|
377
|
+
}
|
package/dist/engine/brief.js
CHANGED
|
@@ -114,6 +114,21 @@ function laneObjective(modules, goal) {
|
|
|
114
114
|
const what = `Own ${owned}`;
|
|
115
115
|
return goal ? `${what} — ${goal}` : what;
|
|
116
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* How every lane works, whoever briefs it: formatBriefs passes these to a
|
|
119
|
+
* planner's lane agents, and an unattended run's lanes (`scenescout ci
|
|
120
|
+
* --lanes`) are given the same lines in their prompt. One list, so the two
|
|
121
|
+
* kinds of lane cannot drift apart.
|
|
122
|
+
*/
|
|
123
|
+
export const LANE_RULES = [
|
|
124
|
+
`A lane works ITS routes only. Two lanes auditing the same register while a third module is never opened is the failure this plan exists to prevent — and route coverage will look complete either way. A defect on a page it does not own is that page's lane's to file.`,
|
|
125
|
+
`Every acting tool takes a \`task\`. A lane that acts with none is refused, and the person watching the live view would otherwise see a browser clicking through their app with nothing to say why.`,
|
|
126
|
+
`File each defect with scout_finding the moment it is judged, before writing the report. A defect only in the report is not in the run's report.`,
|
|
127
|
+
`Before calling a list empty, stale or stuck, read the status of the request behind it: a correct empty result and a failed one can render identically.`,
|
|
128
|
+
`On a create form, submit one markup-shaped value, then open where that record is listed. Any lane's session catches it rendering as markup, so the list may belong to another lane.`,
|
|
129
|
+
`When a control is withheld from this role, call the endpoint behind it with scout_request: hiding a button is not enforcing a rule. When a sort or filter runs, check the result is what it claims.`,
|
|
130
|
+
`Check scout_coverage before finishing.`,
|
|
131
|
+
];
|
|
117
132
|
/**
|
|
118
133
|
* The briefing the planner gives the lanes.
|
|
119
134
|
*
|
|
@@ -133,13 +148,7 @@ export function formatBriefs(briefs, opts = {}) {
|
|
|
133
148
|
` scout_attach { session: "<lane>", url: "<origin><landing>", projectPath, mode: "${mode}"${signInArgument(opts)}, objective: "<objective>" }`,
|
|
134
149
|
``,
|
|
135
150
|
`Rules to pass on, which a hand-written brief tends to drop:`,
|
|
136
|
-
` ·
|
|
137
|
-
` · Every acting tool takes a \`task\`. A lane that acts with none is refused, and the person watching the live view would otherwise see a browser clicking through their app with nothing to say why.`,
|
|
138
|
-
` · File each defect with scout_finding the moment it is judged, before writing the report. A defect only in the report is not in the run's report.`,
|
|
139
|
-
` · Before calling a list empty, stale or stuck, read the status of the request behind it: a correct empty result and a failed one can render identically.`,
|
|
140
|
-
` · On a create form, submit one markup-shaped value, then open where that record is listed. Any lane's session catches it rendering as markup, so the list may belong to another lane.`,
|
|
141
|
-
` · When a control is withheld from this role, call the endpoint behind it with scout_request: hiding a button is not enforcing a rule. When a sort or filter runs, check the result is what it claims.`,
|
|
142
|
-
` · Check scout_coverage before finishing.`,
|
|
151
|
+
...LANE_RULES.map((rule) => ` · ${rule}`),
|
|
143
152
|
` · A lane reports back with scout_lane_report, not prose, and does NOT close its session: the planner folds the report, which checks each judged defect was filed, then closes it. Call scout_lane_report with no reply to get the instruction to put in its prompt.`,
|
|
144
153
|
``,
|
|
145
154
|
];
|