@graphty/visual-review 0.0.1 → 0.1.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.
@@ -0,0 +1,669 @@
1
+ /**
2
+ * Capture: screenshot every story of one built Storybook and classify each against its baseline.
3
+ *
4
+ * Serves storybook-static on 127.0.0.1, reads the story list from index.json and every story's
5
+ * `parameters.chromatic` from the preview's own `extract()`, then opens each story and mode in a
6
+ * fresh browser context: a fixed start time, SwiftShader WebGL, a 1200 x 900 viewport at device
7
+ * scale factor 2, as Chromatic captures. Each PNG is the whole canvas, never cropped to the content:
8
+ * the full page of the story iframe, which is the viewport unless the story overflows it. It waits
9
+ * for Storybook's render (play functions included) and, when the project's config names a
10
+ * `waitFor` (an element selector and a method returning a promise, such as graphty-element's
11
+ * `waitForStableFrame()`), for that; a story that errors or never settles is `failed`, never a picture,
12
+ * after one retry in a new context, so a single timeout on a busy runner does not block a pull
13
+ * request. WebGPU is removed from every page (`navigator.gpu` is deleted before any script runs):
14
+ * no Chromium switch hides it, and whether an adapter request fails differs by host, so without
15
+ * this a component with a CPU and a GPU path would draw whichever the machine offers.
16
+ * Anything that differs from its baseline, or has none, is captured once more in a new context,
17
+ * so a real change, an unstable story and a one-off flake are told apart (see compare.mjs).
18
+ *
19
+ * The output directory gets results.json (rewritten after every item; `complete: true` only at
20
+ * the end), the first capture of every changed, new and unstable item, `second/<file>` for the
21
+ * second capture of an unstable one, and `baselines/<file>`: the baseline each changed, unstable
22
+ * and removed item was compared with.
23
+ *
24
+ * With `reference`, a directory holding the default branch's newest capture of the project (CI
25
+ * downloads it on pull requests), a story with no baseline whose capture matches it is `unseeded`, not
26
+ * `new`: seeding is per story, so a story nobody has accepted yet does not block every pull
27
+ * request, only one that changes it.
28
+ */
29
+
30
+ import { execFileSync } from "node:child_process";
31
+ import { readFileSync } from "node:fs";
32
+ import { mkdir, readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
33
+ import { createServer } from "node:http";
34
+ import { dirname, extname, join, normalize, sep } from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+
37
+ import { chromium } from "playwright";
38
+
39
+ import { classify, DEFAULT_THRESHOLD, readBaseline, sha256 } from "../trusted/lib/compare.mjs";
40
+ import { validateResults } from "../trusted/lib/results.mjs";
41
+
42
+ /* global document, window, requestAnimationFrame -- read only inside the page */
43
+
44
+ /** The instant every page's clock starts at. It keeps running from there. */
45
+ const CLOCK_START = "2026-01-01T12:00:00Z";
46
+
47
+ const VIEWPORT = { width: 1200, height: 900 };
48
+ /** Device pixels per CSS pixel, as Chromatic captures; recorded in results.json as `scale`. */
49
+ const SCALE = 2;
50
+ const RENDER_TIMEOUT = 30_000;
51
+ const CHROMIUM_ARGS = [
52
+ "--use-gl=angle",
53
+ "--use-angle=swiftshader",
54
+ "--enable-unsafe-swiftshader",
55
+ "--force-color-profile=srgb",
56
+ "--disable-lcd-text",
57
+ "--font-render-hinting=none",
58
+ ];
59
+ const MAX_CONSOLE = 100;
60
+ const MAX_LINE = 2000;
61
+
62
+ /**
63
+ * The iframe URL of one story, relative to the Storybook root. `chromatic=true` makes
64
+ * `isChromatic()` true inside the stories.
65
+ * @param {string} id the story id
66
+ * @param {Record<string, string> | null} globals the mode's Storybook globals
67
+ * @returns {string} the relative URL
68
+ */
69
+ export function storyUrl(id, globals) {
70
+ const url = `iframe.html?id=${encodeURIComponent(id)}&viewMode=story&chromatic=true`;
71
+ if (!globals) {
72
+ return url;
73
+ }
74
+ return `${url}&globals=${Object.entries(globals)
75
+ .map(([k, v]) => `${k}:${v}`)
76
+ .join(";")}`;
77
+ }
78
+
79
+ /**
80
+ * The story ids in a Storybook index.json, docs entries left out.
81
+ * @param {{ entries: Record<string, { id: string, type: string }> }} index the parsed index.json
82
+ * @returns {string[]} the ids, sorted
83
+ */
84
+ export const storyIds = (index) =>
85
+ Object.values(index.entries)
86
+ .filter((e) => e.type === "story")
87
+ .map((e) => e.id)
88
+ .sort();
89
+
90
+ /**
91
+ * The PNG name of a story and mode.
92
+ * @param {string} id the story id
93
+ * @param {string | null} mode the mode name
94
+ * @returns {string} `<id>.png` or `<id>.<mode>.png`
95
+ */
96
+ export const fileName = (id, mode) => (mode === null ? `${id}.png` : `${id}.${mode}.png`);
97
+
98
+ /**
99
+ * Whether a project has any baseline PNG, which is what makes its results `seeded`.
100
+ * @param {string} dir the project's baselines directory
101
+ * @returns {Promise<boolean>} true when the directory holds a PNG
102
+ */
103
+ export async function hasBaselines(dir) {
104
+ return (await pngsIn(dir)).length > 0;
105
+ }
106
+
107
+ /**
108
+ * Reads a story's settings file, `<dir>/<id>.json`.
109
+ * @param {string} dir the project's baselines directory
110
+ * @param {string} id the story id
111
+ * @returns {Promise<object | null>} the parsed settings, or null when the story has none
112
+ */
113
+ export async function loadSettings(dir, id) {
114
+ try {
115
+ return JSON.parse(await readFile(join(dir, `${id}.json`), "utf8"));
116
+ } catch (e) {
117
+ if (e.code === "ENOENT") {
118
+ return null;
119
+ }
120
+ throw e;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * A story's capture settings: its `parameters.chromatic`, with every key its settings file sets
126
+ * taking precedence. Modes map a name to Storybook globals; `disable: true` drops a mode.
127
+ * @param {{ chromatic?: object }} parameters the story's parameters (only `chromatic` is read)
128
+ * @param {object | null} file the story's settings file
129
+ * @returns {{ disableSnapshot: boolean, excludedByStory: boolean, reason: string | null, delay: number, threshold: number,
130
+ * includeAA: boolean, modes: { name: string | null, globals: object | null }[] }} one mode
131
+ * with a null name when the story has none
132
+ */
133
+ export function storySettings(parameters, file) {
134
+ const fromStory = parameters.chromatic ?? {};
135
+ const s = { ...fromStory, ...file };
136
+ const disableSnapshot = s.disableSnapshot === true;
137
+ const byFile = file?.disableSnapshot === true;
138
+ const modes = Object.entries(s.modes ?? {})
139
+ .filter(([, m]) => m?.disable !== true)
140
+ .map(([name, m]) => ({
141
+ name,
142
+ globals: Object.fromEntries(Object.entries(m).filter(([k]) => k !== "disable")),
143
+ }));
144
+ const where = byFile ? "settings file" : "story's parameters";
145
+ return {
146
+ disableSnapshot,
147
+ excludedByStory: disableSnapshot && !byFile,
148
+ reason: disableSnapshot ? (file?.reason ?? `disableSnapshot in the ${where}`) : null,
149
+ delay: s.delay ?? 0,
150
+ threshold: s.diffThreshold ?? DEFAULT_THRESHOLD,
151
+ includeAA: s.diffIncludeAntiAliasing === true,
152
+ modes: modes.length > 0 ? modes : [{ name: null, globals: null }],
153
+ };
154
+ }
155
+
156
+ async function pngsIn(dir) {
157
+ try {
158
+ return (await readdir(dir)).filter((f) => f.endsWith(".png")).sort();
159
+ } catch (e) {
160
+ if (e.code === "ENOENT") {
161
+ return [];
162
+ }
163
+ throw e;
164
+ }
165
+ }
166
+
167
+ const TYPES = {
168
+ ".html": "text/html",
169
+ ".js": "text/javascript",
170
+ ".mjs": "text/javascript",
171
+ ".css": "text/css",
172
+ ".json": "application/json",
173
+ ".svg": "image/svg+xml",
174
+ ".png": "image/png",
175
+ ".jpg": "image/jpeg",
176
+ ".woff2": "font/woff2",
177
+ ".woff": "font/woff",
178
+ ".ttf": "font/ttf",
179
+ ".map": "application/json",
180
+ ".wasm": "application/wasm",
181
+ ".csv": "text/csv",
182
+ ".txt": "text/plain",
183
+ ".ico": "image/x-icon",
184
+ };
185
+
186
+ /**
187
+ * Serves a directory on 127.0.0.1 at a port the OS picks.
188
+ * @param {string} dir the directory to serve
189
+ * @returns {Promise<[import("node:http").Server, string]>} the server and its base URL
190
+ */
191
+ function serve(dir) {
192
+ const server = createServer(async (req, res) => {
193
+ try {
194
+ let p = normalize(decodeURIComponent(new URL(req.url, "http://x").pathname)).replace(/^(\.\.[/\\])+/, "");
195
+ if (p === "/") {
196
+ p = "/index.html";
197
+ }
198
+ const f = join(dir, p);
199
+ if ((await stat(f)).isDirectory()) {
200
+ throw new Error("directory");
201
+ }
202
+ res.writeHead(200, { "Content-Type": TYPES[extname(f)] ?? "application/octet-stream" });
203
+ res.end(await readFile(f));
204
+ } catch {
205
+ res.writeHead(404);
206
+ res.end("not found");
207
+ }
208
+ });
209
+ return new Promise((r) =>
210
+ server.listen(0, "127.0.0.1", () =>
211
+ r([server, `http://127.0.0.1:${/** @type {import("node:net").AddressInfo} */ (server.address()).port}/`]),
212
+ ),
213
+ );
214
+ }
215
+
216
+ async function newContext(browser) {
217
+ const context = await browser.newContext({
218
+ viewport: VIEWPORT,
219
+ deviceScaleFactor: SCALE,
220
+ timezoneId: "UTC",
221
+ locale: "en-US",
222
+ });
223
+ await context.addInitScript(() => {
224
+ delete (/** @type {any} */ (Navigator.prototype).gpu);
225
+ });
226
+ return context;
227
+ }
228
+
229
+ /**
230
+ * Loads the preview once and reads every story's `parameters.chromatic`, plus what the page
231
+ * says about its renderer.
232
+ * @param {import("playwright").Browser} browser the browser
233
+ * @param {string} base the Storybook's base URL
234
+ * @returns {Promise<{ params: Record<string, { chromatic?: object }>, renderer: string | null,
235
+ * gpu: boolean }>} parameters by story id
236
+ */
237
+ async function extract(browser, base) {
238
+ const context = await newContext(browser);
239
+ try {
240
+ const page = await context.newPage();
241
+ await page.goto(`${base}iframe.html`, { waitUntil: "load", timeout: RENDER_TIMEOUT });
242
+ await page.waitForFunction(() => /** @type {any} */ (window).__STORYBOOK_PREVIEW__?.storyStoreValue, null, {
243
+ timeout: RENDER_TIMEOUT,
244
+ });
245
+ return await page.evaluate(async () => {
246
+ const preview = /** @type {any} */ (window).__STORYBOOK_PREVIEW__;
247
+ const all = await preview.extract();
248
+ const params = {};
249
+ for (const [id, story] of Object.entries(all)) {
250
+ // Through JSON so functions and class instances never cross into Node.
251
+ params[id] = { chromatic: JSON.parse(JSON.stringify(story.parameters?.chromatic ?? {})) };
252
+ }
253
+ const gl = document.createElement("canvas").getContext("webgl");
254
+ const info = gl?.getExtension("WEBGL_debug_renderer_info");
255
+ const renderer = gl ? String(gl.getParameter(info ? info.UNMASKED_RENDERER_WEBGL : gl.RENDERER)) : null;
256
+ return { params, renderer, gpu: "gpu" in navigator };
257
+ });
258
+ } finally {
259
+ await context.close();
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Renders one story and mode, retrying once in a new context when it fails.
265
+ * @param {import("playwright").Browser} browser the browser
266
+ * @param {string} url the story's full URL
267
+ * @param {{ delay: number, waitFor: object | null }} options as for shootOnce
268
+ * @returns {Promise<{ png: Buffer | null, reason: string | null, console: string[] }>} the
269
+ * second attempt's result when the first failed
270
+ */
271
+ async function shoot(browser, url, options) {
272
+ const first = await shootOnce(browser, url, options);
273
+ return first.png ? first : shootOnce(browser, url, options);
274
+ }
275
+
276
+ /**
277
+ * Renders one story and mode in a fresh context and screenshots it.
278
+ * @param {import("playwright").Browser} browser the browser
279
+ * @param {string} url the story's full URL
280
+ * @param {{ delay: number, waitFor: { selector: string, method: string, failOnConsole: string |
281
+ * null } | null }} options the story's delay, and what to wait for after the render: the
282
+ * promise `method` returns on every element matching `selector`, failing the story when a
283
+ * console line contains `failOnConsole`
284
+ * @returns {Promise<{ png: Buffer | null, reason: string | null, console: string[] }>} the PNG,
285
+ * or a reason it failed; a failure's console holds the rest of its message and any stack
286
+ */
287
+ async function shootOnce(browser, url, { delay, waitFor }) {
288
+ const context = await newContext(browser);
289
+ const lines = [];
290
+ const fail = (reason) => {
291
+ const [first, ...rest] = reason.split("\n");
292
+ lines.push(...rest.filter((l) => l.trim() !== ""));
293
+ return { png: null, reason: first.slice(0, MAX_LINE), console: lines };
294
+ };
295
+ try {
296
+ const page = await context.newPage();
297
+ page.on("console", (m) => lines.push(`${m.type()}: ${m.text()}`));
298
+ page.on("pageerror", (e) => lines.push(`pageerror: ${e.stack ?? e.message}`));
299
+ // A fixed start that keeps running: setFixedTime would freeze Date.now(), which hangs any
300
+ // component timed with it (graphty-element's input playback and recording, for one).
301
+ await page.clock.install({ time: CLOCK_START });
302
+ await page.clock.resume();
303
+ await page.goto(url, { waitUntil: "load", timeout: RENDER_TIMEOUT });
304
+ const phase = await page
305
+ .waitForFunction(
306
+ () => {
307
+ if (document.body.classList.contains("sb-show-errordisplay")) {
308
+ return "errored";
309
+ }
310
+ const p = /** @type {any} */ (window).__STORYBOOK_PREVIEW__?.currentRender?.phase;
311
+ return ["completed", "afterEach", "finished", "errored", "aborted"].includes(p) && p;
312
+ },
313
+ null,
314
+ { timeout: RENDER_TIMEOUT },
315
+ )
316
+ .then((h) => h.jsonValue());
317
+ if (phase === "errored" || phase === "aborted") {
318
+ // Storybook's error screen holds the thrown message and its stack (a play function's
319
+ // failed expect included).
320
+ const shown = await page.evaluate(() =>
321
+ ["error-message", "error-stack"].map((id) => document.getElementById(id)?.textContent?.trim() ?? ""),
322
+ );
323
+ return fail([`story render ${phase}`, ...shown].join("\n"));
324
+ }
325
+ // A web font the story uses is fetched only once text needs it, which can be after the
326
+ // render completed; a capture taken before it arrives draws the fallback face, so the
327
+ // text and anything placed beside the text differ from a later capture.
328
+ // Wait for every font in use, then for one frame drawn with them.
329
+ await page.evaluate(async () => {
330
+ await document.fonts.ready;
331
+ await new Promise((r) => requestAnimationFrame(() => r()));
332
+ await document.fonts.ready;
333
+ });
334
+ if (waitFor) {
335
+ await page.evaluate(
336
+ async ({ selector, method }) => {
337
+ const found = [...document.querySelectorAll(selector)];
338
+ await Promise.all(found.map((el) => /** @type {any} */ (el)[method]()));
339
+ await new Promise((r) => requestAnimationFrame(() => r()));
340
+ },
341
+ { selector: waitFor.selector, method: waitFor.method },
342
+ );
343
+ if (waitFor.failOnConsole && lines.some((l) => l.includes(waitFor.failOnConsole))) {
344
+ return fail(waitFor.failOnConsole);
345
+ }
346
+ }
347
+ if (delay > 0) {
348
+ await page.waitForTimeout(delay);
349
+ }
350
+ // The owner's rule: always the whole canvas, never cropped to the content. That is the
351
+ // full page of the story iframe -- the viewport, or everything a scroll would reach when
352
+ // the story is taller or wider -- so every story of a project is the same size unless
353
+ // it overflows.
354
+ const png = await page.screenshot({ animations: "disabled", caret: "hide", fullPage: true });
355
+ return { png, reason: null, console: lines };
356
+ } catch (e) {
357
+ return fail(e.message);
358
+ } finally {
359
+ await context.close();
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Which build of this tool captured: the commit of its source checkout when it runs from one
365
+ * (a monorepo that develops it), else the installed package's name and version.
366
+ * @returns {string} a commit sha, or `@graphty/visual-review@<version>`
367
+ */
368
+ function toolVersion() {
369
+ const here = dirname(fileURLToPath(import.meta.url));
370
+ const pkg = JSON.parse(readFileSync(join(here, "../package.json"), "utf8"));
371
+ const installed = `${pkg.name}@${pkg.version}`;
372
+ if (here.split(sep).includes("node_modules")) {
373
+ return installed;
374
+ }
375
+ try {
376
+ return git("-C", here, "rev-parse", "HEAD");
377
+ } catch {
378
+ return installed;
379
+ }
380
+ }
381
+
382
+ const clip = (lines) => lines.slice(0, MAX_CONSOLE).map((l) => l.slice(0, MAX_LINE));
383
+
384
+ const git = (...args) => execFileSync("git", args, { encoding: "utf8", maxBuffer: 1 << 30 }).trim();
385
+
386
+ /**
387
+ * Where this run came from: GitHub Actions' environment in CI, the working tree locally.
388
+ * @returns {Promise<object>} the commit, pull request and run fields of results.json, and `local`
389
+ */
390
+ async function provenance() {
391
+ if (process.env.GITHUB_ACTIONS !== "true") {
392
+ const diff = execFileSync("git", ["diff", "HEAD", "--binary"], { maxBuffer: 1 << 30 });
393
+ return {
394
+ commit: git("rev-parse", "HEAD"),
395
+ headSha: null,
396
+ pr: null,
397
+ runId: null,
398
+ runAttempt: null,
399
+ local: { describe: git("describe", "--always", "--dirty"), diff: sha256(diff) },
400
+ };
401
+ }
402
+ const event = process.env.GITHUB_EVENT_PATH
403
+ ? JSON.parse(await readFile(process.env.GITHUB_EVENT_PATH, "utf8"))
404
+ : {};
405
+ return {
406
+ commit: process.env.GITHUB_SHA,
407
+ headSha: event.pull_request?.head?.sha ?? null,
408
+ pr: event.pull_request?.number ?? null,
409
+ runId: Number(process.env.GITHUB_RUN_ID),
410
+ runAttempt: Number(process.env.GITHUB_RUN_ATTEMPT),
411
+ local: null,
412
+ };
413
+ }
414
+
415
+ /**
416
+ * Whether any font on this machine draws emoji, asked of fontconfig with one common emoji
417
+ * (U+1F680). Without one, every emoji in a story renders as an empty box, so capture warns.
418
+ * ponytail: one code point, a machine-level check; the pinned fonts of milestone 2 replace it.
419
+ * @returns {boolean | null} null when fc-list is not installed
420
+ */
421
+ export function hasEmojiFont() {
422
+ try {
423
+ return execFileSync("fc-list", [":charset=1f680", "family"], { encoding: "utf8" }).trim() !== "";
424
+ } catch {
425
+ return null;
426
+ }
427
+ }
428
+
429
+ async function cpuModel() {
430
+ try {
431
+ return /^model name\s*:\s*(.*)$/m.exec(await readFile("/proc/cpuinfo", "utf8"))?.[1] ?? null;
432
+ } catch {
433
+ return null;
434
+ }
435
+ }
436
+
437
+ /**
438
+ * Reads an earlier capture's images of stories that had no baseline, to compare new captures with.
439
+ * Only complete, valid results are used, and only `new` items (captured twice and stable) whose
440
+ * file hashes to the capture results.json names.
441
+ * @param {string | null} dir the earlier capture's directory, or null
442
+ * @returns {Promise<{ images: Map<string, Buffer>, runId: number | null }>} images by file name
443
+ */
444
+ async function loadReference(dir) {
445
+ const images = new Map();
446
+ let results = null;
447
+ try {
448
+ results = dir ? JSON.parse(await readFile(join(dir, "results.json"), "utf8")) : null;
449
+ } catch {
450
+ // No reference downloaded: every story without a baseline is new.
451
+ }
452
+ if (!results || validateResults(results).length > 0 || !results.complete) {
453
+ return { images, runId: null };
454
+ }
455
+ for (const item of results.items.filter((i) => i.status === "new")) {
456
+ const bytes = await readFile(join(dir, item.file)).catch(() => null);
457
+ if (bytes && sha256(bytes) === item.capture) {
458
+ images.set(item.file, bytes);
459
+ }
460
+ }
461
+ return { images, runId: results.runId };
462
+ }
463
+
464
+ /**
465
+ * Captures one project.
466
+ * @param {{ project: string, storybook: string, baselines: string, out: string, workers: number,
467
+ * waitFor?: object | null, reference?: string | null, stories?: string[] | null,
468
+ * log?: (line: string) => void }} options `waitFor` is the project's config entry (see
469
+ * shootOnce); `reference` is the default branch's capture (see above); `stories` keeps only
470
+ * the story ids starting with one of these prefixes, for a quick local preview, and then no
471
+ * baseline is reported removed
472
+ * @returns {Promise<object>} the final results.json contents
473
+ */
474
+ export async function capture({
475
+ project,
476
+ storybook,
477
+ baselines,
478
+ out,
479
+ workers,
480
+ waitFor = null,
481
+ reference = null,
482
+ stories = null,
483
+ log = console.log,
484
+ }) {
485
+ const started = Date.now();
486
+ await mkdir(join(out, "baselines"), { recursive: true });
487
+ await mkdir(join(out, "second"), { recursive: true });
488
+ const ids = storyIds(JSON.parse(await readFile(join(storybook, "index.json"), "utf8"))).filter(
489
+ (id) => !stories || stories.some((p) => id.startsWith(p)),
490
+ );
491
+ const refs = await loadReference(reference);
492
+ const [server, base] = await serve(storybook);
493
+ // One browser per worker: every page of a browser shares its one GPU process, so with
494
+ // SwiftShader a busy WebGL page on one worker stalls the others' renders and screenshots.
495
+ const browsers = await Promise.all(
496
+ Array.from({ length: Math.max(1, workers) }, () =>
497
+ chromium.launch({ args: CHROMIUM_ARGS, env: { ...process.env, TZ: "UTC" } }),
498
+ ),
499
+ );
500
+ const [browser] = browsers;
501
+ try {
502
+ const { params, renderer, gpu } = await extract(browser, base);
503
+
504
+ const jobs = [];
505
+ const items = [];
506
+ const planned = new Set();
507
+ const existing = new Set(await pngsIn(baselines));
508
+ // A story whose own parameters exclude it while it still has a baseline is reported as
509
+ // removed, so a pull request cannot drop a story from review without the owner seeing it.
510
+ const newlyExcluded = new Set();
511
+ for (const id of ids) {
512
+ const s = storySettings(params[id] ?? {}, await loadSettings(baselines, id));
513
+ for (const mode of s.modes) {
514
+ const file = fileName(id, mode.name);
515
+ const common = { id, mode: mode.name, file, threshold: s.threshold, includeAA: s.includeAA };
516
+ if (s.excludedByStory && existing.has(file)) {
517
+ newlyExcluded.add(file);
518
+ continue;
519
+ }
520
+ planned.add(file);
521
+ if (s.disableSnapshot) {
522
+ items.push({ ...common, ...EMPTY, status: "excluded", reason: s.reason });
523
+ } else {
524
+ jobs.push({ ...common, url: base + storyUrl(id, mode.globals), delay: s.delay });
525
+ }
526
+ }
527
+ }
528
+ const gone = stories ? [] : [...existing].filter((f) => !planned.has(f) && BASELINE_NAME.test(f));
529
+
530
+ const emojiFont = hasEmojiFont();
531
+ if (emojiFont === false) {
532
+ log(
533
+ "warning: no font on this machine draws emoji (fc-list :charset=1f680 found none), so " +
534
+ "every emoji in a story is captured as an empty box; install fonts-noto-color-emoji",
535
+ );
536
+ }
537
+ const results = {
538
+ version: 1,
539
+ project,
540
+ ...(await provenance()),
541
+ seeded: await hasBaselines(baselines),
542
+ reference: refs.runId,
543
+ complete: false,
544
+ expected: items.length + jobs.length + gone.length,
545
+ capturedAt: new Date().toISOString(),
546
+ clock: { start: CLOCK_START, running: true },
547
+ scale: SCALE,
548
+ environment: {
549
+ chromium: browser.version(),
550
+ renderer,
551
+ gpu,
552
+ cpu: await cpuModel(),
553
+ emojiFont,
554
+ tool: toolVersion(),
555
+ },
556
+ items,
557
+ };
558
+ let saving = Promise.resolve();
559
+ const save = () => (saving = saving.then(() => writeResults(out, results)));
560
+ await save();
561
+
562
+ for (const file of gone) {
563
+ const baseline = await readBaseline(join(baselines, file));
564
+ const [id, mode = null] = file.slice(0, -4).split(".");
565
+ await writeFile(join(out, "baselines", file), baseline);
566
+ items.push({
567
+ id,
568
+ mode,
569
+ file,
570
+ ...classify({ baseline, first: null, threshold: DEFAULT_THRESHOLD, includeAA: false }),
571
+ threshold: DEFAULT_THRESHOLD,
572
+ includeAA: false,
573
+ reason: newlyExcluded.has(file) ? "the story's parameters now set disableSnapshot" : null,
574
+ console: [],
575
+ });
576
+ }
577
+
578
+ const run = async (browser, job) => {
579
+ const { url, delay, ...common } = job;
580
+ const baseline = await readBaseline(join(baselines, job.file));
581
+ const reference = baseline ? null : (refs.images.get(job.file) ?? null);
582
+ const opts = { threshold: job.threshold, includeAA: job.includeAA, reference };
583
+ const failed = (shot, prefix = "") => ({
584
+ ...common,
585
+ ...EMPTY,
586
+ baseline: baseline && sha256(baseline),
587
+ status: "failed",
588
+ reason: prefix + shot.reason,
589
+ console: clip(shot.console),
590
+ });
591
+ const first = await shoot(browser, url, { delay, waitFor });
592
+ if (!first.png) {
593
+ return failed(first);
594
+ }
595
+ let result = classify({ baseline, first: first.png, ...opts });
596
+ let second = null;
597
+ if (result.status === "changed" || result.status === "new") {
598
+ second = await shoot(browser, url, { delay, waitFor });
599
+ if (!second.png) {
600
+ return failed(second, "second capture: ");
601
+ }
602
+ result = classify({ baseline, first: first.png, second: second.png, ...opts });
603
+ }
604
+ const { status } = result;
605
+ if (["changed", "new", "unseeded", "unstable"].includes(status)) {
606
+ await writeFile(join(out, job.file), first.png);
607
+ }
608
+ if (status === "unstable") {
609
+ await writeFile(join(out, "second", job.file), second.png);
610
+ }
611
+ if (baseline && (status === "changed" || status === "unstable")) {
612
+ await writeFile(join(out, "baselines", job.file), baseline);
613
+ }
614
+ const lines = status === "unchanged" ? [] : clip([...first.console, ...(second?.console ?? [])]);
615
+ return { ...common, ...result, reason: null, console: lines };
616
+ };
617
+
618
+ let next = 0;
619
+ await Promise.all(
620
+ browsers.map(async (b) => {
621
+ while (next < jobs.length) {
622
+ const item = await run(b, jobs[next++]);
623
+ items.push(item);
624
+ if (item.status !== "unchanged") {
625
+ log(`${item.status} ${item.file}${item.reason ? `: ${item.reason}` : ""}`);
626
+ }
627
+ await save();
628
+ }
629
+ }),
630
+ );
631
+
632
+ items.sort((a, b) => (a.file < b.file ? -1 : 1));
633
+ // Validated before it is saved as complete, so an invalid file is never uploaded as finished.
634
+ const problems = validateResults({ ...results, complete: true });
635
+ if (problems.length > 0) {
636
+ throw new Error(`results.json is invalid:\n${problems.slice(0, 20).join("\n")}`);
637
+ }
638
+ results.complete = true;
639
+ await save();
640
+ const counts = {};
641
+ for (const item of items) {
642
+ counts[item.status] = (counts[item.status] ?? 0) + 1;
643
+ }
644
+ log(JSON.stringify({ project, items: items.length, seconds: (Date.now() - started) / 1000, counts }));
645
+ return results;
646
+ } finally {
647
+ await Promise.all(browsers.map((b) => b.close()));
648
+ server.close();
649
+ }
650
+ }
651
+
652
+ const EMPTY = {
653
+ flaky: false,
654
+ baseline: null,
655
+ capture: null,
656
+ size: null,
657
+ baselineSize: null,
658
+ changedPixels: null,
659
+ bbox: null,
660
+ console: [],
661
+ };
662
+
663
+ const BASELINE_NAME = /^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)?\.png$/;
664
+
665
+ async function writeResults(out, results) {
666
+ const tmp = join(out, "results.json.tmp");
667
+ await writeFile(tmp, `${JSON.stringify(results, null, 2)}\n`);
668
+ await rename(tmp, join(out, "results.json"));
669
+ }