stateofpixel 1.0.0 → 1.2.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 +38 -2
- package/dist/index.d.mts +1 -0
- package/dist/index.mjs +160 -553
- 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-DKzFVTAz.mjs +575 -0
- package/dist/upload-DKzFVTAz.mjs.map +1 -0
- package/package.json +19 -1
package/dist/index.mjs
CHANGED
|
@@ -1,74 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { _ as readPngSize, a as defaultNonce, c as resolveToken, d as createApiClient, f as isRateLimited, g as collectSnapshots, h as createDiffEngine, i as waitForFinalize, l as ApiError, m as formatCounts, n as uploadCommand, o as readCiInfo, p as isServerError, r as metadataFile, s as readGitInfo, t as parseShard, u as DEFAULT_API_URL, v as sha256, y as mapConcurrent } from "./upload-DKzFVTAz.mjs";
|
|
3
3
|
import { Command, InvalidArgumentError } from "commander";
|
|
4
|
-
import { copyFile, mkdir, mkdtemp,
|
|
4
|
+
import { copyFile, mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { availableParallelism, tmpdir } from "node:os";
|
|
7
|
-
import {
|
|
8
|
-
import
|
|
9
|
-
import { PNG } from "pngjs";
|
|
10
|
-
import { execFile } from "node:child_process";
|
|
11
|
-
import { promisify } from "node:util";
|
|
7
|
+
import { createReadStream } from "node:fs";
|
|
8
|
+
import { createServer } from "node:http";
|
|
12
9
|
|
|
13
10
|
//#region package.json
|
|
14
|
-
var version = "1.
|
|
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
|
-
}
|
|
11
|
+
var version = "1.2.0";
|
|
72
12
|
|
|
73
13
|
//#endregion
|
|
74
14
|
//#region src/compare.ts
|
|
@@ -140,93 +80,6 @@ async function storeImage(filePath, imagesDir, { move = false } = {}) {
|
|
|
140
80
|
};
|
|
141
81
|
}
|
|
142
82
|
|
|
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
83
|
//#endregion
|
|
231
84
|
//#region src/report/template.ts
|
|
232
85
|
function renderReport(snapshots, meta) {
|
|
@@ -623,7 +476,7 @@ async function compareCommand(dir, baselineDir, options) {
|
|
|
623
476
|
const counts = countByStatus(results.map((result) => result.status));
|
|
624
477
|
const seconds = ((performance.now() - startedAt) / 1e3).toFixed(1);
|
|
625
478
|
console.log(`stateofpixel compare ${dir} vs ${baselineDir}`);
|
|
626
|
-
console.log(
|
|
479
|
+
console.log(formatCounts(results.length, counts));
|
|
627
480
|
console.log(` compared with ${engine.name} in ${seconds} s`);
|
|
628
481
|
console.log(` report: ${path.relative(process.cwd(), reportPath)}`);
|
|
629
482
|
}
|
|
@@ -645,406 +498,6 @@ function countByStatus(statuses) {
|
|
|
645
498
|
for (const status of statuses) counts[status]++;
|
|
646
499
|
return counts;
|
|
647
500
|
}
|
|
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
501
|
|
|
1049
502
|
//#endregion
|
|
1050
503
|
//#region src/commands/finalize.ts
|
|
@@ -1070,6 +523,10 @@ async function finalizeCommand(options) {
|
|
|
1070
523
|
});
|
|
1071
524
|
final = await waitForFinalize(api, build.buildId, true);
|
|
1072
525
|
} catch (error) {
|
|
526
|
+
if (isRateLimited(error) && !options.strict) {
|
|
527
|
+
console.warn(`stateofpixel: skipped, ${error.message} Use --strict to fail instead.`);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
1073
530
|
if (isServerError(error) && !options.strict) {
|
|
1074
531
|
console.warn(`stateofpixel: skipped, the service is not reachable (${error instanceof Error ? error.message : error}). Use --strict to fail instead.`);
|
|
1075
532
|
return;
|
|
@@ -1086,11 +543,161 @@ async function finalizeCommand(options) {
|
|
|
1086
543
|
console.log(` review: ${build.url}`);
|
|
1087
544
|
}
|
|
1088
545
|
|
|
546
|
+
//#endregion
|
|
547
|
+
//#region src/commands/storybook.ts
|
|
548
|
+
const CAPTURE_CONCURRENCY = 4;
|
|
549
|
+
const STORY_TIMEOUT_MS = 15e3;
|
|
550
|
+
const VIEWPORT_HEIGHT = 720;
|
|
551
|
+
const CONTENT_TYPES = {
|
|
552
|
+
".html": "text/html",
|
|
553
|
+
".js": "text/javascript",
|
|
554
|
+
".mjs": "text/javascript",
|
|
555
|
+
".css": "text/css",
|
|
556
|
+
".json": "application/json",
|
|
557
|
+
".svg": "image/svg+xml",
|
|
558
|
+
".png": "image/png",
|
|
559
|
+
".jpg": "image/jpeg",
|
|
560
|
+
".gif": "image/gif",
|
|
561
|
+
".webp": "image/webp",
|
|
562
|
+
".woff": "font/woff",
|
|
563
|
+
".woff2": "font/woff2",
|
|
564
|
+
".ttf": "font/ttf"
|
|
565
|
+
};
|
|
566
|
+
async function storybookCommand(staticDir, options) {
|
|
567
|
+
const stories = filterStories(await readStories(staticDir), options.include, options.exclude);
|
|
568
|
+
if (stories.length === 0) throw new Error(`No stories found in ${staticDir}/index.json.`);
|
|
569
|
+
const outDir = await mkdtemp(path.join(tmpdir(), "stateofpixel-storybook-"));
|
|
570
|
+
try {
|
|
571
|
+
const failed = await captureStories(staticDir, outDir, stories, options);
|
|
572
|
+
if (failed.length > 0) throw new Error(`Stories that did not render: ${failed.join(", ")}`);
|
|
573
|
+
await uploadCommand(outDir, options);
|
|
574
|
+
} finally {
|
|
575
|
+
await rm(outDir, {
|
|
576
|
+
recursive: true,
|
|
577
|
+
force: true
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
async function readStories(staticDir) {
|
|
582
|
+
const text = await readFile(path.join(staticDir, "index.json"), "utf8").catch(() => {
|
|
583
|
+
throw new Error(`No index.json in ${staticDir}. Run storybook build first, Storybook 7 or newer.`);
|
|
584
|
+
});
|
|
585
|
+
const index = JSON.parse(text);
|
|
586
|
+
return Object.values(index.entries ?? {}).filter((entry) => entry.type === "story").map(({ id, title, name, importPath }) => ({
|
|
587
|
+
id,
|
|
588
|
+
title,
|
|
589
|
+
name,
|
|
590
|
+
importPath
|
|
591
|
+
}));
|
|
592
|
+
}
|
|
593
|
+
function filterStories(stories, include, exclude) {
|
|
594
|
+
const includes = include === void 0 ? null : globToRegExp(include);
|
|
595
|
+
const excludes = exclude === void 0 ? null : globToRegExp(exclude);
|
|
596
|
+
return stories.filter((story) => {
|
|
597
|
+
const name = `${story.title}/${story.name}`;
|
|
598
|
+
return (includes === null || includes.test(name)) && (excludes === null || !excludes.test(name));
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
function globToRegExp(glob) {
|
|
602
|
+
const source = glob.split("**").map((part) => part.split("*").map((text) => text.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("[^/]*")).join(".*");
|
|
603
|
+
return new RegExp(`^${source}$`);
|
|
604
|
+
}
|
|
605
|
+
function storySnapshotName(story, width) {
|
|
606
|
+
return `${story.title}/${story.name} [chromium ${width}]`;
|
|
607
|
+
}
|
|
608
|
+
async function captureStories(staticDir, outDir, stories, options) {
|
|
609
|
+
const { chromium } = await import("playwright").catch(() => {
|
|
610
|
+
throw new Error("stateofpixel storybook needs Playwright. Run npm install -D playwright, then npx playwright install chromium.");
|
|
611
|
+
});
|
|
612
|
+
const server = await serveStatic(staticDir);
|
|
613
|
+
const baseUrl = `http://127.0.0.1:${server.address().port}`;
|
|
614
|
+
const browser = await chromium.launch();
|
|
615
|
+
const failed = [];
|
|
616
|
+
try {
|
|
617
|
+
for (const width of options.viewports) {
|
|
618
|
+
const context = await browser.newContext({
|
|
619
|
+
viewport: {
|
|
620
|
+
width,
|
|
621
|
+
height: VIEWPORT_HEIGHT
|
|
622
|
+
},
|
|
623
|
+
deviceScaleFactor: 1,
|
|
624
|
+
reducedMotion: "reduce"
|
|
625
|
+
});
|
|
626
|
+
await mapConcurrent(stories, CAPTURE_CONCURRENCY, async (story) => {
|
|
627
|
+
const name = storySnapshotName(story, width);
|
|
628
|
+
const file = path.join(outDir, `${name}.png`);
|
|
629
|
+
const page = await context.newPage();
|
|
630
|
+
try {
|
|
631
|
+
await page.goto(`${baseUrl}/iframe.html?id=${encodeURIComponent(story.id)}&viewMode=story`, { timeout: STORY_TIMEOUT_MS });
|
|
632
|
+
await page.waitForSelector(options.waitForSelector, { timeout: STORY_TIMEOUT_MS });
|
|
633
|
+
await page.evaluate("document.fonts.ready");
|
|
634
|
+
if (options.delay > 0) await page.waitForTimeout(options.delay);
|
|
635
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
636
|
+
await page.screenshot({
|
|
637
|
+
path: file,
|
|
638
|
+
fullPage: true,
|
|
639
|
+
animations: "disabled",
|
|
640
|
+
caret: "hide"
|
|
641
|
+
});
|
|
642
|
+
await writeFile(metadataFile(file), JSON.stringify({
|
|
643
|
+
browser: "chromium",
|
|
644
|
+
viewport: width,
|
|
645
|
+
storyId: story.id,
|
|
646
|
+
importPath: story.importPath
|
|
647
|
+
}));
|
|
648
|
+
} catch {
|
|
649
|
+
failed.push(name);
|
|
650
|
+
} finally {
|
|
651
|
+
await page.close();
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
await context.close();
|
|
655
|
+
}
|
|
656
|
+
} finally {
|
|
657
|
+
await browser.close();
|
|
658
|
+
server.close();
|
|
659
|
+
}
|
|
660
|
+
console.log(`stateofpixel captured ${stories.length * options.viewports.length - failed.length} screenshots from ${stories.length} stories`);
|
|
661
|
+
return failed;
|
|
662
|
+
}
|
|
663
|
+
async function serveStatic(root) {
|
|
664
|
+
const base = path.resolve(root);
|
|
665
|
+
const server = createServer((request, response) => {
|
|
666
|
+
const pathname = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname);
|
|
667
|
+
const file = path.join(base, pathname.endsWith("/") ? `${pathname}index.html` : pathname);
|
|
668
|
+
if (!file.startsWith(`${base}${path.sep}`)) {
|
|
669
|
+
response.writeHead(403).end();
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
stat(file).then((stats) => {
|
|
673
|
+
if (!stats.isFile()) {
|
|
674
|
+
response.writeHead(404).end();
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
response.writeHead(200, { "Content-Type": CONTENT_TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream" });
|
|
678
|
+
createReadStream(file).pipe(response);
|
|
679
|
+
}, () => response.writeHead(404).end());
|
|
680
|
+
});
|
|
681
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
682
|
+
return server;
|
|
683
|
+
}
|
|
684
|
+
function parseViewports(value) {
|
|
685
|
+
const widths = value.split(",").map((width) => Number(width.trim()));
|
|
686
|
+
if (widths.length === 0 || widths.some((width) => !Number.isInteger(width) || width < 1)) throw new InvalidArgumentError("Must look like 375,1280.");
|
|
687
|
+
return widths;
|
|
688
|
+
}
|
|
689
|
+
function parseDelay(value) {
|
|
690
|
+
const delay = Number(value);
|
|
691
|
+
if (!Number.isInteger(delay) || delay < 0) throw new InvalidArgumentError("Must be a whole number of milliseconds.");
|
|
692
|
+
return delay;
|
|
693
|
+
}
|
|
694
|
+
|
|
1089
695
|
//#endregion
|
|
1090
696
|
//#region src/index.ts
|
|
1091
697
|
const program = new Command().name("stateofpixel").description("Visual regression testing that runs in your CI").version(version);
|
|
1092
698
|
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
699
|
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);
|
|
700
|
+
program.command("storybook").description("Capture every story of a built Storybook, then upload").argument("<static-dir>", "the output of storybook build").option("--viewports <widths>", "comma separated viewport widths", parseViewports, [1280]).option("--include <glob>", "only stories whose title/name match").option("--exclude <glob>", "skip stories whose title/name match").option("--wait-for-selector <selector>", "wait for this before each screenshot", "#storybook-root > *").option("--delay <ms>", "wait this long before each screenshot", parseDelay, 0).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", "capture and print the plan, upload nothing", false).action(storybookCommand);
|
|
1094
701
|
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
702
|
try {
|
|
1096
703
|
await program.parseAsync();
|