stateofpixel 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -0
- package/dist/index.mjs +1104 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +35 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { Command, InvalidArgumentError } from "commander";
|
|
4
|
+
import { copyFile, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { availableParallelism, tmpdir } from "node:os";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import pixelmatch from "pixelmatch";
|
|
9
|
+
import { PNG } from "pngjs";
|
|
10
|
+
import { execFile } from "node:child_process";
|
|
11
|
+
import { promisify } from "node:util";
|
|
12
|
+
|
|
13
|
+
//#region package.json
|
|
14
|
+
var version = "0.1.0";
|
|
15
|
+
|
|
16
|
+
//#endregion
|
|
17
|
+
//#region src/map-concurrent.ts
|
|
18
|
+
async function mapConcurrent(items, concurrency, fn) {
|
|
19
|
+
const results = new Array(items.length);
|
|
20
|
+
let nextIndex = 0;
|
|
21
|
+
async function worker() {
|
|
22
|
+
while (nextIndex < items.length) {
|
|
23
|
+
const index = nextIndex++;
|
|
24
|
+
results[index] = await fn(items[index], index);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
|
|
28
|
+
return results;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/png.ts
|
|
33
|
+
const PNG_SIGNATURE = Buffer.from([
|
|
34
|
+
137,
|
|
35
|
+
80,
|
|
36
|
+
78,
|
|
37
|
+
71,
|
|
38
|
+
13,
|
|
39
|
+
10,
|
|
40
|
+
26,
|
|
41
|
+
10
|
|
42
|
+
]);
|
|
43
|
+
function sha256(bytes) {
|
|
44
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
45
|
+
}
|
|
46
|
+
function readPngSize(bytes) {
|
|
47
|
+
if (bytes.length < 24 || !bytes.subarray(0, 8).equals(PNG_SIGNATURE)) throw new Error("Not a PNG file");
|
|
48
|
+
return {
|
|
49
|
+
width: bytes.readUInt32BE(16),
|
|
50
|
+
height: bytes.readUInt32BE(20)
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
//#endregion
|
|
55
|
+
//#region src/snapshots.ts
|
|
56
|
+
async function collectSnapshots(dir) {
|
|
57
|
+
const entries = await readdir(dir, {
|
|
58
|
+
recursive: true,
|
|
59
|
+
withFileTypes: true
|
|
60
|
+
});
|
|
61
|
+
const snapshots = /* @__PURE__ */ new Map();
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".png")) continue;
|
|
64
|
+
const filePath = path.join(entry.parentPath, entry.name);
|
|
65
|
+
snapshots.set(snapshotName(dir, filePath), filePath);
|
|
66
|
+
}
|
|
67
|
+
return new Map([...snapshots].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
|
|
68
|
+
}
|
|
69
|
+
function snapshotName(dir, filePath) {
|
|
70
|
+
return path.relative(dir, filePath).split(path.sep).join("/").slice(0, -4);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
//#endregion
|
|
74
|
+
//#region src/compare.ts
|
|
75
|
+
async function compareDirectories({ dir, baselineDir, outDir, engine, options }) {
|
|
76
|
+
const [snapshots, baselines] = await Promise.all([collectSnapshots(dir), collectSnapshots(baselineDir)]);
|
|
77
|
+
const imagesDir = path.join(outDir, "images");
|
|
78
|
+
const workDir = path.join(outDir, ".work");
|
|
79
|
+
await mkdir(imagesDir, { recursive: true });
|
|
80
|
+
await mkdir(workDir, { recursive: true });
|
|
81
|
+
const names = [.../* @__PURE__ */ new Set([...snapshots.keys(), ...baselines.keys()])].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
|
|
82
|
+
const results = await mapConcurrent(names, availableParallelism(), async (name, index) => {
|
|
83
|
+
const filePath = snapshots.get(name);
|
|
84
|
+
const baselinePath = baselines.get(name);
|
|
85
|
+
if (!filePath && baselinePath) return {
|
|
86
|
+
name,
|
|
87
|
+
status: "removed",
|
|
88
|
+
baselineImage: await storeImage(baselinePath, imagesDir)
|
|
89
|
+
};
|
|
90
|
+
if (!filePath) throw new Error(`Snapshot ${name} has no file`);
|
|
91
|
+
const image = await storeImage(filePath, imagesDir);
|
|
92
|
+
if (!baselinePath) return {
|
|
93
|
+
name,
|
|
94
|
+
status: "added",
|
|
95
|
+
image
|
|
96
|
+
};
|
|
97
|
+
const baselineImage = await storeImage(baselinePath, imagesDir);
|
|
98
|
+
if (image.file === baselineImage.file) return {
|
|
99
|
+
name,
|
|
100
|
+
status: "unchanged",
|
|
101
|
+
image,
|
|
102
|
+
baselineImage
|
|
103
|
+
};
|
|
104
|
+
const workPath = path.join(workDir, `${index}.png`);
|
|
105
|
+
const { diffPixels, diffRatio } = await engine.diff(baselinePath, filePath, workPath, options);
|
|
106
|
+
if (diffPixels === 0) {
|
|
107
|
+
await rm(workPath, { force: true });
|
|
108
|
+
return {
|
|
109
|
+
name,
|
|
110
|
+
status: "unchanged",
|
|
111
|
+
image,
|
|
112
|
+
baselineImage
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
name,
|
|
117
|
+
status: "changed",
|
|
118
|
+
image,
|
|
119
|
+
baselineImage,
|
|
120
|
+
diffImage: await storeImage(workPath, imagesDir, { move: true }),
|
|
121
|
+
diffPixels,
|
|
122
|
+
diffRatio
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
await rm(workDir, {
|
|
126
|
+
recursive: true,
|
|
127
|
+
force: true
|
|
128
|
+
});
|
|
129
|
+
return results;
|
|
130
|
+
}
|
|
131
|
+
async function storeImage(filePath, imagesDir, { move = false } = {}) {
|
|
132
|
+
const bytes = await readFile(filePath);
|
|
133
|
+
const file = `images/${sha256(bytes)}.png`;
|
|
134
|
+
const target = path.join(imagesDir, path.basename(file));
|
|
135
|
+
if (move) await rename(filePath, target);
|
|
136
|
+
else await copyFile(filePath, target);
|
|
137
|
+
return {
|
|
138
|
+
file,
|
|
139
|
+
...readPngSize(bytes)
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region src/diff/odiff.ts
|
|
145
|
+
async function createOdiffEngine() {
|
|
146
|
+
const { findBinary } = createRequire(import.meta.url)("odiff-bin/binary.js");
|
|
147
|
+
findBinary();
|
|
148
|
+
const { compare } = await import("odiff-bin");
|
|
149
|
+
return {
|
|
150
|
+
name: "odiff",
|
|
151
|
+
async diff(basePath, newPath, diffPath, options) {
|
|
152
|
+
const result = await compare(basePath, newPath, diffPath, {
|
|
153
|
+
threshold: options.threshold,
|
|
154
|
+
antialiasing: !options.includeAA,
|
|
155
|
+
outputDiffMask: true
|
|
156
|
+
});
|
|
157
|
+
if (result.match) return {
|
|
158
|
+
diffPixels: 0,
|
|
159
|
+
diffRatio: 0
|
|
160
|
+
};
|
|
161
|
+
if (result.reason === "pixel-diff") {
|
|
162
|
+
const [base, next] = await Promise.all([readPngSizeFromFile(basePath), readPngSizeFromFile(newPath)]);
|
|
163
|
+
const pixels = Math.max(base.width, next.width) * Math.max(base.height, next.height);
|
|
164
|
+
return {
|
|
165
|
+
diffPixels: result.diffCount,
|
|
166
|
+
diffRatio: result.diffCount / pixels
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
throw new Error(`odiff could not compare ${newPath}: ${result.reason}`);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
async function readPngSizeFromFile(filePath) {
|
|
174
|
+
const file = await open(filePath);
|
|
175
|
+
try {
|
|
176
|
+
const { buffer } = await file.read(Buffer.alloc(24), 0, 24, 0);
|
|
177
|
+
return readPngSize(buffer);
|
|
178
|
+
} finally {
|
|
179
|
+
await file.close();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
//#endregion
|
|
184
|
+
//#region src/diff/pixelmatch.ts
|
|
185
|
+
function createPixelmatchEngine() {
|
|
186
|
+
return {
|
|
187
|
+
name: "pixelmatch",
|
|
188
|
+
async diff(basePath, newPath, diffPath, options) {
|
|
189
|
+
const [base, next] = await Promise.all([readPng(basePath), readPng(newPath)]);
|
|
190
|
+
const width = Math.max(base.width, next.width);
|
|
191
|
+
const height = Math.max(base.height, next.height);
|
|
192
|
+
const diff = new PNG({
|
|
193
|
+
width,
|
|
194
|
+
height
|
|
195
|
+
});
|
|
196
|
+
const diffPixels = pixelmatch(padTo(base, width, height), padTo(next, width, height), diff.data, width, height, {
|
|
197
|
+
threshold: options.threshold,
|
|
198
|
+
includeAA: options.includeAA,
|
|
199
|
+
diffMask: true
|
|
200
|
+
});
|
|
201
|
+
if (diffPixels > 0) await writeFile(diffPath, PNG.sync.write(diff));
|
|
202
|
+
return {
|
|
203
|
+
diffPixels,
|
|
204
|
+
diffRatio: diffPixels / (width * height)
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
async function readPng(filePath) {
|
|
210
|
+
return PNG.sync.read(await readFile(filePath));
|
|
211
|
+
}
|
|
212
|
+
function padTo(image, width, height) {
|
|
213
|
+
if (image.width === width && image.height === height) return image.data;
|
|
214
|
+
const padded = new Uint8Array(width * height * 4);
|
|
215
|
+
const rowBytes = image.width * 4;
|
|
216
|
+
for (let y = 0; y < image.height; y++) padded.set(image.data.subarray(y * rowBytes, (y + 1) * rowBytes), y * width * 4);
|
|
217
|
+
return padded;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
//#endregion
|
|
221
|
+
//#region src/diff/engine.ts
|
|
222
|
+
async function createDiffEngine() {
|
|
223
|
+
try {
|
|
224
|
+
return await createOdiffEngine();
|
|
225
|
+
} catch {
|
|
226
|
+
return createPixelmatchEngine();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
//#endregion
|
|
231
|
+
//#region src/report/template.ts
|
|
232
|
+
function renderReport(snapshots, meta) {
|
|
233
|
+
const data = JSON.stringify({
|
|
234
|
+
meta,
|
|
235
|
+
snapshots
|
|
236
|
+
}).replace(/</g, "\\u003c");
|
|
237
|
+
return `<!doctype html>
|
|
238
|
+
<html lang="en">
|
|
239
|
+
<head>
|
|
240
|
+
<meta charset="utf-8">
|
|
241
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
242
|
+
<title>stateofpixel report</title>
|
|
243
|
+
<style>${STYLES}</style>
|
|
244
|
+
</head>
|
|
245
|
+
<body>
|
|
246
|
+
<header class="header">
|
|
247
|
+
<div class="title"><strong>stateofpixel</strong> <span class="muted mono" id="dirs"></span></div>
|
|
248
|
+
<div class="counts" id="counts"></div>
|
|
249
|
+
</header>
|
|
250
|
+
<div class="layout">
|
|
251
|
+
<aside class="sidebar">
|
|
252
|
+
<input class="filter" id="filter" type="search" placeholder="Filter snapshots" aria-label="Filter snapshots">
|
|
253
|
+
<div id="list" role="listbox" aria-label="Snapshots"></div>
|
|
254
|
+
</aside>
|
|
255
|
+
<main class="viewer" id="viewer"></main>
|
|
256
|
+
</div>
|
|
257
|
+
<script id="data" type="application/json">${data}<\/script>
|
|
258
|
+
<script>${SCRIPT}<\/script>
|
|
259
|
+
</body>
|
|
260
|
+
</html>
|
|
261
|
+
`;
|
|
262
|
+
}
|
|
263
|
+
const STYLES = `
|
|
264
|
+
:root {
|
|
265
|
+
color-scheme: light dark;
|
|
266
|
+
--bg: #fafafa; --surface: #ffffff; --surface-2: #f2f2f2; --hover: #ebebeb; --border: #ebebeb;
|
|
267
|
+
--text: #171717; --muted: #666666; --accent: #171717; --accent-fg: #ffffff; --link: #0068d6;
|
|
268
|
+
--unchanged: #666666; --unchanged-bg: #f2f2f2; --changed: #a35200; --changed-bg: #fff6e6;
|
|
269
|
+
--added: #0068d6; --added-bg: #f0f7ff; --removed: #7820bc; --removed-bg: #f9f0ff;
|
|
270
|
+
--canvas: #f2f2f2; --checker-a: #ffffff; --checker-b: #e6e6e6;
|
|
271
|
+
--sans: "IBM Plex Sans Variable", "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
|
|
272
|
+
--mono: "Lilex Variable", "Lilex", ui-monospace, "SF Mono", monospace;
|
|
273
|
+
}
|
|
274
|
+
@media (prefers-color-scheme: dark) {
|
|
275
|
+
:root {
|
|
276
|
+
--bg: #000000; --surface: #0a0a0a; --surface-2: #1a1a1a; --hover: #1f1f1f; --border: #2e2e2e;
|
|
277
|
+
--text: #ededed; --muted: #a1a1a1; --accent: #ededed; --accent-fg: #0a0a0a; --link: #52a8ff;
|
|
278
|
+
--unchanged: #a1a1a1; --unchanged-bg: #1a1a1a; --changed: #f2a20d; --changed-bg: #291800;
|
|
279
|
+
--added: #52a8ff; --added-bg: #0f1c2e; --removed: #bf7af0; --removed-bg: #231528;
|
|
280
|
+
--canvas: #000000; --checker-a: #1a1a1a; --checker-b: #292929;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
* { box-sizing: border-box; }
|
|
284
|
+
html, body { height: 100%; margin: 0; }
|
|
285
|
+
body { display: flex; flex-direction: column; background: var(--bg); color: var(--text); font: 400 13px/20px var(--sans); -webkit-font-smoothing: antialiased; }
|
|
286
|
+
:focus-visible { outline: 2px solid var(--link); outline-offset: 2px; }
|
|
287
|
+
.muted { color: var(--muted); }
|
|
288
|
+
.mono { font-family: var(--mono); }
|
|
289
|
+
.num { font-variant-numeric: tabular-nums; }
|
|
290
|
+
.header { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; justify-content: space-between; padding: 12px 16px; background: var(--surface); border-bottom: 1px solid var(--border); }
|
|
291
|
+
.title strong { font-size: 14px; font-weight: 600; }
|
|
292
|
+
.counts { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
293
|
+
.pill { display: inline-flex; align-items: center; gap: 4px; height: 20px; padding: 0 6px; border-radius: 5px; font-size: 11px; font-weight: 500; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
294
|
+
.pill[data-status=unchanged] { color: var(--unchanged); background: var(--unchanged-bg); }
|
|
295
|
+
.pill[data-status=changed] { color: var(--changed); background: var(--changed-bg); }
|
|
296
|
+
.pill[data-status=added] { color: var(--added); background: var(--added-bg); }
|
|
297
|
+
.pill[data-status=removed] { color: var(--removed); background: var(--removed-bg); }
|
|
298
|
+
.layout { flex: 1; display: flex; min-height: 0; }
|
|
299
|
+
.sidebar { width: 300px; flex-shrink: 0; display: flex; flex-direction: column; background: var(--surface); border-right: 1px solid var(--border); }
|
|
300
|
+
.filter { margin: 8px; height: 28px; padding: 0 10px; border: 0; border-radius: 8px; background: var(--surface); color: var(--text); font: inherit; box-shadow: inset 0 0 0 1px #8f8f8f; }
|
|
301
|
+
#list { flex: 1; overflow-y: auto; padding: 0 8px 8px; }
|
|
302
|
+
.group { display: flex; align-items: center; gap: 6px; width: 100%; height: 28px; padding: 0 8px; border: 0; background: none; color: var(--muted); font: 500 12px/16px var(--sans); cursor: pointer; text-align: left; }
|
|
303
|
+
.row { display: flex; align-items: center; gap: 8px; width: 100%; height: 32px; padding: 0 8px; border: 0; border-radius: 7px; background: none; color: var(--text); font: 13px/20px var(--mono); text-align: left; cursor: pointer; position: relative; }
|
|
304
|
+
.row:hover { background: var(--hover); }
|
|
305
|
+
.row[aria-selected=true] { background: var(--hover); }
|
|
306
|
+
.row[aria-selected=true]::before { content: ""; position: absolute; left: 0; top: 6px; bottom: 6px; width: 2px; border-radius: 2px; background: var(--link); }
|
|
307
|
+
.row .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
308
|
+
.row .ratio { color: var(--muted); font: 12px/16px var(--sans); font-variant-numeric: tabular-nums; }
|
|
309
|
+
.viewer { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--surface); }
|
|
310
|
+
.viewer-title { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; min-height: 48px; padding: 8px 16px; border-bottom: 1px solid var(--border); }
|
|
311
|
+
.viewer-title .name { font: 500 14px/20px var(--mono); }
|
|
312
|
+
.viewer-title .dims { margin-left: auto; }
|
|
313
|
+
.toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; padding: 6px 16px; border-bottom: 1px solid var(--border); }
|
|
314
|
+
.segmented { display: inline-flex; padding: 2px; border-radius: 11px; background: var(--surface-2); }
|
|
315
|
+
.segmented button { height: 28px; padding: 0 10px; border: 1px solid transparent; border-radius: 7px; background: none; color: var(--muted); font: 500 13px/20px var(--sans); cursor: pointer; }
|
|
316
|
+
.segmented button[aria-pressed=true] { background: var(--surface); color: var(--text); border-color: var(--border); }
|
|
317
|
+
.segmented button:disabled { opacity: .45; cursor: default; }
|
|
318
|
+
kbd { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; margin-left: 6px; padding: 0 4px; border: 1px solid var(--border); border-radius: 5px; background: var(--surface); color: var(--muted); font: 500 11px/16px var(--mono); }
|
|
319
|
+
.stage { flex: 1; overflow: auto; padding: 16px; background: var(--canvas); }
|
|
320
|
+
.pair { display: flex; gap: 16px; align-items: flex-start; }
|
|
321
|
+
.pair > figure { flex: 1; min-width: 0; }
|
|
322
|
+
figure { margin: 0; }
|
|
323
|
+
figcaption { height: 24px; color: var(--muted); font-size: 12px; }
|
|
324
|
+
.frame { position: relative; background-color: var(--checker-a); background-image: conic-gradient(var(--checker-b) 25%, transparent 0 50%, var(--checker-b) 0 75%, transparent 0); background-size: 16px 16px; }
|
|
325
|
+
.frame img { position: absolute; top: 0; left: 0; display: block; }
|
|
326
|
+
.zoom-actual .frame img { image-rendering: pixelated; }
|
|
327
|
+
.frame[data-flip=baseline] { outline: 1px solid var(--link); }
|
|
328
|
+
.diff-layer { opacity: .7; }
|
|
329
|
+
.slider { display: block; width: 100%; margin: 12px 0 0; accent-color: var(--link); }
|
|
330
|
+
.handle { position: absolute; top: 0; bottom: 0; width: 2px; margin-left: -1px; background: var(--link); pointer-events: none; }
|
|
331
|
+
.empty { padding: 24px; color: var(--muted); font-size: 24px; line-height: 34px; }
|
|
332
|
+
.empty strong { color: var(--text); font-weight: 600; }
|
|
333
|
+
@media (max-width: 767px) {
|
|
334
|
+
.layout { flex-direction: column; }
|
|
335
|
+
.sidebar { width: auto; max-height: 40vh; border-right: 0; border-bottom: 1px solid var(--border); }
|
|
336
|
+
.pair { flex-direction: column; }
|
|
337
|
+
.pair > figure { width: 100%; }
|
|
338
|
+
}
|
|
339
|
+
`;
|
|
340
|
+
const SCRIPT = `
|
|
341
|
+
const { meta, snapshots } = JSON.parse(document.getElementById("data").textContent);
|
|
342
|
+
const GROUPS = ["changed", "added", "removed", "unchanged"];
|
|
343
|
+
const MODES = [["side", "Side by side"], ["diff", "Diff"], ["slider", "Slider"], ["flip", "Flip"]];
|
|
344
|
+
const store = {
|
|
345
|
+
get(key, fallback) { try { return localStorage.getItem("stateofpixel:" + key) || fallback; } catch { return fallback; } },
|
|
346
|
+
set(key, value) { try { localStorage.setItem("stateofpixel:" + key, value); } catch {} },
|
|
347
|
+
};
|
|
348
|
+
const state = {
|
|
349
|
+
filter: "",
|
|
350
|
+
collapsed: new Set(["unchanged"]),
|
|
351
|
+
selected: null,
|
|
352
|
+
mode: store.get("mode", "side"),
|
|
353
|
+
zoom: store.get("zoom", "fit"),
|
|
354
|
+
flip: "new",
|
|
355
|
+
slider: 50,
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
function el(tag, props, children) {
|
|
359
|
+
const node = document.createElement(tag);
|
|
360
|
+
Object.assign(node, props || {});
|
|
361
|
+
for (const child of children || []) node.append(child);
|
|
362
|
+
return node;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function pill(status, label) {
|
|
366
|
+
const node = el("span", { className: "pill", textContent: label });
|
|
367
|
+
node.dataset.status = status;
|
|
368
|
+
return node;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function percent(ratio) {
|
|
372
|
+
return (ratio * 100).toFixed(ratio < 0.001 ? 3 : 2) + "%";
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function visibleGroups() {
|
|
376
|
+
const query = state.filter.toLowerCase();
|
|
377
|
+
return GROUPS.map((status) => ({
|
|
378
|
+
status,
|
|
379
|
+
items: snapshots.filter((s) => s.status === status && s.name.toLowerCase().includes(query)),
|
|
380
|
+
})).filter((group) => group.items.length > 0);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function navigable() {
|
|
384
|
+
return visibleGroups().flatMap((group) => (state.collapsed.has(group.status) ? [] : group.items));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function renderHeader() {
|
|
388
|
+
document.getElementById("dirs").textContent = meta.dir + " vs " + meta.baselineDir;
|
|
389
|
+
const counts = document.getElementById("counts");
|
|
390
|
+
counts.replaceChildren(
|
|
391
|
+
...GROUPS.map((status) => {
|
|
392
|
+
const count = snapshots.filter((s) => s.status === status).length;
|
|
393
|
+
return pill(status, count.toLocaleString() + " " + status);
|
|
394
|
+
}),
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function renderList() {
|
|
399
|
+
const list = document.getElementById("list");
|
|
400
|
+
list.replaceChildren(
|
|
401
|
+
...visibleGroups().flatMap((group) => {
|
|
402
|
+
const collapsed = state.collapsed.has(group.status);
|
|
403
|
+
const header = el("button", {
|
|
404
|
+
className: "group",
|
|
405
|
+
textContent: (collapsed ? "+ " : "- ") + group.status[0].toUpperCase() + group.status.slice(1) + " (" + group.items.length.toLocaleString() + ")",
|
|
406
|
+
onclick: () => {
|
|
407
|
+
if (collapsed) state.collapsed.delete(group.status);
|
|
408
|
+
else state.collapsed.add(group.status);
|
|
409
|
+
renderList();
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
header.setAttribute("aria-expanded", String(!collapsed));
|
|
413
|
+
if (collapsed) return [header];
|
|
414
|
+
return [
|
|
415
|
+
header,
|
|
416
|
+
...group.items.map((snapshot) => {
|
|
417
|
+
const row = el("button", { className: "row", title: snapshot.name, onclick: () => select(snapshot) }, [
|
|
418
|
+
el("span", { className: "name", textContent: snapshot.name }),
|
|
419
|
+
]);
|
|
420
|
+
row.setAttribute("role", "option");
|
|
421
|
+
row.setAttribute("aria-selected", String(snapshot === state.selected));
|
|
422
|
+
if (snapshot.diffRatio !== undefined) row.append(el("span", { className: "ratio", textContent: percent(snapshot.diffRatio) }));
|
|
423
|
+
return row;
|
|
424
|
+
}),
|
|
425
|
+
];
|
|
426
|
+
}),
|
|
427
|
+
);
|
|
428
|
+
list.querySelector("[aria-selected=true]")?.scrollIntoView({ block: "nearest" });
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function frame(images, width, height) {
|
|
432
|
+
const node = el("div", { className: "frame" });
|
|
433
|
+
if (state.zoom === "fit") {
|
|
434
|
+
node.style.width = "100%";
|
|
435
|
+
node.style.maxWidth = width + "px";
|
|
436
|
+
node.style.aspectRatio = width + " / " + height;
|
|
437
|
+
} else {
|
|
438
|
+
node.style.width = width + "px";
|
|
439
|
+
node.style.height = height + "px";
|
|
440
|
+
}
|
|
441
|
+
for (const { image, className, style } of images) {
|
|
442
|
+
const img = el("img", { src: image.file, alt: "", className: className || "" });
|
|
443
|
+
img.style.width = (image.width / width) * 100 + "%";
|
|
444
|
+
Object.assign(img.style, style || {});
|
|
445
|
+
node.append(img);
|
|
446
|
+
}
|
|
447
|
+
return node;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function figure(caption, image) {
|
|
451
|
+
return el("figure", {}, [el("figcaption", { textContent: caption }), frame([{ image }], image.width, image.height)]);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function renderStage(snapshot) {
|
|
455
|
+
const { image, baselineImage, diffImage } = snapshot;
|
|
456
|
+
if (!image || !baselineImage) {
|
|
457
|
+
const only = image || baselineImage;
|
|
458
|
+
return [figure(image ? "New" : "Baseline", only)];
|
|
459
|
+
}
|
|
460
|
+
const width = Math.max(image.width, baselineImage.width);
|
|
461
|
+
const height = Math.max(image.height, baselineImage.height);
|
|
462
|
+
if (state.mode === "side") {
|
|
463
|
+
return [el("div", { className: "pair" }, [figure("Baseline", baselineImage), figure("New", image)])];
|
|
464
|
+
}
|
|
465
|
+
if (state.mode === "diff") {
|
|
466
|
+
const layers = [{ image }];
|
|
467
|
+
if (diffImage) layers.push({ image: diffImage, className: "diff-layer" });
|
|
468
|
+
return [el("figure", {}, [el("figcaption", { textContent: "New with diff overlay" }), frame(layers, width, height)])];
|
|
469
|
+
}
|
|
470
|
+
if (state.mode === "slider") {
|
|
471
|
+
const clipFor = (value) => "inset(0 " + (100 - value) + "% 0 0)";
|
|
472
|
+
const stageNode = frame([{ image }, { image: baselineImage, style: { clipPath: clipFor(state.slider) } }], width, height);
|
|
473
|
+
const handle = el("div", { className: "handle" });
|
|
474
|
+
handle.style.left = state.slider + "%";
|
|
475
|
+
stageNode.append(handle);
|
|
476
|
+
const slider = el("input", {
|
|
477
|
+
className: "slider",
|
|
478
|
+
type: "range",
|
|
479
|
+
min: 0,
|
|
480
|
+
max: 100,
|
|
481
|
+
value: state.slider,
|
|
482
|
+
oninput: (event) => {
|
|
483
|
+
state.slider = Number(event.target.value);
|
|
484
|
+
stageNode.querySelectorAll("img")[1].style.clipPath = clipFor(state.slider);
|
|
485
|
+
handle.style.left = state.slider + "%";
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
slider.setAttribute("aria-label", "Baseline to new");
|
|
489
|
+
const node = el("figure", {}, [el("figcaption", { textContent: "Baseline on the left of the handle, new on the right" }), stageNode, slider]);
|
|
490
|
+
node.style[state.zoom === "fit" ? "maxWidth" : "width"] = width + "px";
|
|
491
|
+
return [node];
|
|
492
|
+
}
|
|
493
|
+
const showing = state.flip === "baseline" ? baselineImage : image;
|
|
494
|
+
const node = frame([{ image: showing }], width, height);
|
|
495
|
+
node.dataset.flip = state.flip;
|
|
496
|
+
return [el("figure", {}, [el("figcaption", { textContent: "Showing " + state.flip + ". Press space to flip." }), node])];
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function renderViewer() {
|
|
500
|
+
const viewer = document.getElementById("viewer");
|
|
501
|
+
const snapshot = state.selected;
|
|
502
|
+
if (!snapshot) {
|
|
503
|
+
const empty = el("p", { className: "empty" }, [el("strong", { textContent: "No visual changes." }), " Every snapshot matches its baseline."]);
|
|
504
|
+
viewer.replaceChildren(empty);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const comparable = Boolean(snapshot.image && snapshot.baselineImage);
|
|
508
|
+
const title = el("div", { className: "viewer-title" }, [
|
|
509
|
+
el("span", { className: "name", textContent: snapshot.name }),
|
|
510
|
+
pill(snapshot.status, snapshot.status),
|
|
511
|
+
]);
|
|
512
|
+
if (snapshot.diffPixels !== undefined) {
|
|
513
|
+
title.append(el("span", { className: "muted num", textContent: percent(snapshot.diffRatio) + " diff, " + snapshot.diffPixels.toLocaleString() + " px" }));
|
|
514
|
+
}
|
|
515
|
+
const dims = [snapshot.baselineImage, snapshot.image].filter(Boolean).map((i) => i.width + "x" + i.height);
|
|
516
|
+
title.append(el("span", { className: "dims muted num", textContent: [...new Set(dims)].join(" to ") }));
|
|
517
|
+
|
|
518
|
+
const modes = el("div", { className: "segmented" }, MODES.map(([mode, label], index) => {
|
|
519
|
+
const button = el("button", { disabled: !comparable, onclick: () => setMode(mode) }, [label, el("kbd", { textContent: String(index + 1) })]);
|
|
520
|
+
button.setAttribute("aria-pressed", String(comparable && state.mode === mode));
|
|
521
|
+
return button;
|
|
522
|
+
}));
|
|
523
|
+
const zoom = el("div", { className: "segmented" }, [["fit", "Fit", "f"], ["actual", "100%", "0"]].map(([value, label, key]) => {
|
|
524
|
+
const button = el("button", { onclick: () => setZoom(value) }, [label, el("kbd", { textContent: key })]);
|
|
525
|
+
button.setAttribute("aria-pressed", String(state.zoom === value));
|
|
526
|
+
return button;
|
|
527
|
+
}));
|
|
528
|
+
const stage = el("div", { className: "stage zoom-" + state.zoom }, renderStage(snapshot));
|
|
529
|
+
viewer.replaceChildren(title, el("div", { className: "toolbar" }, [modes, zoom]), stage);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function select(snapshot) {
|
|
533
|
+
state.selected = snapshot;
|
|
534
|
+
state.flip = "new";
|
|
535
|
+
renderList();
|
|
536
|
+
renderViewer();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function setMode(mode) {
|
|
540
|
+
state.mode = mode;
|
|
541
|
+
store.set("mode", mode);
|
|
542
|
+
renderViewer();
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function setZoom(zoom) {
|
|
546
|
+
state.zoom = zoom;
|
|
547
|
+
store.set("zoom", zoom);
|
|
548
|
+
renderViewer();
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function move(step) {
|
|
552
|
+
const items = navigable();
|
|
553
|
+
if (items.length === 0) return;
|
|
554
|
+
const index = items.indexOf(state.selected);
|
|
555
|
+
const next = index === -1 ? 0 : Math.min(items.length - 1, Math.max(0, index + step));
|
|
556
|
+
select(items[next]);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const filter = document.getElementById("filter");
|
|
560
|
+
filter.addEventListener("input", () => {
|
|
561
|
+
state.filter = filter.value;
|
|
562
|
+
renderList();
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
document.addEventListener("keydown", (event) => {
|
|
566
|
+
if (event.target === filter) {
|
|
567
|
+
if (event.key === "Escape") filter.blur();
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
571
|
+
const modeIndex = ["1", "2", "3", "4"].indexOf(event.key);
|
|
572
|
+
if (event.key === "j") move(1);
|
|
573
|
+
else if (event.key === "k") move(-1);
|
|
574
|
+
else if (modeIndex !== -1) setMode(MODES[modeIndex][0]);
|
|
575
|
+
else if (event.key === "f") setZoom("fit");
|
|
576
|
+
else if (event.key === "0") setZoom("actual");
|
|
577
|
+
else if (event.key === "/") { event.preventDefault(); filter.focus(); }
|
|
578
|
+
else if (event.key === " " && state.mode === "flip") {
|
|
579
|
+
event.preventDefault();
|
|
580
|
+
state.flip = state.flip === "new" ? "baseline" : "new";
|
|
581
|
+
renderViewer();
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
renderHeader();
|
|
586
|
+
const first = navigable().find((s) => s.status !== "unchanged");
|
|
587
|
+
if (first) select(first);
|
|
588
|
+
else { renderList(); renderViewer(); }
|
|
589
|
+
`;
|
|
590
|
+
|
|
591
|
+
//#endregion
|
|
592
|
+
//#region src/report/write-report.ts
|
|
593
|
+
async function writeReport(outDir, results, meta) {
|
|
594
|
+
await mkdir(outDir, { recursive: true });
|
|
595
|
+
const reportPath = path.join(outDir, "index.html");
|
|
596
|
+
await writeFile(reportPath, renderReport(results, meta));
|
|
597
|
+
return reportPath;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
//#endregion
|
|
601
|
+
//#region src/commands/compare.ts
|
|
602
|
+
async function compareCommand(dir, baselineDir, options) {
|
|
603
|
+
await assertDirectory(dir);
|
|
604
|
+
await assertDirectory(baselineDir);
|
|
605
|
+
const startedAt = performance.now();
|
|
606
|
+
const engine = await createDiffEngine();
|
|
607
|
+
const results = await compareDirectories({
|
|
608
|
+
dir,
|
|
609
|
+
baselineDir,
|
|
610
|
+
outDir: options.out,
|
|
611
|
+
engine,
|
|
612
|
+
options: {
|
|
613
|
+
threshold: options.threshold,
|
|
614
|
+
includeAA: options.includeAa
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
const reportPath = await writeReport(options.out, results, {
|
|
618
|
+
dir,
|
|
619
|
+
baselineDir,
|
|
620
|
+
engine: engine.name,
|
|
621
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
622
|
+
});
|
|
623
|
+
const counts = countByStatus(results.map((result) => result.status));
|
|
624
|
+
const seconds = ((performance.now() - startedAt) / 1e3).toFixed(1);
|
|
625
|
+
console.log(`stateofpixel compare ${dir} vs ${baselineDir}`);
|
|
626
|
+
console.log(` ${formatCount$1(results.length)} snapshots ${formatCount$1(counts.unchanged)} unchanged ${formatCount$1(counts.changed)} changed ${formatCount$1(counts.added)} added ${formatCount$1(counts.removed)} removed`);
|
|
627
|
+
console.log(` compared with ${engine.name} in ${seconds} s`);
|
|
628
|
+
console.log(` report: ${path.relative(process.cwd(), reportPath)}`);
|
|
629
|
+
}
|
|
630
|
+
function parseThreshold(value) {
|
|
631
|
+
const threshold = Number(value);
|
|
632
|
+
if (!Number.isFinite(threshold) || threshold < 0 || threshold > 1) throw new InvalidArgumentError("Must be a number from 0 to 1.");
|
|
633
|
+
return threshold;
|
|
634
|
+
}
|
|
635
|
+
async function assertDirectory(dir) {
|
|
636
|
+
if (!(await stat(dir).catch(() => void 0))?.isDirectory()) throw new Error(`Not a directory: ${dir}`);
|
|
637
|
+
}
|
|
638
|
+
function countByStatus(statuses) {
|
|
639
|
+
const counts = {
|
|
640
|
+
unchanged: 0,
|
|
641
|
+
changed: 0,
|
|
642
|
+
added: 0,
|
|
643
|
+
removed: 0
|
|
644
|
+
};
|
|
645
|
+
for (const status of statuses) counts[status]++;
|
|
646
|
+
return counts;
|
|
647
|
+
}
|
|
648
|
+
function formatCount$1(count) {
|
|
649
|
+
return count.toLocaleString("en-US");
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
//#endregion
|
|
653
|
+
//#region src/api.ts
|
|
654
|
+
const DEFAULT_API_URL = "https://graceful-dogfish-423.convex.site/api/v1";
|
|
655
|
+
var ApiError = class extends Error {
|
|
656
|
+
status;
|
|
657
|
+
code;
|
|
658
|
+
constructor(status, code, message) {
|
|
659
|
+
super(message);
|
|
660
|
+
this.status = status;
|
|
661
|
+
this.code = code;
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
function createApiClient({ baseUrl, token, fetch = globalThis.fetch, retries = 3, retryDelayMs = 1e3 }) {
|
|
665
|
+
async function withRetries(fn) {
|
|
666
|
+
for (let attempt = 0;; attempt++) try {
|
|
667
|
+
return await fn();
|
|
668
|
+
} catch (error) {
|
|
669
|
+
if (attempt >= retries || !isServerError(error)) throw error;
|
|
670
|
+
await sleep(retryDelayMs * 2 ** attempt);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
function request(method, path, body) {
|
|
674
|
+
return withRetries(async () => {
|
|
675
|
+
const response = await fetch(`${baseUrl}${path}`, {
|
|
676
|
+
method,
|
|
677
|
+
headers: {
|
|
678
|
+
Authorization: `Bearer ${token}`,
|
|
679
|
+
"Content-Type": "application/json"
|
|
680
|
+
},
|
|
681
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
682
|
+
});
|
|
683
|
+
if (!response.ok) throw await toApiError(response);
|
|
684
|
+
return await response.json();
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
function upload(uploadUrl, bytes) {
|
|
688
|
+
return withRetries(async () => {
|
|
689
|
+
const response = await fetch(uploadUrl, {
|
|
690
|
+
method: "POST",
|
|
691
|
+
headers: { "Content-Type": "image/png" },
|
|
692
|
+
body: new Uint8Array(bytes)
|
|
693
|
+
});
|
|
694
|
+
if (!response.ok) throw await toApiError(response);
|
|
695
|
+
const { storageId } = await response.json();
|
|
696
|
+
return storageId;
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
function download(url) {
|
|
700
|
+
return withRetries(async () => {
|
|
701
|
+
const response = await fetch(url);
|
|
702
|
+
if (!response.ok) throw await toApiError(response);
|
|
703
|
+
return Buffer.from(await response.arrayBuffer());
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
return {
|
|
707
|
+
request,
|
|
708
|
+
upload,
|
|
709
|
+
download
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
async function toApiError(response) {
|
|
713
|
+
const body = await response.json().catch(() => null);
|
|
714
|
+
return new ApiError(response.status, body?.error?.code ?? "http_error", body?.error?.message ?? `HTTP ${response.status}`);
|
|
715
|
+
}
|
|
716
|
+
function isServerError(error) {
|
|
717
|
+
return error instanceof ApiError && error.status >= 500 || error instanceof TypeError;
|
|
718
|
+
}
|
|
719
|
+
function sleep(ms) {
|
|
720
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
//#endregion
|
|
724
|
+
//#region src/ci-env.ts
|
|
725
|
+
const execFileAsync = promisify(execFile);
|
|
726
|
+
const MAX_ANCESTORS = 100;
|
|
727
|
+
const OIDC_AUDIENCE = "stateofpixel";
|
|
728
|
+
async function readGitInfo(env, cwd, baselineBranchOverride) {
|
|
729
|
+
const event = await readGithubEvent(env);
|
|
730
|
+
const pullRequest = event?.pull_request;
|
|
731
|
+
const commit = pullRequest?.head.sha || env.GITHUB_SHA || await git(cwd, "rev-parse", "HEAD");
|
|
732
|
+
if (commit === null) throw new Error("Could not find the commit. Run inside a git checkout.");
|
|
733
|
+
const branch = pullRequest?.head.ref || env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME || await git(cwd, "rev-parse", "--abbrev-ref", "HEAD") || "HEAD";
|
|
734
|
+
const baselineBranch = baselineBranchOverride || pullRequest?.base.ref || event?.repository?.default_branch || await defaultBranch(cwd) || "main";
|
|
735
|
+
return {
|
|
736
|
+
commit,
|
|
737
|
+
commitMessage: await git(cwd, "log", "-1", "--format=%s", commit) ?? event?.head_commit?.message?.split("\n")[0] ?? "",
|
|
738
|
+
branch,
|
|
739
|
+
baselineBranch,
|
|
740
|
+
prNumber: pullRequest?.number ?? null,
|
|
741
|
+
mergeBase: await git(cwd, "merge-base", commit, `origin/${baselineBranch}`),
|
|
742
|
+
ancestors: await ancestors(cwd, commit)
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
function readCiInfo(env) {
|
|
746
|
+
if (env.GITHUB_ACTIONS === "true") return {
|
|
747
|
+
provider: "github-actions",
|
|
748
|
+
runUrl: env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID ? `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}` : void 0
|
|
749
|
+
};
|
|
750
|
+
return env.CI ? { provider: "unknown" } : {};
|
|
751
|
+
}
|
|
752
|
+
function defaultNonce(env) {
|
|
753
|
+
if (env.GITHUB_RUN_ID) return `${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT ?? "1"}`;
|
|
754
|
+
return null;
|
|
755
|
+
}
|
|
756
|
+
async function resolveToken(env, fetchImpl = fetch) {
|
|
757
|
+
if (env.STATEOFPIXEL_TOKEN) return env.STATEOFPIXEL_TOKEN;
|
|
758
|
+
const requestUrl = env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
|
759
|
+
const requestToken = env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
|
760
|
+
if (!requestUrl || !requestToken) throw new Error("No token. On GitHub Actions add `permissions: id-token: write`, elsewhere set STATEOFPIXEL_TOKEN.");
|
|
761
|
+
const url = new URL(requestUrl);
|
|
762
|
+
url.searchParams.set("audience", OIDC_AUDIENCE);
|
|
763
|
+
const response = await fetchImpl(url, { headers: { Authorization: `Bearer ${requestToken}` } });
|
|
764
|
+
if (!response.ok) throw new Error(`GitHub Actions OIDC token request failed with HTTP ${response.status}.`);
|
|
765
|
+
const { value } = await response.json();
|
|
766
|
+
return value;
|
|
767
|
+
}
|
|
768
|
+
async function readGithubEvent(env) {
|
|
769
|
+
if (!env.GITHUB_EVENT_PATH) return null;
|
|
770
|
+
try {
|
|
771
|
+
return JSON.parse(await readFile(env.GITHUB_EVENT_PATH, "utf8"));
|
|
772
|
+
} catch {
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
async function ancestors(cwd, commit) {
|
|
777
|
+
for (const start of [commit, "HEAD"]) {
|
|
778
|
+
const output = await git(cwd, "rev-list", `--max-count=102`, start);
|
|
779
|
+
if (output !== null) return output.split("\n").filter((sha) => sha !== "" && sha !== commit).slice(start === "HEAD" ? 1 : 0).slice(0, MAX_ANCESTORS);
|
|
780
|
+
}
|
|
781
|
+
return [];
|
|
782
|
+
}
|
|
783
|
+
async function defaultBranch(cwd) {
|
|
784
|
+
return (await git(cwd, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"))?.replace(/^origin\//, "") ?? null;
|
|
785
|
+
}
|
|
786
|
+
async function git(cwd, ...args) {
|
|
787
|
+
try {
|
|
788
|
+
const { stdout } = await execFileAsync("git", args, { cwd });
|
|
789
|
+
return stdout.trim();
|
|
790
|
+
} catch {
|
|
791
|
+
return null;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
//#endregion
|
|
796
|
+
//#region src/upload.ts
|
|
797
|
+
const UPLOAD_CONCURRENCY = 8;
|
|
798
|
+
const FINALIZE_POLLS = 10;
|
|
799
|
+
const FINALIZE_POLL_MS = 1e3;
|
|
800
|
+
async function readSnapshots(dir) {
|
|
801
|
+
const files = await collectSnapshots(dir);
|
|
802
|
+
return mapConcurrent([...files], availableParallelism(), async ([name, file]) => {
|
|
803
|
+
const bytes = await readFile(file);
|
|
804
|
+
return {
|
|
805
|
+
name,
|
|
806
|
+
file,
|
|
807
|
+
hash: sha256(bytes),
|
|
808
|
+
bytes: bytes.length,
|
|
809
|
+
...readPngSize(bytes)
|
|
810
|
+
};
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
async function uploadDirectory(input) {
|
|
814
|
+
const snapshots = await readSnapshots(input.dir);
|
|
815
|
+
const build = await input.api.request("POST", "/builds", {
|
|
816
|
+
buildName: input.buildName,
|
|
817
|
+
nonce: input.nonce,
|
|
818
|
+
shard: input.shard,
|
|
819
|
+
subset: input.subset,
|
|
820
|
+
git: input.git,
|
|
821
|
+
ci: input.ci,
|
|
822
|
+
snapshots: snapshots.map(({ name, hash, bytes, width, height }) => ({
|
|
823
|
+
name,
|
|
824
|
+
hash,
|
|
825
|
+
bytes,
|
|
826
|
+
width,
|
|
827
|
+
height
|
|
828
|
+
}))
|
|
829
|
+
});
|
|
830
|
+
const lookups = new Map(build.snapshots.map((lookup) => [lookup.name, lookup]));
|
|
831
|
+
const failedUploads = [];
|
|
832
|
+
const uploads = [];
|
|
833
|
+
let uploadedBytes = 0;
|
|
834
|
+
const pendingUploads = uniqueByHash(snapshots.filter((snapshot) => lookups.get(snapshot.name)?.uploadUrl));
|
|
835
|
+
await mapConcurrent(pendingUploads, UPLOAD_CONCURRENCY, async (snapshot) => {
|
|
836
|
+
const uploadUrl = lookups.get(snapshot.name)?.uploadUrl;
|
|
837
|
+
try {
|
|
838
|
+
const bytes = await readFile(snapshot.file);
|
|
839
|
+
uploads.push({
|
|
840
|
+
hash: snapshot.hash,
|
|
841
|
+
storageId: await input.api.upload(uploadUrl, bytes),
|
|
842
|
+
kind: "screenshot",
|
|
843
|
+
width: snapshot.width,
|
|
844
|
+
height: snapshot.height
|
|
845
|
+
});
|
|
846
|
+
uploadedBytes += bytes.length;
|
|
847
|
+
} catch {
|
|
848
|
+
failedUploads.push(snapshot.hash);
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
const failedScreenshots = new Set(failedUploads);
|
|
852
|
+
const options = {
|
|
853
|
+
threshold: input.threshold ?? build.diff.threshold,
|
|
854
|
+
includeAA: build.diff.includeAA
|
|
855
|
+
};
|
|
856
|
+
const diffs = /* @__PURE__ */ new Map();
|
|
857
|
+
const results = await mapConcurrent(snapshots, availableParallelism(), async (snapshot, index) => {
|
|
858
|
+
const lookup = lookups.get(snapshot.name);
|
|
859
|
+
const base = {
|
|
860
|
+
name: snapshot.name,
|
|
861
|
+
hash: snapshot.hash
|
|
862
|
+
};
|
|
863
|
+
if (failedScreenshots.has(snapshot.hash)) return {
|
|
864
|
+
...base,
|
|
865
|
+
status: "failed"
|
|
866
|
+
};
|
|
867
|
+
if (lookup.status !== "changed") return {
|
|
868
|
+
...base,
|
|
869
|
+
status: lookup.status
|
|
870
|
+
};
|
|
871
|
+
if (lookup.baselineUrl === void 0) return {
|
|
872
|
+
...base,
|
|
873
|
+
status: "changed"
|
|
874
|
+
};
|
|
875
|
+
try {
|
|
876
|
+
const baselineFile = path.join(input.workDir, `baseline-${index}.png`);
|
|
877
|
+
const diffFile = path.join(input.workDir, `diff-${index}.png`);
|
|
878
|
+
await writeFile(baselineFile, await input.api.download(lookup.baselineUrl));
|
|
879
|
+
const { diffPixels, diffRatio } = await input.engine.diff(baselineFile, snapshot.file, diffFile, options);
|
|
880
|
+
if (diffPixels === 0) return {
|
|
881
|
+
...base,
|
|
882
|
+
status: "unchanged"
|
|
883
|
+
};
|
|
884
|
+
const diffBytes = await readFile(diffFile);
|
|
885
|
+
const diffHash = sha256(diffBytes);
|
|
886
|
+
diffs.set(diffHash, {
|
|
887
|
+
file: diffFile,
|
|
888
|
+
hash: diffHash,
|
|
889
|
+
...readPngSize(diffBytes)
|
|
890
|
+
});
|
|
891
|
+
return {
|
|
892
|
+
...base,
|
|
893
|
+
status: "changed",
|
|
894
|
+
diffHash,
|
|
895
|
+
diffRatio,
|
|
896
|
+
diffPixels
|
|
897
|
+
};
|
|
898
|
+
} catch {
|
|
899
|
+
return {
|
|
900
|
+
...base,
|
|
901
|
+
status: "failed"
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
if (diffs.size > 0) {
|
|
906
|
+
const { uploads: diffTargets } = await input.api.request("POST", `/builds/${build.buildId}/upload-urls`, { hashes: [...diffs.keys()] });
|
|
907
|
+
await mapConcurrent(diffTargets, UPLOAD_CONCURRENCY, async ({ hash, uploadUrl }) => {
|
|
908
|
+
const diff = diffs.get(hash);
|
|
909
|
+
if (diff === void 0) return;
|
|
910
|
+
try {
|
|
911
|
+
const bytes = await readFile(diff.file);
|
|
912
|
+
uploads.push({
|
|
913
|
+
hash,
|
|
914
|
+
storageId: await input.api.upload(uploadUrl, bytes),
|
|
915
|
+
kind: "diff",
|
|
916
|
+
width: diff.width,
|
|
917
|
+
height: diff.height
|
|
918
|
+
});
|
|
919
|
+
uploadedBytes += bytes.length;
|
|
920
|
+
} catch {
|
|
921
|
+
failedUploads.push(hash);
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
const { rejectedUploads } = await input.api.request("POST", `/builds/${build.buildId}/shards/${build.shardIndex}/complete`, {
|
|
926
|
+
uploads,
|
|
927
|
+
results
|
|
928
|
+
});
|
|
929
|
+
failedUploads.push(...rejectedUploads);
|
|
930
|
+
return {
|
|
931
|
+
build,
|
|
932
|
+
results,
|
|
933
|
+
uploadedImages: uploads.length,
|
|
934
|
+
uploadedBytes,
|
|
935
|
+
failedUploads,
|
|
936
|
+
final: await waitForFinalize(input.api, build.buildId, false)
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
async function waitForFinalize(api, buildId, finalizeRequested) {
|
|
940
|
+
for (let poll = 0; poll < FINALIZE_POLLS; poll++) {
|
|
941
|
+
const build = await api.request("GET", `/builds/${buildId}`);
|
|
942
|
+
const lastShardDone = build.shards.total !== null && build.shards.done >= build.shards.total;
|
|
943
|
+
if (build.status !== "pending" || !(finalizeRequested || lastShardDone)) return build.status === "finalized" ? build : null;
|
|
944
|
+
await new Promise((resolve) => setTimeout(resolve, FINALIZE_POLL_MS));
|
|
945
|
+
}
|
|
946
|
+
return null;
|
|
947
|
+
}
|
|
948
|
+
function uniqueByHash(snapshots) {
|
|
949
|
+
return [...new Map(snapshots.map((snapshot) => [snapshot.hash, snapshot])).values()];
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
//#endregion
|
|
953
|
+
//#region src/commands/upload.ts
|
|
954
|
+
async function uploadCommand(dir, options) {
|
|
955
|
+
const env = process.env;
|
|
956
|
+
if (!(await stat(dir).catch(() => void 0))?.isDirectory()) throw new Error(`Not a directory: ${dir}`);
|
|
957
|
+
const shard = options.shard ?? parseShard(env.STATEOFPIXEL_SHARD ?? "1/1");
|
|
958
|
+
const buildName = options.buildName ?? env.STATEOFPIXEL_BUILD_NAME ?? "default";
|
|
959
|
+
const nonce = options.nonce ?? env.STATEOFPIXEL_NONCE ?? defaultNonce(env);
|
|
960
|
+
if (nonce === null && shard.total !== 1) throw new Error("Set --nonce so every shard joins the same build.");
|
|
961
|
+
const git = await readGitInfo(env, process.cwd(), options.baselineBranch ?? env.STATEOFPIXEL_BASELINE_BRANCH);
|
|
962
|
+
if (options.dryRun) {
|
|
963
|
+
const snapshots = await readSnapshots(dir);
|
|
964
|
+
console.log(`stateofpixel dry run ${git.branch} vs ${git.baselineBranch}`);
|
|
965
|
+
console.log(` ${formatCount(snapshots.length)} snapshots in ${dir}`);
|
|
966
|
+
console.log(` build ${buildName}, shard ${shard.total === null ? "auto" : `${shard.index}/${shard.total}`}, commit ${git.commit.slice(0, 7)}, ${git.ancestors.length} ancestors`);
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
const startedAt = performance.now();
|
|
970
|
+
const workDir = await mkdtemp(path.join(tmpdir(), "stateofpixel-upload-"));
|
|
971
|
+
let output;
|
|
972
|
+
try {
|
|
973
|
+
output = await uploadDirectory({
|
|
974
|
+
dir,
|
|
975
|
+
workDir,
|
|
976
|
+
api: createApiClient({
|
|
977
|
+
baseUrl: env.STATEOFPIXEL_API_URL ?? "https://graceful-dogfish-423.convex.site/api/v1",
|
|
978
|
+
token: await resolveToken(env)
|
|
979
|
+
}),
|
|
980
|
+
engine: await createDiffEngine(),
|
|
981
|
+
buildName,
|
|
982
|
+
nonce: nonce ?? `local-${Date.now()}`,
|
|
983
|
+
shard,
|
|
984
|
+
subset: options.subset,
|
|
985
|
+
threshold: options.threshold,
|
|
986
|
+
git,
|
|
987
|
+
ci: readCiInfo(env)
|
|
988
|
+
});
|
|
989
|
+
} catch (error) {
|
|
990
|
+
if (isServerError(error) && !options.strict) {
|
|
991
|
+
console.warn(`stateofpixel: skipped, the service is not reachable (${error instanceof Error ? error.message : error}). Use --strict to fail instead.`);
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
if (error instanceof ApiError) throw new Error(`${error.message} (${error.code})`);
|
|
995
|
+
throw error;
|
|
996
|
+
} finally {
|
|
997
|
+
await rm(workDir, {
|
|
998
|
+
recursive: true,
|
|
999
|
+
force: true
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
printSummary(output, git.branch, git.baselineBranch, startedAt);
|
|
1003
|
+
if (output.failedUploads.length > 0) throw new Error(`${output.failedUploads.length} uploads failed.`);
|
|
1004
|
+
}
|
|
1005
|
+
function printSummary(output, branch, baselineBranch, startedAt) {
|
|
1006
|
+
const { build, results, final } = output;
|
|
1007
|
+
const counts = final?.counts ?? countResults(results);
|
|
1008
|
+
const baseline = build.baseline === null ? "no baseline" : `#${build.baseline.buildNumber}`;
|
|
1009
|
+
const seconds = ((performance.now() - startedAt) / 1e3).toFixed(1);
|
|
1010
|
+
const megabytes = (output.uploadedBytes / 1024 / 1024).toFixed(1);
|
|
1011
|
+
console.log(`stateofpixel build #${build.buildNumber} ${branch} vs ${baselineBranch} (${baseline})`);
|
|
1012
|
+
console.log(formatCounts(results.length, counts));
|
|
1013
|
+
console.log(` uploaded ${formatCount(output.uploadedImages)} images (${megabytes} MB) in ${seconds} s`);
|
|
1014
|
+
for (const warning of build.warnings) console.log(` warning: ${warning}`);
|
|
1015
|
+
console.log(` review: ${build.url}`);
|
|
1016
|
+
}
|
|
1017
|
+
function formatCounts(total, counts) {
|
|
1018
|
+
return ` ${formatCount(total)} snapshots ${formatCount(counts.unchanged)} unchanged ${formatCount(counts.changed)} changed ${formatCount(counts.added)} added ${formatCount(counts.removed)} removed${counts.failed > 0 ? ` ${formatCount(counts.failed)} failed` : ""}`;
|
|
1019
|
+
}
|
|
1020
|
+
function countResults(results) {
|
|
1021
|
+
const counts = {
|
|
1022
|
+
unchanged: 0,
|
|
1023
|
+
changed: 0,
|
|
1024
|
+
added: 0,
|
|
1025
|
+
removed: 0,
|
|
1026
|
+
failed: 0
|
|
1027
|
+
};
|
|
1028
|
+
for (const result of results) counts[result.status]++;
|
|
1029
|
+
return counts;
|
|
1030
|
+
}
|
|
1031
|
+
function parseShard(value) {
|
|
1032
|
+
if (value === "auto") return {
|
|
1033
|
+
index: null,
|
|
1034
|
+
total: null
|
|
1035
|
+
};
|
|
1036
|
+
const match = /^(\d+)\/(\d+)$/.exec(value);
|
|
1037
|
+
const index = Number(match?.[1]);
|
|
1038
|
+
const total = Number(match?.[2]);
|
|
1039
|
+
if (!match || index < 1 || total < 1 || index > total) throw new InvalidArgumentError("Must look like 1/4, or auto.");
|
|
1040
|
+
return {
|
|
1041
|
+
index,
|
|
1042
|
+
total
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
function formatCount(count) {
|
|
1046
|
+
return count.toLocaleString("en-US");
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
//#endregion
|
|
1050
|
+
//#region src/commands/finalize.ts
|
|
1051
|
+
async function finalizeCommand(options) {
|
|
1052
|
+
const env = process.env;
|
|
1053
|
+
const buildName = options.buildName ?? env.STATEOFPIXEL_BUILD_NAME ?? "default";
|
|
1054
|
+
const nonce = options.nonce ?? env.STATEOFPIXEL_NONCE ?? defaultNonce(env);
|
|
1055
|
+
if (nonce === null) throw new Error("Set --nonce to the nonce the shards used.");
|
|
1056
|
+
const git = options.skipIfEmpty ? await readGitInfo(env, process.cwd(), options.baselineBranch ?? env.STATEOFPIXEL_BASELINE_BRANCH) : void 0;
|
|
1057
|
+
const api = createApiClient({
|
|
1058
|
+
baseUrl: env.STATEOFPIXEL_API_URL ?? "https://graceful-dogfish-423.convex.site/api/v1",
|
|
1059
|
+
token: await resolveToken(env)
|
|
1060
|
+
});
|
|
1061
|
+
let build;
|
|
1062
|
+
let final;
|
|
1063
|
+
try {
|
|
1064
|
+
build = await api.request("POST", "/builds/finalize", {
|
|
1065
|
+
buildName,
|
|
1066
|
+
nonce,
|
|
1067
|
+
skipIfEmpty: options.skipIfEmpty,
|
|
1068
|
+
git,
|
|
1069
|
+
ci: readCiInfo(env)
|
|
1070
|
+
});
|
|
1071
|
+
final = await waitForFinalize(api, build.buildId, true);
|
|
1072
|
+
} catch (error) {
|
|
1073
|
+
if (isServerError(error) && !options.strict) {
|
|
1074
|
+
console.warn(`stateofpixel: skipped, the service is not reachable (${error instanceof Error ? error.message : error}). Use --strict to fail instead.`);
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
if (error instanceof ApiError) throw new Error(`${error.message} (${error.code})`);
|
|
1078
|
+
throw error;
|
|
1079
|
+
}
|
|
1080
|
+
if (final === null) console.log(`stateofpixel build #${build.buildNumber} finalizing`);
|
|
1081
|
+
else {
|
|
1082
|
+
const { counts, shards } = final;
|
|
1083
|
+
console.log(`stateofpixel build #${build.buildNumber} finalized, ${shards.done} ${shards.done === 1 ? "shard" : "shards"}`);
|
|
1084
|
+
console.log(formatCounts(counts.unchanged + counts.changed + counts.added + counts.failed, counts));
|
|
1085
|
+
}
|
|
1086
|
+
console.log(` review: ${build.url}`);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
//#endregion
|
|
1090
|
+
//#region src/index.ts
|
|
1091
|
+
const program = new Command().name("stateofpixel").description("Visual regression testing that runs in your CI").version(version);
|
|
1092
|
+
program.command("compare").description("Compare a folder of screenshots against a baseline folder").argument("<dir>", "folder with new screenshots").argument("<baseline-dir>", "folder with baseline screenshots").option("--out <dir>", "report folder", "stateofpixel-report").option("--threshold <number>", "color difference threshold, 0 to 1", parseThreshold, .1).option("--include-aa", "count anti-aliased pixels as changes", false).action(compareCommand);
|
|
1093
|
+
program.command("upload").description("Upload a folder of screenshots and compare it with the baseline").argument("<dir>", "folder with screenshots").option("--build-name <name>", "separate builds of one project, like storybook").option("--shard <i/n>", "this shard and the shard count, or auto with a finalize step", parseShard).option("--nonce <id>", "shared by every shard of one build").option("--baseline-branch <branch>", "branch to compare against").option("--subset", "only some snapshots ran, do not mark others removed", false).option("--threshold <number>", "color difference threshold, 0 to 1, overrides project settings", parseThreshold).option("--strict", "fail when the service is not reachable", false).option("--dry-run", "hash and print the plan, upload nothing", false).action(uploadCommand);
|
|
1094
|
+
program.command("finalize").description("Finish a build whose shards ran with --shard auto").option("--build-name <name>", "the build name the shards used").option("--nonce <id>", "the nonce the shards used").option("--baseline-branch <branch>", "branch to compare against, for --skip-if-empty").option("--skip-if-empty", "create a build with no changes when no shard ran", false).option("--strict", "fail when the service is not reachable", false).action(finalizeCommand);
|
|
1095
|
+
try {
|
|
1096
|
+
await program.parseAsync();
|
|
1097
|
+
} catch (error) {
|
|
1098
|
+
console.error(`stateofpixel: ${error instanceof Error ? error.message : error}`);
|
|
1099
|
+
process.exitCode = 1;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
//#endregion
|
|
1103
|
+
export { };
|
|
1104
|
+
//# sourceMappingURL=index.mjs.map
|