@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
@@ -0,0 +1,33 @@
1
+ import type { BrowserType, Page } from 'playwright-core';
2
+ import type { log } from '../log.js';
3
+ import type { StabilizationResult } from '../types.js';
4
+ /**
5
+ * Wait until the page looks visually settled, up to `maxMs`.
6
+ *
7
+ * Returns a `StabilizationResult` describing how the wait concluded:
8
+ *
9
+ * - `stable`: web fonts loaded, every pending `<img>` resolved, one
10
+ * animation frame committed, and the DOM stayed quiet for `idleMs`.
11
+ * - `fonts-not-ready` / `images-not-ready` / `unstable`: the cap was hit
12
+ * waiting for the named step.
13
+ */
14
+ export declare const waitForStability: (page: Page, { maxMs, idleMs }: {
15
+ maxMs: number;
16
+ idleMs?: number;
17
+ }) => Promise<StabilizationResult>;
18
+ export declare const waitForNetworkRequests: ({ page, logger, timeout, waitForFirstRequest, waitForLastRequest, ignoreUrls, }: {
19
+ page: Page;
20
+ logger: ReturnType<typeof log.item>;
21
+ timeout?: number;
22
+ waitForFirstRequest?: number;
23
+ waitForLastRequest?: number;
24
+ ignoreUrls?: string[];
25
+ }) => Promise<unknown>;
26
+ export declare const resizeViewportToFullscreen: ({ page }: {
27
+ page: Page;
28
+ }) => Promise<void>;
29
+ export declare const selectBreakpoints: (topLevelBreakpoints?: number[], modeBreakpoints?: number[], shotBreakpoints?: number[]) => number[];
30
+ export declare const generateLabel: ({ breakpoint, browser, }: {
31
+ breakpoint?: number;
32
+ browser?: BrowserType;
33
+ }) => string;
@@ -0,0 +1,177 @@
1
+ import { config } from '../config.js';
2
+ /**
3
+ * Wait until the page looks visually settled, up to `maxMs`.
4
+ *
5
+ * Returns a `StabilizationResult` describing how the wait concluded:
6
+ *
7
+ * - `stable`: web fonts loaded, every pending `<img>` resolved, one
8
+ * animation frame committed, and the DOM stayed quiet for `idleMs`.
9
+ * - `fonts-not-ready` / `images-not-ready` / `unstable`: the cap was hit
10
+ * waiting for the named step.
11
+ */
12
+ export const waitForStability = async (page, { maxMs, idleMs = 100 }) => {
13
+ if (maxMs <= 0) {
14
+ return { status: 'stable', elapsedMs: 0, capMs: maxMs };
15
+ }
16
+ const inner = await page.evaluate(async ({ maxMs, idleMs }) => {
17
+ const start = performance.now();
18
+ const deadline = start + maxMs;
19
+ const remaining = () => Math.max(0, deadline - performance.now());
20
+ const settled = (promise) => Promise.race([
21
+ promise.then(() => true),
22
+ new Promise((resolve) => setTimeout(() => resolve(false), remaining())),
23
+ ]);
24
+ // 1. Web fonts.
25
+ if (document.fonts?.ready) {
26
+ if (!(await settled(document.fonts.ready))) {
27
+ return {
28
+ status: 'fonts-not-ready',
29
+ elapsedMs: performance.now() - start,
30
+ };
31
+ }
32
+ }
33
+ // 2. Pending images.
34
+ const pendingImages = [...document.images].filter((img) => !img.complete);
35
+ if (pendingImages.length > 0) {
36
+ const all = Promise.all(pendingImages.map((img) => new Promise((resolve) => {
37
+ img.addEventListener('load', () => resolve(), { once: true });
38
+ img.addEventListener('error', () => resolve(), {
39
+ once: true,
40
+ });
41
+ })));
42
+ if (!(await settled(all))) {
43
+ return {
44
+ status: 'images-not-ready',
45
+ elapsedMs: performance.now() - start,
46
+ };
47
+ }
48
+ }
49
+ // 3. One frame committed.
50
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
51
+ // 4. DOM idle for `idleMs`, capped by the deadline.
52
+ if (remaining() <= 0) {
53
+ return {
54
+ status: 'unstable',
55
+ elapsedMs: performance.now() - start,
56
+ };
57
+ }
58
+ const stable = await new Promise((resolve) => {
59
+ const finish = (didSettle) => {
60
+ clearTimeout(idleTimer);
61
+ clearTimeout(capTimer);
62
+ observer.disconnect();
63
+ resolve(didSettle);
64
+ };
65
+ let idleTimer = setTimeout(() => finish(true), idleMs);
66
+ const capTimer = setTimeout(() => finish(false), remaining());
67
+ const observer = new MutationObserver(() => {
68
+ clearTimeout(idleTimer);
69
+ idleTimer = setTimeout(() => finish(true), idleMs);
70
+ });
71
+ observer.observe(document.body, {
72
+ childList: true,
73
+ subtree: true,
74
+ attributes: true,
75
+ characterData: true,
76
+ });
77
+ });
78
+ return {
79
+ status: stable ? 'stable' : 'unstable',
80
+ elapsedMs: performance.now() - start,
81
+ };
82
+ }, { maxMs, idleMs });
83
+ return { ...inner, capMs: maxMs };
84
+ };
85
+ const checkIgnoreUrls = (url, ignoreUrls) => {
86
+ for (const ignoreUrl of ignoreUrls) {
87
+ if (url.includes(ignoreUrl)) {
88
+ return true;
89
+ }
90
+ }
91
+ return false;
92
+ };
93
+ export const waitForNetworkRequests = async ({ page, logger, timeout = config.timeouts.networkRequests, waitForFirstRequest = config.waitForFirstRequest, waitForLastRequest = config.waitForLastRequest, ignoreUrls = [], }) => new Promise((resolve, reject) => {
94
+ let requestCounter = 0;
95
+ const requests = new Set();
96
+ let lastRequestTimeoutId;
97
+ const timeoutId = setTimeout(() => {
98
+ const pendingUrls = [...requests].map((request) => request.url());
99
+ logger.process('info', 'network', 'Pending requests:', pendingUrls);
100
+ cleanup();
101
+ reject(new Error('Timeout'));
102
+ }, timeout);
103
+ const firstRequestTimeoutId = setTimeout(() => {
104
+ cleanup();
105
+ resolve(true);
106
+ }, waitForFirstRequest);
107
+ const onRequest = (request) => {
108
+ if (!checkIgnoreUrls(request.url(), ignoreUrls)) {
109
+ clearTimeout(firstRequestTimeoutId);
110
+ clearTimeout(lastRequestTimeoutId);
111
+ requestCounter++;
112
+ requests.add(request);
113
+ logger.browser('info', 'network', `+ ${request.url()}`);
114
+ }
115
+ };
116
+ const onRequestFinished = async (request) => {
117
+ clearTimeout(lastRequestTimeoutId);
118
+ if (!checkIgnoreUrls(request.url(), ignoreUrls)) {
119
+ const failure = request.failure();
120
+ const response = await request.response();
121
+ requestCounter--;
122
+ requests.delete(request);
123
+ const statusText = failure
124
+ ? failure.errorText
125
+ : `${response?.status() ?? 'unknown'} ${response?.statusText() ?? 'unknown'}`;
126
+ logger.browser('info', 'network', `- ${request.url()} [${statusText}]`);
127
+ }
128
+ lastRequestTimeoutId = setTimeout(() => {
129
+ // `requestCounter` can be below 0 if requests have completed before they were being tracked
130
+ if (requestCounter <= 0) {
131
+ cleanup();
132
+ resolve(true);
133
+ }
134
+ }, waitForLastRequest);
135
+ };
136
+ function cleanup() {
137
+ clearTimeout(timeoutId);
138
+ clearTimeout(firstRequestTimeoutId);
139
+ clearTimeout(lastRequestTimeoutId);
140
+ page.removeListener('request', onRequest);
141
+ page.removeListener('requestfinished', onRequestFinished);
142
+ page.removeListener('requestfailed', onRequestFinished);
143
+ }
144
+ page.on('request', onRequest);
145
+ page.on('requestfinished', onRequestFinished);
146
+ page.on('requestfailed', onRequestFinished);
147
+ });
148
+ export const resizeViewportToFullscreen = async ({ page }) => {
149
+ const viewport = await page.evaluate(async () => new Promise((resolve) => {
150
+ const { body } = document;
151
+ const html = document.documentElement;
152
+ const height = Math.max(body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight);
153
+ const width = Math.max(body.scrollWidth, body.offsetWidth, html.clientWidth, html.scrollWidth, html.offsetWidth);
154
+ resolve({ height, width });
155
+ }));
156
+ await page.setViewportSize({
157
+ width: Math.max(page.viewportSize()?.width ?? 800, viewport.width),
158
+ height: viewport.height,
159
+ });
160
+ };
161
+ export const selectBreakpoints = (topLevelBreakpoints, modeBreakpoints, shotBreakpoints) => {
162
+ if (shotBreakpoints && shotBreakpoints.length > 0) {
163
+ return shotBreakpoints;
164
+ }
165
+ if (modeBreakpoints && modeBreakpoints.length > 0) {
166
+ return modeBreakpoints;
167
+ }
168
+ return topLevelBreakpoints ?? [];
169
+ };
170
+ export const generateLabel = ({ breakpoint, browser, }) => {
171
+ const widthLabel = breakpoint && breakpoint > 0 ? `w${breakpoint}px` : '';
172
+ const browserLabel = browser?.name() ?? '';
173
+ const labels = [widthLabel, browserLabel].filter(Boolean);
174
+ if (labels.length === 0)
175
+ return '';
176
+ return `__[${labels.join('|')}]`;
177
+ };
@@ -0,0 +1,12 @@
1
+ import type * as z from 'zod';
2
+ import type { BrowserSchema, MaskSchema, ShotItemSchema, ShotModeSchema, StabilizationResultSchema, StabilizationStatusSchema } from './schemas.js';
3
+ export type BrowserName = z.infer<typeof BrowserSchema>;
4
+ export type ShotMode = z.infer<typeof ShotModeSchema>;
5
+ export type Mask = z.infer<typeof MaskSchema>;
6
+ export type ShotItem = z.infer<typeof ShotItemSchema>;
7
+ export type StabilizationStatus = z.infer<typeof StabilizationStatusSchema>;
8
+ export type StabilizationResult = z.infer<typeof StabilizationResultSchema>;
9
+ export type ExtendedShotItem = ShotItem & {
10
+ uniqueName: string;
11
+ hash: string;
12
+ };
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ import type { PlatformModeConfig } from './config.js';
2
+ import type { ExtendedShotItem } from './types.js';
3
+ export declare const uploadRequiredShots: ({ config, apiToken, uploadToken, uploadUrl, requiredFileHashes, extendedShotItems, }: {
4
+ config: PlatformModeConfig;
5
+ apiToken: string;
6
+ uploadToken: string;
7
+ uploadUrl: string;
8
+ requiredFileHashes: string[];
9
+ extendedShotItems: ExtendedShotItem[];
10
+ }) => Promise<boolean>;
package/dist/upload.js ADDED
@@ -0,0 +1,32 @@
1
+ import { uploadShot } from './api.js';
2
+ import { mapLimit } from './concurrency.js';
3
+ import { MEDIA_UPLOAD_CONCURRENCY } from './constants.js';
4
+ import { log } from './log.js';
5
+ import { parseHrtimeToSeconds } from './utils.js';
6
+ export const uploadRequiredShots = async ({ config, apiToken, uploadToken, uploadUrl, requiredFileHashes, extendedShotItems, }) => {
7
+ if (requiredFileHashes.length > 0) {
8
+ log.process('info', 'api', '📤 Uploading shots');
9
+ const uploadStart = process.hrtime();
10
+ const requiredShotItems = extendedShotItems.filter((shotItem) => requiredFileHashes.includes(shotItem.hash));
11
+ await mapLimit([...requiredShotItems.entries()], MEDIA_UPLOAD_CONCURRENCY, async ([index, shotItem]) => {
12
+ const logger = log.item({
13
+ shotMode: shotItem.shotMode,
14
+ uniqueItemId: shotItem.shotName,
15
+ itemIndex: index,
16
+ totalItems: requiredShotItems.length,
17
+ });
18
+ await uploadShot({
19
+ config,
20
+ apiToken,
21
+ uploadToken,
22
+ uploadUrl,
23
+ name: `${shotItem.shotMode}/${shotItem.shotName}`,
24
+ file: shotItem.filePathCurrent,
25
+ logger,
26
+ });
27
+ });
28
+ const uploadStop = process.hrtime(uploadStart);
29
+ log.process('info', 'api', `📤 Uploading shots took ${parseHrtimeToSeconds(uploadStop)} seconds`);
30
+ }
31
+ return true;
32
+ };
@@ -0,0 +1,2 @@
1
+ import type { PlatformModeConfig } from './config.js';
2
+ export declare const uploadStorybook: (config: PlatformModeConfig, apiToken: string) => Promise<void>;
@@ -0,0 +1,56 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { existsSync, unlinkSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { isAbsolute, join, resolve } from 'node:path';
5
+ import { uploadStorybookBuild } from './api.js';
6
+ import { log } from './log.js';
7
+ import { parseHrtimeToSeconds } from './utils.js';
8
+ const resolveStorybookBuildPath = (config) => {
9
+ const storybookUrl = config.storybookShots?.storybookUrl;
10
+ if (!storybookUrl) {
11
+ log.process('error', 'general', 'No storybookShots.storybookUrl configured. Cannot determine storybook build path.');
12
+ process.exit(1);
13
+ }
14
+ // Remote URLs are not supported — we need a local directory to archive
15
+ if (storybookUrl.startsWith('http://') ||
16
+ storybookUrl.startsWith('https://')) {
17
+ log.process('error', 'general', 'upload-storybook requires a local storybook build directory, not a remote URL.');
18
+ process.exit(1);
19
+ }
20
+ const cleaned = storybookUrl.replace(/^file:\/\//, '');
21
+ if (isAbsolute(cleaned)) {
22
+ return cleaned;
23
+ }
24
+ return resolve(process.cwd(), cleaned);
25
+ };
26
+ const createArchive = (buildPath) => {
27
+ const archivePath = join(tmpdir(), `pixel-storybook-${Date.now()}.tar.gz`);
28
+ log.process('info', 'general', `📦 Archiving storybook build: ${buildPath}`);
29
+ execSync(`tar czf "${archivePath}" -C "${buildPath}" .`, {
30
+ stdio: 'pipe',
31
+ });
32
+ log.process('info', 'general', `📦 Archive created: ${archivePath}`);
33
+ return archivePath;
34
+ };
35
+ export const uploadStorybook = async (config, apiToken) => {
36
+ const executionStart = process.hrtime();
37
+ const buildPath = resolveStorybookBuildPath(config);
38
+ if (!existsSync(buildPath)) {
39
+ log.process('error', 'general', `Storybook build directory not found: ${buildPath}`);
40
+ process.exit(1);
41
+ }
42
+ log.process('info', 'general', `🚀 Uploading storybook build from: ${buildPath}`);
43
+ let archivePath;
44
+ try {
45
+ archivePath = createArchive(buildPath);
46
+ await uploadStorybookBuild(config, apiToken, archivePath);
47
+ log.process('info', 'general', '✅ Storybook build uploaded successfully');
48
+ }
49
+ finally {
50
+ if (archivePath && existsSync(archivePath)) {
51
+ unlinkSync(archivePath);
52
+ }
53
+ }
54
+ const executionStop = process.hrtime(executionStart);
55
+ log.process('info', 'general', `⏱ Storybook upload took ${parseHrtimeToSeconds(executionStop)} seconds`);
56
+ };
@@ -0,0 +1,50 @@
1
+ import { type BrowserType } from 'playwright-core';
2
+ import type { ShotItem } from './types.js';
3
+ export type ShardConfig = {
4
+ current: number;
5
+ total: number;
6
+ };
7
+ export declare const getShardConfig: () => ShardConfig | undefined;
8
+ type FilenameWithPath = {
9
+ name: string;
10
+ path: string;
11
+ };
12
+ type FilenameWithAllPaths = {
13
+ name: string;
14
+ path: string;
15
+ pathCurrent?: string;
16
+ };
17
+ type Files = {
18
+ baseline: FilenameWithPath[];
19
+ current: FilenameWithPath[];
20
+ difference: FilenameWithPath[];
21
+ };
22
+ export type Changes = {
23
+ difference: FilenameWithAllPaths[];
24
+ deletion: FilenameWithAllPaths[];
25
+ addition: FilenameWithAllPaths[];
26
+ };
27
+ export declare const isUpdateMode: () => boolean;
28
+ export declare const isLocalDebugMode: () => boolean;
29
+ export declare const shallGenerateMeta: () => boolean;
30
+ export declare const getChanges: (files: Files) => Changes;
31
+ type ExtendFileName = {
32
+ fileName: string;
33
+ extension: 'after' | 'before' | 'difference';
34
+ };
35
+ export declare const extendFileName: ({ fileName, extension }: ExtendFileName) => string;
36
+ export declare const createShotsFolders: () => void;
37
+ export declare const sleep: (ms: number) => Promise<unknown>;
38
+ export declare const removeFilesInFolder: (path: string, excludePaths?: string[]) => void;
39
+ export declare const getBrowser: () => BrowserType;
40
+ export declare const getBrowsers: () => BrowserType[];
41
+ export declare const getVersion: () => string | undefined;
42
+ export declare const readDirIntoShotItems: (path: string) => ShotItem[];
43
+ export declare const parseHrtimeToSeconds: (hrtime: [number, number]) => string;
44
+ export declare const exitProcess: (properties: {
45
+ exitCode?: 0 | 1;
46
+ }) => never;
47
+ export declare const hashFile: (filePath: string) => string;
48
+ export declare const featureNotSupported: (feature: string) => never;
49
+ export declare const launchBrowser: (_browser?: BrowserType) => Promise<import("playwright-core").Browser>;
50
+ export {};
package/dist/utils.js ADDED
@@ -0,0 +1,194 @@
1
+ import * as crypto from 'node:crypto';
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { join, normalize } from 'node:path';
5
+ import { parseArgs } from 'node:util';
6
+ import { chromium, firefox, webkit } from 'playwright-core';
7
+ import { config, isPlatformModeConfig } from './config.js';
8
+ import { notSupported } from './constants.js';
9
+ import { log } from './log.js';
10
+ const { positionals, values } = parseArgs({
11
+ args: process.argv.slice(2),
12
+ options: {
13
+ m: { type: 'string', short: 'm' },
14
+ shard: { type: 'string' },
15
+ },
16
+ allowPositionals: true,
17
+ strict: false,
18
+ });
19
+ export const getShardConfig = () => {
20
+ const raw = values.shard ??
21
+ process.env.LOST_PIXEL_SHARD;
22
+ if (!raw || typeof raw !== 'string')
23
+ return undefined;
24
+ const match = raw.match(/^(\d+)\/(\d+)$/);
25
+ if (!match) {
26
+ throw new Error(`Invalid --shard format "${raw}". Expected "current/total" (e.g. "2/4").`);
27
+ }
28
+ const current = Number(match[1]);
29
+ const total = Number(match[2]);
30
+ if (total < 1)
31
+ throw new Error(`Shard total must be >= 1, got ${total}`);
32
+ if (current < 1 || current > total) {
33
+ throw new Error(`Shard current must be between 1 and ${total}, got ${current}`);
34
+ }
35
+ return { current, total };
36
+ };
37
+ export const isUpdateMode = () => {
38
+ return (positionals.includes('update') ||
39
+ values.m === 'update' ||
40
+ process.env.LOST_PIXEL_MODE === 'update');
41
+ };
42
+ export const isLocalDebugMode = () => {
43
+ return (positionals.includes('local') || process.env.LOST_PIXEL_LOCAL === 'true');
44
+ };
45
+ export const shallGenerateMeta = () => {
46
+ return (positionals.includes('meta') ||
47
+ process.env.LOST_PIXEL_GENERATE_META === 'true');
48
+ };
49
+ export const getChanges = (files) => {
50
+ return {
51
+ difference: files.difference
52
+ .map((file) => ({
53
+ ...file,
54
+ pathCurrent: files.current.find(({ name }) => name === file.name)?.path, // Keep track of custom shots path
55
+ }))
56
+ .sort((a, b) => a.name.localeCompare(b.name)),
57
+ deletion: files.baseline
58
+ .filter((file1) => !files.current.some((file2) => file1.name === file2.name))
59
+ .sort((a, b) => a.name.localeCompare(b.name)),
60
+ addition: files.current
61
+ .filter((file1) => !files.baseline.some((file2) => file1.name === file2.name))
62
+ .sort((a, b) => a.name.localeCompare(b.name)),
63
+ };
64
+ };
65
+ export const extendFileName = ({ fileName, extension }) => {
66
+ const parts = fileName.split('.').filter((part) => part !== '');
67
+ const extensionIndex = parts.length - 1;
68
+ if (parts.length === 1) {
69
+ return `${extension}.${parts[0]}`;
70
+ }
71
+ if (parts.length === 0) {
72
+ return extension;
73
+ }
74
+ parts[extensionIndex] = `${extension}.${parts[extensionIndex]}`;
75
+ return parts.join('.');
76
+ };
77
+ export const createShotsFolders = () => {
78
+ const paths = isPlatformModeConfig(config)
79
+ ? [config.imagePathCurrent]
80
+ : [
81
+ config.imagePathBaseline,
82
+ config.imagePathCurrent,
83
+ config.imagePathDifference,
84
+ ];
85
+ for (const path of paths) {
86
+ if (!existsSync(path)) {
87
+ mkdirSync(path, { recursive: true });
88
+ }
89
+ }
90
+ const ignoreFile = normalize(join(config.imagePathCurrent, '..', '.gitignore'));
91
+ if (!existsSync(ignoreFile)) {
92
+ writeFileSync(ignoreFile, 'current\ndifference\n');
93
+ }
94
+ };
95
+ export const sleep = async (ms) => new Promise((resolve) => {
96
+ setTimeout(resolve, ms);
97
+ });
98
+ export const removeFilesInFolder = (path, excludePaths) => {
99
+ const files = readdirSync(path);
100
+ const filesPathsIgnoringExclude = files
101
+ .map((file) => join(path, file))
102
+ .filter((filePath) => !excludePaths?.includes(filePath));
103
+ log.process('info', 'general', `Removing ${filesPathsIgnoringExclude.length} files from ${path}`);
104
+ for (const filePath of filesPathsIgnoringExclude) {
105
+ unlinkSync(filePath);
106
+ }
107
+ };
108
+ const convertBrowser = (browserKey) => {
109
+ switch (browserKey) {
110
+ case 'chromium': {
111
+ return chromium;
112
+ }
113
+ case 'firefox': {
114
+ return firefox;
115
+ }
116
+ case 'webkit': {
117
+ return webkit;
118
+ }
119
+ default: {
120
+ return chromium;
121
+ }
122
+ }
123
+ };
124
+ export const getBrowser = () => {
125
+ if (Array.isArray(config.browser))
126
+ return convertBrowser(config.browser[0]);
127
+ return convertBrowser(config.browser);
128
+ };
129
+ export const getBrowsers = () => {
130
+ if (!Array.isArray(config.browser) || config.browser.length === 0)
131
+ return [getBrowser()];
132
+ const browsers = config.browser.map((key) => convertBrowser(key));
133
+ return [...new Set(browsers)];
134
+ };
135
+ const require = createRequire(import.meta.url);
136
+ export const getVersion = () => {
137
+ try {
138
+ const packageJson = require('../package.json');
139
+ return packageJson.version;
140
+ }
141
+ catch { }
142
+ return undefined;
143
+ };
144
+ const fileNameWithoutExtension = (fileName) => {
145
+ return fileName.split('.').slice(0, -1).join('.');
146
+ };
147
+ export const readDirIntoShotItems = (path) => {
148
+ const files = readdirSync(path);
149
+ return files
150
+ .filter((name) => name.endsWith('.png'))
151
+ .map((fileNameWithExt) => {
152
+ const fileName = fileNameWithoutExtension(fileNameWithExt);
153
+ return {
154
+ id: fileName,
155
+ shotName: fileName,
156
+ shotMode: 'storybook',
157
+ filePathBaseline: isPlatformModeConfig(config)
158
+ ? notSupported
159
+ : join(config.imagePathBaseline, fileNameWithExt),
160
+ filePathCurrent: join(path, fileNameWithExt),
161
+ filePathDifference: isPlatformModeConfig(config)
162
+ ? notSupported
163
+ : join(config.imagePathDifference, fileNameWithExt),
164
+ url: fileName,
165
+ // TODO: custom shots take thresholds only from config - not possible to source configs from individual story
166
+ threshold: config.threshold,
167
+ };
168
+ });
169
+ };
170
+ export const parseHrtimeToSeconds = (hrtime) => {
171
+ const seconds = (hrtime[0] + hrtime[1] / 1e9).toFixed(3);
172
+ return seconds;
173
+ };
174
+ export const exitProcess = (properties) => {
175
+ process.exit(properties.exitCode ?? 1);
176
+ };
177
+ const hashBuffer = (buffer) => {
178
+ const hashSum = crypto.createHash('sha256');
179
+ hashSum.update(buffer);
180
+ return hashSum.digest('hex');
181
+ };
182
+ export const hashFile = (filePath) => {
183
+ const file = readFileSync(filePath);
184
+ return hashBuffer(file);
185
+ };
186
+ export const featureNotSupported = (feature) => {
187
+ log.process('error', 'general', `${feature} is not supported in this configuration mode`);
188
+ process.exit(1);
189
+ };
190
+ export const launchBrowser = async (_browser) => {
191
+ const browserType = _browser ?? getBrowser();
192
+ const browserName = browserType.name();
193
+ return browserType.launch(config.browserLaunchOptions?.[browserName]);
194
+ };
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@coder/pixel-storybook",
3
+ "version": "0.1.0",
4
+ "description": "Visual regression testing tool for Storybook",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "bin": {
15
+ "pixel-storybook": "dist/bin.js"
16
+ },
17
+ "files": [
18
+ "dist/**/*.js",
19
+ "dist/**/*.d.ts"
20
+ ],
21
+ "engines": {
22
+ "node": ">=22"
23
+ },
24
+ "repository": "https://github.com/coder/pixel-storybook",
25
+ "keywords": [],
26
+ "authors": [
27
+ "Jeremy Ruppel <jeremy@coder.com>",
28
+ "McKayla Washburn <kayla@coder.com>"
29
+ ],
30
+ "license": "MIT",
31
+ "dependencies": {
32
+ "get-port-please": "3.1.2",
33
+ "odiff-bin": "2.6.1",
34
+ "pixelmatch": "7.2.0",
35
+ "pngjs": "7.0.0",
36
+ "serve-handler": "6.1.7",
37
+ "tsx": "^4.22.0",
38
+ "zod": "3.23.8"
39
+ },
40
+ "devDependencies": {
41
+ "@biomejs/biome": "^2.4.15",
42
+ "@playwright/test": "1.60.0",
43
+ "@types/node": "22.9.3",
44
+ "@types/pngjs": "6.0.5",
45
+ "@types/serve-handler": "6.1.4",
46
+ "typescript": "5.6.2",
47
+ "vitest": "^4.1.6"
48
+ },
49
+ "peerDependencies": {
50
+ "playwright-core": ">=1.47.2"
51
+ },
52
+ "scripts": {
53
+ "test": "vitest run",
54
+ "test:watch": "vitest",
55
+ "build": "rm -rf dist/ && tsc",
56
+ "lint": "biome check .",
57
+ "lint-fix": "biome check --fix .",
58
+ "release": "np --no-publish",
59
+ "version": "./update-versions.sh",
60
+ "dev": "node --loader ts-node/esm src/bin.ts",
61
+ "start": "NODE_ENV=production node dist/bin.js",
62
+ "knip": "knip"
63
+ }
64
+ }