@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,334 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * visual-review: capture Storybook stories, compare them with the baselines in git, and serve
4
+ * the page where the owner accepts or rejects the differences.
5
+ *
6
+ * Every command runs in a git checkout and reads its settings from visual-review.config.json at
7
+ * the checkout's root (see config.mjs and the README). `visual-review <command> --help` describes
8
+ * each command. Each command's modules are imported only when it runs, so `gate` needs nothing
9
+ * beyond Node's standard library and runs from a checkout without an install.
10
+ */
11
+
12
+ import { readFile, readdir } from "node:fs/promises";
13
+ import { join, resolve } from "node:path";
14
+ import { parseArgs } from "node:util";
15
+
16
+ const HELP = {
17
+ init: `usage: visual-review init [--force]
18
+
19
+ Sets up visual review in the current git repository. Writes, at the repository's root:
20
+ visual-review.config.json the projects to capture and the repository's settings
21
+ .gitattributes a rule storing the baseline PNGs in Git LFS
22
+ .gitignore an entry for the work directory (downloads, review state)
23
+ .github/workflows/<workflow> captures every pull request and push, and gates pull requests
24
+ .github/workflows/visual-seed.yml captures an older commit, to seed baselines from
25
+
26
+ An existing file is kept, and a line already present is not added again. --force rewrites the
27
+ workflows init wrote before (they start with "Generated by visual-review init"), to pick up a
28
+ newer template; it never replaces the config or a workflow you wrote. Edit
29
+ visual-review.config.json afterwards: it starts with one Storybook built by
30
+ \`<package manager> run build-storybook\` into storybook-static/.`,
31
+
32
+ capture: `usage: visual-review capture --project <id> --out <dir> [options]
33
+
34
+ Captures every story of one built Storybook and writes results.json and the PNGs to review into
35
+ <dir>. Exits 0 whatever it finds; non-zero only when the tool itself fails, including a baseline
36
+ that is an LFS pointer (run \`git lfs pull\`).
37
+
38
+ --project <id> a project of visual-review.config.json
39
+ --out <dir> where results.json and the PNGs go
40
+ --storybook <dir> the built Storybook (default: the project's "storybook")
41
+ --baselines <dir> the baselines (default: <baselines>/<id> from the config)
42
+ --workers <n> browsers in parallel (default: the project's "workers")
43
+ --reference <dir> the default branch's capture (from \`reference\`): a story with no
44
+ baseline that looks as it does there is "unseeded", not "new"
45
+ --stories <prefix,...> only the story ids starting with a prefix, for a quick local preview`,
46
+
47
+ reference: `usage: visual-review reference --project <id> --out <dir>
48
+
49
+ Downloads the default branch's newest complete capture of the project with gh into <dir> and
50
+ prints its directory, or prints nothing when there is none. CI runs it before \`capture\`.`,
51
+
52
+ compare: `usage: visual-review compare --baselines <dir> --captures <dir> [--threshold <0..1>] [--include-aa]
53
+
54
+ Compares every PNG in the two directories by name and prints one JSON line per file that is not
55
+ unchanged, then a summary. Exits 1 when anything differs. For local use.`,
56
+
57
+ serve: `usage: PORT=<n> visual-review serve [--master-run <id>] [--results <dir>]
58
+
59
+ Serves the review page on $PORT, bound to $HOST (default localhost). With $HTTPS_CERT_PATH and
60
+ $HTTPS_KEY_PATH set it serves HTTPS; without them, plain HTTP, and then only on a loopback HOST.
61
+ Lists the open pull requests that have a run of the config's workflow and downloads their
62
+ captures with gh. Refuses to start without git-lfs, because an accept would commit raw PNGs.
63
+ The URL to open, with its session token, is printed at every start.
64
+
65
+ --master-run <id> also list the default branch at that workflow run, for seeding baselines
66
+ --results <dir> serve a local directory of <project>/results.json instead, offline, as a
67
+ preview to look at: gh is never run, nothing can be decided, no Finish`,
68
+
69
+ gate: null, // gate.mjs's own usage
70
+
71
+ "install-browser": `usage: visual-review install-browser
72
+
73
+ Installs the Chromium, and on Linux the system libraries, that this package's Playwright uses
74
+ (\`playwright install --with-deps chromium\` of the same Playwright version capture imports).`,
75
+ };
76
+
77
+ const COMMANDS = {
78
+ init,
79
+ capture,
80
+ reference,
81
+ compare,
82
+ serve,
83
+ gate,
84
+ "install-browser": installBrowser,
85
+ };
86
+
87
+ const MAIN_HELP = `usage: visual-review <command> [options]
88
+
89
+ Screenshots every story of your Storybooks in CI, compares them with baseline PNGs kept in git
90
+ (Git LFS), and serves a local page where you accept or reject each difference.
91
+
92
+ Commands:
93
+ init set up a repository: config, .gitattributes, GitHub Actions workflows
94
+ capture screenshot one project's built Storybook and compare with its baselines
95
+ reference download the default branch's newest capture (CI, before capture)
96
+ gate fail a pull request that holds changes nobody accepted (CI)
97
+ serve the review page
98
+ compare compare two directories of PNGs (local use)
99
+ install-browser install the Chromium capture uses
100
+
101
+ \`visual-review <command> --help\` describes each command. Documentation:
102
+ https://graphty.app/docs/visual-review/`;
103
+
104
+ /**
105
+ * The repository and its config, loaded only by the commands that need them.
106
+ * @returns {Promise<{ root: string, config: object }>} the checkout's root and its settings
107
+ */
108
+ async function settings() {
109
+ const { loadConfig, repoRoot } = await import("./lib/config.mjs");
110
+ const root = repoRoot();
111
+ return { root, config: loadConfig(root) };
112
+ }
113
+
114
+ async function capture(args) {
115
+ const { values } = parseArgs({
116
+ args,
117
+ options: {
118
+ project: { type: "string" },
119
+ out: { type: "string" },
120
+ storybook: { type: "string" },
121
+ baselines: { type: "string" },
122
+ workers: { type: "string" },
123
+ reference: { type: "string" },
124
+ stories: { type: "string" },
125
+ },
126
+ });
127
+ const { root, config } = await settings();
128
+ const project = Object.hasOwn(config.projects, values.project ?? "") ? config.projects[values.project] : undefined;
129
+ const workers = Number(values.workers ?? project?.workers);
130
+ if (!project || !values.out || !(Number.isInteger(workers) && workers > 0)) {
131
+ console.error(`${HELP.capture}\n\nprojects: ${Object.keys(config.projects).join(", ")}`);
132
+ return 2;
133
+ }
134
+ // Playwright is capture's only dependency, so it loads only for this command.
135
+ const { capture: run } = await import("../capture/capture.mjs");
136
+ await run({
137
+ project: values.project,
138
+ storybook: resolve(values.storybook ?? join(root, project.storybook)),
139
+ baselines: resolve(values.baselines ?? join(root, config.baselines, values.project)),
140
+ out: resolve(values.out),
141
+ workers,
142
+ waitFor: project.waitFor,
143
+ reference: values.reference ? resolve(values.reference) : null,
144
+ stories: values.stories ? values.stories.split(",").filter(Boolean) : null,
145
+ });
146
+ return 0;
147
+ }
148
+
149
+ async function reference(args) {
150
+ const { values } = parseArgs({ args, options: { project: { type: "string" }, out: { type: "string" } } });
151
+ if (!values.project || !values.out) {
152
+ console.error(HELP.reference);
153
+ return 2;
154
+ }
155
+ const { root, config } = await settings();
156
+ const { ghRunner, newestMasterCapture } = await import("./lib/github.mjs");
157
+ console.log((await newestMasterCapture(ghRunner(root), values.project, resolve(values.out), config)) ?? "");
158
+ return 0;
159
+ }
160
+
161
+ const LOOPBACK = new Set(["localhost", "127.0.0.1", "::1"]);
162
+
163
+ async function serve(args) {
164
+ if (args.includes("--branch")) {
165
+ console.error("visual-review serve: --branch is gone: a --results preview is look only and has no Finish");
166
+ return 2;
167
+ }
168
+ const { values } = parseArgs({
169
+ args,
170
+ options: {
171
+ "master-run": { type: "string" },
172
+ results: { type: "string" },
173
+ },
174
+ });
175
+ const { PORT, HOST = "localhost", HTTPS_CERT_PATH, HTTPS_KEY_PATH } = process.env;
176
+ const masterRun = values["master-run"] === undefined ? undefined : Number(values["master-run"]);
177
+ const https = Boolean(HTTPS_CERT_PATH && HTTPS_KEY_PATH);
178
+ if (!PORT || (masterRun !== undefined && !Number.isInteger(masterRun))) {
179
+ console.error(HELP.serve);
180
+ return 2;
181
+ }
182
+ if (!https && !LOOPBACK.has(HOST)) {
183
+ console.error(
184
+ `visual-review serve: HOST=${HOST} is reachable from other machines, so the page needs HTTPS: ` +
185
+ "set HTTPS_CERT_PATH and HTTPS_KEY_PATH, or serve on localhost",
186
+ );
187
+ return 2;
188
+ }
189
+ const { root, config } = await settings();
190
+ const { readFileSync } = await import("node:fs");
191
+ const { lfsProblem } = await import("./lib/accept.mjs");
192
+ const { ghRunner } = await import("./lib/github.mjs");
193
+ const { createApp, sessionToken } = await import("./lib/serve.mjs");
194
+ const lfs = await lfsProblem(root);
195
+ if (lfs) {
196
+ console.error(`visual-review serve: ${lfs}`);
197
+ return 1;
198
+ }
199
+ const tmp = join(root, config.workDir);
200
+ const token = sessionToken(join(tmp, "state"));
201
+ const origin = `${https ? "https" : "http"}://${HOST.includes(":") ? `[${HOST}]` : HOST}:${PORT}`;
202
+ // Offline: a local results directory is a preview, so nothing is posted to GitHub.
203
+ const offline = async (ghArgs, input) => {
204
+ console.log(`gh (offline, not run): ${ghArgs.join(" ")}\n${input ?? ""}`);
205
+ return JSON.stringify({ html_url: null });
206
+ };
207
+ const certs = https ? `HTTPS_CERT_PATH=${HTTPS_CERT_PATH} HTTPS_KEY_PATH=${HTTPS_KEY_PATH} ` : "";
208
+ const app = createApp({
209
+ repo: root,
210
+ gh: values.results ? offline : ghRunner(root),
211
+ config,
212
+ tmp,
213
+ token,
214
+ origin,
215
+ masterRun,
216
+ results: values.results && resolve(values.results),
217
+ // What the owner types to run this server from their own shell, so Finish signs with
218
+ // their key rather than the environment of whoever started it (an agent, say).
219
+ startCommand:
220
+ `cd ${root} && PORT=${PORT} HOST=${HOST} ${certs}node ${process.argv[1]} serve ${args.join(" ")}`.trim(),
221
+ });
222
+ const server = https
223
+ ? (await import("node:https")).createServer(
224
+ { cert: readFileSync(HTTPS_CERT_PATH), key: readFileSync(HTTPS_KEY_PATH) },
225
+ app,
226
+ )
227
+ : (await import("node:http")).createServer(app);
228
+ await new Promise((done) => server.listen({ port: Number(PORT), host: HOST }, () => done(null)));
229
+ console.log(`visual-review: open ${origin}/#token=${token}`);
230
+ // Keep running until the process is stopped.
231
+ await new Promise(() => {});
232
+ return 0;
233
+ }
234
+
235
+ async function compare(args) {
236
+ const { values } = parseArgs({
237
+ args,
238
+ options: {
239
+ baselines: { type: "string" },
240
+ captures: { type: "string" },
241
+ threshold: { type: "string" },
242
+ "include-aa": { type: "boolean", default: false },
243
+ },
244
+ });
245
+ const { classify, DEFAULT_THRESHOLD, readBaseline } = await import("./lib/compare.mjs");
246
+ const threshold = Number(values.threshold ?? DEFAULT_THRESHOLD);
247
+ if (!values.baselines || !values.captures || !(threshold >= 0 && threshold <= 1)) {
248
+ console.error(HELP.compare);
249
+ return 2;
250
+ }
251
+ const pngs = async (dir) => (await readdir(dir)).filter((f) => f.endsWith(".png"));
252
+ const read = (dir, file, present) =>
253
+ present.includes(file) ? (dir === values.baselines ? readBaseline : readFile)(join(dir, file)) : null;
254
+ const [baselines, captures] = await Promise.all([pngs(values.baselines), pngs(values.captures)]);
255
+ const counts = {};
256
+ for (const file of [...new Set([...baselines, ...captures])].sort()) {
257
+ const item = classify({
258
+ baseline: await read(values.baselines, file, baselines),
259
+ first: await read(values.captures, file, captures),
260
+ threshold,
261
+ includeAA: values["include-aa"],
262
+ });
263
+ counts[item.status] = (counts[item.status] ?? 0) + 1;
264
+ if (item.status !== "unchanged") {
265
+ console.log(JSON.stringify({ file, ...item }));
266
+ }
267
+ }
268
+ console.log(JSON.stringify({ summary: counts }));
269
+ return Object.keys(counts).some((status) => status !== "unchanged") ? 1 : 0;
270
+ }
271
+
272
+ async function gate(args) {
273
+ const { runGate } = await import("./gate.mjs");
274
+ return runGate(args);
275
+ }
276
+
277
+ async function init(args) {
278
+ const { values } = parseArgs({ args, options: { force: { type: "boolean", default: false } } });
279
+ const { repoRoot } = await import("./lib/config.mjs");
280
+ const { init: run } = await import("./lib/init.mjs");
281
+ const root = repoRoot();
282
+ for (const line of run(root, { force: values.force })) {
283
+ console.log(line);
284
+ }
285
+ return 0;
286
+ }
287
+
288
+ async function installBrowser() {
289
+ const { createRequire } = await import("node:module");
290
+ const { spawnSync } = await import("node:child_process");
291
+ const { dirname } = await import("node:path");
292
+ let pkg;
293
+ try {
294
+ pkg = createRequire(import.meta.url).resolve("playwright/package.json");
295
+ } catch {
296
+ console.error(
297
+ "visual-review: playwright is not installed; add it to your devDependencies (npm i -D playwright)",
298
+ );
299
+ return 1;
300
+ }
301
+ const cli = join(dirname(pkg), JSON.parse(await readFile(pkg, "utf8")).bin.playwright);
302
+ return spawnSync(process.execPath, [cli, "install", "--with-deps", "chromium"], { stdio: "inherit" }).status ?? 1;
303
+ }
304
+
305
+ const [name, ...rest] = process.argv.slice(2);
306
+ if (name === undefined || name === "--help" || name === "-h" || name === "help") {
307
+ console.log(MAIN_HELP);
308
+ process.exit(name === undefined ? 2 : 0);
309
+ }
310
+ if (name === "--version" || name === "-v") {
311
+ console.log(JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")).version);
312
+ process.exit(0);
313
+ }
314
+ const run = Object.hasOwn(COMMANDS, name) ? COMMANDS[name] : undefined;
315
+ if (run === undefined) {
316
+ console.error(`visual-review: unknown command "${name}"\n\n${MAIN_HELP}`);
317
+ process.exit(2);
318
+ }
319
+ if (rest.includes("--help") || rest.includes("-h")) {
320
+ const text = HELP[name] ?? (await import("./gate.mjs")).GATE_USAGE;
321
+ console.log(text);
322
+ process.exit(0);
323
+ }
324
+ try {
325
+ process.exitCode = await run(rest);
326
+ } catch (err) {
327
+ // A bad option, a missing config or a failed git call: say what, without a stack.
328
+ if (err?.code?.startsWith?.("ERR_PARSE_ARGS") || /visual-review\.config\.json|git repository/.test(err?.message)) {
329
+ console.error(`visual-review ${name}: ${err.message}`);
330
+ process.exitCode = 2;
331
+ } else {
332
+ throw err;
333
+ }
334
+ }
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The pull request gate: fails while a project that has baselines on the base branch holds visual
4
+ * changes the owner has not reviewed.
5
+ *
6
+ * Seeding is per story, so a seeded project can hold stories with no baseline yet. Those that the
7
+ * pull request did not change are `unseeded` (capture compared them with master's newest capture)
8
+ * and pass; a story the pull request adds or changes is `new` and blocks until the owner accepts
9
+ * it there, which creates its first baseline.
10
+ *
11
+ * <dir> holds the downloaded `visual-<project>-<attempt>` artifacts of this CI run, every attempt
12
+ * of it. For each project only the highest attempt counts, so re-running failed jobs (which
13
+ * leaves the visual jobs' old attempt as the newest) can neither hide nor resurrect a capture.
14
+ * Which projects exist and are seeded is read from <ref> (the base branch tip, fetched by the
15
+ * caller), not from the pull request, and so is visual-review.config.json (where the baselines
16
+ * live), so neither deleting a project's baselines nor moving the baselines directory in the pull
17
+ * request turns the gate off. A seeded project with no results.json, or an incomplete one, fails: a capture that
18
+ * crashed has shown the owner nothing. An invalid results.json counts as missing.
19
+ *
20
+ * It also fails when a baseline PNG, or a settings file that excludes a story, differs from the
21
+ * base without a review record added in the pull request (<baselines>/reviews/*.json) naming
22
+ * that path and its new hash. Without that, committing the captured PNGs straight into
23
+ * the baselines directory would turn the capture check green with no review at all. Only a
24
+ * record's items[].path and items[].to are read, so this proves a record names the change, not
25
+ * that Finish wrote it or the owner pressed it (the README, "What the gate does and does not
26
+ * guarantee").
27
+ *
28
+ * Usage: visual-review gate --captures <dir> --base <ref> [--head <ref>], or node gate.mjs with
29
+ * the same options. Standard library only (results.mjs and config.mjs have no dependencies), so
30
+ * it runs from a checkout of this package without an install.
31
+ */
32
+
33
+ import { execFileSync } from "node:child_process";
34
+ import { createHash } from "node:crypto";
35
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
36
+ import { join } from "node:path";
37
+ import { fileURLToPath } from "node:url";
38
+ import { parseArgs } from "node:util";
39
+
40
+ import { loadConfigAt, repoRoot } from "./lib/config.mjs";
41
+ import { validateResults } from "./lib/results.mjs";
42
+
43
+ const PASSING = new Set(["unchanged", "excluded", "unseeded"]);
44
+
45
+ /**
46
+ * The newest attempt's results.json of every project in a directory of downloaded artifacts.
47
+ * @param {string} dir the download directory, one subdirectory per artifact
48
+ * @returns {Record<string, { attempt: number, results: object | null }>} by project
49
+ */
50
+ export function newestResults(dir) {
51
+ /** @type {Record<string, { attempt: number, results: object | null }>} */
52
+ const out = {};
53
+ const names = existsSync(dir) ? readdirSync(dir) : [];
54
+ for (const name of names) {
55
+ const m = /^visual-(.+)-(\d+)$/.exec(name);
56
+ if (!m) {
57
+ continue;
58
+ }
59
+ const [, project, n] = m;
60
+ const attempt = Number(n);
61
+ if (out[project] && out[project].attempt > attempt) {
62
+ continue;
63
+ }
64
+ const file = join(dir, name, "results.json");
65
+ out[project] = { attempt, results: existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : null };
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /**
71
+ * What blocks the pull request.
72
+ * @param {{ projects: string[], seeded: Set<string>, captures: Record<string, { attempt: number,
73
+ * results: object | null }> }} input every captured project, those with baselines on the base
74
+ * branch, and the newest capture of each
75
+ * @returns {string[]} one line per blocked project; empty when the gate passes
76
+ */
77
+ export function gateProblems({ projects, seeded, captures }) {
78
+ const problems = [];
79
+ for (const p of projects) {
80
+ if (!seeded.has(p)) {
81
+ continue;
82
+ }
83
+ const r = captures[p]?.results;
84
+ if (!r) {
85
+ problems.push(`${p}: no capture results (the visual job failed or uploaded nothing); re-run it`);
86
+ continue;
87
+ }
88
+ const invalid = validateResults(r);
89
+ if (invalid.length > 0) {
90
+ problems.push(`${p}: results.json is invalid (${invalid[0]}); re-run the visual job`);
91
+ continue;
92
+ }
93
+ if (!r.complete) {
94
+ problems.push(`${p}: the capture did not finish (${r.items.length} of ${r.expected} items); re-run it`);
95
+ continue;
96
+ }
97
+ const open = r.items.map((i) => i.status).filter((s) => !PASSING.has(s));
98
+ if (open.length > 0) {
99
+ // No Object.groupBy: the gate runs on the runner's own Node, which may be 20.
100
+ const counts = new Map();
101
+ for (const s of open) {
102
+ counts.set(s, (counts.get(s) ?? 0) + 1);
103
+ }
104
+ problems.push(
105
+ `${p}: ${[...counts].map(([s, n]) => `${n} ${s}`).join(", ")} ` +
106
+ "(not accepted; a rejected item needs a code change, not another review)",
107
+ );
108
+ }
109
+ }
110
+ return problems;
111
+ }
112
+
113
+ const gitOut = (cwd, args) => execFileSync("git", args, { cwd, maxBuffer: 1 << 28 });
114
+
115
+ /**
116
+ * Baseline changes between two refs that no review record added between them accounts for.
117
+ * @param {string} base the base branch tip
118
+ * @param {string} head the pull request's checkout
119
+ * @param {string} [cwd] the repository
120
+ * @param {string} [baselines] the baselines directory
121
+ * @returns {string[]} one line per unaccounted change; empty when every change has a record
122
+ */
123
+ export function unrecordedChanges(base, head, cwd = process.cwd(), baselines = "visual-baselines") {
124
+ const fields = gitOut(cwd, ["diff", "-z", "--no-renames", "--name-status", base, head, "--", `${baselines}/`])
125
+ .toString("utf8")
126
+ .split("\0");
127
+ const show = (path) => gitOut(cwd, ["show", `${head}:${path}`]);
128
+ const problems = [];
129
+ const records = [];
130
+ const changed = [];
131
+ for (let i = 0; i + 1 < fields.length; i += 2) {
132
+ const [status, path] = [fields[i], fields[i + 1]];
133
+ if (path.startsWith(`${baselines}/reviews/`)) {
134
+ if (status === "A") {
135
+ records.push(path);
136
+ } else {
137
+ problems.push(`${path}: review records are append-only, but this one was changed or deleted`);
138
+ }
139
+ } else if (path.endsWith(".png")) {
140
+ changed.push({ path, hash: status === "D" ? null : contentHash(show(path)) });
141
+ } else if (path.endsWith(".json") && status !== "D") {
142
+ // ponytail: only settings that exclude a story need a record. Any other settings edit,
143
+ // and deleting a settings file, passes: the capture it causes is itself reviewed.
144
+ const bytes = show(path);
145
+ if (parseOr(bytes)?.disableSnapshot === true) {
146
+ changed.push({ path, hash: sha256(bytes) });
147
+ }
148
+ }
149
+ }
150
+ const reviewed = new Set();
151
+ for (const path of records) {
152
+ const items = parseOr(show(path))?.items;
153
+ for (const item of Array.isArray(items) ? items : []) {
154
+ reviewed.add(`${item?.path}\0${item?.to ?? null}`);
155
+ }
156
+ }
157
+ const missing = changed.filter((c) => !reviewed.has(`${c.path}\0${c.hash}`)).map((c) => c.path);
158
+ for (const path of missing.slice(0, 20)) {
159
+ problems.push(`${path}: changed with no review record naming its new contents`);
160
+ }
161
+ if (missing.length > 20) {
162
+ problems.push(`... and ${missing.length - 20} more baseline files with no review record`);
163
+ }
164
+ return problems;
165
+ }
166
+
167
+ const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
168
+
169
+ /**
170
+ * The SHA-256 of the file a blob stands for. Baseline PNGs are Git LFS pointers in git, and a
171
+ * pointer names its object's SHA-256 (`oid sha256:<hex>`), which is the PNG's own hash, so no
172
+ * LFS object is ever downloaded to check a record. Any other blob is hashed as it is.
173
+ * @param {Buffer} bytes the blob
174
+ * @returns {string} the hex SHA-256 of the content
175
+ */
176
+ export function contentHash(bytes) {
177
+ const text = bytes.subarray(0, 200).toString("latin1");
178
+ const oid = /^version https:\/\/git-lfs\.github\.com\/spec\/v1\noid sha256:([0-9a-f]{64})\n/.exec(text);
179
+ return oid ? oid[1] : sha256(bytes);
180
+ }
181
+
182
+ function parseOr(bytes) {
183
+ try {
184
+ return JSON.parse(bytes.toString("utf8"));
185
+ } catch {
186
+ return null;
187
+ }
188
+ }
189
+
190
+ /**
191
+ * The projects with at least one baseline PNG at a git ref: the directories under the baselines
192
+ * directory at the base tip, so a pull request cannot drop a project from the gate by editing
193
+ * visual-review.config.json.
194
+ * @param {string} ref the base branch tip
195
+ * @param {string} [cwd] the repository
196
+ * @param {string} [baselines] the baselines directory
197
+ * @returns {Set<string>} the seeded ones
198
+ */
199
+ export function seededAt(ref, cwd = process.cwd(), baselines = "visual-baselines") {
200
+ const files = execFileSync("git", ["ls-tree", "-r", "--name-only", ref, "--", `${baselines}/`], {
201
+ cwd,
202
+ encoding: "utf8",
203
+ maxBuffer: 1 << 28,
204
+ }).split("\n");
205
+ const prefix = `${baselines}/`;
206
+ return new Set(
207
+ files
208
+ .filter((f) => f.startsWith(prefix) && f.endsWith(".png"))
209
+ .map((f) => f.slice(prefix.length).split("/"))
210
+ .filter((parts) => parts.length > 1)
211
+ .map((parts) => parts[0]),
212
+ );
213
+ }
214
+
215
+ export const GATE_USAGE = `usage: visual-review gate --captures <dir> --base <ref> [--head <ref>]
216
+
217
+ Fails (exit 1) while a pull request holds visual changes nobody accepted, or a baseline change
218
+ with no review record. Run it in CI after the capture jobs, on the pull request's merge commit.
219
+
220
+ --captures <dir> the downloaded visual-<project>-<attempt> artifacts of this run
221
+ --base <ref> the base branch tip (HEAD^1 on a pull request's merge commit)
222
+ --head <ref> the pull request's checkout (default HEAD)`;
223
+
224
+ /**
225
+ * The gate as a command.
226
+ * @param {string[]} args the command line after "gate"
227
+ * @returns {number} the exit code
228
+ */
229
+ export function runGate(args) {
230
+ const { values } = parseArgs({
231
+ args,
232
+ options: {
233
+ captures: { type: "string" },
234
+ base: { type: "string" },
235
+ head: { type: "string", default: "HEAD" },
236
+ help: { type: "boolean", default: false },
237
+ },
238
+ });
239
+ if (values.help) {
240
+ console.log(GATE_USAGE);
241
+ return 0;
242
+ }
243
+ if (!values.captures || !values.base) {
244
+ console.error(GATE_USAGE);
245
+ return 2;
246
+ }
247
+ const root = repoRoot();
248
+ const { baselines } = loadConfigAt(values.base, root);
249
+ const seeded = seededAt(values.base, root, baselines);
250
+ const problems = gateProblems({
251
+ projects: [...seeded],
252
+ seeded,
253
+ captures: newestResults(values.captures),
254
+ });
255
+ for (const line of problems) {
256
+ console.log(`::error::visual changes not accepted -- ${line}`);
257
+ }
258
+ const unrecorded = unrecordedChanges(values.base, values.head, root, baselines);
259
+ for (const line of unrecorded) {
260
+ console.log(`::error::baseline without a review -- ${line}`);
261
+ }
262
+ if (problems.length + unrecorded.length > 0) {
263
+ console.log("Review them with `visual-review serve` (the @graphty/visual-review README).");
264
+ return 1;
265
+ }
266
+ console.log("No unaccepted visual changes, and every baseline change has a review record.");
267
+ return 0;
268
+ }
269
+
270
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
271
+ process.exitCode = runGate(process.argv.slice(2));
272
+ }