@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.
- package/LICENSE +21 -0
- package/README.md +19 -0
- package/dist/api.d.ts +56 -0
- package/dist/api.js +238 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +102 -0
- package/dist/checkDifferences.d.ts +63 -0
- package/dist/checkDifferences.js +67 -0
- package/dist/compare/compare.d.ts +5 -0
- package/dist/compare/compare.js +80 -0
- package/dist/compare/pixelmatch.d.ts +7 -0
- package/dist/compare/pixelmatch.js +46 -0
- package/dist/compare/utils.d.ts +2 -0
- package/dist/compare/utils.js +18 -0
- package/dist/compare/worker.d.ts +1 -0
- package/dist/compare/worker.js +6 -0
- package/dist/concurrency.d.ts +19 -0
- package/dist/concurrency.js +61 -0
- package/dist/config.d.ts +3795 -0
- package/dist/config.js +472 -0
- package/dist/configHelper.d.ts +2 -0
- package/dist/configHelper.js +34 -0
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +2 -0
- package/dist/crawler/storybook.d.ts +51 -0
- package/dist/crawler/storybook.js +317 -0
- package/dist/crawler/utils.d.ts +6 -0
- package/dist/crawler/utils.js +20 -0
- package/dist/createShots.d.ts +30 -0
- package/dist/createShots.js +54 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/log.d.ts +26 -0
- package/dist/log.js +99 -0
- package/dist/runner.d.ts +4 -0
- package/dist/runner.js +186 -0
- package/dist/schemas.d.ts +174 -0
- package/dist/schemas.js +73 -0
- package/dist/shard.d.ts +3 -0
- package/dist/shard.js +17 -0
- package/dist/shots/shots.d.ts +3 -0
- package/dist/shots/shots.js +196 -0
- package/dist/shots/utils.d.ts +33 -0
- package/dist/shots/utils.js +177 -0
- package/dist/types.d.ts +12 -0
- package/dist/types.js +1 -0
- package/dist/upload.d.ts +10 -0
- package/dist/upload.js +32 -0
- package/dist/uploadStorybook.d.ts +2 -0
- package/dist/uploadStorybook.js +56 -0
- package/dist/utils.d.ts +50 -0
- package/dist/utils.js +194 -0
- package/package.json +64 -0
package/dist/runner.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { copyFileSync } from 'node:fs';
|
|
2
|
+
import { getApiToken, prepareUpload, processShots, sendInitToAPI, sendRecordLogsToAPI, } from './api.js';
|
|
3
|
+
import { checkDifferences } from './checkDifferences.js';
|
|
4
|
+
import { createShots } from './createShots.js';
|
|
5
|
+
import { log } from './log.js';
|
|
6
|
+
import { shardShotItems } from './shard.js';
|
|
7
|
+
import { uploadRequiredShots } from './upload.js';
|
|
8
|
+
import { createShotsFolders, exitProcess, getShardConfig, hashFile, isUpdateMode, parseHrtimeToSeconds, readDirIntoShotItems, removeFilesInFolder, } from './utils.js';
|
|
9
|
+
export const runner = async (config) => {
|
|
10
|
+
const executionStart = process.hrtime();
|
|
11
|
+
try {
|
|
12
|
+
if (isUpdateMode()) {
|
|
13
|
+
log.process('info', 'general', 'Running lost-pixel in update mode. Baseline screenshots will be updated');
|
|
14
|
+
}
|
|
15
|
+
log.process('info', 'general', '📂 Creating shot folders');
|
|
16
|
+
const createShotsStart = process.hrtime();
|
|
17
|
+
createShotsFolders();
|
|
18
|
+
log.process('info', 'general', '📸 Creating shots');
|
|
19
|
+
let shotItems = await createShots();
|
|
20
|
+
const shardConfig = getShardConfig();
|
|
21
|
+
if (shardConfig) {
|
|
22
|
+
const totalBefore = shotItems.length;
|
|
23
|
+
shotItems = shardShotItems(shotItems, shardConfig);
|
|
24
|
+
log.process('info', 'general', `🔀 Shard ${shardConfig.current}/${shardConfig.total}: ${shotItems.length} of ${totalBefore} shots`);
|
|
25
|
+
}
|
|
26
|
+
const createShotsStop = process.hrtime(createShotsStart);
|
|
27
|
+
log.process('info', 'general', `Creating shots took ${parseHrtimeToSeconds(createShotsStop)} seconds`);
|
|
28
|
+
if (config.generateOnly && shotItems.length === 0) {
|
|
29
|
+
log.process('info', 'general', '👋 Exiting process with nothing to compare.');
|
|
30
|
+
exitProcess({});
|
|
31
|
+
}
|
|
32
|
+
log.process('info', 'general', '🔍 Checking differences');
|
|
33
|
+
const checkDifferenceStart = process.hrtime();
|
|
34
|
+
const { filterItemsToCheck } = config;
|
|
35
|
+
const filteredShotItems = filterItemsToCheck
|
|
36
|
+
? shotItems.filter((item) => filterItemsToCheck(item))
|
|
37
|
+
: shotItems;
|
|
38
|
+
const { aboveThresholdDifferenceItems, noBaselinesItems } = await checkDifferences(filteredShotItems);
|
|
39
|
+
if (isUpdateMode()) {
|
|
40
|
+
// Remove only the files which are no longer present in our shot items
|
|
41
|
+
removeFilesInFolder(config.imagePathBaseline, shotItems.map((shotItem) => shotItem.filePathBaseline));
|
|
42
|
+
// Synchronize differences from both lack of baseline and over threshold difference
|
|
43
|
+
for (const noBaselineItem of noBaselinesItems) {
|
|
44
|
+
copyFileSync(noBaselineItem.filePathCurrent, noBaselineItem.filePathBaseline);
|
|
45
|
+
}
|
|
46
|
+
for (const aboveThresholdDifferenceItem of aboveThresholdDifferenceItems) {
|
|
47
|
+
copyFileSync(aboveThresholdDifferenceItem.filePathCurrent, aboveThresholdDifferenceItem.filePathBaseline);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if ((aboveThresholdDifferenceItems.length > 0 ||
|
|
51
|
+
noBaselinesItems.length > 0) &&
|
|
52
|
+
config.failOnDifference) {
|
|
53
|
+
log.process('info', 'general', `👋 Exiting process with ${aboveThresholdDifferenceItems.length} found differences & ${noBaselinesItems.length} baselines to update`);
|
|
54
|
+
if (config.generateOnly) {
|
|
55
|
+
exitProcess({});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const checkDifferenceStop = process.hrtime(checkDifferenceStart);
|
|
59
|
+
log.process('info', 'general', `⏱ Checking differences took ${parseHrtimeToSeconds(checkDifferenceStop)} seconds`);
|
|
60
|
+
const executionStop = process.hrtime(executionStart);
|
|
61
|
+
log.process('info', 'general', `⏱ Lost Pixel run took ${parseHrtimeToSeconds(executionStop)} seconds`);
|
|
62
|
+
exitProcess({
|
|
63
|
+
exitCode: 0,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error instanceof Error) {
|
|
68
|
+
log.process('error', 'general', error.message);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
log.process('error', 'general', error);
|
|
72
|
+
}
|
|
73
|
+
exitProcess({});
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
export const getPlatformApiToken = async (config) => {
|
|
77
|
+
if (!config.apiKey) {
|
|
78
|
+
log.process('error', 'general', `Running Lost Pixel in 'platform' mode requires an API key`);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
if (isUpdateMode()) {
|
|
82
|
+
log.process('error', 'general', `Running Lost Pixel in 'update' mode requires the 'generateOnly' option to be set to true`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const result = await getApiToken(config);
|
|
87
|
+
return result.apiToken;
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (error instanceof Error) {
|
|
91
|
+
log.process('error', 'general', error.message);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
log.process('error', 'general', error);
|
|
95
|
+
}
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
export const platformRunner = async (config, apiToken) => {
|
|
100
|
+
const executionStart = process.hrtime();
|
|
101
|
+
try {
|
|
102
|
+
log.process('info', 'general', [
|
|
103
|
+
'📀 Using details:',
|
|
104
|
+
`ciBuildId = ${config.ciBuildId}`,
|
|
105
|
+
`ciBuildNumber = ${config.ciBuildNumber}`,
|
|
106
|
+
`repository = ${config.repository}`,
|
|
107
|
+
`commitRefName = ${config.commitRefName}`,
|
|
108
|
+
`commitHash = ${config.commitHash}`,
|
|
109
|
+
].join('\n - '));
|
|
110
|
+
if (config.setPendingStatusCheck) {
|
|
111
|
+
await sendInitToAPI(config, apiToken);
|
|
112
|
+
}
|
|
113
|
+
let shotItems;
|
|
114
|
+
if (config.uploadOnly) {
|
|
115
|
+
log.process('info', 'general', '📤 Upload-only mode — skipping screenshot creation');
|
|
116
|
+
shotItems = readDirIntoShotItems(config.imagePathCurrent);
|
|
117
|
+
log.process('info', 'general', `Found ${shotItems.length} existing shot(s) in ${config.imagePathCurrent}`);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
log.process('info', 'general', '📂 Creating shot folders');
|
|
121
|
+
const createShotsStart = process.hrtime();
|
|
122
|
+
createShotsFolders();
|
|
123
|
+
log.process('info', 'general', '📸 Creating shots');
|
|
124
|
+
const innerShotItems = await createShots();
|
|
125
|
+
const shotNames = innerShotItems.map((shotItem) => shotItem.shotName);
|
|
126
|
+
const uniqueShotNames = new Set(shotNames);
|
|
127
|
+
if (shotNames.length !== uniqueShotNames.size) {
|
|
128
|
+
const duplicates = shotNames.filter((shotName) => shotNames.filter((item) => item === shotName).length > 1);
|
|
129
|
+
throw new Error(`Error: Shot names must be unique (check for duplicate Story names: [ ${[
|
|
130
|
+
...new Set(duplicates),
|
|
131
|
+
].join(', ')} ])`);
|
|
132
|
+
}
|
|
133
|
+
const createShotsStop = process.hrtime(createShotsStart);
|
|
134
|
+
log.process('info', 'general', `⏱ Creating shots took ${parseHrtimeToSeconds(createShotsStop)} seconds`);
|
|
135
|
+
shotItems = innerShotItems;
|
|
136
|
+
}
|
|
137
|
+
const shardConfig = getShardConfig();
|
|
138
|
+
if (shardConfig) {
|
|
139
|
+
const totalBefore = shotItems.length;
|
|
140
|
+
shotItems = shardShotItems(shotItems, shardConfig);
|
|
141
|
+
log.process('info', 'general', `🔀 Shard ${shardConfig.current}/${shardConfig.total}: ${shotItems.length} of ${totalBefore} shots`);
|
|
142
|
+
}
|
|
143
|
+
const extendedShotItems = shotItems.map((shotItem) => ({
|
|
144
|
+
...shotItem,
|
|
145
|
+
uniqueName: `${shotItem.shotMode}/${shotItem.shotName}`,
|
|
146
|
+
hash: hashFile(shotItem.filePathCurrent),
|
|
147
|
+
}));
|
|
148
|
+
const { requiredFileHashes, uploadToken, uploadUrl } = await prepareUpload(config, apiToken, extendedShotItems.map((shotItem) => ({
|
|
149
|
+
name: shotItem.uniqueName,
|
|
150
|
+
hash: shotItem.hash,
|
|
151
|
+
})));
|
|
152
|
+
log.process('info', 'general', [
|
|
153
|
+
'🏙 ',
|
|
154
|
+
`${shotItems.length} shot(s) in total.`,
|
|
155
|
+
`${shotItems.length - requiredFileHashes.length} shot(s) already exist on platform.`,
|
|
156
|
+
`${requiredFileHashes.length} shot(s) will be uploaded at ${uploadUrl}.`,
|
|
157
|
+
].join(' '));
|
|
158
|
+
await uploadRequiredShots({
|
|
159
|
+
config,
|
|
160
|
+
apiToken,
|
|
161
|
+
uploadToken,
|
|
162
|
+
uploadUrl,
|
|
163
|
+
requiredFileHashes,
|
|
164
|
+
extendedShotItems,
|
|
165
|
+
});
|
|
166
|
+
const shotsConfig = shotItems.map((shotItem) => ({
|
|
167
|
+
name: `${shotItem.shotMode}/${shotItem.shotName}`,
|
|
168
|
+
threshold: shotItem.threshold,
|
|
169
|
+
stabilization: shotItem.stabilization,
|
|
170
|
+
}));
|
|
171
|
+
await processShots(config, apiToken, uploadToken, shotsConfig);
|
|
172
|
+
const executionStop = process.hrtime(executionStart);
|
|
173
|
+
log.process('info', 'general', `⏱ Lost Pixel run took ${parseHrtimeToSeconds(executionStop)} seconds`);
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
if (error instanceof Error) {
|
|
177
|
+
log.process('error', 'general', error.message);
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
log.process('error', 'general', error);
|
|
181
|
+
}
|
|
182
|
+
log.process('info', 'general', '🪵 Sending logs to platform.');
|
|
183
|
+
await sendRecordLogsToAPI(config, apiToken);
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { BrowserContextOptions } from 'playwright-core';
|
|
2
|
+
import * as z from 'zod';
|
|
3
|
+
export declare const BrowserSchema: z.ZodEnum<["chromium", "firefox", "webkit"]>;
|
|
4
|
+
export declare const ShotModeSchema: z.ZodEnum<["storybook"]>;
|
|
5
|
+
/**
|
|
6
|
+
* Describes how the pre-screenshot stability wait concluded for a shot.
|
|
7
|
+
*
|
|
8
|
+
* - `stable`: the page settled within the cap (or `stabilizeBeforeScreenshot`
|
|
9
|
+
* was disabled and we slept the full duration).
|
|
10
|
+
* - `fonts-not-ready`: the cap was hit waiting for `document.fonts.ready`.
|
|
11
|
+
* - `images-not-ready`: the cap was hit waiting for pending `<img>` elements
|
|
12
|
+
* to load or error.
|
|
13
|
+
* - `unstable`: fonts and images were ready but the DOM kept mutating past
|
|
14
|
+
* the cap.
|
|
15
|
+
*/
|
|
16
|
+
export declare const StabilizationStatusSchema: z.ZodEnum<["stable", "fonts-not-ready", "images-not-ready", "unstable"]>;
|
|
17
|
+
export declare const StabilizationResultSchema: z.ZodObject<{
|
|
18
|
+
status: z.ZodEnum<["stable", "fonts-not-ready", "images-not-ready", "unstable"]>;
|
|
19
|
+
/** Wall-clock spent in the stability wait, in milliseconds. */
|
|
20
|
+
elapsedMs: z.ZodNumber;
|
|
21
|
+
/** The cap (in milliseconds) applied to the wait. */
|
|
22
|
+
capMs: z.ZodNumber;
|
|
23
|
+
}, "strip", z.ZodTypeAny, {
|
|
24
|
+
status: "stable" | "fonts-not-ready" | "images-not-ready" | "unstable";
|
|
25
|
+
elapsedMs: number;
|
|
26
|
+
capMs: number;
|
|
27
|
+
}, {
|
|
28
|
+
status: "stable" | "fonts-not-ready" | "images-not-ready" | "unstable";
|
|
29
|
+
elapsedMs: number;
|
|
30
|
+
capMs: number;
|
|
31
|
+
}>;
|
|
32
|
+
export declare const MaskSchema: z.ZodObject<{
|
|
33
|
+
/**
|
|
34
|
+
* CSS selector for the element to mask
|
|
35
|
+
* Examples:
|
|
36
|
+
* - `#my-id`: Selects the element with the id `my-id`
|
|
37
|
+
* - `.my-class`: Selects all elements with the class `my-class`
|
|
38
|
+
* - `div`: Selects all `div` elements
|
|
39
|
+
* - `div.my-class`: Selects all `div` elements with the class `my-class`
|
|
40
|
+
* - `li:nth-child(2n)`: Selects all even `li` elements
|
|
41
|
+
* - `[data-testid="hero-banner"]`: Selects all elements with the attribute `data-testid` set to `hero-banner`
|
|
42
|
+
* - `div > p`: Selects all `p` elements that are direct children of a `div` element
|
|
43
|
+
*/
|
|
44
|
+
selector: z.ZodString;
|
|
45
|
+
}, "strip", z.ZodTypeAny, {
|
|
46
|
+
selector: string;
|
|
47
|
+
}, {
|
|
48
|
+
selector: string;
|
|
49
|
+
}>;
|
|
50
|
+
export declare const ShotItemSchema: z.ZodObject<{
|
|
51
|
+
shotMode: z.ZodEnum<["storybook"]>;
|
|
52
|
+
id: z.ZodString;
|
|
53
|
+
shotName: z.ZodString;
|
|
54
|
+
url: z.ZodString;
|
|
55
|
+
filePathBaseline: z.ZodString;
|
|
56
|
+
filePathCurrent: z.ZodString;
|
|
57
|
+
filePathDifference: z.ZodString;
|
|
58
|
+
browserConfig: z.ZodOptional<z.ZodType<BrowserContextOptions, z.ZodTypeDef, BrowserContextOptions>>;
|
|
59
|
+
threshold: z.ZodNumber;
|
|
60
|
+
waitBeforeScreenshot: z.ZodOptional<z.ZodNumber>;
|
|
61
|
+
stabilizeBeforeScreenshot: z.ZodOptional<z.ZodBoolean>;
|
|
62
|
+
/**
|
|
63
|
+
* Result of the pre-screenshot stability wait. Populated by
|
|
64
|
+
* `takeScreenShot` after the wait completes; not present on items that
|
|
65
|
+
* have not yet been captured or for shots that opted out via
|
|
66
|
+
* `stabilizeBeforeScreenshot: false`.
|
|
67
|
+
*/
|
|
68
|
+
stabilization: z.ZodOptional<z.ZodObject<{
|
|
69
|
+
status: z.ZodEnum<["stable", "fonts-not-ready", "images-not-ready", "unstable"]>;
|
|
70
|
+
/** Wall-clock spent in the stability wait, in milliseconds. */
|
|
71
|
+
elapsedMs: z.ZodNumber;
|
|
72
|
+
/** The cap (in milliseconds) applied to the wait. */
|
|
73
|
+
capMs: z.ZodNumber;
|
|
74
|
+
}, "strip", z.ZodTypeAny, {
|
|
75
|
+
status: "stable" | "fonts-not-ready" | "images-not-ready" | "unstable";
|
|
76
|
+
elapsedMs: number;
|
|
77
|
+
capMs: number;
|
|
78
|
+
}, {
|
|
79
|
+
status: "stable" | "fonts-not-ready" | "images-not-ready" | "unstable";
|
|
80
|
+
elapsedMs: number;
|
|
81
|
+
capMs: number;
|
|
82
|
+
}>>;
|
|
83
|
+
importPath: z.ZodOptional<z.ZodString>;
|
|
84
|
+
mask: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
85
|
+
/**
|
|
86
|
+
* CSS selector for the element to mask
|
|
87
|
+
* Examples:
|
|
88
|
+
* - `#my-id`: Selects the element with the id `my-id`
|
|
89
|
+
* - `.my-class`: Selects all elements with the class `my-class`
|
|
90
|
+
* - `div`: Selects all `div` elements
|
|
91
|
+
* - `div.my-class`: Selects all `div` elements with the class `my-class`
|
|
92
|
+
* - `li:nth-child(2n)`: Selects all even `li` elements
|
|
93
|
+
* - `[data-testid="hero-banner"]`: Selects all elements with the attribute `data-testid` set to `hero-banner`
|
|
94
|
+
* - `div > p`: Selects all `p` elements that are direct children of a `div` element
|
|
95
|
+
*/
|
|
96
|
+
selector: z.ZodString;
|
|
97
|
+
}, "strip", z.ZodTypeAny, {
|
|
98
|
+
selector: string;
|
|
99
|
+
}, {
|
|
100
|
+
selector: string;
|
|
101
|
+
}>, "many">>;
|
|
102
|
+
viewport: z.ZodOptional<z.ZodObject<{
|
|
103
|
+
width: z.ZodNumber;
|
|
104
|
+
height: z.ZodOptional<z.ZodNumber>;
|
|
105
|
+
}, "strip", z.ZodTypeAny, {
|
|
106
|
+
width: number;
|
|
107
|
+
height?: number | undefined;
|
|
108
|
+
}, {
|
|
109
|
+
width: number;
|
|
110
|
+
height?: number | undefined;
|
|
111
|
+
}>>;
|
|
112
|
+
breakpoint: z.ZodOptional<z.ZodNumber>;
|
|
113
|
+
breakpointGroup: z.ZodOptional<z.ZodString>;
|
|
114
|
+
elementLocator: z.ZodOptional<z.ZodString>;
|
|
115
|
+
waitForSelector: z.ZodOptional<z.ZodString>;
|
|
116
|
+
}, "strip", z.ZodTypeAny, {
|
|
117
|
+
shotMode: "storybook";
|
|
118
|
+
id: string;
|
|
119
|
+
shotName: string;
|
|
120
|
+
url: string;
|
|
121
|
+
filePathBaseline: string;
|
|
122
|
+
filePathCurrent: string;
|
|
123
|
+
filePathDifference: string;
|
|
124
|
+
threshold: number;
|
|
125
|
+
browserConfig?: BrowserContextOptions | undefined;
|
|
126
|
+
waitBeforeScreenshot?: number | undefined;
|
|
127
|
+
stabilizeBeforeScreenshot?: boolean | undefined;
|
|
128
|
+
stabilization?: {
|
|
129
|
+
status: "stable" | "fonts-not-ready" | "images-not-ready" | "unstable";
|
|
130
|
+
elapsedMs: number;
|
|
131
|
+
capMs: number;
|
|
132
|
+
} | undefined;
|
|
133
|
+
importPath?: string | undefined;
|
|
134
|
+
mask?: {
|
|
135
|
+
selector: string;
|
|
136
|
+
}[] | undefined;
|
|
137
|
+
viewport?: {
|
|
138
|
+
width: number;
|
|
139
|
+
height?: number | undefined;
|
|
140
|
+
} | undefined;
|
|
141
|
+
breakpoint?: number | undefined;
|
|
142
|
+
breakpointGroup?: string | undefined;
|
|
143
|
+
elementLocator?: string | undefined;
|
|
144
|
+
waitForSelector?: string | undefined;
|
|
145
|
+
}, {
|
|
146
|
+
shotMode: "storybook";
|
|
147
|
+
id: string;
|
|
148
|
+
shotName: string;
|
|
149
|
+
url: string;
|
|
150
|
+
filePathBaseline: string;
|
|
151
|
+
filePathCurrent: string;
|
|
152
|
+
filePathDifference: string;
|
|
153
|
+
threshold: number;
|
|
154
|
+
browserConfig?: BrowserContextOptions | undefined;
|
|
155
|
+
waitBeforeScreenshot?: number | undefined;
|
|
156
|
+
stabilizeBeforeScreenshot?: boolean | undefined;
|
|
157
|
+
stabilization?: {
|
|
158
|
+
status: "stable" | "fonts-not-ready" | "images-not-ready" | "unstable";
|
|
159
|
+
elapsedMs: number;
|
|
160
|
+
capMs: number;
|
|
161
|
+
} | undefined;
|
|
162
|
+
importPath?: string | undefined;
|
|
163
|
+
mask?: {
|
|
164
|
+
selector: string;
|
|
165
|
+
}[] | undefined;
|
|
166
|
+
viewport?: {
|
|
167
|
+
width: number;
|
|
168
|
+
height?: number | undefined;
|
|
169
|
+
} | undefined;
|
|
170
|
+
breakpoint?: number | undefined;
|
|
171
|
+
breakpointGroup?: string | undefined;
|
|
172
|
+
elementLocator?: string | undefined;
|
|
173
|
+
waitForSelector?: string | undefined;
|
|
174
|
+
}>;
|
package/dist/schemas.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import * as z from 'zod';
|
|
2
|
+
export const BrowserSchema = z.enum(['chromium', 'firefox', 'webkit']);
|
|
3
|
+
export const ShotModeSchema = z.enum(['storybook']);
|
|
4
|
+
/**
|
|
5
|
+
* Describes how the pre-screenshot stability wait concluded for a shot.
|
|
6
|
+
*
|
|
7
|
+
* - `stable`: the page settled within the cap (or `stabilizeBeforeScreenshot`
|
|
8
|
+
* was disabled and we slept the full duration).
|
|
9
|
+
* - `fonts-not-ready`: the cap was hit waiting for `document.fonts.ready`.
|
|
10
|
+
* - `images-not-ready`: the cap was hit waiting for pending `<img>` elements
|
|
11
|
+
* to load or error.
|
|
12
|
+
* - `unstable`: fonts and images were ready but the DOM kept mutating past
|
|
13
|
+
* the cap.
|
|
14
|
+
*/
|
|
15
|
+
export const StabilizationStatusSchema = z.enum([
|
|
16
|
+
'stable',
|
|
17
|
+
'fonts-not-ready',
|
|
18
|
+
'images-not-ready',
|
|
19
|
+
'unstable',
|
|
20
|
+
]);
|
|
21
|
+
export const StabilizationResultSchema = z.object({
|
|
22
|
+
status: StabilizationStatusSchema,
|
|
23
|
+
/** Wall-clock spent in the stability wait, in milliseconds. */
|
|
24
|
+
elapsedMs: z.number(),
|
|
25
|
+
/** The cap (in milliseconds) applied to the wait. */
|
|
26
|
+
capMs: z.number(),
|
|
27
|
+
});
|
|
28
|
+
export const MaskSchema = z.object({
|
|
29
|
+
/**
|
|
30
|
+
* CSS selector for the element to mask
|
|
31
|
+
* Examples:
|
|
32
|
+
* - `#my-id`: Selects the element with the id `my-id`
|
|
33
|
+
* - `.my-class`: Selects all elements with the class `my-class`
|
|
34
|
+
* - `div`: Selects all `div` elements
|
|
35
|
+
* - `div.my-class`: Selects all `div` elements with the class `my-class`
|
|
36
|
+
* - `li:nth-child(2n)`: Selects all even `li` elements
|
|
37
|
+
* - `[data-testid="hero-banner"]`: Selects all elements with the attribute `data-testid` set to `hero-banner`
|
|
38
|
+
* - `div > p`: Selects all `p` elements that are direct children of a `div` element
|
|
39
|
+
*/
|
|
40
|
+
selector: z.string(),
|
|
41
|
+
});
|
|
42
|
+
export const ShotItemSchema = z.object({
|
|
43
|
+
shotMode: ShotModeSchema,
|
|
44
|
+
id: z.string(),
|
|
45
|
+
shotName: z.string(),
|
|
46
|
+
url: z.string(),
|
|
47
|
+
filePathBaseline: z.string(),
|
|
48
|
+
filePathCurrent: z.string(),
|
|
49
|
+
filePathDifference: z.string(),
|
|
50
|
+
browserConfig: z.custom().optional(),
|
|
51
|
+
threshold: z.number(),
|
|
52
|
+
waitBeforeScreenshot: z.number().optional(),
|
|
53
|
+
stabilizeBeforeScreenshot: z.boolean().optional(),
|
|
54
|
+
/**
|
|
55
|
+
* Result of the pre-screenshot stability wait. Populated by
|
|
56
|
+
* `takeScreenShot` after the wait completes; not present on items that
|
|
57
|
+
* have not yet been captured or for shots that opted out via
|
|
58
|
+
* `stabilizeBeforeScreenshot: false`.
|
|
59
|
+
*/
|
|
60
|
+
stabilization: StabilizationResultSchema.optional(),
|
|
61
|
+
importPath: z.string().optional(),
|
|
62
|
+
mask: z.array(MaskSchema).optional(),
|
|
63
|
+
viewport: z
|
|
64
|
+
.object({
|
|
65
|
+
width: z.number(),
|
|
66
|
+
height: z.number().optional(),
|
|
67
|
+
})
|
|
68
|
+
.optional(),
|
|
69
|
+
breakpoint: z.number().optional(),
|
|
70
|
+
breakpointGroup: z.string().optional(),
|
|
71
|
+
elementLocator: z.string().optional(),
|
|
72
|
+
waitForSelector: z.string().optional(),
|
|
73
|
+
});
|
package/dist/shard.d.ts
ADDED
package/dist/shard.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
export const shardShotItems = (shotItems, shard) => {
|
|
3
|
+
const hashed = shotItems.map((item) => ({
|
|
4
|
+
item,
|
|
5
|
+
hash: createHash('sha256').update(item.shotName).digest('hex'),
|
|
6
|
+
}));
|
|
7
|
+
hashed.sort((a, b) => a.hash.localeCompare(b.hash));
|
|
8
|
+
const total = hashed.length;
|
|
9
|
+
const baseSize = Math.floor(total / shard.total);
|
|
10
|
+
const remainder = total % shard.total;
|
|
11
|
+
// First `remainder` shards get baseSize+1 items, rest get baseSize
|
|
12
|
+
const shardStart = shard.current <= remainder
|
|
13
|
+
? (shard.current - 1) * (baseSize + 1)
|
|
14
|
+
: remainder * (baseSize + 1) + (shard.current - 1 - remainder) * baseSize;
|
|
15
|
+
const shardSize = shard.current <= remainder ? baseSize + 1 : baseSize;
|
|
16
|
+
return hashed.slice(shardStart, shardStart + shardSize).map((h) => h.item);
|
|
17
|
+
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { mapLimit } from '../concurrency.js';
|
|
3
|
+
import { config } from '../config.js';
|
|
4
|
+
import { log } from '../log.js';
|
|
5
|
+
import { hashFile, launchBrowser, sleep } from '../utils.js';
|
|
6
|
+
import { resizeViewportToFullscreen, waitForNetworkRequests, waitForStability, } from './utils.js';
|
|
7
|
+
const takeScreenShot = async ({ browser, shotItem, logger, }) => {
|
|
8
|
+
const context = await browser.newContext(shotItem.browserConfig);
|
|
9
|
+
await context.addInitScript(() => {
|
|
10
|
+
window.__IS_PIXEL__ = true;
|
|
11
|
+
});
|
|
12
|
+
const page = await context.newPage();
|
|
13
|
+
let success = false;
|
|
14
|
+
page.on('pageerror', (exception) => {
|
|
15
|
+
logger.browser('error', 'general', 'Uncaught exception:', exception);
|
|
16
|
+
});
|
|
17
|
+
page.on('console', async (message) => {
|
|
18
|
+
const values = [];
|
|
19
|
+
try {
|
|
20
|
+
for (const arg of message.args()) {
|
|
21
|
+
// eslint-disable-next-line no-await-in-loop
|
|
22
|
+
values.push(await arg.jsonValue());
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
logger.browser('error', 'console', 'Error while collecting console output', error);
|
|
27
|
+
}
|
|
28
|
+
logger.browser('info', 'console', String(values.shift()), ...values);
|
|
29
|
+
});
|
|
30
|
+
try {
|
|
31
|
+
await page.goto(shotItem.url);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
35
|
+
logger.process('error', 'timeout', `Timeout while loading page: ${shotItem.url}`);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
logger.process('error', 'general', 'Page loading failed', error);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
await page.waitForLoadState('load', {
|
|
43
|
+
timeout: config.timeouts.loadState,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
logger.process('error', 'timeout', `Timeout while waiting for page load state: ${shotItem.url}`, error);
|
|
48
|
+
}
|
|
49
|
+
if (shotItem.waitForSelector) {
|
|
50
|
+
try {
|
|
51
|
+
await page.waitForSelector(shotItem.waitForSelector, {
|
|
52
|
+
state: 'attached',
|
|
53
|
+
timeout: config.timeouts.loadState,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
logger.process('error', 'timeout', `Timeout while waiting for Selector ('${shotItem.waitForSelector}') to appear: ${shotItem.url}`, error);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
await waitForNetworkRequests({
|
|
62
|
+
page,
|
|
63
|
+
logger,
|
|
64
|
+
ignoreUrls: ['/__webpack_hmr'],
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
logger.process('error', 'timeout', `Timeout while waiting for all network requests: ${shotItem.url}`, error);
|
|
69
|
+
}
|
|
70
|
+
if (config.beforeScreenshot) {
|
|
71
|
+
await config.beforeScreenshot(page, {
|
|
72
|
+
shotMode: shotItem.shotMode,
|
|
73
|
+
id: shotItem.id,
|
|
74
|
+
shotName: shotItem.shotName,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
let fullScreenMode = true;
|
|
78
|
+
const waitCapMs = shotItem.waitBeforeScreenshot ?? config.waitBeforeScreenshot;
|
|
79
|
+
const stabilize = shotItem.stabilizeBeforeScreenshot ?? config.stabilizeBeforeScreenshot;
|
|
80
|
+
if (stabilize) {
|
|
81
|
+
const result = await waitForStability(page, { maxMs: waitCapMs });
|
|
82
|
+
shotItem.stabilization = result;
|
|
83
|
+
const rounded = Math.round(result.elapsedMs);
|
|
84
|
+
if (result.status === 'stable') {
|
|
85
|
+
logger.process('info', 'general', `Stabilized '${shotItem.shotName}' in ${rounded}ms (status: stable)`);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
logger.process('info', 'general', `Stability of '${shotItem.shotName}' hit cap of ${waitCapMs}ms in ${rounded}ms (status: ${result.status})`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
shotItem.stabilization = {
|
|
93
|
+
status: 'stable',
|
|
94
|
+
elapsedMs: waitCapMs,
|
|
95
|
+
capMs: waitCapMs,
|
|
96
|
+
};
|
|
97
|
+
await sleep(waitCapMs);
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
if (shotItem.viewport) {
|
|
101
|
+
const currentViewport = page.viewportSize();
|
|
102
|
+
await page.setViewportSize({
|
|
103
|
+
width: shotItem.viewport.width,
|
|
104
|
+
height: currentViewport?.height ?? 500,
|
|
105
|
+
});
|
|
106
|
+
fullScreenMode = true;
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
await resizeViewportToFullscreen({ page });
|
|
110
|
+
fullScreenMode = false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
logger.process('error', 'general', `Could not resize viewport to fullscreen: ${shotItem.shotName}`, error);
|
|
115
|
+
}
|
|
116
|
+
let retryCount = 0;
|
|
117
|
+
let lastShotHash;
|
|
118
|
+
try {
|
|
119
|
+
while (retryCount <= config.flakynessRetries) {
|
|
120
|
+
const { elementLocator } = shotItem;
|
|
121
|
+
let screenshotOptions = {
|
|
122
|
+
path: shotItem.filePathCurrent,
|
|
123
|
+
animations: 'disabled',
|
|
124
|
+
mask: shotItem.mask
|
|
125
|
+
? shotItem.mask.map((mask) => page.locator(mask.selector))
|
|
126
|
+
: [],
|
|
127
|
+
};
|
|
128
|
+
// add fullPage option if no elementLocator is set
|
|
129
|
+
if (elementLocator) {
|
|
130
|
+
// eslint-disable-next-line no-await-in-loop
|
|
131
|
+
await page.locator(elementLocator).screenshot(screenshotOptions);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
screenshotOptions = { ...screenshotOptions, fullPage: fullScreenMode };
|
|
135
|
+
// eslint-disable-next-line no-await-in-loop
|
|
136
|
+
await page.screenshot(screenshotOptions);
|
|
137
|
+
}
|
|
138
|
+
const currentShotHash = hashFile(shotItem.filePathCurrent);
|
|
139
|
+
if (lastShotHash) {
|
|
140
|
+
logger.process('info', 'general', `Screenshot of '${shotItem.shotName}' taken (Retry ${retryCount}). Hash: ${currentShotHash} - Previous hash: ${lastShotHash}`);
|
|
141
|
+
if (lastShotHash === currentShotHash) {
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
lastShotHash = currentShotHash;
|
|
146
|
+
if (retryCount < config.flakynessRetries) {
|
|
147
|
+
// eslint-disable-next-line no-await-in-loop
|
|
148
|
+
await sleep(config.waitBetweenFlakynessRetries);
|
|
149
|
+
}
|
|
150
|
+
retryCount++;
|
|
151
|
+
}
|
|
152
|
+
success = true;
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
logger.process('error', 'general', 'Error when taking screenshot', error);
|
|
156
|
+
}
|
|
157
|
+
if (config.afterScreenshot) {
|
|
158
|
+
await config.afterScreenshot(page, shotItem);
|
|
159
|
+
}
|
|
160
|
+
await context.close();
|
|
161
|
+
const videoPath = await page.video()?.path();
|
|
162
|
+
if (videoPath) {
|
|
163
|
+
const dirname = path.dirname(videoPath);
|
|
164
|
+
const ext = videoPath.split('.').pop() ?? 'webm';
|
|
165
|
+
const newVideoPath = `${dirname}/${shotItem.shotName}.${ext}`;
|
|
166
|
+
await page.video()?.saveAs(newVideoPath);
|
|
167
|
+
await page.video()?.delete();
|
|
168
|
+
logger.process('info', 'general', `Video of '${shotItem.shotName}' recorded and saved to '${newVideoPath}`);
|
|
169
|
+
}
|
|
170
|
+
return success;
|
|
171
|
+
};
|
|
172
|
+
export const takeScreenShots = async (shotItems, _browser) => {
|
|
173
|
+
const browser = await launchBrowser(_browser);
|
|
174
|
+
const total = shotItems.length;
|
|
175
|
+
log.process('info', 'general', `Taking ${total} screenshot(s) with shotConcurrency=${config.shotConcurrency}`);
|
|
176
|
+
await mapLimit([...shotItems.entries()], config.shotConcurrency, async ([index, shotItem]) => {
|
|
177
|
+
const logger = log.item({
|
|
178
|
+
shotMode: shotItem.shotMode,
|
|
179
|
+
uniqueItemId: shotItem.shotName,
|
|
180
|
+
itemIndex: index,
|
|
181
|
+
totalItems: total,
|
|
182
|
+
});
|
|
183
|
+
logger.process('info', 'general', `Taking screenshot of '${shotItem.shotName} ${shotItem.breakpoint ? `[${shotItem.breakpoint}]` : ''}'`);
|
|
184
|
+
const startTime = Date.now();
|
|
185
|
+
const result = await takeScreenShot({ browser, shotItem, logger });
|
|
186
|
+
const endTime = Date.now();
|
|
187
|
+
const elapsedTime = Number((endTime - startTime) / 1000).toFixed(3);
|
|
188
|
+
if (result) {
|
|
189
|
+
logger.process('info', 'general', `Screenshot of '${shotItem.shotName}' taken and saved to '${shotItem.filePathCurrent}' in ${elapsedTime}s`);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
logger.process('info', 'general', `Screenshot of '${shotItem.shotName}' failed and took ${elapsedTime}s`);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
await browser.close();
|
|
196
|
+
};
|