@coder/pixel-storybook 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +19 -0
  3. package/dist/api.d.ts +56 -0
  4. package/dist/api.js +238 -0
  5. package/dist/bin.d.ts +2 -0
  6. package/dist/bin.js +102 -0
  7. package/dist/checkDifferences.d.ts +63 -0
  8. package/dist/checkDifferences.js +67 -0
  9. package/dist/compare/compare.d.ts +5 -0
  10. package/dist/compare/compare.js +80 -0
  11. package/dist/compare/pixelmatch.d.ts +7 -0
  12. package/dist/compare/pixelmatch.js +46 -0
  13. package/dist/compare/utils.d.ts +2 -0
  14. package/dist/compare/utils.js +18 -0
  15. package/dist/compare/worker.d.ts +1 -0
  16. package/dist/compare/worker.js +6 -0
  17. package/dist/concurrency.d.ts +19 -0
  18. package/dist/concurrency.js +61 -0
  19. package/dist/config.d.ts +3795 -0
  20. package/dist/config.js +472 -0
  21. package/dist/configHelper.d.ts +2 -0
  22. package/dist/configHelper.js +34 -0
  23. package/dist/constants.d.ts +2 -0
  24. package/dist/constants.js +2 -0
  25. package/dist/crawler/storybook.d.ts +51 -0
  26. package/dist/crawler/storybook.js +317 -0
  27. package/dist/crawler/utils.d.ts +6 -0
  28. package/dist/crawler/utils.js +20 -0
  29. package/dist/createShots.d.ts +30 -0
  30. package/dist/createShots.js +54 -0
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.js +1 -0
  33. package/dist/log.d.ts +26 -0
  34. package/dist/log.js +99 -0
  35. package/dist/runner.d.ts +4 -0
  36. package/dist/runner.js +186 -0
  37. package/dist/schemas.d.ts +174 -0
  38. package/dist/schemas.js +73 -0
  39. package/dist/shard.d.ts +3 -0
  40. package/dist/shard.js +17 -0
  41. package/dist/shots/shots.d.ts +3 -0
  42. package/dist/shots/shots.js +196 -0
  43. package/dist/shots/utils.d.ts +33 -0
  44. package/dist/shots/utils.js +177 -0
  45. package/dist/types.d.ts +12 -0
  46. package/dist/types.js +1 -0
  47. package/dist/upload.d.ts +10 -0
  48. package/dist/upload.js +32 -0
  49. package/dist/uploadStorybook.d.ts +2 -0
  50. package/dist/uploadStorybook.js +56 -0
  51. package/dist/utils.d.ts +50 -0
  52. package/dist/utils.js +194 -0
  53. package/package.json +64 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021 Chris Kalmar & Dimitri Ivashchuk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,19 @@
1
+ # @coder/pixel-storybook
2
+
3
+ Visual regression testing tool for Storybook.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ npm install @coder/pixel-storybook
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```sh
14
+ npx pixel-storybook
15
+ ```
16
+
17
+ ## License
18
+
19
+ [MIT](LICENSE)
package/dist/api.d.ts ADDED
@@ -0,0 +1,56 @@
1
+ import type { PlatformModeConfig } from './config.js';
2
+ import { type LogMemory, log } from './log.js';
3
+ import type { StabilizationResult } from './types.js';
4
+ export type ShotConfig = {
5
+ name: string;
6
+ threshold?: number;
7
+ /**
8
+ * Metadata about the pre-screenshot stability wait for this shot. Sent
9
+ * alongside the shot config so the platform can track stability metrics
10
+ * across builds.
11
+ */
12
+ stabilization?: StabilizationResult;
13
+ };
14
+ type ApiPayloadProcessShots = {
15
+ uploadToken: string;
16
+ config: {
17
+ threshold?: number;
18
+ shots?: ShotConfig[];
19
+ };
20
+ log: LogMemory;
21
+ };
22
+ export declare const getApiToken: (config: PlatformModeConfig) => Promise<{
23
+ apiToken: string;
24
+ }>;
25
+ export declare const sendInitToAPI: (config: PlatformModeConfig, apiToken: string) => Promise<unknown>;
26
+ export declare const sendFinalizeToAPI: (config: PlatformModeConfig, apiToken: string) => Promise<unknown>;
27
+ export declare const prepareUpload: (config: PlatformModeConfig, apiToken: string, shotNamesWithHashes: Array<{
28
+ name: string;
29
+ hash: string;
30
+ }>) => Promise<{
31
+ requiredFileHashes: string[];
32
+ uploadToken: string;
33
+ uploadUrl: string;
34
+ }>;
35
+ export declare const uploadShot: ({ config, apiToken, uploadToken, uploadUrl, name, file, logger, }: {
36
+ config: PlatformModeConfig;
37
+ apiToken: string;
38
+ uploadToken: string;
39
+ uploadUrl: string;
40
+ name: string;
41
+ file: string;
42
+ logger?: ReturnType<typeof log.item>;
43
+ }) => Promise<{
44
+ success: true;
45
+ details: {
46
+ projectId: string;
47
+ commit: string;
48
+ buildNumber: string;
49
+ branchName: string;
50
+ name: string;
51
+ };
52
+ }>;
53
+ export declare const processShots: (config: PlatformModeConfig, apiToken: string, uploadToken: string, shotsConfig?: ApiPayloadProcessShots["config"]["shots"]) => Promise<void>;
54
+ export declare const uploadStorybookBuild: (config: PlatformModeConfig, apiToken: string, filePath: string) => Promise<void>;
55
+ export declare const sendRecordLogsToAPI: (config: PlatformModeConfig, apiToken: string) => Promise<void>;
56
+ export {};
package/dist/api.js ADDED
@@ -0,0 +1,238 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { retry } from './concurrency.js';
3
+ import { log, logMemory } from './log.js';
4
+ import { getVersion } from './utils.js';
5
+ const version = getVersion();
6
+ const defaultHeaders = {
7
+ 'x-api-version': '3',
8
+ 'x-client-version': version ?? 'unknown',
9
+ };
10
+ const apiRoutes = {
11
+ getApiToken: '/auth/get-api-token',
12
+ init: '/app/init',
13
+ finalize: '/app/finalize',
14
+ prepareUpload: '/file/prepare-upload',
15
+ uploadShot: '/file/upload-shot',
16
+ processShots: '/app/process-shots',
17
+ recordLogs: '/app/record-logs',
18
+ uploadStorybookBuild: '/app/upload-storybook',
19
+ };
20
+ class ApiError extends Error {
21
+ status;
22
+ responseData;
23
+ constructor(message, status, responseData) {
24
+ super(message);
25
+ this.name = 'ApiError';
26
+ this.status = status;
27
+ this.responseData = responseData;
28
+ }
29
+ }
30
+ const sendToAPI = async (config, parameters, fileKey, customLogger) => {
31
+ const logger = customLogger?.process ?? log.process;
32
+ logger('info', 'api', `⚡️ Sending to API [${parameters.action}]`);
33
+ logger('info', 'api', `Endpoint: ${config.lostPixelPlatform}${apiRoutes[parameters.action]}`);
34
+ try {
35
+ const apiCall = async () => {
36
+ const url = `${config.lostPixelPlatform}${apiRoutes[parameters.action]}`;
37
+ let body;
38
+ const headers = {
39
+ ...defaultHeaders,
40
+ Authorization: `Bearer ${parameters.apiToken ?? ''}`,
41
+ 'x-api-key': config.apiKey ?? 'undefined',
42
+ };
43
+ if (fileKey) {
44
+ const form = new FormData();
45
+ for (const [key, element] of Object.entries(parameters.payload)) {
46
+ if (key === fileKey) {
47
+ const fileBuffer = await readFile(element);
48
+ const blob = new Blob([fileBuffer], { type: 'image/png' });
49
+ form.append(key, blob, element.split('/').pop());
50
+ }
51
+ else {
52
+ form.append(key, element);
53
+ }
54
+ }
55
+ body = form;
56
+ // Let fetch set Content-Type with boundary for FormData
57
+ }
58
+ else {
59
+ body = JSON.stringify(parameters.payload);
60
+ headers['Content-type'] = 'application/json';
61
+ }
62
+ const response = await fetch(url, {
63
+ method: 'POST',
64
+ headers,
65
+ body,
66
+ });
67
+ const responseData = await response.json().catch(() => ({}));
68
+ if (!response.ok) {
69
+ const error = new ApiError(`API request failed with status ${response.status} ${response.statusText}`, response.status, responseData);
70
+ throw error;
71
+ }
72
+ return {
73
+ ok: response.ok,
74
+ status: response.status,
75
+ data: responseData,
76
+ headers: Object.fromEntries(response.headers.entries()),
77
+ };
78
+ };
79
+ const response = await retry({
80
+ times: 3,
81
+ interval(retryCount) {
82
+ const delay = Math.round(2 ** retryCount * 3000 * Math.random());
83
+ logger('info', 'api', `🔄 Retry attempt ${retryCount} in ${delay}ms [${parameters.action}]`);
84
+ logger('info', 'api', `${config.lostPixelPlatform}${apiRoutes[parameters.action]}`);
85
+ return delay;
86
+ },
87
+ errorFilter(error) {
88
+ if (error instanceof ApiError) {
89
+ return ((error.status >= 500 && error.status <= 599) || error.status === 0);
90
+ }
91
+ // Network errors (fetch throws TypeError for network failures)
92
+ return true;
93
+ },
94
+ }, apiCall);
95
+ if (!response.ok) {
96
+ logger('error', 'api', `Error: Failed to send to API [${parameters.action}]. Status: ${response.status}`);
97
+ process.exit(1);
98
+ }
99
+ const outdatedApiRequest = response?.headers?.['x-api-version-warning'];
100
+ if (outdatedApiRequest &&
101
+ (parameters.action === 'prepareUpload' ||
102
+ parameters.action === 'finalize')) {
103
+ logger('info', 'api', [
104
+ '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
105
+ `~~ ⚠️ ${outdatedApiRequest}`,
106
+ '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
107
+ ].join('\n'));
108
+ }
109
+ logger('info', 'api', `🤘 Successfully sent to API [${parameters.action}]`);
110
+ return response.data;
111
+ }
112
+ catch (error) {
113
+ if (error instanceof ApiError) {
114
+ logger('error', 'api', 'API response: ', error.responseData || error.message);
115
+ }
116
+ else if (error instanceof Error) {
117
+ logger('error', 'api', error.message);
118
+ }
119
+ else {
120
+ logger('error', 'api', error);
121
+ }
122
+ if (parameters.action === 'getApiToken') {
123
+ process.exit(1);
124
+ }
125
+ throw error;
126
+ }
127
+ };
128
+ export const getApiToken = async (config) => {
129
+ return sendToAPI(config, {
130
+ action: 'getApiToken',
131
+ payload: {
132
+ projectId: config.lostPixelProjectId,
133
+ },
134
+ });
135
+ };
136
+ export const sendInitToAPI = async (config, apiToken) => {
137
+ return sendToAPI(config, {
138
+ action: 'init',
139
+ apiToken,
140
+ payload: {
141
+ commit: config.commitHash,
142
+ branchName: config.commitRefName,
143
+ buildNumber: config.ciBuildNumber,
144
+ },
145
+ });
146
+ };
147
+ export const sendFinalizeToAPI = async (config, apiToken) => {
148
+ return sendToAPI(config, {
149
+ action: 'finalize',
150
+ apiToken,
151
+ payload: {
152
+ projectId: config.lostPixelProjectId,
153
+ branchName: config.commitRefName,
154
+ commit: config.commitHash,
155
+ buildNumber: config.ciBuildNumber,
156
+ },
157
+ });
158
+ };
159
+ export const prepareUpload = async (config, apiToken, shotNamesWithHashes) => {
160
+ return sendToAPI(config, {
161
+ action: 'prepareUpload',
162
+ apiToken,
163
+ payload: {
164
+ branchName: config.commitRefName,
165
+ commit: config.commitHash,
166
+ buildNumber: config.ciBuildNumber,
167
+ currentShots: shotNamesWithHashes,
168
+ },
169
+ });
170
+ };
171
+ export const uploadShot = async ({ config, apiToken, uploadToken, uploadUrl, name, file, logger, }) => {
172
+ return sendToAPI({
173
+ ...config,
174
+ lostPixelPlatform: uploadUrl,
175
+ }, {
176
+ action: 'uploadShot',
177
+ apiToken,
178
+ payload: {
179
+ uploadToken,
180
+ name,
181
+ file,
182
+ },
183
+ }, 'file', logger);
184
+ };
185
+ export const processShots = async (config, apiToken, uploadToken, shotsConfig) => {
186
+ return sendToAPI(config, {
187
+ action: 'processShots',
188
+ apiToken,
189
+ payload: {
190
+ uploadToken,
191
+ config: {
192
+ shots: shotsConfig,
193
+ threshold: config.threshold,
194
+ },
195
+ log: logMemory,
196
+ },
197
+ });
198
+ };
199
+ export const uploadStorybookBuild = async (config, apiToken, filePath) => {
200
+ return sendToAPI(config, {
201
+ action: 'uploadStorybookBuild',
202
+ apiToken,
203
+ payload: {
204
+ projectId: config.lostPixelProjectId,
205
+ buildId: config.ciBuildId,
206
+ buildNumber: config.ciBuildNumber,
207
+ branchName: config.commitRefName,
208
+ commit: config.commitHash,
209
+ file: filePath,
210
+ },
211
+ }, 'file');
212
+ };
213
+ export const sendRecordLogsToAPI = async (config, apiToken) => {
214
+ try {
215
+ await sendToAPI(config, {
216
+ action: 'recordLogs',
217
+ apiToken,
218
+ payload: {
219
+ branchName: config.commitRefName,
220
+ buildNumber: config.ciBuildNumber,
221
+ commit: config.commitHash,
222
+ log: logMemory,
223
+ },
224
+ });
225
+ }
226
+ catch (error) {
227
+ if (error instanceof ApiError) {
228
+ log.process('error', 'api', 'API response: ', error.responseData || error.message);
229
+ }
230
+ else if (error instanceof Error) {
231
+ log.process('error', 'api', error.message);
232
+ }
233
+ else {
234
+ log.process('error', 'api', error);
235
+ }
236
+ log.process('error', 'api', 'Error: Failed to send logs to API');
237
+ }
238
+ };
package/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
+ import { cp } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { parseArgs } from 'node:util';
7
+ import { sendFinalizeToAPI } from './api.js';
8
+ import { config, configure, isPlatformModeConfig } from './config.js';
9
+ import { log } from './log.js';
10
+ import { getPlatformApiToken, platformRunner, runner } from './runner.js';
11
+ import { uploadStorybook } from './uploadStorybook.js';
12
+ import { getVersion, isLocalDebugMode } from './utils.js';
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = path.dirname(__filename);
15
+ const { positionals: commandArgs, values: optionValues } = parseArgs({
16
+ args: process.argv.slice(2),
17
+ options: {
18
+ help: { type: 'boolean', short: 'h' },
19
+ version: { type: 'boolean', short: 'v' },
20
+ },
21
+ allowPositionals: true,
22
+ strict: false,
23
+ });
24
+ const version = getVersion();
25
+ if (optionValues.version) {
26
+ log.process('info', 'general', version ?? 'unknown');
27
+ process.exit(0);
28
+ }
29
+ if (optionValues.help) {
30
+ printHelp();
31
+ process.exit(0);
32
+ }
33
+ if (version) {
34
+ log.process('info', 'general', `Version: ${version}`);
35
+ }
36
+ function printHelp() {
37
+ log.process('info', 'general', [
38
+ 'Usage: pixel-storybook [command] [options]',
39
+ '',
40
+ 'Visual regression testing tool for Storybook. Captures screenshots',
41
+ 'via Playwright and compares them against baselines.',
42
+ '',
43
+ 'Commands:',
44
+ ' init-js Create a lostpixel.config.js in the current directory',
45
+ ' init-ts Create a lostpixel.config.ts in the current directory',
46
+ ' update Update baseline images with the current shots',
47
+ ' local Enable local debug mode',
48
+ ' meta Generate metadata for each shot',
49
+ ' finalize Finalize a platform run (platform mode)',
50
+ ' upload-storybook Upload a Storybook build to the platform',
51
+ '',
52
+ 'Options:',
53
+ ' -h, --help Print this help message and exit',
54
+ ' -v, --version Print the version and exit',
55
+ ' -m <mode> Set the mode (e.g. update)',
56
+ ' --shard <n/total> Run a specific shard of the suite (e.g. 2/4)',
57
+ '',
58
+ 'Environment variables:',
59
+ ' LOST_PIXEL_MODE Same as -m',
60
+ ' LOST_PIXEL_LOCAL Set to "true" for local debug mode',
61
+ ' LOST_PIXEL_GENERATE_META Set to "true" to generate metadata',
62
+ ' LOST_PIXEL_SHARD Same as --shard',
63
+ ].join('\n'));
64
+ }
65
+ (async () => {
66
+ if (commandArgs.includes('init-js')) {
67
+ log.process('info', 'general', 'Initializing javascript lost-pixel config');
68
+ await cp(path.join(__dirname, '..', 'config-templates', 'example.lostpixel.config.js'), path.join(process.cwd(), './lostpixel.config.js'));
69
+ log.process('info', 'general', '✅ Config successfully initialized');
70
+ }
71
+ else if (commandArgs.includes('init-ts')) {
72
+ log.process('info', 'general', 'Initializing typescript lost-pixel config');
73
+ // Replace local type resolution with module resolution
74
+ const file = readFileSync(path.join(__dirname, '..', 'config-templates', 'example.lostpixel.config.ts'));
75
+ const modifiedFile = file.toString().replace('../src/config', 'lost-pixel');
76
+ writeFileSync(path.join(process.cwd(), './lostpixel.config.ts'), modifiedFile);
77
+ log.process('info', 'general', '✅ Config successfully initialized');
78
+ }
79
+ else {
80
+ await configure({
81
+ localDebugMode: isLocalDebugMode(),
82
+ });
83
+ if (isPlatformModeConfig(config)) {
84
+ log.process('info', 'general', `🚀 Starting Pixel Storybook in 'platform' mode`);
85
+ const apiToken = await getPlatformApiToken(config);
86
+ if (commandArgs.includes('finalize')) {
87
+ await sendFinalizeToAPI(config, apiToken);
88
+ }
89
+ else if (commandArgs.includes('upload-storybook')) {
90
+ log.process('info', 'general', '📤 Starting Pixel storybook build upload');
91
+ await uploadStorybook(config, apiToken);
92
+ }
93
+ else {
94
+ await platformRunner(config, apiToken);
95
+ }
96
+ }
97
+ else {
98
+ log.process('info', 'general', `🚀 Starting Pixel Storybook in 'generateOnly' mode`);
99
+ await runner(config);
100
+ }
101
+ }
102
+ })();
@@ -0,0 +1,63 @@
1
+ import type { ShotItem } from './types.js';
2
+ export declare const checkDifferences: (shotItems: ShotItem[]) => Promise<{
3
+ aboveThresholdDifferenceItems: {
4
+ shotMode: "storybook";
5
+ id: string;
6
+ shotName: string;
7
+ url: string;
8
+ filePathBaseline: string;
9
+ filePathCurrent: string;
10
+ filePathDifference: string;
11
+ threshold: number;
12
+ browserConfig?: import("playwright-core").BrowserContextOptions | undefined;
13
+ waitBeforeScreenshot?: number | undefined;
14
+ stabilizeBeforeScreenshot?: boolean | undefined;
15
+ stabilization?: {
16
+ status: "stable" | "fonts-not-ready" | "images-not-ready" | "unstable";
17
+ elapsedMs: number;
18
+ capMs: number;
19
+ } | undefined;
20
+ importPath?: string | undefined;
21
+ mask?: {
22
+ selector: string;
23
+ }[] | undefined;
24
+ viewport?: {
25
+ width: number;
26
+ height?: number | undefined;
27
+ } | undefined;
28
+ breakpoint?: number | undefined;
29
+ breakpointGroup?: string | undefined;
30
+ elementLocator?: string | undefined;
31
+ waitForSelector?: string | undefined;
32
+ }[];
33
+ noBaselinesItems: {
34
+ shotMode: "storybook";
35
+ id: string;
36
+ shotName: string;
37
+ url: string;
38
+ filePathBaseline: string;
39
+ filePathCurrent: string;
40
+ filePathDifference: string;
41
+ threshold: number;
42
+ browserConfig?: import("playwright-core").BrowserContextOptions | undefined;
43
+ waitBeforeScreenshot?: number | undefined;
44
+ stabilizeBeforeScreenshot?: boolean | undefined;
45
+ stabilization?: {
46
+ status: "stable" | "fonts-not-ready" | "images-not-ready" | "unstable";
47
+ elapsedMs: number;
48
+ capMs: number;
49
+ } | undefined;
50
+ importPath?: string | undefined;
51
+ mask?: {
52
+ selector: string;
53
+ }[] | undefined;
54
+ viewport?: {
55
+ width: number;
56
+ height?: number | undefined;
57
+ } | undefined;
58
+ breakpoint?: number | undefined;
59
+ breakpointGroup?: string | undefined;
60
+ elementLocator?: string | undefined;
61
+ waitForSelector?: string | undefined;
62
+ }[];
63
+ }>;
@@ -0,0 +1,67 @@
1
+ import { existsSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { compareImages } from './compare/compare.js';
4
+ import { mapLimit } from './concurrency.js';
5
+ import { config, isPlatformModeConfig } from './config.js';
6
+ import { log } from './log.js';
7
+ import { featureNotSupported, shallGenerateMeta } from './utils.js';
8
+ export const checkDifferences = async (shotItems) => {
9
+ if (isPlatformModeConfig(config)) {
10
+ return featureNotSupported('checkDifferences()');
11
+ }
12
+ log.process('info', 'general', `Comparing ${shotItems.length} screenshots using '${config.compareEngine}' as compare engine`);
13
+ const total = shotItems.length;
14
+ const noBaselinesItems = [];
15
+ const aboveThresholdDifferenceItems = [];
16
+ const comparisonResults = {};
17
+ await mapLimit([...shotItems.entries()], config.compareConcurrency, async ([index, shotItem]) => {
18
+ const logger = (message) => {
19
+ log
20
+ .item({
21
+ shotMode: shotItem.shotMode,
22
+ uniqueItemId: shotItem.shotName,
23
+ itemIndex: index,
24
+ totalItems: total,
25
+ })
26
+ .process('info', 'general', message);
27
+ };
28
+ logger(`Comparing '${shotItem.id}'`);
29
+ const baselineImageExists = existsSync(shotItem.filePathBaseline);
30
+ if (!baselineImageExists) {
31
+ logger('Baseline image missing. Will be treated as addition.');
32
+ noBaselinesItems.push(shotItem);
33
+ return;
34
+ }
35
+ const currentImageExists = existsSync(shotItem.filePathCurrent);
36
+ if (!currentImageExists) {
37
+ throw new Error(`Error: Missing current image: ${shotItem.filePathCurrent}`);
38
+ }
39
+ const { pixelDifference, pixelDifferencePercentage, isWithinThreshold } = await compareImages(shotItem.threshold, shotItem.filePathBaseline, shotItem.filePathCurrent, shotItem.filePathDifference);
40
+ if (shallGenerateMeta()) {
41
+ comparisonResults[shotItem.id] = {
42
+ pixelDifference,
43
+ pixelDifferencePercentage,
44
+ isWithinThreshold,
45
+ };
46
+ }
47
+ if (pixelDifference > 0) {
48
+ const percentage = (pixelDifferencePercentage * 100).toFixed(2);
49
+ if (isWithinThreshold) {
50
+ logger(`Difference of ${pixelDifference} pixels (${percentage}%) found but within threshold.`);
51
+ }
52
+ else {
53
+ aboveThresholdDifferenceItems.push(shotItem);
54
+ logger(`Difference of ${pixelDifference} pixels (${percentage}%) found. Difference image saved to: ${shotItem.filePathDifference}`);
55
+ }
56
+ }
57
+ else {
58
+ logger('No difference found.');
59
+ }
60
+ });
61
+ if (shallGenerateMeta()) {
62
+ log.process('info', 'general', `Writing meta file with ${Object.entries(comparisonResults).length} items.`);
63
+ writeFileSync(`${path.join(config.imagePathCurrent, 'meta')}.json`, JSON.stringify(comparisonResults, null, 2));
64
+ }
65
+ log.process('info', 'general', 'Comparison done!');
66
+ return { aboveThresholdDifferenceItems, noBaselinesItems };
67
+ };
@@ -0,0 +1,5 @@
1
+ import { type CompareResult } from './pixelmatch.js';
2
+ export { checkThreshold } from './pixelmatch.js';
3
+ export declare const compareImagesViaPixelmatch: (threshold: number, baselineShotPath: string, currentShotPath: string, differenceShotPath?: string) => Promise<CompareResult>;
4
+ export declare const compareImagesViaOdiff: (threshold: number, baselineShotPath: string, currentShotPath: string, differenceShotPath: string) => Promise<CompareResult>;
5
+ export declare const compareImages: (threshold: number, baselineShotPath: string, currentShotPath: string, differenceShotPath: string) => Promise<CompareResult>;
@@ -0,0 +1,80 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { Worker } from 'node:worker_threads';
5
+ import { compare as odiffCompare } from 'odiff-bin';
6
+ import { config, isPlatformModeConfig } from '../config.js';
7
+ import { featureNotSupported } from '../utils.js';
8
+ import { runPixelmatchComparison } from './pixelmatch.js';
9
+ export { checkThreshold } from './pixelmatch.js';
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = path.dirname(__filename);
12
+ // Resolve the compiled worker script. Available after `pnpm build`.
13
+ // When running from source (e.g. vitest), the .js file won't exist
14
+ // and we fall back to inline comparison on the main thread.
15
+ const workerScript = path.join(__dirname, 'worker.js');
16
+ const canUseWorker = existsSync(workerScript);
17
+ const comparePixelmatchWorker = (threshold, baselineShotPath, currentShotPath, differenceShotPath) => {
18
+ return new Promise((resolve, reject) => {
19
+ const worker = new Worker(workerScript, {
20
+ workerData: {
21
+ threshold,
22
+ baselineShotPath,
23
+ currentShotPath,
24
+ differenceShotPath,
25
+ },
26
+ });
27
+ worker.on('message', resolve);
28
+ worker.on('error', reject);
29
+ worker.on('exit', (code) => {
30
+ if (code !== 0) {
31
+ reject(new Error(`Pixelmatch worker exited with code ${code}`));
32
+ }
33
+ });
34
+ });
35
+ };
36
+ export const compareImagesViaPixelmatch = async (threshold, baselineShotPath, currentShotPath, differenceShotPath) => {
37
+ if (canUseWorker) {
38
+ return comparePixelmatchWorker(threshold, baselineShotPath, currentShotPath, differenceShotPath);
39
+ }
40
+ return runPixelmatchComparison(threshold, baselineShotPath, currentShotPath, differenceShotPath);
41
+ };
42
+ export const compareImagesViaOdiff = async (threshold, baselineShotPath, currentShotPath, differenceShotPath) => {
43
+ const result = await odiffCompare(baselineShotPath, currentShotPath, differenceShotPath, {
44
+ failOnLayoutDiff: false,
45
+ });
46
+ if (result.match) {
47
+ return {
48
+ pixelDifference: 0,
49
+ pixelDifferencePercentage: 0,
50
+ isWithinThreshold: true,
51
+ };
52
+ }
53
+ if (result.reason === 'pixel-diff') {
54
+ let isWithinThreshold = true;
55
+ // Treat theshold as percentage
56
+ const pixelDifferencePercentage = Number(result.diffPercentage / 100);
57
+ if (threshold < 1) {
58
+ isWithinThreshold = pixelDifferencePercentage <= threshold;
59
+ }
60
+ else {
61
+ // Treat threshold as absolute value
62
+ isWithinThreshold = result.diffCount <= threshold;
63
+ }
64
+ return {
65
+ pixelDifference: Number(result.diffCount),
66
+ pixelDifferencePercentage,
67
+ isWithinThreshold,
68
+ };
69
+ }
70
+ throw new Error("Couldn't compare images");
71
+ };
72
+ export const compareImages = async (threshold, baselineShotPath, currentShotPath, differenceShotPath) => {
73
+ if (isPlatformModeConfig(config)) {
74
+ return featureNotSupported('compareImages()');
75
+ }
76
+ if (config.compareEngine === 'pixelmatch') {
77
+ return compareImagesViaPixelmatch(threshold, baselineShotPath, currentShotPath, differenceShotPath);
78
+ }
79
+ return compareImagesViaOdiff(threshold, baselineShotPath, currentShotPath, differenceShotPath);
80
+ };
@@ -0,0 +1,7 @@
1
+ export type CompareResult = {
2
+ pixelDifference: number;
3
+ pixelDifferencePercentage: number;
4
+ isWithinThreshold: boolean;
5
+ };
6
+ export declare const checkThreshold: (threshold: number, pixelsTotal: number, pixelDifference: number) => boolean;
7
+ export declare const runPixelmatchComparison: (threshold: number, baselineShotPath: string, currentShotPath: string, differenceShotPath?: string) => CompareResult;