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