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/dist/index.mjs CHANGED
@@ -1,74 +1,14 @@
1
1
  #!/usr/bin/env node
2
- import { createRequire } from "node:module";
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, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
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 { 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";
7
+ import { createReadStream } from "node:fs";
8
+ import { createServer } from "node:http";
12
9
 
13
10
  //#region package.json
14
- var version = "1.0.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
- }
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(` ${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`);
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();