stateofpixel 0.0.0 → 1.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.
- package/README.md +28 -1
- package/dist/index.d.mts +1 -0
- package/dist/index.mjs +156 -550
- package/dist/index.mjs.map +1 -1
- package/dist/playwright.d.mts +27 -0
- package/dist/playwright.mjs +81 -0
- package/dist/playwright.mjs.map +1 -0
- package/dist/upload-CFcYa9LE.mjs +564 -0
- package/dist/upload-CFcYa9LE.mjs.map +1 -0
- package/package.json +20 -2
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { InvalidArgumentError } from "commander";
|
|
3
|
+
import { mkdtemp, open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { availableParallelism, tmpdir } from "node:os";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import pixelmatch from "pixelmatch";
|
|
8
|
+
import { PNG } from "pngjs";
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
|
|
12
|
+
//#region src/map-concurrent.ts
|
|
13
|
+
async function mapConcurrent(items, concurrency, fn) {
|
|
14
|
+
const results = new Array(items.length);
|
|
15
|
+
let nextIndex = 0;
|
|
16
|
+
async function worker() {
|
|
17
|
+
while (nextIndex < items.length) {
|
|
18
|
+
const index = nextIndex++;
|
|
19
|
+
results[index] = await fn(items[index], index);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
|
|
23
|
+
return results;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
//#endregion
|
|
27
|
+
//#region src/png.ts
|
|
28
|
+
const PNG_SIGNATURE = Buffer.from([
|
|
29
|
+
137,
|
|
30
|
+
80,
|
|
31
|
+
78,
|
|
32
|
+
71,
|
|
33
|
+
13,
|
|
34
|
+
10,
|
|
35
|
+
26,
|
|
36
|
+
10
|
|
37
|
+
]);
|
|
38
|
+
function sha256(bytes) {
|
|
39
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
40
|
+
}
|
|
41
|
+
function readPngSize(bytes) {
|
|
42
|
+
if (bytes.length < 24 || !bytes.subarray(0, 8).equals(PNG_SIGNATURE)) throw new Error("Not a PNG file");
|
|
43
|
+
return {
|
|
44
|
+
width: bytes.readUInt32BE(16),
|
|
45
|
+
height: bytes.readUInt32BE(20)
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region src/snapshots.ts
|
|
51
|
+
async function collectSnapshots(dir) {
|
|
52
|
+
const entries = await readdir(dir, {
|
|
53
|
+
recursive: true,
|
|
54
|
+
withFileTypes: true
|
|
55
|
+
});
|
|
56
|
+
const snapshots = /* @__PURE__ */ new Map();
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".png")) continue;
|
|
59
|
+
const filePath = path.join(entry.parentPath, entry.name);
|
|
60
|
+
snapshots.set(snapshotName(dir, filePath), filePath);
|
|
61
|
+
}
|
|
62
|
+
return new Map([...snapshots].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
|
|
63
|
+
}
|
|
64
|
+
function snapshotName(dir, filePath) {
|
|
65
|
+
return path.relative(dir, filePath).split(path.sep).join("/").slice(0, -4);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
//#endregion
|
|
69
|
+
//#region src/diff/odiff.ts
|
|
70
|
+
async function createOdiffEngine() {
|
|
71
|
+
const { findBinary } = createRequire(import.meta.url)("odiff-bin/binary.js");
|
|
72
|
+
findBinary();
|
|
73
|
+
const { compare } = await import("odiff-bin");
|
|
74
|
+
return {
|
|
75
|
+
name: "odiff",
|
|
76
|
+
async diff(basePath, newPath, diffPath, options) {
|
|
77
|
+
const result = await compare(basePath, newPath, diffPath, {
|
|
78
|
+
threshold: options.threshold,
|
|
79
|
+
antialiasing: !options.includeAA,
|
|
80
|
+
outputDiffMask: true
|
|
81
|
+
});
|
|
82
|
+
if (result.match) return {
|
|
83
|
+
diffPixels: 0,
|
|
84
|
+
diffRatio: 0
|
|
85
|
+
};
|
|
86
|
+
if (result.reason === "pixel-diff") {
|
|
87
|
+
const [base, next] = await Promise.all([readPngSizeFromFile(basePath), readPngSizeFromFile(newPath)]);
|
|
88
|
+
const pixels = Math.max(base.width, next.width) * Math.max(base.height, next.height);
|
|
89
|
+
return {
|
|
90
|
+
diffPixels: result.diffCount,
|
|
91
|
+
diffRatio: result.diffCount / pixels
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
throw new Error(`odiff could not compare ${newPath}: ${result.reason}`);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
async function readPngSizeFromFile(filePath) {
|
|
99
|
+
const file = await open(filePath);
|
|
100
|
+
try {
|
|
101
|
+
const { buffer } = await file.read(Buffer.alloc(24), 0, 24, 0);
|
|
102
|
+
return readPngSize(buffer);
|
|
103
|
+
} finally {
|
|
104
|
+
await file.close();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
//#endregion
|
|
109
|
+
//#region src/diff/pixelmatch.ts
|
|
110
|
+
function createPixelmatchEngine() {
|
|
111
|
+
return {
|
|
112
|
+
name: "pixelmatch",
|
|
113
|
+
async diff(basePath, newPath, diffPath, options) {
|
|
114
|
+
const [base, next] = await Promise.all([readPng(basePath), readPng(newPath)]);
|
|
115
|
+
const width = Math.max(base.width, next.width);
|
|
116
|
+
const height = Math.max(base.height, next.height);
|
|
117
|
+
const diff = new PNG({
|
|
118
|
+
width,
|
|
119
|
+
height
|
|
120
|
+
});
|
|
121
|
+
const diffPixels = pixelmatch(padTo(base, width, height), padTo(next, width, height), diff.data, width, height, {
|
|
122
|
+
threshold: options.threshold,
|
|
123
|
+
includeAA: options.includeAA,
|
|
124
|
+
diffMask: true
|
|
125
|
+
});
|
|
126
|
+
if (diffPixels > 0) await writeFile(diffPath, PNG.sync.write(diff));
|
|
127
|
+
return {
|
|
128
|
+
diffPixels,
|
|
129
|
+
diffRatio: diffPixels / (width * height)
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
async function readPng(filePath) {
|
|
135
|
+
return PNG.sync.read(await readFile(filePath));
|
|
136
|
+
}
|
|
137
|
+
function padTo(image, width, height) {
|
|
138
|
+
if (image.width === width && image.height === height) return image.data;
|
|
139
|
+
const padded = new Uint8Array(width * height * 4);
|
|
140
|
+
const rowBytes = image.width * 4;
|
|
141
|
+
for (let y = 0; y < image.height; y++) padded.set(image.data.subarray(y * rowBytes, (y + 1) * rowBytes), y * width * 4);
|
|
142
|
+
return padded;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
//#endregion
|
|
146
|
+
//#region src/diff/engine.ts
|
|
147
|
+
async function createDiffEngine() {
|
|
148
|
+
try {
|
|
149
|
+
return await createOdiffEngine();
|
|
150
|
+
} catch {
|
|
151
|
+
return createPixelmatchEngine();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
//#endregion
|
|
156
|
+
//#region src/api.ts
|
|
157
|
+
const DEFAULT_API_URL = "https://graceful-dogfish-423.convex.site/api/v1";
|
|
158
|
+
var ApiError = class extends Error {
|
|
159
|
+
status;
|
|
160
|
+
code;
|
|
161
|
+
constructor(status, code, message) {
|
|
162
|
+
super(message);
|
|
163
|
+
this.status = status;
|
|
164
|
+
this.code = code;
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
function createApiClient({ baseUrl, token, fetch = globalThis.fetch, retries = 3, retryDelayMs = 1e3 }) {
|
|
168
|
+
async function withRetries(fn) {
|
|
169
|
+
for (let attempt = 0;; attempt++) try {
|
|
170
|
+
return await fn();
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (attempt >= retries || !isServerError(error)) throw error;
|
|
173
|
+
await sleep(retryDelayMs * 2 ** attempt);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function request(method, path, body) {
|
|
177
|
+
return withRetries(async () => {
|
|
178
|
+
const response = await fetch(`${baseUrl}${path}`, {
|
|
179
|
+
method,
|
|
180
|
+
headers: {
|
|
181
|
+
Authorization: `Bearer ${token}`,
|
|
182
|
+
"Content-Type": "application/json"
|
|
183
|
+
},
|
|
184
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
185
|
+
});
|
|
186
|
+
if (!response.ok) throw await toApiError(response);
|
|
187
|
+
return await response.json();
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
function upload(uploadUrl, bytes) {
|
|
191
|
+
return withRetries(async () => {
|
|
192
|
+
const response = await fetch(uploadUrl, {
|
|
193
|
+
method: "POST",
|
|
194
|
+
headers: { "Content-Type": "image/png" },
|
|
195
|
+
body: new Uint8Array(bytes)
|
|
196
|
+
});
|
|
197
|
+
if (!response.ok) throw await toApiError(response);
|
|
198
|
+
const { storageId } = await response.json();
|
|
199
|
+
return storageId;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
function download(url) {
|
|
203
|
+
return withRetries(async () => {
|
|
204
|
+
const response = await fetch(url);
|
|
205
|
+
if (!response.ok) throw await toApiError(response);
|
|
206
|
+
return Buffer.from(await response.arrayBuffer());
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
request,
|
|
211
|
+
upload,
|
|
212
|
+
download
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
async function toApiError(response) {
|
|
216
|
+
const body = await response.json().catch(() => null);
|
|
217
|
+
return new ApiError(response.status, body?.error?.code ?? "http_error", body?.error?.message ?? `HTTP ${response.status}`);
|
|
218
|
+
}
|
|
219
|
+
function isServerError(error) {
|
|
220
|
+
return error instanceof ApiError && error.status >= 500 || error instanceof TypeError;
|
|
221
|
+
}
|
|
222
|
+
function sleep(ms) {
|
|
223
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
//#endregion
|
|
227
|
+
//#region src/ci-env.ts
|
|
228
|
+
const execFileAsync = promisify(execFile);
|
|
229
|
+
const MAX_ANCESTORS = 100;
|
|
230
|
+
const OIDC_AUDIENCE = "stateofpixel";
|
|
231
|
+
async function readGitInfo(env, cwd, baselineBranchOverride) {
|
|
232
|
+
const event = await readGithubEvent(env);
|
|
233
|
+
const pullRequest = event?.pull_request;
|
|
234
|
+
const commit = pullRequest?.head.sha || env.GITHUB_SHA || await git(cwd, "rev-parse", "HEAD");
|
|
235
|
+
if (commit === null) throw new Error("Could not find the commit. Run inside a git checkout.");
|
|
236
|
+
const branch = pullRequest?.head.ref || env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME || await git(cwd, "rev-parse", "--abbrev-ref", "HEAD") || "HEAD";
|
|
237
|
+
const baselineBranch = baselineBranchOverride || pullRequest?.base.ref || event?.repository?.default_branch || await defaultBranch(cwd) || "main";
|
|
238
|
+
return {
|
|
239
|
+
commit,
|
|
240
|
+
commitMessage: await git(cwd, "log", "-1", "--format=%s", commit) ?? event?.head_commit?.message?.split("\n")[0] ?? "",
|
|
241
|
+
branch,
|
|
242
|
+
baselineBranch,
|
|
243
|
+
prNumber: pullRequest?.number ?? null,
|
|
244
|
+
mergeBase: await git(cwd, "merge-base", commit, `origin/${baselineBranch}`),
|
|
245
|
+
ancestors: await ancestors(cwd, commit)
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
function readCiInfo(env) {
|
|
249
|
+
if (env.GITHUB_ACTIONS === "true") return {
|
|
250
|
+
provider: "github-actions",
|
|
251
|
+
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
|
|
252
|
+
};
|
|
253
|
+
return env.CI ? { provider: "unknown" } : {};
|
|
254
|
+
}
|
|
255
|
+
function defaultNonce(env) {
|
|
256
|
+
if (env.GITHUB_RUN_ID) return `${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT ?? "1"}`;
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
async function resolveToken(env, fetchImpl = fetch) {
|
|
260
|
+
if (env.STATEOFPIXEL_TOKEN) return env.STATEOFPIXEL_TOKEN;
|
|
261
|
+
const requestUrl = env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
|
262
|
+
const requestToken = env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
|
263
|
+
if (!requestUrl || !requestToken) throw new Error("No token. On GitHub Actions add `permissions: id-token: write`, elsewhere set STATEOFPIXEL_TOKEN.");
|
|
264
|
+
const url = new URL(requestUrl);
|
|
265
|
+
url.searchParams.set("audience", OIDC_AUDIENCE);
|
|
266
|
+
const response = await fetchImpl(url, { headers: { Authorization: `Bearer ${requestToken}` } });
|
|
267
|
+
if (!response.ok) throw new Error(`GitHub Actions OIDC token request failed with HTTP ${response.status}.`);
|
|
268
|
+
const { value } = await response.json();
|
|
269
|
+
return value;
|
|
270
|
+
}
|
|
271
|
+
async function readGithubEvent(env) {
|
|
272
|
+
if (!env.GITHUB_EVENT_PATH) return null;
|
|
273
|
+
try {
|
|
274
|
+
return JSON.parse(await readFile(env.GITHUB_EVENT_PATH, "utf8"));
|
|
275
|
+
} catch {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
async function ancestors(cwd, commit) {
|
|
280
|
+
for (const start of [commit, "HEAD"]) {
|
|
281
|
+
const output = await git(cwd, "rev-list", `--max-count=102`, start);
|
|
282
|
+
if (output !== null) return output.split("\n").filter((sha) => sha !== "" && sha !== commit).slice(start === "HEAD" ? 1 : 0).slice(0, MAX_ANCESTORS);
|
|
283
|
+
}
|
|
284
|
+
return [];
|
|
285
|
+
}
|
|
286
|
+
async function defaultBranch(cwd) {
|
|
287
|
+
return (await git(cwd, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"))?.replace(/^origin\//, "") ?? null;
|
|
288
|
+
}
|
|
289
|
+
async function git(cwd, ...args) {
|
|
290
|
+
try {
|
|
291
|
+
const { stdout } = await execFileAsync("git", args, { cwd });
|
|
292
|
+
return stdout.trim();
|
|
293
|
+
} catch {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
//#endregion
|
|
299
|
+
//#region src/upload.ts
|
|
300
|
+
const UPLOAD_CONCURRENCY = 8;
|
|
301
|
+
const FINALIZE_POLLS = 10;
|
|
302
|
+
const FINALIZE_POLL_MS = 1e3;
|
|
303
|
+
async function readSnapshots(dir) {
|
|
304
|
+
const files = await collectSnapshots(dir);
|
|
305
|
+
return mapConcurrent([...files], availableParallelism(), async ([name, file]) => {
|
|
306
|
+
const bytes = await readFile(file);
|
|
307
|
+
const metadata = await readMetadata(file);
|
|
308
|
+
return {
|
|
309
|
+
name,
|
|
310
|
+
file,
|
|
311
|
+
hash: sha256(bytes),
|
|
312
|
+
bytes: bytes.length,
|
|
313
|
+
...readPngSize(bytes),
|
|
314
|
+
...metadata === void 0 ? {} : { metadata }
|
|
315
|
+
};
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
function metadataFile(pngFile) {
|
|
319
|
+
return `${pngFile.slice(0, -4)}.meta.json`;
|
|
320
|
+
}
|
|
321
|
+
async function readMetadata(pngFile) {
|
|
322
|
+
const text = await readFile(metadataFile(pngFile), "utf8").catch(() => void 0);
|
|
323
|
+
return text === void 0 ? void 0 : JSON.parse(text);
|
|
324
|
+
}
|
|
325
|
+
async function uploadDirectory(input) {
|
|
326
|
+
const snapshots = await readSnapshots(input.dir);
|
|
327
|
+
const build = await input.api.request("POST", "/builds", {
|
|
328
|
+
buildName: input.buildName,
|
|
329
|
+
nonce: input.nonce,
|
|
330
|
+
shard: input.shard,
|
|
331
|
+
subset: input.subset,
|
|
332
|
+
git: input.git,
|
|
333
|
+
ci: input.ci,
|
|
334
|
+
snapshots: snapshots.map(({ name, hash, bytes, width, height }) => ({
|
|
335
|
+
name,
|
|
336
|
+
hash,
|
|
337
|
+
bytes,
|
|
338
|
+
width,
|
|
339
|
+
height
|
|
340
|
+
}))
|
|
341
|
+
});
|
|
342
|
+
const lookups = new Map(build.snapshots.map((lookup) => [lookup.name, lookup]));
|
|
343
|
+
const failedUploads = [];
|
|
344
|
+
const uploads = [];
|
|
345
|
+
let uploadedBytes = 0;
|
|
346
|
+
const pendingUploads = uniqueByHash(snapshots.filter((snapshot) => lookups.get(snapshot.name)?.uploadUrl));
|
|
347
|
+
await mapConcurrent(pendingUploads, UPLOAD_CONCURRENCY, async (snapshot) => {
|
|
348
|
+
const uploadUrl = lookups.get(snapshot.name)?.uploadUrl;
|
|
349
|
+
try {
|
|
350
|
+
const bytes = await readFile(snapshot.file);
|
|
351
|
+
uploads.push({
|
|
352
|
+
hash: snapshot.hash,
|
|
353
|
+
storageId: await input.api.upload(uploadUrl, bytes),
|
|
354
|
+
kind: "screenshot",
|
|
355
|
+
width: snapshot.width,
|
|
356
|
+
height: snapshot.height
|
|
357
|
+
});
|
|
358
|
+
uploadedBytes += bytes.length;
|
|
359
|
+
} catch {
|
|
360
|
+
failedUploads.push(snapshot.hash);
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
const failedScreenshots = new Set(failedUploads);
|
|
364
|
+
const options = {
|
|
365
|
+
threshold: input.threshold ?? build.diff.threshold,
|
|
366
|
+
includeAA: build.diff.includeAA
|
|
367
|
+
};
|
|
368
|
+
const diffs = /* @__PURE__ */ new Map();
|
|
369
|
+
const results = await mapConcurrent(snapshots, availableParallelism(), async (snapshot, index) => {
|
|
370
|
+
const lookup = lookups.get(snapshot.name);
|
|
371
|
+
const base = {
|
|
372
|
+
name: snapshot.name,
|
|
373
|
+
hash: snapshot.hash,
|
|
374
|
+
...snapshot.metadata === void 0 ? {} : { metadata: snapshot.metadata }
|
|
375
|
+
};
|
|
376
|
+
if (failedScreenshots.has(snapshot.hash)) return {
|
|
377
|
+
...base,
|
|
378
|
+
status: "failed"
|
|
379
|
+
};
|
|
380
|
+
if (lookup.status !== "changed") return {
|
|
381
|
+
...base,
|
|
382
|
+
status: lookup.status
|
|
383
|
+
};
|
|
384
|
+
if (lookup.baselineUrl === void 0) return {
|
|
385
|
+
...base,
|
|
386
|
+
status: "changed"
|
|
387
|
+
};
|
|
388
|
+
try {
|
|
389
|
+
const baselineFile = path.join(input.workDir, `baseline-${index}.png`);
|
|
390
|
+
const diffFile = path.join(input.workDir, `diff-${index}.png`);
|
|
391
|
+
await writeFile(baselineFile, await input.api.download(lookup.baselineUrl));
|
|
392
|
+
const { diffPixels, diffRatio } = await input.engine.diff(baselineFile, snapshot.file, diffFile, options);
|
|
393
|
+
if (diffPixels === 0) return {
|
|
394
|
+
...base,
|
|
395
|
+
status: "unchanged"
|
|
396
|
+
};
|
|
397
|
+
const diffBytes = await readFile(diffFile);
|
|
398
|
+
const diffHash = sha256(diffBytes);
|
|
399
|
+
diffs.set(diffHash, {
|
|
400
|
+
file: diffFile,
|
|
401
|
+
hash: diffHash,
|
|
402
|
+
...readPngSize(diffBytes)
|
|
403
|
+
});
|
|
404
|
+
return {
|
|
405
|
+
...base,
|
|
406
|
+
status: "changed",
|
|
407
|
+
diffHash,
|
|
408
|
+
diffRatio,
|
|
409
|
+
diffPixels
|
|
410
|
+
};
|
|
411
|
+
} catch {
|
|
412
|
+
return {
|
|
413
|
+
...base,
|
|
414
|
+
status: "failed"
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
if (diffs.size > 0) {
|
|
419
|
+
const { uploads: diffTargets } = await input.api.request("POST", `/builds/${build.buildId}/upload-urls`, { hashes: [...diffs.keys()] });
|
|
420
|
+
await mapConcurrent(diffTargets, UPLOAD_CONCURRENCY, async ({ hash, uploadUrl }) => {
|
|
421
|
+
const diff = diffs.get(hash);
|
|
422
|
+
if (diff === void 0) return;
|
|
423
|
+
try {
|
|
424
|
+
const bytes = await readFile(diff.file);
|
|
425
|
+
uploads.push({
|
|
426
|
+
hash,
|
|
427
|
+
storageId: await input.api.upload(uploadUrl, bytes),
|
|
428
|
+
kind: "diff",
|
|
429
|
+
width: diff.width,
|
|
430
|
+
height: diff.height
|
|
431
|
+
});
|
|
432
|
+
uploadedBytes += bytes.length;
|
|
433
|
+
} catch {
|
|
434
|
+
failedUploads.push(hash);
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
const { rejectedUploads } = await input.api.request("POST", `/builds/${build.buildId}/shards/${build.shardIndex}/complete`, {
|
|
439
|
+
uploads,
|
|
440
|
+
results
|
|
441
|
+
});
|
|
442
|
+
failedUploads.push(...rejectedUploads);
|
|
443
|
+
return {
|
|
444
|
+
build,
|
|
445
|
+
results,
|
|
446
|
+
uploadedImages: uploads.length,
|
|
447
|
+
uploadedBytes,
|
|
448
|
+
failedUploads,
|
|
449
|
+
final: await waitForFinalize(input.api, build.buildId, false)
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
async function waitForFinalize(api, buildId, finalizeRequested) {
|
|
453
|
+
for (let poll = 0; poll < FINALIZE_POLLS; poll++) {
|
|
454
|
+
const build = await api.request("GET", `/builds/${buildId}`);
|
|
455
|
+
const lastShardDone = build.shards.total !== null && build.shards.done >= build.shards.total;
|
|
456
|
+
if (build.status !== "pending" || !(finalizeRequested || lastShardDone)) return build.status === "finalized" ? build : null;
|
|
457
|
+
await new Promise((resolve) => setTimeout(resolve, FINALIZE_POLL_MS));
|
|
458
|
+
}
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
function uniqueByHash(snapshots) {
|
|
462
|
+
return [...new Map(snapshots.map((snapshot) => [snapshot.hash, snapshot])).values()];
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
//#endregion
|
|
466
|
+
//#region src/commands/upload.ts
|
|
467
|
+
async function uploadCommand(dir, options) {
|
|
468
|
+
const env = process.env;
|
|
469
|
+
if (!(await stat(dir).catch(() => void 0))?.isDirectory()) throw new Error(`Not a directory: ${dir}`);
|
|
470
|
+
const shard = options.shard ?? parseShard(env.STATEOFPIXEL_SHARD ?? "1/1");
|
|
471
|
+
const buildName = options.buildName ?? env.STATEOFPIXEL_BUILD_NAME ?? "default";
|
|
472
|
+
const nonce = options.nonce ?? env.STATEOFPIXEL_NONCE ?? defaultNonce(env);
|
|
473
|
+
if (nonce === null && shard.total !== 1) throw new Error("Set --nonce so every shard joins the same build.");
|
|
474
|
+
const git = await readGitInfo(env, process.cwd(), options.baselineBranch ?? env.STATEOFPIXEL_BASELINE_BRANCH);
|
|
475
|
+
if (options.dryRun) {
|
|
476
|
+
const snapshots = await readSnapshots(dir);
|
|
477
|
+
console.log(`stateofpixel dry run ${git.branch} vs ${git.baselineBranch}`);
|
|
478
|
+
console.log(` ${formatCount(snapshots.length)} snapshots in ${dir}`);
|
|
479
|
+
console.log(` build ${buildName}, shard ${shard.total === null ? "auto" : `${shard.index}/${shard.total}`}, commit ${git.commit.slice(0, 7)}, ${git.ancestors.length} ancestors`);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const startedAt = performance.now();
|
|
483
|
+
const workDir = await mkdtemp(path.join(tmpdir(), "stateofpixel-upload-"));
|
|
484
|
+
let output;
|
|
485
|
+
try {
|
|
486
|
+
output = await uploadDirectory({
|
|
487
|
+
dir,
|
|
488
|
+
workDir,
|
|
489
|
+
api: createApiClient({
|
|
490
|
+
baseUrl: env.STATEOFPIXEL_API_URL ?? "https://graceful-dogfish-423.convex.site/api/v1",
|
|
491
|
+
token: await resolveToken(env)
|
|
492
|
+
}),
|
|
493
|
+
engine: await createDiffEngine(),
|
|
494
|
+
buildName,
|
|
495
|
+
nonce: nonce ?? `local-${Date.now()}`,
|
|
496
|
+
shard,
|
|
497
|
+
subset: options.subset,
|
|
498
|
+
threshold: options.threshold,
|
|
499
|
+
git,
|
|
500
|
+
ci: readCiInfo(env)
|
|
501
|
+
});
|
|
502
|
+
} catch (error) {
|
|
503
|
+
if (isServerError(error) && !options.strict) {
|
|
504
|
+
console.warn(`stateofpixel: skipped, the service is not reachable (${error instanceof Error ? error.message : error}). Use --strict to fail instead.`);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (error instanceof ApiError) throw new Error(`${error.message} (${error.code})`);
|
|
508
|
+
throw error;
|
|
509
|
+
} finally {
|
|
510
|
+
await rm(workDir, {
|
|
511
|
+
recursive: true,
|
|
512
|
+
force: true
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
printSummary(output, git.branch, git.baselineBranch, startedAt);
|
|
516
|
+
if (output.failedUploads.length > 0) throw new Error(`${output.failedUploads.length} uploads failed.`);
|
|
517
|
+
}
|
|
518
|
+
function printSummary(output, branch, baselineBranch, startedAt) {
|
|
519
|
+
const { build, results, final } = output;
|
|
520
|
+
const counts = final?.counts ?? countResults(results);
|
|
521
|
+
const baseline = build.baseline === null ? "no baseline" : `#${build.baseline.buildNumber}`;
|
|
522
|
+
const seconds = ((performance.now() - startedAt) / 1e3).toFixed(1);
|
|
523
|
+
const megabytes = (output.uploadedBytes / 1024 / 1024).toFixed(1);
|
|
524
|
+
console.log(`stateofpixel build #${build.buildNumber} ${branch} vs ${baselineBranch} (${baseline})`);
|
|
525
|
+
console.log(formatCounts(results.length, counts));
|
|
526
|
+
console.log(` uploaded ${formatCount(output.uploadedImages)} images (${megabytes} MB) in ${seconds} s`);
|
|
527
|
+
for (const warning of build.warnings) console.log(` warning: ${warning}`);
|
|
528
|
+
console.log(` review: ${build.url}`);
|
|
529
|
+
}
|
|
530
|
+
function formatCounts(total, counts) {
|
|
531
|
+
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` : ""}`;
|
|
532
|
+
}
|
|
533
|
+
function countResults(results) {
|
|
534
|
+
const counts = {
|
|
535
|
+
unchanged: 0,
|
|
536
|
+
changed: 0,
|
|
537
|
+
added: 0,
|
|
538
|
+
removed: 0,
|
|
539
|
+
failed: 0
|
|
540
|
+
};
|
|
541
|
+
for (const result of results) counts[result.status]++;
|
|
542
|
+
return counts;
|
|
543
|
+
}
|
|
544
|
+
function parseShard(value) {
|
|
545
|
+
if (value === "auto") return {
|
|
546
|
+
index: null,
|
|
547
|
+
total: null
|
|
548
|
+
};
|
|
549
|
+
const match = /^(\d+)\/(\d+)$/.exec(value);
|
|
550
|
+
const index = Number(match?.[1]);
|
|
551
|
+
const total = Number(match?.[2]);
|
|
552
|
+
if (!match || index < 1 || total < 1 || index > total) throw new InvalidArgumentError("Must look like 1/4, or auto.");
|
|
553
|
+
return {
|
|
554
|
+
index,
|
|
555
|
+
total
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
function formatCount(count) {
|
|
559
|
+
return count.toLocaleString("en-US");
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
//#endregion
|
|
563
|
+
export { sha256 as _, waitForFinalize as a, readGitInfo as c, DEFAULT_API_URL as d, createApiClient as f, readPngSize as g, collectSnapshots as h, metadataFile as i, resolveToken as l, createDiffEngine as m, parseShard as n, defaultNonce as o, isServerError as p, uploadCommand as r, readCiInfo as s, formatCounts as t, ApiError as u, mapConcurrent as v };
|
|
564
|
+
//# sourceMappingURL=upload-CFcYa9LE.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"upload-CFcYa9LE.mjs","names":[],"sources":["../src/map-concurrent.ts","../src/png.ts","../src/snapshots.ts","../src/diff/odiff.ts","../src/diff/pixelmatch.ts","../src/diff/engine.ts","../src/api.ts","../src/ci-env.ts","../src/upload.ts","../src/commands/upload.ts"],"sourcesContent":["export async function mapConcurrent<Item, Result>(\n items: Item[],\n concurrency: number,\n fn: (item: Item, index: number) => Promise<Result>,\n): Promise<Result[]> {\n const results = new Array<Result>(items.length);\n let nextIndex = 0;\n\n async function worker() {\n while (nextIndex < items.length) {\n const index = nextIndex++;\n results[index] = await fn(items[index] as Item, index);\n }\n }\n\n await Promise.all(\n Array.from({ length: Math.min(concurrency, items.length) }, worker),\n );\n return results;\n}\n","import { createHash } from \"node:crypto\";\n\nconst PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);\n\nexport function sha256(bytes: Buffer): string {\n return createHash(\"sha256\").update(bytes).digest(\"hex\");\n}\n\nexport function readPngSize(bytes: Buffer): { width: number; height: number } {\n if (bytes.length < 24 || !bytes.subarray(0, 8).equals(PNG_SIGNATURE)) {\n throw new Error(\"Not a PNG file\");\n }\n return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };\n}\n","import { readdir } from \"node:fs/promises\";\nimport path from \"node:path\";\n\nexport async function collectSnapshots(\n dir: string,\n): Promise<Map<string, string>> {\n const entries = await readdir(dir, { recursive: true, withFileTypes: true });\n const snapshots = new Map<string, string>();\n\n for (const entry of entries) {\n if (!entry.isFile() || !entry.name.toLowerCase().endsWith(\".png\")) {\n continue;\n }\n const filePath = path.join(entry.parentPath, entry.name);\n snapshots.set(snapshotName(dir, filePath), filePath);\n }\n\n return new Map(\n [...snapshots].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),\n );\n}\n\nfunction snapshotName(dir: string, filePath: string): string {\n const relativePath = path.relative(dir, filePath).split(path.sep).join(\"/\");\n return relativePath.slice(0, -\".png\".length);\n}\n","import { open } from \"node:fs/promises\";\nimport { createRequire } from \"node:module\";\nimport { readPngSize } from \"../png\";\nimport type { DiffEngine } from \"./engine\";\n\nexport async function createOdiffEngine(): Promise<DiffEngine> {\n const require = createRequire(import.meta.url);\n const { findBinary } = require(\"odiff-bin/binary.js\") as {\n findBinary(): string;\n };\n findBinary();\n const { compare } = await import(\"odiff-bin\");\n\n return {\n name: \"odiff\",\n async diff(basePath, newPath, diffPath, options) {\n const result = await compare(basePath, newPath, diffPath, {\n threshold: options.threshold,\n antialiasing: !options.includeAA,\n outputDiffMask: true,\n });\n\n if (result.match) {\n return { diffPixels: 0, diffRatio: 0 };\n }\n if (result.reason === \"pixel-diff\") {\n const [base, next] = await Promise.all([\n readPngSizeFromFile(basePath),\n readPngSizeFromFile(newPath),\n ]);\n const pixels =\n Math.max(base.width, next.width) * Math.max(base.height, next.height);\n return {\n diffPixels: result.diffCount,\n diffRatio: result.diffCount / pixels,\n };\n }\n throw new Error(`odiff could not compare ${newPath}: ${result.reason}`);\n },\n };\n}\n\nasync function readPngSizeFromFile(\n filePath: string,\n): Promise<{ width: number; height: number }> {\n const file = await open(filePath);\n try {\n const { buffer } = await file.read(Buffer.alloc(24), 0, 24, 0);\n return readPngSize(buffer);\n } finally {\n await file.close();\n }\n}\n","import { readFile, writeFile } from \"node:fs/promises\";\nimport pixelmatch from \"pixelmatch\";\nimport { PNG } from \"pngjs\";\nimport type { DiffEngine } from \"./engine\";\n\nexport function createPixelmatchEngine(): DiffEngine {\n return {\n name: \"pixelmatch\",\n async diff(basePath, newPath, diffPath, options) {\n const [base, next] = await Promise.all([\n readPng(basePath),\n readPng(newPath),\n ]);\n const width = Math.max(base.width, next.width);\n const height = Math.max(base.height, next.height);\n const diff = new PNG({ width, height });\n\n const diffPixels = pixelmatch(\n padTo(base, width, height),\n padTo(next, width, height),\n diff.data,\n width,\n height,\n {\n threshold: options.threshold,\n includeAA: options.includeAA,\n diffMask: true,\n },\n );\n\n if (diffPixels > 0) {\n await writeFile(diffPath, PNG.sync.write(diff));\n }\n return { diffPixels, diffRatio: diffPixels / (width * height) };\n },\n };\n}\n\nasync function readPng(filePath: string): Promise<PNG> {\n return PNG.sync.read(await readFile(filePath));\n}\n\nfunction padTo(image: PNG, width: number, height: number): Uint8Array {\n if (image.width === width && image.height === height) {\n return image.data;\n }\n const padded = new Uint8Array(width * height * 4);\n const rowBytes = image.width * 4;\n for (let y = 0; y < image.height; y++) {\n padded.set(\n image.data.subarray(y * rowBytes, (y + 1) * rowBytes),\n y * width * 4,\n );\n }\n return padded;\n}\n","import { createOdiffEngine } from \"./odiff\";\nimport { createPixelmatchEngine } from \"./pixelmatch\";\n\nexport type DiffOptions = {\n threshold: number;\n includeAA: boolean;\n};\n\nexport type DiffResult = {\n diffPixels: number;\n diffRatio: number;\n};\n\nexport type DiffEngine = {\n name: \"odiff\" | \"pixelmatch\";\n diff(\n basePath: string,\n newPath: string,\n diffPath: string,\n options: DiffOptions,\n ): Promise<DiffResult>;\n};\n\nexport async function createDiffEngine(): Promise<DiffEngine> {\n try {\n return await createOdiffEngine();\n } catch {\n return createPixelmatchEngine();\n }\n}\n","export const DEFAULT_API_URL =\n \"https://graceful-dogfish-423.convex.site/api/v1\";\n\nexport class ApiError extends Error {\n constructor(\n readonly status: number,\n readonly code: string,\n message: string,\n ) {\n super(message);\n }\n}\n\nexport type SnapshotLookup = {\n name: string;\n status: \"unchanged\" | \"changed\" | \"added\";\n baselineUrl?: string;\n uploadUrl?: string;\n};\n\nexport type CreateBuildResponse = {\n buildId: string;\n buildNumber: number;\n shardIndex: number;\n url: string;\n diff: { threshold: number; includeAA: boolean };\n baseline: { buildNumber: number; commit: string } | null;\n snapshots: SnapshotLookup[];\n warnings: string[];\n};\n\nexport type BuildCounts = {\n unchanged: number;\n changed: number;\n added: number;\n removed: number;\n failed: number;\n pending: number;\n approved: number;\n rejected: number;\n};\n\nexport type BuildResponse = {\n buildId: string;\n buildNumber: number;\n url: string;\n status: \"pending\" | \"finalized\" | \"expired\" | \"error\";\n conclusion: \"no_changes\" | \"changes\" | \"approved\" | \"rejected\" | null;\n counts: BuildCounts;\n shards: { done: number; total: number | null };\n};\n\ntype ApiClientOptions = {\n baseUrl: string;\n token: string;\n fetch?: typeof fetch;\n retries?: number;\n retryDelayMs?: number;\n};\n\nexport type ApiClient = ReturnType<typeof createApiClient>;\n\nexport function createApiClient({\n baseUrl,\n token,\n fetch = globalThis.fetch,\n retries = 3,\n retryDelayMs = 1000,\n}: ApiClientOptions) {\n async function withRetries<Result>(\n fn: () => Promise<Result>,\n ): Promise<Result> {\n for (let attempt = 0; ; attempt++) {\n try {\n return await fn();\n } catch (error) {\n if (attempt >= retries || !isServerError(error)) {\n throw error;\n }\n await sleep(retryDelayMs * 2 ** attempt);\n }\n }\n }\n\n function request<Result>(\n method: \"GET\" | \"POST\",\n path: string,\n body?: unknown,\n ): Promise<Result> {\n return withRetries(async () => {\n const response = await fetch(`${baseUrl}${path}`, {\n method,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n ...(body === undefined ? {} : { body: JSON.stringify(body) }),\n });\n if (!response.ok) {\n throw await toApiError(response);\n }\n return (await response.json()) as Result;\n });\n }\n\n function upload(uploadUrl: string, bytes: Buffer): Promise<string> {\n return withRetries(async () => {\n const response = await fetch(uploadUrl, {\n method: \"POST\",\n headers: { \"Content-Type\": \"image/png\" },\n body: new Uint8Array(bytes),\n });\n if (!response.ok) {\n throw await toApiError(response);\n }\n const { storageId } = (await response.json()) as { storageId: string };\n return storageId;\n });\n }\n\n function download(url: string): Promise<Buffer> {\n return withRetries(async () => {\n const response = await fetch(url);\n if (!response.ok) {\n throw await toApiError(response);\n }\n return Buffer.from(await response.arrayBuffer());\n });\n }\n\n return { request, upload, download };\n}\n\nasync function toApiError(response: Response): Promise<ApiError> {\n const body = (await response.json().catch(() => null)) as {\n error?: { code?: string; message?: string };\n } | null;\n return new ApiError(\n response.status,\n body?.error?.code ?? \"http_error\",\n body?.error?.message ?? `HTTP ${response.status}`,\n );\n}\n\nexport function isServerError(error: unknown): boolean {\n return (\n (error instanceof ApiError && error.status >= 500) ||\n error instanceof TypeError\n );\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n","import { execFile } from \"node:child_process\";\nimport { readFile } from \"node:fs/promises\";\nimport { promisify } from \"node:util\";\n\nconst execFileAsync = promisify(execFile);\nconst MAX_ANCESTORS = 100;\nconst OIDC_AUDIENCE = \"stateofpixel\";\n\nexport type Env = Record<string, string | undefined>;\n\nexport type GitInfo = {\n commit: string;\n commitMessage: string;\n branch: string;\n baselineBranch: string;\n prNumber: number | null;\n mergeBase: string | null;\n ancestors: string[];\n};\n\nexport type CiInfo = { provider?: string; runUrl?: string };\n\ntype GithubEvent = {\n pull_request?: {\n number: number;\n head: { sha: string; ref: string };\n base: { ref: string };\n };\n head_commit?: { message: string } | null;\n repository?: { default_branch?: string };\n};\n\nexport async function readGitInfo(\n env: Env,\n cwd: string,\n baselineBranchOverride?: string,\n): Promise<GitInfo> {\n const event = await readGithubEvent(env);\n const pullRequest = event?.pull_request;\n const commit =\n pullRequest?.head.sha ||\n env.GITHUB_SHA ||\n (await git(cwd, \"rev-parse\", \"HEAD\"));\n if (commit === null) {\n throw new Error(\"Could not find the commit. Run inside a git checkout.\");\n }\n const branch =\n pullRequest?.head.ref ||\n env.GITHUB_HEAD_REF ||\n env.GITHUB_REF_NAME ||\n (await git(cwd, \"rev-parse\", \"--abbrev-ref\", \"HEAD\")) ||\n \"HEAD\";\n const baselineBranch =\n baselineBranchOverride ||\n pullRequest?.base.ref ||\n event?.repository?.default_branch ||\n (await defaultBranch(cwd)) ||\n \"main\";\n\n return {\n commit,\n commitMessage:\n (await git(cwd, \"log\", \"-1\", \"--format=%s\", commit)) ??\n event?.head_commit?.message?.split(\"\\n\")[0] ??\n \"\",\n branch,\n baselineBranch,\n prNumber: pullRequest?.number ?? null,\n mergeBase: await git(cwd, \"merge-base\", commit, `origin/${baselineBranch}`),\n ancestors: await ancestors(cwd, commit),\n };\n}\n\nexport function readCiInfo(env: Env): CiInfo {\n if (env.GITHUB_ACTIONS === \"true\") {\n return {\n provider: \"github-actions\",\n runUrl:\n env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID\n ? `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`\n : undefined,\n };\n }\n return env.CI ? { provider: \"unknown\" } : {};\n}\n\nexport function defaultNonce(env: Env): string | null {\n if (env.GITHUB_RUN_ID) {\n return `${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT ?? \"1\"}`;\n }\n return null;\n}\n\nexport async function resolveToken(\n env: Env,\n fetchImpl: typeof fetch = fetch,\n): Promise<string> {\n if (env.STATEOFPIXEL_TOKEN) {\n return env.STATEOFPIXEL_TOKEN;\n }\n const requestUrl = env.ACTIONS_ID_TOKEN_REQUEST_URL;\n const requestToken = env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;\n if (!requestUrl || !requestToken) {\n throw new Error(\n \"No token. On GitHub Actions add `permissions: id-token: write`, elsewhere set STATEOFPIXEL_TOKEN.\",\n );\n }\n const url = new URL(requestUrl);\n url.searchParams.set(\"audience\", OIDC_AUDIENCE);\n const response = await fetchImpl(url, {\n headers: { Authorization: `Bearer ${requestToken}` },\n });\n if (!response.ok) {\n throw new Error(\n `GitHub Actions OIDC token request failed with HTTP ${response.status}.`,\n );\n }\n const { value } = (await response.json()) as { value: string };\n return value;\n}\n\nasync function readGithubEvent(env: Env): Promise<GithubEvent | null> {\n if (!env.GITHUB_EVENT_PATH) {\n return null;\n }\n try {\n return JSON.parse(await readFile(env.GITHUB_EVENT_PATH, \"utf8\"));\n } catch {\n return null;\n }\n}\n\nasync function ancestors(cwd: string, commit: string): Promise<string[]> {\n for (const start of [commit, \"HEAD\"]) {\n const output = await git(\n cwd,\n \"rev-list\",\n `--max-count=${MAX_ANCESTORS + 2}`,\n start,\n );\n if (output !== null) {\n return output\n .split(\"\\n\")\n .filter((sha) => sha !== \"\" && sha !== commit)\n .slice(start === \"HEAD\" ? 1 : 0)\n .slice(0, MAX_ANCESTORS);\n }\n }\n return [];\n}\n\nasync function defaultBranch(cwd: string): Promise<string | null> {\n const ref = await git(\n cwd,\n \"symbolic-ref\",\n \"--short\",\n \"refs/remotes/origin/HEAD\",\n );\n return ref?.replace(/^origin\\//, \"\") ?? null;\n}\n\nasync function git(cwd: string, ...args: string[]): Promise<string | null> {\n try {\n const { stdout } = await execFileAsync(\"git\", args, { cwd });\n return stdout.trim();\n } catch {\n return null;\n }\n}\n","import { readFile, writeFile } from \"node:fs/promises\";\nimport { availableParallelism } from \"node:os\";\nimport path from \"node:path\";\nimport type {\n ApiClient,\n BuildResponse,\n CreateBuildResponse,\n SnapshotLookup,\n} from \"./api\";\nimport type { CiInfo, GitInfo } from \"./ci-env\";\nimport type { DiffEngine } from \"./diff/engine\";\nimport { mapConcurrent } from \"./map-concurrent\";\nimport { readPngSize, sha256 } from \"./png\";\nimport { collectSnapshots } from \"./snapshots\";\n\nconst UPLOAD_CONCURRENCY = 8;\nconst FINALIZE_POLLS = 10;\nconst FINALIZE_POLL_MS = 1000;\n\nexport type LocalSnapshot = {\n name: string;\n file: string;\n hash: string;\n bytes: number;\n width: number;\n height: number;\n metadata?: Record<string, unknown>;\n};\n\ntype Upload = {\n hash: string;\n storageId: string;\n kind: \"screenshot\" | \"diff\";\n width: number;\n height: number;\n};\n\nexport type ShardResult = {\n name: string;\n hash: string;\n status: \"unchanged\" | \"changed\" | \"added\" | \"failed\";\n diffHash?: string;\n diffRatio?: number;\n diffPixels?: number;\n metadata?: Record<string, unknown>;\n};\n\nexport type Shard = { index: number | null; total: number | null };\n\nexport type UploadInput = {\n dir: string;\n workDir: string;\n api: ApiClient;\n engine: DiffEngine;\n buildName: string;\n nonce: string;\n shard: Shard;\n subset: boolean;\n threshold?: number;\n git: GitInfo;\n ci: CiInfo;\n};\n\nexport type UploadOutput = {\n build: CreateBuildResponse;\n results: ShardResult[];\n uploadedImages: number;\n uploadedBytes: number;\n failedUploads: string[];\n final: BuildResponse | null;\n};\n\nexport async function readSnapshots(dir: string): Promise<LocalSnapshot[]> {\n const files = await collectSnapshots(dir);\n return mapConcurrent(\n [...files],\n availableParallelism(),\n async ([name, file]) => {\n const bytes = await readFile(file);\n const metadata = await readMetadata(file);\n return {\n name,\n file,\n hash: sha256(bytes),\n bytes: bytes.length,\n ...readPngSize(bytes),\n ...(metadata === undefined ? {} : { metadata }),\n };\n },\n );\n}\n\nexport function metadataFile(pngFile: string): string {\n return `${pngFile.slice(0, -\".png\".length)}.meta.json`;\n}\n\nasync function readMetadata(\n pngFile: string,\n): Promise<Record<string, unknown> | undefined> {\n const text = await readFile(metadataFile(pngFile), \"utf8\").catch(\n () => undefined,\n );\n return text === undefined ? undefined : JSON.parse(text);\n}\n\nexport async function uploadDirectory(\n input: UploadInput,\n): Promise<UploadOutput> {\n const snapshots = await readSnapshots(input.dir);\n const build = await input.api.request<CreateBuildResponse>(\n \"POST\",\n \"/builds\",\n {\n buildName: input.buildName,\n nonce: input.nonce,\n shard: input.shard,\n subset: input.subset,\n git: input.git,\n ci: input.ci,\n snapshots: snapshots.map(({ name, hash, bytes, width, height }) => ({\n name,\n hash,\n bytes,\n width,\n height,\n })),\n },\n );\n const lookups = new Map(\n build.snapshots.map((lookup) => [lookup.name, lookup]),\n );\n\n const failedUploads: string[] = [];\n const uploads: Upload[] = [];\n let uploadedBytes = 0;\n const pendingUploads = uniqueByHash(\n snapshots.filter((snapshot) => lookups.get(snapshot.name)?.uploadUrl),\n );\n await mapConcurrent(pendingUploads, UPLOAD_CONCURRENCY, async (snapshot) => {\n const uploadUrl = lookups.get(snapshot.name)?.uploadUrl as string;\n try {\n const bytes = await readFile(snapshot.file);\n uploads.push({\n hash: snapshot.hash,\n storageId: await input.api.upload(uploadUrl, bytes),\n kind: \"screenshot\",\n width: snapshot.width,\n height: snapshot.height,\n });\n uploadedBytes += bytes.length;\n } catch {\n failedUploads.push(snapshot.hash);\n }\n });\n\n const failedScreenshots = new Set(failedUploads);\n const options = {\n threshold: input.threshold ?? build.diff.threshold,\n includeAA: build.diff.includeAA,\n };\n const diffs = new Map<\n string,\n { file: string; hash: string; width: number; height: number }\n >();\n const results = await mapConcurrent(\n snapshots,\n availableParallelism(),\n async (snapshot, index): Promise<ShardResult> => {\n const lookup = lookups.get(snapshot.name) as SnapshotLookup;\n const base = {\n name: snapshot.name,\n hash: snapshot.hash,\n ...(snapshot.metadata === undefined\n ? {}\n : { metadata: snapshot.metadata }),\n };\n if (failedScreenshots.has(snapshot.hash)) {\n return { ...base, status: \"failed\" };\n }\n if (lookup.status !== \"changed\") {\n return { ...base, status: lookup.status };\n }\n if (lookup.baselineUrl === undefined) {\n return { ...base, status: \"changed\" };\n }\n try {\n const baselineFile = path.join(input.workDir, `baseline-${index}.png`);\n const diffFile = path.join(input.workDir, `diff-${index}.png`);\n await writeFile(\n baselineFile,\n await input.api.download(lookup.baselineUrl),\n );\n const { diffPixels, diffRatio } = await input.engine.diff(\n baselineFile,\n snapshot.file,\n diffFile,\n options,\n );\n if (diffPixels === 0) {\n return { ...base, status: \"unchanged\" };\n }\n const diffBytes = await readFile(diffFile);\n const diffHash = sha256(diffBytes);\n diffs.set(diffHash, {\n file: diffFile,\n hash: diffHash,\n ...readPngSize(diffBytes),\n });\n return { ...base, status: \"changed\", diffHash, diffRatio, diffPixels };\n } catch {\n return { ...base, status: \"failed\" };\n }\n },\n );\n\n if (diffs.size > 0) {\n const { uploads: diffTargets } = await input.api.request<{\n uploads: { hash: string; uploadUrl: string }[];\n }>(\"POST\", `/builds/${build.buildId}/upload-urls`, {\n hashes: [...diffs.keys()],\n });\n await mapConcurrent(\n diffTargets,\n UPLOAD_CONCURRENCY,\n async ({ hash, uploadUrl }) => {\n const diff = diffs.get(hash);\n if (diff === undefined) {\n return;\n }\n try {\n const bytes = await readFile(diff.file);\n uploads.push({\n hash,\n storageId: await input.api.upload(uploadUrl, bytes),\n kind: \"diff\",\n width: diff.width,\n height: diff.height,\n });\n uploadedBytes += bytes.length;\n } catch {\n failedUploads.push(hash);\n }\n },\n );\n }\n\n const { rejectedUploads } = await input.api.request<{\n rejectedUploads: string[];\n }>(\"POST\", `/builds/${build.buildId}/shards/${build.shardIndex}/complete`, {\n uploads,\n results,\n });\n failedUploads.push(...rejectedUploads);\n\n return {\n build,\n results,\n uploadedImages: uploads.length,\n uploadedBytes,\n failedUploads,\n final: await waitForFinalize(input.api, build.buildId, false),\n };\n}\n\nexport async function waitForFinalize(\n api: ApiClient,\n buildId: string,\n finalizeRequested: boolean,\n): Promise<BuildResponse | null> {\n for (let poll = 0; poll < FINALIZE_POLLS; poll++) {\n const build = await api.request<BuildResponse>(\"GET\", `/builds/${buildId}`);\n const lastShardDone =\n build.shards.total !== null && build.shards.done >= build.shards.total;\n if (build.status !== \"pending\" || !(finalizeRequested || lastShardDone)) {\n return build.status === \"finalized\" ? build : null;\n }\n await new Promise((resolve) => setTimeout(resolve, FINALIZE_POLL_MS));\n }\n return null;\n}\n\nfunction uniqueByHash(snapshots: LocalSnapshot[]): LocalSnapshot[] {\n return [\n ...new Map(snapshots.map((snapshot) => [snapshot.hash, snapshot])).values(),\n ];\n}\n","import { mkdtemp, rm, stat } from \"node:fs/promises\";\nimport { tmpdir } from \"node:os\";\nimport path from \"node:path\";\nimport { InvalidArgumentError } from \"commander\";\nimport {\n ApiError,\n type BuildCounts,\n createApiClient,\n DEFAULT_API_URL,\n isServerError,\n} from \"../api\";\nimport { defaultNonce, readCiInfo, readGitInfo, resolveToken } from \"../ci-env\";\nimport { createDiffEngine } from \"../diff/engine\";\nimport {\n readSnapshots,\n type Shard,\n type UploadOutput,\n uploadDirectory,\n} from \"../upload\";\n\nexport type UploadCommandOptions = {\n buildName?: string;\n shard?: Shard;\n nonce?: string;\n baselineBranch?: string;\n subset: boolean;\n threshold?: number;\n strict: boolean;\n dryRun: boolean;\n};\n\nexport async function uploadCommand(\n dir: string,\n options: UploadCommandOptions,\n): Promise<void> {\n const env = process.env;\n const stats = await stat(dir).catch(() => undefined);\n if (!stats?.isDirectory()) {\n throw new Error(`Not a directory: ${dir}`);\n }\n const shard = options.shard ?? parseShard(env.STATEOFPIXEL_SHARD ?? \"1/1\");\n const buildName =\n options.buildName ?? env.STATEOFPIXEL_BUILD_NAME ?? \"default\";\n const nonce = options.nonce ?? env.STATEOFPIXEL_NONCE ?? defaultNonce(env);\n if (nonce === null && shard.total !== 1) {\n throw new Error(\"Set --nonce so every shard joins the same build.\");\n }\n const git = await readGitInfo(\n env,\n process.cwd(),\n options.baselineBranch ?? env.STATEOFPIXEL_BASELINE_BRANCH,\n );\n\n if (options.dryRun) {\n const snapshots = await readSnapshots(dir);\n console.log(\n `stateofpixel dry run ${git.branch} vs ${git.baselineBranch}`,\n );\n console.log(` ${formatCount(snapshots.length)} snapshots in ${dir}`);\n console.log(\n ` build ${buildName}, shard ${shard.total === null ? \"auto\" : `${shard.index}/${shard.total}`}, commit ${git.commit.slice(0, 7)}, ${git.ancestors.length} ancestors`,\n );\n return;\n }\n\n const startedAt = performance.now();\n const workDir = await mkdtemp(path.join(tmpdir(), \"stateofpixel-upload-\"));\n let output: UploadOutput;\n try {\n output = await uploadDirectory({\n dir,\n workDir,\n api: createApiClient({\n baseUrl: env.STATEOFPIXEL_API_URL ?? DEFAULT_API_URL,\n token: await resolveToken(env),\n }),\n engine: await createDiffEngine(),\n buildName,\n nonce: nonce ?? `local-${Date.now()}`,\n shard,\n subset: options.subset,\n threshold: options.threshold,\n git,\n ci: readCiInfo(env),\n });\n } catch (error) {\n if (isServerError(error) && !options.strict) {\n console.warn(\n `stateofpixel: skipped, the service is not reachable (${error instanceof Error ? error.message : error}). Use --strict to fail instead.`,\n );\n return;\n }\n if (error instanceof ApiError) {\n throw new Error(`${error.message} (${error.code})`);\n }\n throw error;\n } finally {\n await rm(workDir, { recursive: true, force: true });\n }\n\n printSummary(output, git.branch, git.baselineBranch, startedAt);\n if (output.failedUploads.length > 0) {\n throw new Error(`${output.failedUploads.length} uploads failed.`);\n }\n}\n\nfunction printSummary(\n output: UploadOutput,\n branch: string,\n baselineBranch: string,\n startedAt: number,\n) {\n const { build, results, final } = output;\n const counts = final?.counts ?? countResults(results);\n const baseline =\n build.baseline === null ? \"no baseline\" : `#${build.baseline.buildNumber}`;\n const seconds = ((performance.now() - startedAt) / 1000).toFixed(1);\n const megabytes = (output.uploadedBytes / 1024 / 1024).toFixed(1);\n console.log(\n `stateofpixel build #${build.buildNumber} ${branch} vs ${baselineBranch} (${baseline})`,\n );\n console.log(formatCounts(results.length, counts));\n console.log(\n ` uploaded ${formatCount(output.uploadedImages)} images (${megabytes} MB) in ${seconds} s`,\n );\n for (const warning of build.warnings) {\n console.log(` warning: ${warning}`);\n }\n console.log(` review: ${build.url}`);\n}\n\nexport function formatCounts(\n total: number,\n counts: Pick<\n BuildCounts,\n \"unchanged\" | \"changed\" | \"added\" | \"removed\" | \"failed\"\n >,\n): string {\n 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` : \"\"}`;\n}\n\nfunction countResults(results: UploadOutput[\"results\"]) {\n const counts = { unchanged: 0, changed: 0, added: 0, removed: 0, failed: 0 };\n for (const result of results) {\n counts[result.status]++;\n }\n return counts;\n}\n\nexport function parseShard(value: string): Shard {\n if (value === \"auto\") {\n return { index: null, total: null };\n }\n const match = /^(\\d+)\\/(\\d+)$/.exec(value);\n const index = Number(match?.[1]);\n const total = Number(match?.[2]);\n if (!match || index < 1 || total < 1 || index > total) {\n throw new InvalidArgumentError(\"Must look like 1/4, or auto.\");\n }\n return { index, total };\n}\n\nexport function formatCount(count: number): string {\n return count.toLocaleString(\"en-US\");\n}\n"],"mappings":"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"}
|