@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/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
+ }>;
@@ -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
+ });
@@ -0,0 +1,3 @@
1
+ import type { ShotItem } from './types.js';
2
+ import type { ShardConfig } from './utils.js';
3
+ export declare const shardShotItems: (shotItems: ShotItem[], shard: ShardConfig) => ShotItem[];
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,3 @@
1
+ import type { BrowserType } from 'playwright-core';
2
+ import type { ShotItem } from '../types.js';
3
+ export declare const takeScreenShots: (shotItems: ShotItem[], _browser?: BrowserType) => Promise<void>;
@@ -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
+ };