@gfxlabs/third-eye-cli 3.22.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/config-templates/example.lostpixel.config.js +6 -0
- package/config-templates/example.lostpixel.config.ts +8 -0
- package/dist/bin.mjs +2084 -0
- package/dist/bin.mjs.map +1 -0
- package/package.json +84 -0
package/dist/bin.mjs
ADDED
|
@@ -0,0 +1,2084 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path, { join, normalize } from "node:path";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import yargs from "yargs";
|
|
6
|
+
import { hideBin } from "yargs/helpers";
|
|
7
|
+
import fs from "fs-extra";
|
|
8
|
+
import { execSync } from "node:child_process";
|
|
9
|
+
import { mapLimit, retry } from "async";
|
|
10
|
+
import pixelmatch from "pixelmatch";
|
|
11
|
+
import { compare } from "odiff-bin";
|
|
12
|
+
import { PNG } from "pngjs";
|
|
13
|
+
import * as z$1 from "zod";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import { bundleRequire } from "bundle-require";
|
|
16
|
+
import * as crypto from "node:crypto";
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
import { PostHog } from "posthog-node";
|
|
19
|
+
import { chromium, firefox, webkit } from "playwright-core";
|
|
20
|
+
import axios, { isAxiosError } from "axios";
|
|
21
|
+
import http from "node:http";
|
|
22
|
+
import handler from "serve-handler";
|
|
23
|
+
import { getPort } from "get-port-please";
|
|
24
|
+
import { createORPCClient } from "@orpc/client";
|
|
25
|
+
import { RPCLink } from "@orpc/client/fetch";
|
|
26
|
+
import { execa } from "execa";
|
|
27
|
+
import { XMLParser } from "fast-xml-parser";
|
|
28
|
+
//#region src/log.ts
|
|
29
|
+
const logMemory = [];
|
|
30
|
+
const serializeError = (error) => ({
|
|
31
|
+
message: error.message,
|
|
32
|
+
name: error.name,
|
|
33
|
+
stack: error.stack
|
|
34
|
+
});
|
|
35
|
+
const serializeErrors = (content) => content.map((item) => {
|
|
36
|
+
if (item instanceof Error) return serializeError(item);
|
|
37
|
+
return item;
|
|
38
|
+
});
|
|
39
|
+
const renderLog = (entry) => {
|
|
40
|
+
if (entry.source === "browser" && entry.context === "console") return;
|
|
41
|
+
if (entry.source === "browser" && entry.context === "network") return;
|
|
42
|
+
const { log } = console;
|
|
43
|
+
const logPrefix = [];
|
|
44
|
+
if (entry.item) logPrefix.push(`[${entry.item.itemIndex + 1}/${entry.item.totalItems}]`);
|
|
45
|
+
if (![
|
|
46
|
+
"general",
|
|
47
|
+
"api",
|
|
48
|
+
"config"
|
|
49
|
+
].includes(entry.context)) logPrefix.push(`[${entry.context}]`);
|
|
50
|
+
if (entry.level === "error") logPrefix.push(`❌`);
|
|
51
|
+
if (entry.item?.uniqueItemId) log(...logPrefix, ...entry.content, `(${entry.item?.uniqueItemId})`);
|
|
52
|
+
else log(...logPrefix, ...entry.content);
|
|
53
|
+
};
|
|
54
|
+
const log = {
|
|
55
|
+
item: (item) => ({
|
|
56
|
+
process(level, context, ...content) {
|
|
57
|
+
const entry = {
|
|
58
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
59
|
+
level,
|
|
60
|
+
item,
|
|
61
|
+
source: "process",
|
|
62
|
+
context,
|
|
63
|
+
content
|
|
64
|
+
};
|
|
65
|
+
renderLog(entry);
|
|
66
|
+
logMemory.push({
|
|
67
|
+
...entry,
|
|
68
|
+
content: serializeErrors(content)
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
browser(level, context, ...content) {
|
|
72
|
+
const entry = {
|
|
73
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
74
|
+
level,
|
|
75
|
+
item,
|
|
76
|
+
source: "browser",
|
|
77
|
+
context,
|
|
78
|
+
content
|
|
79
|
+
};
|
|
80
|
+
renderLog(entry);
|
|
81
|
+
logMemory.push({
|
|
82
|
+
...entry,
|
|
83
|
+
content: serializeErrors(content)
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}),
|
|
87
|
+
process(level, context, ...content) {
|
|
88
|
+
const entry = {
|
|
89
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
90
|
+
level,
|
|
91
|
+
source: "process",
|
|
92
|
+
context,
|
|
93
|
+
content
|
|
94
|
+
};
|
|
95
|
+
renderLog(entry);
|
|
96
|
+
logMemory.push({
|
|
97
|
+
...entry,
|
|
98
|
+
content: serializeErrors(content)
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
browser(level, context, ...content) {
|
|
102
|
+
const entry = {
|
|
103
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
104
|
+
level,
|
|
105
|
+
source: "browser",
|
|
106
|
+
context,
|
|
107
|
+
content
|
|
108
|
+
};
|
|
109
|
+
renderLog(entry);
|
|
110
|
+
logMemory.push({
|
|
111
|
+
...entry,
|
|
112
|
+
content: serializeErrors(content)
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
//#endregion
|
|
117
|
+
//#region src/configHelper.ts
|
|
118
|
+
const loadProjectConfigFile = async (configFilepath) => {
|
|
119
|
+
try {
|
|
120
|
+
const { mod } = await bundleRequire({
|
|
121
|
+
filepath: configFilepath,
|
|
122
|
+
esbuildOptions: {}
|
|
123
|
+
});
|
|
124
|
+
return mod?.default ?? mod?.config ?? mod;
|
|
125
|
+
} catch (error) {
|
|
126
|
+
log.process("error", "config", error);
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const loadTSProjectConfigFile = async (configFilepath) => {
|
|
131
|
+
try {
|
|
132
|
+
const imported = await import(configFilepath);
|
|
133
|
+
return imported?.default ?? imported?.config;
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (["ERR_MODULE_NOT_FOUND", "MODULE_NOT_FOUND"].includes(error.code)) {
|
|
136
|
+
log.process("error", "config", "Failed to load TypeScript configuration file");
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
log.process("error", "config", error);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region src/schemas.ts
|
|
145
|
+
const BrowserSchema = z$1.enum([
|
|
146
|
+
"chromium",
|
|
147
|
+
"firefox",
|
|
148
|
+
"webkit"
|
|
149
|
+
]);
|
|
150
|
+
const ShotModeSchema = z$1.enum([
|
|
151
|
+
"storybook",
|
|
152
|
+
"ladle",
|
|
153
|
+
"histoire",
|
|
154
|
+
"page",
|
|
155
|
+
"custom"
|
|
156
|
+
]);
|
|
157
|
+
const MaskSchema = z$1.object({ selector: z$1.string() });
|
|
158
|
+
z$1.object({
|
|
159
|
+
shotMode: ShotModeSchema,
|
|
160
|
+
id: z$1.string(),
|
|
161
|
+
shotName: z$1.string(),
|
|
162
|
+
url: z$1.string(),
|
|
163
|
+
filePathBaseline: z$1.string(),
|
|
164
|
+
filePathCurrent: z$1.string(),
|
|
165
|
+
filePathDifference: z$1.string(),
|
|
166
|
+
browserConfig: z$1.custom().optional(),
|
|
167
|
+
threshold: z$1.number(),
|
|
168
|
+
waitBeforeScreenshot: z$1.number().optional(),
|
|
169
|
+
importPath: z$1.string().optional(),
|
|
170
|
+
mask: z$1.array(MaskSchema).optional(),
|
|
171
|
+
viewport: z$1.object({
|
|
172
|
+
width: z$1.number(),
|
|
173
|
+
height: z$1.number().optional()
|
|
174
|
+
}).optional(),
|
|
175
|
+
breakpoint: z$1.number().optional(),
|
|
176
|
+
breakpointGroup: z$1.string().optional(),
|
|
177
|
+
elementLocator: z$1.string().optional(),
|
|
178
|
+
waitForSelector: z$1.string().optional(),
|
|
179
|
+
componentPath: z$1.string().optional(),
|
|
180
|
+
storyName: z$1.string().optional(),
|
|
181
|
+
storyId: z$1.string().optional(),
|
|
182
|
+
storyArgs: z$1.record(z$1.string(), z$1.unknown()).optional(),
|
|
183
|
+
tags: z$1.array(z$1.string()).optional()
|
|
184
|
+
});
|
|
185
|
+
//#endregion
|
|
186
|
+
//#region src/config.ts
|
|
187
|
+
const PageScreenshotParameterSchema = z$1.object({
|
|
188
|
+
path: z$1.string(),
|
|
189
|
+
name: z$1.string(),
|
|
190
|
+
waitBeforeScreenshot: z$1.number().default(1e3),
|
|
191
|
+
threshold: z$1.number().default(0),
|
|
192
|
+
breakpoints: z$1.array(z$1.number()).optional(),
|
|
193
|
+
viewport: z$1.object({
|
|
194
|
+
width: z$1.number().optional(),
|
|
195
|
+
height: z$1.number().optional()
|
|
196
|
+
}).optional(),
|
|
197
|
+
mask: z$1.array(MaskSchema).optional()
|
|
198
|
+
});
|
|
199
|
+
const StorybookShotsSchema = z$1.object({
|
|
200
|
+
storybookUrl: z$1.string(),
|
|
201
|
+
mask: z$1.array(MaskSchema).optional(),
|
|
202
|
+
breakpoints: z$1.array(z$1.number()).optional(),
|
|
203
|
+
elementLocator: z$1.string().optional(),
|
|
204
|
+
waitForSelector: z$1.string().optional()
|
|
205
|
+
});
|
|
206
|
+
const LadleShotsSchema = z$1.object({
|
|
207
|
+
ladleUrl: z$1.string(),
|
|
208
|
+
mask: z$1.array(MaskSchema).optional(),
|
|
209
|
+
breakpoints: z$1.array(z$1.number()).optional(),
|
|
210
|
+
waitForSelector: z$1.string().optional()
|
|
211
|
+
});
|
|
212
|
+
const HistoireShotsSchema = z$1.object({
|
|
213
|
+
histoireUrl: z$1.string(),
|
|
214
|
+
mask: z$1.array(MaskSchema).optional(),
|
|
215
|
+
breakpoints: z$1.array(z$1.number()).optional(),
|
|
216
|
+
waitForSelector: z$1.string().optional()
|
|
217
|
+
});
|
|
218
|
+
const PageShotsSchema = z$1.object({
|
|
219
|
+
pages: z$1.array(PageScreenshotParameterSchema),
|
|
220
|
+
pagesJsonUrl: z$1.string().optional(),
|
|
221
|
+
pagesJsonRefiner: z$1.custom().optional(),
|
|
222
|
+
baseUrl: z$1.string(),
|
|
223
|
+
mask: z$1.array(MaskSchema).optional(),
|
|
224
|
+
breakpoints: z$1.array(z$1.number()).optional(),
|
|
225
|
+
waitForSelector: z$1.string().optional()
|
|
226
|
+
});
|
|
227
|
+
const CustomShotsSchema = z$1.object({ currentShotsPath: z$1.string() });
|
|
228
|
+
z$1.object({
|
|
229
|
+
shotMode: ShotModeSchema,
|
|
230
|
+
id: z$1.string().optional(),
|
|
231
|
+
kind: z$1.string().optional(),
|
|
232
|
+
story: z$1.string().optional(),
|
|
233
|
+
shotName: z$1.string().optional(),
|
|
234
|
+
parameters: z$1.record(z$1.string(), z$1.unknown()).optional(),
|
|
235
|
+
filePathBaseline: z$1.string().optional(),
|
|
236
|
+
filePathCurrent: z$1.string().optional(),
|
|
237
|
+
filePathDifference: z$1.string().optional()
|
|
238
|
+
});
|
|
239
|
+
z$1.object({
|
|
240
|
+
shotMode: ShotModeSchema,
|
|
241
|
+
id: z$1.string(),
|
|
242
|
+
shotName: z$1.string(),
|
|
243
|
+
url: z$1.string(),
|
|
244
|
+
filePathBaseline: z$1.string(),
|
|
245
|
+
filePathCurrent: z$1.string(),
|
|
246
|
+
filePathDifference: z$1.string(),
|
|
247
|
+
browserConfig: z$1.custom().optional(),
|
|
248
|
+
threshold: z$1.number(),
|
|
249
|
+
waitBeforeScreenshot: z$1.number().optional(),
|
|
250
|
+
importPath: z$1.string().optional(),
|
|
251
|
+
mask: z$1.array(MaskSchema).optional(),
|
|
252
|
+
viewport: z$1.object({
|
|
253
|
+
width: z$1.number(),
|
|
254
|
+
height: z$1.number().optional()
|
|
255
|
+
}).optional(),
|
|
256
|
+
breakpoint: z$1.number().optional(),
|
|
257
|
+
breakpointGroup: z$1.string().optional(),
|
|
258
|
+
elementLocator: z$1.string().optional(),
|
|
259
|
+
waitForSelector: z$1.string().optional()
|
|
260
|
+
});
|
|
261
|
+
const TimeoutsSchema = z$1.object({
|
|
262
|
+
fetchStories: z$1.number().default(3e4),
|
|
263
|
+
loadState: z$1.number().default(3e4),
|
|
264
|
+
networkRequests: z$1.number().default(3e4)
|
|
265
|
+
});
|
|
266
|
+
const BaseConfigSchema = z$1.object({
|
|
267
|
+
browser: z$1.union([BrowserSchema, z$1.array(BrowserSchema).default(["chromium"])]).default("chromium"),
|
|
268
|
+
storybookShots: StorybookShotsSchema.optional(),
|
|
269
|
+
ladleShots: LadleShotsSchema.optional(),
|
|
270
|
+
histoireShots: HistoireShotsSchema.optional(),
|
|
271
|
+
pageShots: PageShotsSchema.optional(),
|
|
272
|
+
customShots: CustomShotsSchema.optional(),
|
|
273
|
+
imagePathCurrent: z$1.string().default(".thirdeye/current/"),
|
|
274
|
+
breakpoints: z$1.array(z$1.number()).optional(),
|
|
275
|
+
shotConcurrency: z$1.number().default(5),
|
|
276
|
+
timeouts: TimeoutsSchema.default({
|
|
277
|
+
fetchStories: 3e4,
|
|
278
|
+
loadState: 3e4,
|
|
279
|
+
networkRequests: 3e4
|
|
280
|
+
}),
|
|
281
|
+
waitBeforeScreenshot: z$1.number().default(1e3),
|
|
282
|
+
waitForFirstRequest: z$1.number().default(1e3),
|
|
283
|
+
waitForLastRequest: z$1.number().default(1e3),
|
|
284
|
+
threshold: z$1.number().default(0),
|
|
285
|
+
turboSnap: z$1.boolean().optional().default(false),
|
|
286
|
+
flakynessRetries: z$1.number().default(0),
|
|
287
|
+
waitBetweenFlakynessRetries: z$1.number().default(2e3),
|
|
288
|
+
filterShot: z$1.custom().optional(),
|
|
289
|
+
shotNameGenerator: z$1.custom().optional(),
|
|
290
|
+
configureBrowser: z$1.custom().optional(),
|
|
291
|
+
beforeScreenshot: z$1.custom().optional(),
|
|
292
|
+
afterScreenshot: z$1.custom().optional(),
|
|
293
|
+
browserLaunchOptions: z$1.object({
|
|
294
|
+
chromium: z$1.custom().optional(),
|
|
295
|
+
firefox: z$1.custom().optional(),
|
|
296
|
+
webkit: z$1.custom().optional()
|
|
297
|
+
}).optional()
|
|
298
|
+
});
|
|
299
|
+
const PlatformModeConfigSchema = BaseConfigSchema.extend({
|
|
300
|
+
thirdEyePlatform: z$1.string().default("https://third-eye.gfxlabs.cloud"),
|
|
301
|
+
apiKey: z$1.string(),
|
|
302
|
+
thirdEyeProjectId: z$1.string({ error: "Required" }).default(process.env.THIRD_EYE_PROJECT_ID),
|
|
303
|
+
thirdEyeOrgId: z$1.string({ error: "Required" }).default(process.env.THIRD_EYE_ORG_ID),
|
|
304
|
+
ciBuildId: z$1.string({ error: "Required (can be set via `CI_BUILD_ID` env variable)" }).default(process.env.CI_BUILD_ID),
|
|
305
|
+
ciBuildNumber: z$1.string({ error: "Required (can be set via `CI_BUILD_NUMBER` env variable)" }).default(process.env.CI_BUILD_NUMBER),
|
|
306
|
+
repository: z$1.string({ error: "Required (can be set via `REPOSITORY` env variable)" }).default(process.env.REPOSITORY ?? process.env.GITHUB_REPOSITORY),
|
|
307
|
+
commitRefName: z$1.string({ error: "Required (can be set via `COMMIT_REF_NAME` env variable)" }).default(process.env.COMMIT_REF_NAME ?? process.env.GITHUB_HEAD_REF ?? process.env.GITHUB_REF_NAME),
|
|
308
|
+
commitHash: z$1.string({ error: "Required (can be set via `COMMIT_HASH` env variable)" }).default(process.env.COMMIT_HASH ?? process.env.GITHUB_SHA),
|
|
309
|
+
baseBranch: z$1.string().optional().default(process.env.GITHUB_BASE_REF ?? ""),
|
|
310
|
+
prNumber: z$1.coerce.number().optional().default(process.env.THIRD_EYE_PR_NUMBER ? Number(process.env.THIRD_EYE_PR_NUMBER) : void 0),
|
|
311
|
+
setPendingStatusCheck: z$1.boolean().default(false),
|
|
312
|
+
storybookStaticDir: z$1.string().optional()
|
|
313
|
+
});
|
|
314
|
+
const GenerateOnlyModeConfigSchema = BaseConfigSchema.extend({
|
|
315
|
+
generateOnly: z$1.boolean().optional(),
|
|
316
|
+
failOnDifference: z$1.boolean().optional(),
|
|
317
|
+
imagePathBaseline: z$1.string().default(".thirdeye/baseline/"),
|
|
318
|
+
imagePathDifference: z$1.string().default(".thirdeye/difference/"),
|
|
319
|
+
compareConcurrency: z$1.number().default(10),
|
|
320
|
+
compareEngine: z$1.enum(["pixelmatch", "odiff"]).default("pixelmatch"),
|
|
321
|
+
filterItemsToCheck: z$1.custom().optional()
|
|
322
|
+
});
|
|
323
|
+
z$1.union([PlatformModeConfigSchema, GenerateOnlyModeConfigSchema]);
|
|
324
|
+
z$1.union([PlatformModeConfigSchema.extend({
|
|
325
|
+
timeouts: TimeoutsSchema.partial(),
|
|
326
|
+
pageShots: PageShotsSchema.extend({ pages: z$1.array(PageScreenshotParameterSchema.partial()) })
|
|
327
|
+
}).partial(), GenerateOnlyModeConfigSchema.extend({
|
|
328
|
+
timeouts: TimeoutsSchema.partial(),
|
|
329
|
+
pageShots: PageShotsSchema.extend({ pages: z$1.array(PageScreenshotParameterSchema.partial()) })
|
|
330
|
+
}).partial()]);
|
|
331
|
+
let config;
|
|
332
|
+
const isPlatformModeConfig = (userConfig) => "apiKey" in userConfig && typeof userConfig.apiKey === "string" || "thirdEyeProjectId" in userConfig && typeof userConfig.thirdEyeProjectId === "string" || "thirdEyeOrgId" in userConfig && typeof userConfig.thirdEyeOrgId === "string";
|
|
333
|
+
const printConfigErrors = (error) => {
|
|
334
|
+
for (const issue of error.issues) log.process("error", "config", [
|
|
335
|
+
"Configuration error:",
|
|
336
|
+
` - Path: ${issue.path.join(".")}`,
|
|
337
|
+
` - Message: ${issue.message}`
|
|
338
|
+
].join("\n"));
|
|
339
|
+
};
|
|
340
|
+
const parseConfig = (userConfig) => {
|
|
341
|
+
if (isPlatformModeConfig(userConfig)) {
|
|
342
|
+
const platformCheck = PlatformModeConfigSchema.safeParse(userConfig);
|
|
343
|
+
if (platformCheck.success) return platformCheck.data;
|
|
344
|
+
printConfigErrors(platformCheck.error);
|
|
345
|
+
} else {
|
|
346
|
+
const generateOnlyCheck = GenerateOnlyModeConfigSchema.safeParse(userConfig);
|
|
347
|
+
if (generateOnlyCheck.success) return generateOnlyCheck.data;
|
|
348
|
+
printConfigErrors(generateOnlyCheck.error);
|
|
349
|
+
}
|
|
350
|
+
throw new Error("Configuration error");
|
|
351
|
+
};
|
|
352
|
+
const configDirBase = process.env.THIRD_EYE_CONFIG_DIR ?? process.cwd();
|
|
353
|
+
const configFileNameBase = path.join(path.isAbsolute(configDirBase) ? "" : process.cwd(), configDirBase, "thirdeye.config");
|
|
354
|
+
const loadProjectConfig = async () => {
|
|
355
|
+
log.process("info", "config", "Loading project config ...");
|
|
356
|
+
log.process("info", "config", "Current working directory:", process.cwd());
|
|
357
|
+
if (process.env.THIRD_EYE_CONFIG_DIR) log.process("info", "config", "Defined config directory:", process.env.THIRD_EYE_CONFIG_DIR);
|
|
358
|
+
const configExtensions = [
|
|
359
|
+
"ts",
|
|
360
|
+
"js",
|
|
361
|
+
"cjs",
|
|
362
|
+
"mjs"
|
|
363
|
+
];
|
|
364
|
+
const configExtensionsString = configExtensions.join("|");
|
|
365
|
+
log.process("info", "config", "Looking for config file:", `${configFileNameBase}.(${configExtensionsString})`);
|
|
366
|
+
const configFiles = configExtensions.map((ext) => `${configFileNameBase}.${ext}`).filter((file) => existsSync(file));
|
|
367
|
+
if (configFiles.length === 0) {
|
|
368
|
+
log.process("error", "config", `Couldn't find project config file 'thirdeye.config.(${configExtensionsString})'`);
|
|
369
|
+
process.exit(1);
|
|
370
|
+
}
|
|
371
|
+
if (configFiles.length > 1) log.process("info", "config", "✅ Found multiple config files, taking:", configFiles[0]);
|
|
372
|
+
else log.process("info", "config", "✅ Found config file:", configFiles[0]);
|
|
373
|
+
const configFile = configFiles[0];
|
|
374
|
+
try {
|
|
375
|
+
return await loadProjectConfigFile(configFile);
|
|
376
|
+
} catch {
|
|
377
|
+
log.process("error", "config", "Loading config using ESBuild failed, using fallback option");
|
|
378
|
+
try {
|
|
379
|
+
if (existsSync(`${configFileNameBase}.js`)) {
|
|
380
|
+
const imported = await import(`${configFileNameBase}.js`);
|
|
381
|
+
const projectConfig = imported?.default ?? imported?.config ?? imported;
|
|
382
|
+
log.process("info", "config", "✅ Successfully loaded configuration from:", `${configFileNameBase}.js`);
|
|
383
|
+
return projectConfig;
|
|
384
|
+
}
|
|
385
|
+
if (existsSync(`${configFileNameBase}.ts`)) {
|
|
386
|
+
const imported = await loadTSProjectConfigFile(configFile);
|
|
387
|
+
log.process("info", "config", "✅ Successfully loaded configuration from:", `${configFileNameBase}.ts`);
|
|
388
|
+
return imported;
|
|
389
|
+
}
|
|
390
|
+
log.process("error", "config", "Couldn't find project config file 'thirdeye.config.js'");
|
|
391
|
+
process.exit(1);
|
|
392
|
+
} catch (error) {
|
|
393
|
+
log.process("error", "config", `Failed to load config file: ${configFile}`);
|
|
394
|
+
log.process("error", "config", error);
|
|
395
|
+
process.exit(1);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
const configure = async ({ customProjectConfig, localDebugMode }) => {
|
|
400
|
+
if (customProjectConfig) {
|
|
401
|
+
config = parseConfig(customProjectConfig);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
let loadedProjectConfig = await loadProjectConfig();
|
|
405
|
+
if (localDebugMode) {
|
|
406
|
+
let localDebugConfig = loadedProjectConfig;
|
|
407
|
+
if (isPlatformModeConfig(loadedProjectConfig)) localDebugConfig = {
|
|
408
|
+
...loadedProjectConfig,
|
|
409
|
+
generateOnly: true,
|
|
410
|
+
thirdEyeProjectId: void 0,
|
|
411
|
+
apiKey: void 0
|
|
412
|
+
};
|
|
413
|
+
if (localDebugConfig.pageShots?.baseUrl && [
|
|
414
|
+
"http://",
|
|
415
|
+
"https://",
|
|
416
|
+
"127.0.0.1"
|
|
417
|
+
].some((urlChunk) => localDebugConfig?.pageShots?.baseUrl.includes(urlChunk))) {
|
|
418
|
+
const url = new URL(localDebugConfig.pageShots.baseUrl);
|
|
419
|
+
url.hostname = "localhost";
|
|
420
|
+
localDebugConfig.pageShots.baseUrl = url.toString();
|
|
421
|
+
}
|
|
422
|
+
loadedProjectConfig = localDebugConfig;
|
|
423
|
+
}
|
|
424
|
+
if (!loadedProjectConfig.storybookShots && !loadedProjectConfig.pageShots && !loadedProjectConfig.ladleShots && !loadedProjectConfig.histoireShots && !loadedProjectConfig.customShots) loadedProjectConfig.storybookShots = { storybookUrl: "storybook-static" };
|
|
425
|
+
config = parseConfig(loadedProjectConfig);
|
|
426
|
+
};
|
|
427
|
+
//#endregion
|
|
428
|
+
//#region src/constants.ts
|
|
429
|
+
const notSupported = "not supported";
|
|
430
|
+
const POST_HOG_API_KEY = "phc_RDNnzvANh1mNm9JKogF9UunG3Ky02YCxWP9gXScKShk";
|
|
431
|
+
//#endregion
|
|
432
|
+
//#region src/utils.ts
|
|
433
|
+
const isUpdateMode = () => {
|
|
434
|
+
const args = yargs(hideBin(process.argv)).parseSync();
|
|
435
|
+
return args._.includes("update") || args.m === "update" || process.env.THIRD_EYE_MODE === "update";
|
|
436
|
+
};
|
|
437
|
+
const isSitemapPageGenMode = () => {
|
|
438
|
+
return yargs(hideBin(process.argv)).parseSync()._.includes("page-sitemap-gen") || process.env.THIRD_EYE_MODE === "page-sitemap-gen";
|
|
439
|
+
};
|
|
440
|
+
const isDockerMode = () => {
|
|
441
|
+
return yargs(hideBin(process.argv)).parseSync()._.includes("docker") || process.env.THIRD_EYE_DOCKER === "true";
|
|
442
|
+
};
|
|
443
|
+
const isLocalDebugMode = () => {
|
|
444
|
+
return yargs(hideBin(process.argv)).parseSync()._.includes("local") || process.env.THIRD_EYE_LOCAL === "true";
|
|
445
|
+
};
|
|
446
|
+
const shallGenerateMeta = () => {
|
|
447
|
+
return yargs(hideBin(process.argv)).parseSync()._.includes("meta") || process.env.THIRD_EYE_GENERATE_META === "true";
|
|
448
|
+
};
|
|
449
|
+
const createShotsFolders = () => {
|
|
450
|
+
const paths = isPlatformModeConfig(config) ? [config.imagePathCurrent] : [
|
|
451
|
+
config.imagePathBaseline,
|
|
452
|
+
config.imagePathCurrent,
|
|
453
|
+
config.imagePathDifference
|
|
454
|
+
];
|
|
455
|
+
for (const path of paths) if (!existsSync(path)) mkdirSync(path, { recursive: true });
|
|
456
|
+
const ignoreFile = normalize(join(config.imagePathCurrent, "..", ".gitignore"));
|
|
457
|
+
if (!existsSync(ignoreFile)) writeFileSync(ignoreFile, "current\ndifference\n");
|
|
458
|
+
};
|
|
459
|
+
const sleep = async (ms) => new Promise((resolve) => {
|
|
460
|
+
setTimeout(resolve, ms);
|
|
461
|
+
});
|
|
462
|
+
const removeFilesInFolder = (path, excludePaths) => {
|
|
463
|
+
const filesPathsIgnoringExclude = readdirSync(path).map((file) => join(path, file)).filter((filePath) => !excludePaths?.includes(filePath));
|
|
464
|
+
log.process("info", "general", `Removing ${filesPathsIgnoringExclude.length} files from ${path}`);
|
|
465
|
+
for (const filePath of filesPathsIgnoringExclude) unlinkSync(filePath);
|
|
466
|
+
};
|
|
467
|
+
const convertBrowser = (browserKey) => {
|
|
468
|
+
switch (browserKey) {
|
|
469
|
+
case "chromium": return chromium;
|
|
470
|
+
case "firefox": return firefox;
|
|
471
|
+
case "webkit": return webkit;
|
|
472
|
+
default: return chromium;
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
const getBrowser = () => {
|
|
476
|
+
if (Array.isArray(config.browser)) return convertBrowser(config.browser[0]);
|
|
477
|
+
return convertBrowser(config.browser);
|
|
478
|
+
};
|
|
479
|
+
const getBrowsers = () => {
|
|
480
|
+
if (!Array.isArray(config.browser) || config.browser.length === 0) return [getBrowser()];
|
|
481
|
+
const browsers = config.browser.map((key) => convertBrowser(key));
|
|
482
|
+
return [...new Set(browsers)];
|
|
483
|
+
};
|
|
484
|
+
const getVersion = () => {
|
|
485
|
+
try {
|
|
486
|
+
const packageJsonPath = new URL("../package.json", import.meta.url);
|
|
487
|
+
return JSON.parse(readFileSync(packageJsonPath, "utf8")).version;
|
|
488
|
+
} catch {}
|
|
489
|
+
};
|
|
490
|
+
const fileNameWithoutExtension = (fileName) => {
|
|
491
|
+
return fileName.split(".").slice(0, -1).join(".");
|
|
492
|
+
};
|
|
493
|
+
const readDirIntoShotItems = (path) => {
|
|
494
|
+
return readdirSync(path).filter((name) => name.endsWith(".png")).map((fileNameWithExt) => {
|
|
495
|
+
const fileName = fileNameWithoutExtension(fileNameWithExt);
|
|
496
|
+
return {
|
|
497
|
+
id: fileName,
|
|
498
|
+
shotName: fileName,
|
|
499
|
+
shotMode: "custom",
|
|
500
|
+
filePathBaseline: isPlatformModeConfig(config) ? notSupported : join(config.imagePathBaseline, fileNameWithExt),
|
|
501
|
+
filePathCurrent: join(path, fileNameWithExt),
|
|
502
|
+
filePathDifference: isPlatformModeConfig(config) ? notSupported : join(config.imagePathDifference, fileNameWithExt),
|
|
503
|
+
url: fileName,
|
|
504
|
+
threshold: config.threshold
|
|
505
|
+
};
|
|
506
|
+
});
|
|
507
|
+
};
|
|
508
|
+
const sendTelemetryData = async (properties) => {
|
|
509
|
+
const client = new PostHog(POST_HOG_API_KEY);
|
|
510
|
+
const id = randomUUID();
|
|
511
|
+
try {
|
|
512
|
+
log.process("info", "general", "Sending anonymized telemetry data.");
|
|
513
|
+
const version = getVersion();
|
|
514
|
+
const modes = [];
|
|
515
|
+
if (config.storybookShots) modes.push("storybook");
|
|
516
|
+
if (config.ladleShots) modes.push("ladle");
|
|
517
|
+
if (config.pageShots) modes.push("pages");
|
|
518
|
+
if (config.customShots) modes.push("custom");
|
|
519
|
+
if (properties.error) client.capture({
|
|
520
|
+
distinctId: id,
|
|
521
|
+
event: "third-eye-error",
|
|
522
|
+
properties: { ...properties }
|
|
523
|
+
});
|
|
524
|
+
else client.capture({
|
|
525
|
+
distinctId: id,
|
|
526
|
+
event: "third-eye-run",
|
|
527
|
+
properties: {
|
|
528
|
+
...properties,
|
|
529
|
+
version,
|
|
530
|
+
modes
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
await client.shutdown();
|
|
534
|
+
} catch (error) {
|
|
535
|
+
log.process("error", "general", "Error when sending telemetry data", error);
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
const parseHrtimeToSeconds = (hrtime) => {
|
|
539
|
+
return (hrtime[0] + hrtime[1] / 1e9).toFixed(3);
|
|
540
|
+
};
|
|
541
|
+
const exitProcess = async (properties) => {
|
|
542
|
+
if (process.env.THIRD_EYE_DISABLE_TELEMETRY === "1") process.exit(properties.exitCode ?? 1);
|
|
543
|
+
else await sendTelemetryData(properties).finally(() => {
|
|
544
|
+
process.exit(properties.exitCode ?? 1);
|
|
545
|
+
});
|
|
546
|
+
};
|
|
547
|
+
const hashBuffer = (buffer) => {
|
|
548
|
+
const hashSum = crypto.createHash("sha256");
|
|
549
|
+
hashSum.update(buffer);
|
|
550
|
+
return hashSum.digest("hex");
|
|
551
|
+
};
|
|
552
|
+
const hashFile = (filePath) => {
|
|
553
|
+
return hashBuffer(readFileSync(filePath));
|
|
554
|
+
};
|
|
555
|
+
const featureNotSupported = (feature) => {
|
|
556
|
+
log.process("error", "general", `${feature} is not supported in this configuration mode`);
|
|
557
|
+
process.exit(1);
|
|
558
|
+
};
|
|
559
|
+
const launchBrowser = async (_browser) => {
|
|
560
|
+
const browserType = _browser ?? getBrowser();
|
|
561
|
+
const browserName = browserType.name();
|
|
562
|
+
return browserType.launch(config.browserLaunchOptions?.[browserName]);
|
|
563
|
+
};
|
|
564
|
+
//#endregion
|
|
565
|
+
//#region src/compare/utils.ts
|
|
566
|
+
const resizeImage = (originalImage, width, height) => {
|
|
567
|
+
const newImage = new PNG({
|
|
568
|
+
width,
|
|
569
|
+
height,
|
|
570
|
+
fill: true,
|
|
571
|
+
inputHasAlpha: true
|
|
572
|
+
});
|
|
573
|
+
for (let x = 0; x < width; x++) for (let y = 0; y < height; y++) {
|
|
574
|
+
const index = (width * y + x << 2) + 3;
|
|
575
|
+
newImage.data[index] = 64;
|
|
576
|
+
}
|
|
577
|
+
PNG.bitblt(originalImage, newImage, 0, 0, originalImage.width, originalImage.height, 0, 0);
|
|
578
|
+
return newImage;
|
|
579
|
+
};
|
|
580
|
+
//#endregion
|
|
581
|
+
//#region src/compare/compare.ts
|
|
582
|
+
const checkThreshold = (threshold, pixelsTotal, pixelDifference) => {
|
|
583
|
+
if (threshold < 1) return pixelDifference <= pixelsTotal * threshold;
|
|
584
|
+
return pixelDifference <= threshold;
|
|
585
|
+
};
|
|
586
|
+
const compareImagesViaPixelmatch = async (threshold, baselineShotPath, currentShotPath, differenceShotPath) => {
|
|
587
|
+
const baselineImageBuffer = readFileSync(baselineShotPath);
|
|
588
|
+
const currentImageBuffer = readFileSync(currentShotPath);
|
|
589
|
+
if (baselineImageBuffer.equals(currentImageBuffer)) return {
|
|
590
|
+
pixelDifference: 0,
|
|
591
|
+
pixelDifferencePercentage: 0,
|
|
592
|
+
isWithinThreshold: true
|
|
593
|
+
};
|
|
594
|
+
let baselineImage = PNG.sync.read(baselineImageBuffer);
|
|
595
|
+
let currentImage = PNG.sync.read(currentImageBuffer);
|
|
596
|
+
const maxWidth = Math.max(baselineImage.width || 100, currentImage.width || 100);
|
|
597
|
+
const maxHeight = Math.max(baselineImage.height || 100, currentImage.height || 100);
|
|
598
|
+
if (baselineImage.width !== currentImage.width || baselineImage.height !== currentImage.height) {
|
|
599
|
+
baselineImage = resizeImage(baselineImage, maxWidth, maxHeight);
|
|
600
|
+
currentImage = resizeImage(currentImage, maxWidth, maxHeight);
|
|
601
|
+
}
|
|
602
|
+
const differenceImage = new PNG({
|
|
603
|
+
width: maxWidth,
|
|
604
|
+
height: maxHeight
|
|
605
|
+
});
|
|
606
|
+
const pixelDifference = pixelmatch(baselineImage.data, currentImage.data, differenceImage.data, maxWidth, maxHeight, { threshold: 0 });
|
|
607
|
+
const pixelsTotal = baselineImage.width * baselineImage.height;
|
|
608
|
+
if (pixelDifference > 0 && differenceShotPath) {
|
|
609
|
+
const isWithinThreshold = checkThreshold(threshold, pixelsTotal, pixelDifference);
|
|
610
|
+
if (!isWithinThreshold) writeFileSync(differenceShotPath, PNG.sync.write(differenceImage));
|
|
611
|
+
return {
|
|
612
|
+
pixelDifference,
|
|
613
|
+
pixelDifferencePercentage: pixelDifference / pixelsTotal,
|
|
614
|
+
isWithinThreshold
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
return {
|
|
618
|
+
pixelDifference,
|
|
619
|
+
pixelDifferencePercentage: pixelDifference / pixelsTotal,
|
|
620
|
+
isWithinThreshold: true
|
|
621
|
+
};
|
|
622
|
+
};
|
|
623
|
+
const compareImagesViaOdiff = async (threshold, baselineShotPath, currentShotPath, differenceShotPath) => {
|
|
624
|
+
const result = await compare(baselineShotPath, currentShotPath, differenceShotPath, { failOnLayoutDiff: false });
|
|
625
|
+
if (result.match) return {
|
|
626
|
+
pixelDifference: 0,
|
|
627
|
+
pixelDifferencePercentage: 0,
|
|
628
|
+
isWithinThreshold: true
|
|
629
|
+
};
|
|
630
|
+
if (result.reason === "pixel-diff") {
|
|
631
|
+
let isWithinThreshold = true;
|
|
632
|
+
const pixelDifferencePercentage = Number(result.diffPercentage / 100);
|
|
633
|
+
if (threshold < 1) isWithinThreshold = pixelDifferencePercentage <= threshold;
|
|
634
|
+
else isWithinThreshold = result.diffCount <= threshold;
|
|
635
|
+
return {
|
|
636
|
+
pixelDifference: Number(result.diffCount),
|
|
637
|
+
pixelDifferencePercentage,
|
|
638
|
+
isWithinThreshold
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
throw new Error("Couldn't compare images");
|
|
642
|
+
};
|
|
643
|
+
const compareImages = async (threshold, baselineShotPath, currentShotPath, differenceShotPath) => {
|
|
644
|
+
if (isPlatformModeConfig(config)) return featureNotSupported("compareImages()");
|
|
645
|
+
if (config.compareEngine === "pixelmatch") return compareImagesViaPixelmatch(threshold, baselineShotPath, currentShotPath, differenceShotPath);
|
|
646
|
+
return compareImagesViaOdiff(threshold, baselineShotPath, currentShotPath, differenceShotPath);
|
|
647
|
+
};
|
|
648
|
+
//#endregion
|
|
649
|
+
//#region src/checkDifferences.ts
|
|
650
|
+
const checkDifferences = async (shotItems) => {
|
|
651
|
+
if (isPlatformModeConfig(config)) return featureNotSupported("checkDifferences()");
|
|
652
|
+
log.process("info", "general", `Comparing ${shotItems.length} screenshots using '${config.compareEngine}' as compare engine`);
|
|
653
|
+
const total = shotItems.length;
|
|
654
|
+
const noBaselinesItems = [];
|
|
655
|
+
const aboveThresholdDifferenceItems = [];
|
|
656
|
+
const comparisonResults = {};
|
|
657
|
+
await mapLimit(shotItems.entries(), config.compareConcurrency, async (item) => {
|
|
658
|
+
const [index, shotItem] = item;
|
|
659
|
+
const logger = (message) => {
|
|
660
|
+
log.item({
|
|
661
|
+
shotMode: shotItem.shotMode,
|
|
662
|
+
uniqueItemId: shotItem.shotName,
|
|
663
|
+
itemIndex: index,
|
|
664
|
+
totalItems: total
|
|
665
|
+
}).process("info", "general", message);
|
|
666
|
+
};
|
|
667
|
+
logger(`Comparing '${shotItem.id}'`);
|
|
668
|
+
if (!existsSync(shotItem.filePathBaseline)) {
|
|
669
|
+
logger("Baseline image missing. Will be treated as addition.");
|
|
670
|
+
noBaselinesItems.push(shotItem);
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
if (!existsSync(shotItem.filePathCurrent)) throw new Error(`Error: Missing current image: ${shotItem.filePathCurrent}`);
|
|
674
|
+
const { pixelDifference, pixelDifferencePercentage, isWithinThreshold } = await compareImages(shotItem.threshold, shotItem.filePathBaseline, shotItem.filePathCurrent, shotItem.filePathDifference);
|
|
675
|
+
if (shallGenerateMeta()) comparisonResults[shotItem.id] = {
|
|
676
|
+
pixelDifference,
|
|
677
|
+
pixelDifferencePercentage,
|
|
678
|
+
isWithinThreshold
|
|
679
|
+
};
|
|
680
|
+
if (pixelDifference > 0) {
|
|
681
|
+
const percentage = (pixelDifferencePercentage * 100).toFixed(2);
|
|
682
|
+
if (isWithinThreshold) logger(`Difference of ${pixelDifference} pixels (${percentage}%) found but within threshold.`);
|
|
683
|
+
else {
|
|
684
|
+
aboveThresholdDifferenceItems.push(shotItem);
|
|
685
|
+
logger(`Difference of ${pixelDifference} pixels (${percentage}%) found. Difference image saved to: ${shotItem.filePathDifference}`);
|
|
686
|
+
}
|
|
687
|
+
} else logger("No difference found.");
|
|
688
|
+
});
|
|
689
|
+
if (shallGenerateMeta()) {
|
|
690
|
+
log.process("info", "general", `Writing meta file with ${Object.entries(comparisonResults).length} items.`);
|
|
691
|
+
writeFileSync(`${path.join(config.imagePathCurrent, "meta")}.json`, JSON.stringify(comparisonResults, null, 2));
|
|
692
|
+
}
|
|
693
|
+
log.process("info", "general", "Comparison done!");
|
|
694
|
+
return {
|
|
695
|
+
aboveThresholdDifferenceItems,
|
|
696
|
+
noBaselinesItems
|
|
697
|
+
};
|
|
698
|
+
};
|
|
699
|
+
//#endregion
|
|
700
|
+
//#region src/shots/utils.ts
|
|
701
|
+
const checkIgnoreUrls = (url, ignoreUrls) => {
|
|
702
|
+
for (const ignoreUrl of ignoreUrls) if (url.includes(ignoreUrl)) return true;
|
|
703
|
+
return false;
|
|
704
|
+
};
|
|
705
|
+
const waitForNetworkRequests = async ({ page, logger, timeout = config.timeouts.networkRequests, waitForFirstRequest = config.waitForFirstRequest, waitForLastRequest = config.waitForLastRequest, ignoreUrls = [] }) => new Promise((resolve, reject) => {
|
|
706
|
+
let requestCounter = 0;
|
|
707
|
+
const requests = /* @__PURE__ */ new Set();
|
|
708
|
+
let lastRequestTimeoutId;
|
|
709
|
+
const timeoutId = setTimeout(() => {
|
|
710
|
+
const pendingUrls = [...requests].map((request) => request.url());
|
|
711
|
+
logger.process("info", "network", "Pending requests:", pendingUrls);
|
|
712
|
+
cleanup();
|
|
713
|
+
reject(/* @__PURE__ */ new Error("Timeout"));
|
|
714
|
+
}, timeout);
|
|
715
|
+
const firstRequestTimeoutId = setTimeout(() => {
|
|
716
|
+
cleanup();
|
|
717
|
+
resolve(true);
|
|
718
|
+
}, waitForFirstRequest);
|
|
719
|
+
const onRequest = (request) => {
|
|
720
|
+
if (!checkIgnoreUrls(request.url(), ignoreUrls)) {
|
|
721
|
+
clearTimeout(firstRequestTimeoutId);
|
|
722
|
+
clearTimeout(lastRequestTimeoutId);
|
|
723
|
+
requestCounter++;
|
|
724
|
+
requests.add(request);
|
|
725
|
+
logger.browser("info", "network", `+ ${request.url()}`);
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
const onRequestFinished = async (request) => {
|
|
729
|
+
clearTimeout(lastRequestTimeoutId);
|
|
730
|
+
if (!checkIgnoreUrls(request.url(), ignoreUrls)) {
|
|
731
|
+
const failure = request.failure();
|
|
732
|
+
const response = await request.response();
|
|
733
|
+
requestCounter--;
|
|
734
|
+
requests.delete(request);
|
|
735
|
+
const statusText = failure ? failure.errorText : `${response?.status() ?? "unknown"} ${response?.statusText() ?? "unknown"}`;
|
|
736
|
+
logger.browser("info", "network", `- ${request.url()} [${statusText}]`);
|
|
737
|
+
}
|
|
738
|
+
lastRequestTimeoutId = setTimeout(() => {
|
|
739
|
+
if (requestCounter <= 0) {
|
|
740
|
+
cleanup();
|
|
741
|
+
resolve(true);
|
|
742
|
+
}
|
|
743
|
+
}, waitForLastRequest);
|
|
744
|
+
};
|
|
745
|
+
function cleanup() {
|
|
746
|
+
clearTimeout(timeoutId);
|
|
747
|
+
clearTimeout(firstRequestTimeoutId);
|
|
748
|
+
clearTimeout(lastRequestTimeoutId);
|
|
749
|
+
page.removeListener("request", onRequest);
|
|
750
|
+
page.removeListener("requestfinished", onRequestFinished);
|
|
751
|
+
page.removeListener("requestfailed", onRequestFinished);
|
|
752
|
+
}
|
|
753
|
+
page.on("request", onRequest);
|
|
754
|
+
page.on("requestfinished", onRequestFinished);
|
|
755
|
+
page.on("requestfailed", onRequestFinished);
|
|
756
|
+
});
|
|
757
|
+
const resizeViewportToFullscreen = async ({ page }) => {
|
|
758
|
+
const viewport = await page.evaluate(async () => new Promise((resolve) => {
|
|
759
|
+
const { body } = document;
|
|
760
|
+
const html = document.documentElement;
|
|
761
|
+
resolve({
|
|
762
|
+
height: Math.max(body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight),
|
|
763
|
+
width: Math.max(body.scrollWidth, body.offsetWidth, html.clientWidth, html.scrollWidth, html.offsetWidth)
|
|
764
|
+
});
|
|
765
|
+
}));
|
|
766
|
+
await page.setViewportSize({
|
|
767
|
+
width: Math.max(page.viewportSize()?.width ?? 800, viewport.width),
|
|
768
|
+
height: viewport.height
|
|
769
|
+
});
|
|
770
|
+
};
|
|
771
|
+
const selectBreakpoints = (topLevelBreakpoints, modeBreakpoints, shotBreakpoints) => {
|
|
772
|
+
if (shotBreakpoints && shotBreakpoints.length > 0) return shotBreakpoints;
|
|
773
|
+
if (modeBreakpoints && modeBreakpoints.length > 0) return modeBreakpoints;
|
|
774
|
+
return topLevelBreakpoints ?? [];
|
|
775
|
+
};
|
|
776
|
+
const generateLabel = ({ breakpoint, browser }) => {
|
|
777
|
+
const labels = [breakpoint && breakpoint > 0 ? `w${breakpoint}px` : "", browser?.name() ?? ""].filter(Boolean);
|
|
778
|
+
if (labels.length === 0) return "";
|
|
779
|
+
return `__[${labels.join("|")}]`;
|
|
780
|
+
};
|
|
781
|
+
//#endregion
|
|
782
|
+
//#region src/crawler/ladleScreenshots.ts
|
|
783
|
+
const generateLadleShotItems = (baseUrl, isLocalServer, ladleStories, mask, modeBreakpoints, browser) => {
|
|
784
|
+
const ladleUrl = isLocalServer ? `${baseUrl}/index.html` : baseUrl;
|
|
785
|
+
return ladleStories.filter((story) => story.parameters?.thirdeye?.disable !== true).filter((story) => config.filterShot ? config.filterShot({
|
|
786
|
+
...story,
|
|
787
|
+
shotMode: "ladle"
|
|
788
|
+
}) : true).flatMap((ladleStory) => {
|
|
789
|
+
const shotName = config.shotNameGenerator?.({
|
|
790
|
+
...ladleStory,
|
|
791
|
+
shotMode: "ladle"
|
|
792
|
+
}) ?? ladleStory.id;
|
|
793
|
+
let label = generateLabel({ browser });
|
|
794
|
+
let fileNameWithExt = `${shotName}${label}.png`;
|
|
795
|
+
const shotItem = {
|
|
796
|
+
shotMode: "ladle",
|
|
797
|
+
id: `${ladleStory.story}${label}`,
|
|
798
|
+
shotName: `${shotName}${label}`,
|
|
799
|
+
importPath: ladleStory.importPath,
|
|
800
|
+
url: `${ladleUrl}?story=${ladleStory.story}&mode=preview`,
|
|
801
|
+
filePathBaseline: isPlatformModeConfig(config) ? notSupported : path.join(config.imagePathBaseline, fileNameWithExt),
|
|
802
|
+
filePathCurrent: path.join(config.imagePathCurrent, fileNameWithExt),
|
|
803
|
+
filePathDifference: isPlatformModeConfig(config) ? notSupported : path.join(config.imagePathDifference, fileNameWithExt),
|
|
804
|
+
threshold: ladleStory.parameters?.thirdeye?.threshold ?? config.threshold,
|
|
805
|
+
waitBeforeScreenshot: ladleStory.parameters?.thirdeye?.waitBeforeScreenshot ?? config.waitBeforeScreenshot,
|
|
806
|
+
mask: [...mask ?? [], ...ladleStory.parameters?.thirdeye?.mask ?? []],
|
|
807
|
+
elementLocator: ladleStory.parameters?.thirdeye?.elementLocator ?? config?.storybookShots?.elementLocator ?? "",
|
|
808
|
+
waitForSelector: config?.ladleShots?.waitForSelector ?? "[data-storyloaded]",
|
|
809
|
+
componentPath: ladleStory.kind,
|
|
810
|
+
storyName: ladleStory.story
|
|
811
|
+
};
|
|
812
|
+
const breakpoints = selectBreakpoints(config.breakpoints, modeBreakpoints, ladleStory.parameters?.thirdeye?.breakpoints);
|
|
813
|
+
if (breakpoints.length === 0) return [shotItem];
|
|
814
|
+
return breakpoints.map((breakpoint) => {
|
|
815
|
+
label = generateLabel({
|
|
816
|
+
breakpoint,
|
|
817
|
+
browser
|
|
818
|
+
});
|
|
819
|
+
fileNameWithExt = `${shotName}${label}.png`;
|
|
820
|
+
return {
|
|
821
|
+
...shotItem,
|
|
822
|
+
id: `${ladleStory.story}${label}`,
|
|
823
|
+
shotName: `${ladleStory.story}${label}`,
|
|
824
|
+
breakpoint,
|
|
825
|
+
breakpointGroup: ladleStory.story,
|
|
826
|
+
url: `${ladleUrl}?story=${ladleStory.story}&mode=preview&width=${breakpoint}`,
|
|
827
|
+
filePathBaseline: isPlatformModeConfig(config) ? notSupported : path.join(config.imagePathBaseline, fileNameWithExt),
|
|
828
|
+
filePathCurrent: path.join(config.imagePathCurrent, fileNameWithExt),
|
|
829
|
+
filePathDifference: isPlatformModeConfig(config) ? notSupported : path.join(config.imagePathDifference, fileNameWithExt),
|
|
830
|
+
viewport: { width: breakpoint }
|
|
831
|
+
};
|
|
832
|
+
});
|
|
833
|
+
});
|
|
834
|
+
};
|
|
835
|
+
const collectLadleStories = async (ladleUrl) => {
|
|
836
|
+
const { data } = await axios.get(`${ladleUrl}/meta.json`);
|
|
837
|
+
const collection = [];
|
|
838
|
+
for (const [key, storyConfig] of Object.entries(data.stories)) collection.push({
|
|
839
|
+
id: key,
|
|
840
|
+
story: key,
|
|
841
|
+
kind: key,
|
|
842
|
+
importPath: storyConfig.filePath,
|
|
843
|
+
parameters: storyConfig.meta
|
|
844
|
+
});
|
|
845
|
+
return collection;
|
|
846
|
+
};
|
|
847
|
+
//#endregion
|
|
848
|
+
//#region src/crawler/storybook.ts
|
|
849
|
+
const kebabCase = (str) => (str ?? "").replace(/([a-z\d])([A-Z])/g, "$1-$2").replace(/[\s_/]+/g, "-").toLowerCase();
|
|
850
|
+
const getStoryBookUrl = (url) => {
|
|
851
|
+
if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("file://")) return url;
|
|
852
|
+
if (url.startsWith("/")) return `file://${url}`;
|
|
853
|
+
return `file://${path.normalize(path.join(process.cwd(), url))}`;
|
|
854
|
+
};
|
|
855
|
+
const getIframeUrl = (url) => url.endsWith("/") ? `${url}iframe.html` : `${url}/iframe.html`;
|
|
856
|
+
const collectStoriesViaWindowApi = async (context, url, isIframeUrl) => {
|
|
857
|
+
const page = await context.newPage();
|
|
858
|
+
const iframeUrl = isIframeUrl ? getStoryBookUrl(url) : getIframeUrl(getStoryBookUrl(url));
|
|
859
|
+
await page.goto(iframeUrl);
|
|
860
|
+
await page.waitForFunction(() => window.__STORYBOOK_PREVIEW__, null, { timeout: config.timeouts.fetchStories });
|
|
861
|
+
if (await page.evaluate(async () => {
|
|
862
|
+
const { __STORYBOOK_PREVIEW__: api } = window;
|
|
863
|
+
return api.ready !== void 0;
|
|
864
|
+
})) await page.evaluate(async () => {
|
|
865
|
+
const { __STORYBOOK_PREVIEW__: api } = window;
|
|
866
|
+
if (api.ready) await api.ready();
|
|
867
|
+
});
|
|
868
|
+
else {
|
|
869
|
+
await page.waitForFunction(() => window.__STORYBOOK_CLIENT_API__, null, { timeout: config.timeouts.fetchStories });
|
|
870
|
+
await page.evaluate(async () => {
|
|
871
|
+
const { __STORYBOOK_CLIENT_API__: api } = window;
|
|
872
|
+
if (api.storyStore) await api.storyStore.cacheAllCSFFiles?.();
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
return await page.evaluate(async () => {
|
|
876
|
+
const parseParameters = (parameters, level = 0) => {
|
|
877
|
+
if (level > 10) return "UNSUPPORTED_DEPTH";
|
|
878
|
+
if (Array.isArray(parameters)) return parameters.map((value) => parseParameters(value, level + 1));
|
|
879
|
+
if (typeof parameters === "string" || typeof parameters === "number" || typeof parameters === "boolean" || parameters === void 0 || typeof parameters === "function" || parameters instanceof RegExp || parameters instanceof Date || parameters === null) return parameters;
|
|
880
|
+
if (typeof parameters === "object" && parameters !== null) return Object.keys(parameters).reduce((acc, key) => {
|
|
881
|
+
acc[key] = parseParameters(parameters[key], level + 1);
|
|
882
|
+
return acc;
|
|
883
|
+
}, {});
|
|
884
|
+
return "UNSUPPORTED_TYPE";
|
|
885
|
+
};
|
|
886
|
+
const mapStories = (stories) => stories.map((story) => {
|
|
887
|
+
const parameters = parseParameters(story.parameters);
|
|
888
|
+
return {
|
|
889
|
+
id: story.id,
|
|
890
|
+
kind: story.kind,
|
|
891
|
+
story: story.story,
|
|
892
|
+
importPath: parameters?.fileName,
|
|
893
|
+
parameters
|
|
894
|
+
};
|
|
895
|
+
});
|
|
896
|
+
const { __STORYBOOK_PREVIEW__: previewApi, __STORYBOOK_CLIENT_API__: clientApi } = window;
|
|
897
|
+
let stories = [];
|
|
898
|
+
if (previewApi.extract) {
|
|
899
|
+
const items = await previewApi.extract();
|
|
900
|
+
stories = mapStories(Object.values(items));
|
|
901
|
+
} else if (clientApi.raw) stories = mapStories(clientApi.raw());
|
|
902
|
+
return { stories };
|
|
903
|
+
});
|
|
904
|
+
};
|
|
905
|
+
const collectStoriesViaStoriesJson = async (context, url) => {
|
|
906
|
+
const indexJsonUrl = url.endsWith("/") ? `${url}index.json` : `${url}/index.json`;
|
|
907
|
+
const storiesJsonUrl = url.endsWith("/") ? `${url}stories.json` : `${url}/stories.json`;
|
|
908
|
+
const tryLoadJson = async (jsonUrl) => {
|
|
909
|
+
if (jsonUrl.startsWith("file://")) try {
|
|
910
|
+
const file = readFileSync(jsonUrl.slice(7));
|
|
911
|
+
return JSON.parse(file.toString());
|
|
912
|
+
} catch {
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
try {
|
|
916
|
+
const result = await context.request.get(jsonUrl);
|
|
917
|
+
if (result.status() !== 200) return null;
|
|
918
|
+
return await result.json();
|
|
919
|
+
} catch {
|
|
920
|
+
return null;
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
for (const jsonUrl of [indexJsonUrl, storiesJsonUrl]) {
|
|
924
|
+
const json = await tryLoadJson(jsonUrl);
|
|
925
|
+
if (!json) continue;
|
|
926
|
+
const entries = json.entries ?? json.stories;
|
|
927
|
+
if (typeof entries === "object" && entries !== null) return { stories: Object.values(entries).filter((entry) => entry.type !== "docs").map((entry) => ({
|
|
928
|
+
...entry,
|
|
929
|
+
kind: entry.kind ?? entry.title ?? "",
|
|
930
|
+
story: entry.story ?? entry.name ?? ""
|
|
931
|
+
})) };
|
|
932
|
+
}
|
|
933
|
+
throw new Error(`Cannot load stories from ${indexJsonUrl} or ${storiesJsonUrl}`);
|
|
934
|
+
};
|
|
935
|
+
const collectStories = async (url) => {
|
|
936
|
+
const browser = await launchBrowser();
|
|
937
|
+
const context = await browser.newContext();
|
|
938
|
+
try {
|
|
939
|
+
log.process("info", "general", "Trying to collect stories via window object");
|
|
940
|
+
const result = await collectStoriesViaWindowApi(context, url);
|
|
941
|
+
await browser.close();
|
|
942
|
+
return result;
|
|
943
|
+
} catch (error) {
|
|
944
|
+
log.process("info", "general", "Fallback to /stories.json");
|
|
945
|
+
log.process("error", "general", error);
|
|
946
|
+
}
|
|
947
|
+
try {
|
|
948
|
+
const result = await collectStoriesViaStoriesJson(context, url);
|
|
949
|
+
await browser.close();
|
|
950
|
+
return result;
|
|
951
|
+
} catch (error) {
|
|
952
|
+
await browser.close();
|
|
953
|
+
throw error;
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
const generateBrowserConfig$1 = (story) => {
|
|
957
|
+
const browserConfig = config.configureBrowser?.({
|
|
958
|
+
...story,
|
|
959
|
+
shotMode: "storybook"
|
|
960
|
+
});
|
|
961
|
+
if (story.parameters?.viewport && browserConfig) {
|
|
962
|
+
browserConfig.viewport ??= {
|
|
963
|
+
width: 1280,
|
|
964
|
+
height: 720
|
|
965
|
+
};
|
|
966
|
+
browserConfig.viewport = {
|
|
967
|
+
...browserConfig.viewport,
|
|
968
|
+
...story.parameters.viewport
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
return browserConfig;
|
|
972
|
+
};
|
|
973
|
+
const generateStoryUrl = (iframeUrl, storyId, args, breakpoint) => {
|
|
974
|
+
let url = `${iframeUrl}?id=${storyId}&viewMode=story`;
|
|
975
|
+
if (args) {
|
|
976
|
+
const argsString = Object.entries(args).map(([key, value]) => `${key}:${value}`).join(";");
|
|
977
|
+
url += `&args=${argsString}`;
|
|
978
|
+
}
|
|
979
|
+
if (breakpoint !== void 0) url += `&width=${breakpoint}`;
|
|
980
|
+
return url;
|
|
981
|
+
};
|
|
982
|
+
const generateFilename = (kind, story, prefix, suffix) => {
|
|
983
|
+
return [
|
|
984
|
+
prefix,
|
|
985
|
+
kebabCase(kind),
|
|
986
|
+
kebabCase(story),
|
|
987
|
+
kebabCase(suffix)
|
|
988
|
+
].filter(Boolean).join("--");
|
|
989
|
+
};
|
|
990
|
+
const generateStorybookShotItems = (baseUrl, stories, mask, modeBreakpoints, browser) => {
|
|
991
|
+
const iframeUrl = getIframeUrl(getStoryBookUrl(baseUrl));
|
|
992
|
+
return stories.filter((story) => story.parameters?.thirdeye?.disable !== true).filter((story) => story.parameters?.storyshots?.disable !== true).filter((story) => config.filterShot ? config.filterShot({
|
|
993
|
+
...story,
|
|
994
|
+
shotMode: "storybook"
|
|
995
|
+
}) : true).flatMap((story) => {
|
|
996
|
+
const shotName = config.shotNameGenerator?.({
|
|
997
|
+
...story,
|
|
998
|
+
shotMode: "storybook"
|
|
999
|
+
}) ?? generateFilename(story.kind, story.story);
|
|
1000
|
+
let label = generateLabel({ browser });
|
|
1001
|
+
let fileNameWithExt = `${shotName}${label}.png`;
|
|
1002
|
+
const baseShotItem = {
|
|
1003
|
+
shotMode: "storybook",
|
|
1004
|
+
id: `${story.id}${label}`,
|
|
1005
|
+
shotName: `${shotName}${label}`,
|
|
1006
|
+
importPath: story.importPath,
|
|
1007
|
+
url: generateStoryUrl(iframeUrl, story.id, story.parameters?.thirdeye?.args),
|
|
1008
|
+
filePathBaseline: isPlatformModeConfig(config) ? notSupported : path.join(config.imagePathBaseline, fileNameWithExt),
|
|
1009
|
+
filePathCurrent: path.join(config.imagePathCurrent, fileNameWithExt),
|
|
1010
|
+
filePathDifference: isPlatformModeConfig(config) ? notSupported : path.join(config.imagePathDifference, fileNameWithExt),
|
|
1011
|
+
browserConfig: generateBrowserConfig$1(story),
|
|
1012
|
+
threshold: story.parameters?.thirdeye?.threshold ?? config.threshold,
|
|
1013
|
+
waitBeforeScreenshot: story.parameters?.thirdeye?.waitBeforeScreenshot ?? config.waitBeforeScreenshot,
|
|
1014
|
+
mask: [...mask ?? [], ...story.parameters?.thirdeye?.mask ?? []],
|
|
1015
|
+
elementLocator: story.parameters?.thirdeye?.elementLocator ?? config?.storybookShots?.elementLocator ?? "",
|
|
1016
|
+
waitForSelector: config?.storybookShots?.waitForSelector,
|
|
1017
|
+
componentPath: story.kind,
|
|
1018
|
+
storyName: story.story,
|
|
1019
|
+
storyId: story.id,
|
|
1020
|
+
storyArgs: story.parameters?.thirdeye?.args
|
|
1021
|
+
};
|
|
1022
|
+
const storyLevelBreakpoints = story.parameters?.thirdeye?.breakpoints ?? [];
|
|
1023
|
+
const breakpoints = selectBreakpoints(config.breakpoints, modeBreakpoints, storyLevelBreakpoints);
|
|
1024
|
+
let shotItems = [];
|
|
1025
|
+
if (!breakpoints || breakpoints.length === 0) shotItems = [baseShotItem];
|
|
1026
|
+
else shotItems = breakpoints.map((breakpoint) => {
|
|
1027
|
+
label = generateLabel({
|
|
1028
|
+
breakpoint,
|
|
1029
|
+
browser
|
|
1030
|
+
});
|
|
1031
|
+
fileNameWithExt = `${shotName}${label}.png`;
|
|
1032
|
+
return {
|
|
1033
|
+
...baseShotItem,
|
|
1034
|
+
id: `${story.id}${label}`,
|
|
1035
|
+
shotName: `${shotName}${label}`,
|
|
1036
|
+
breakpoint,
|
|
1037
|
+
breakpointGroup: story.id,
|
|
1038
|
+
filePathBaseline: isPlatformModeConfig(config) ? notSupported : path.join(config.imagePathBaseline, fileNameWithExt),
|
|
1039
|
+
filePathCurrent: path.join(config.imagePathCurrent, fileNameWithExt),
|
|
1040
|
+
filePathDifference: isPlatformModeConfig(config) ? notSupported : path.join(config.imagePathDifference, fileNameWithExt),
|
|
1041
|
+
viewport: {
|
|
1042
|
+
width: breakpoint,
|
|
1043
|
+
height: void 0
|
|
1044
|
+
},
|
|
1045
|
+
url: generateStoryUrl(iframeUrl, story.id, story.parameters?.thirdeye?.args, breakpoint),
|
|
1046
|
+
browserConfig: generateBrowserConfig$1({
|
|
1047
|
+
...story,
|
|
1048
|
+
parameters: {
|
|
1049
|
+
...story.parameters,
|
|
1050
|
+
viewport: { width: breakpoint }
|
|
1051
|
+
}
|
|
1052
|
+
})
|
|
1053
|
+
};
|
|
1054
|
+
});
|
|
1055
|
+
const extraShots = story.parameters?.thirdeye?.extraShots?.flatMap((snapshot) => {
|
|
1056
|
+
const combinedArgs = {
|
|
1057
|
+
...story.parameters?.thirdeye?.args,
|
|
1058
|
+
...snapshot.args
|
|
1059
|
+
};
|
|
1060
|
+
const snapshotShotName = generateFilename(story.kind, story.story, snapshot.prefix, snapshot.suffix);
|
|
1061
|
+
return (breakpoints?.length === 0 ? [void 0] : breakpoints).map((breakpoint) => {
|
|
1062
|
+
label = generateLabel({
|
|
1063
|
+
breakpoint,
|
|
1064
|
+
browser
|
|
1065
|
+
});
|
|
1066
|
+
fileNameWithExt = `${snapshotShotName}${label}.png`;
|
|
1067
|
+
return {
|
|
1068
|
+
...baseShotItem,
|
|
1069
|
+
id: `${story.id}${label}-${snapshot.name ?? "snapshot"}`,
|
|
1070
|
+
shotName: `${snapshotShotName}${label}`,
|
|
1071
|
+
breakpoint,
|
|
1072
|
+
breakpointGroup: story.id,
|
|
1073
|
+
filePathBaseline: isPlatformModeConfig(config) ? "not supported" : path.join(config.imagePathBaseline, fileNameWithExt),
|
|
1074
|
+
filePathCurrent: path.join(config.imagePathCurrent, fileNameWithExt),
|
|
1075
|
+
filePathDifference: isPlatformModeConfig(config) ? "not supported" : path.join(config.imagePathDifference, fileNameWithExt),
|
|
1076
|
+
url: generateStoryUrl(iframeUrl, story.id, combinedArgs, breakpoint),
|
|
1077
|
+
viewport: breakpoint ? {
|
|
1078
|
+
width: breakpoint,
|
|
1079
|
+
height: void 0
|
|
1080
|
+
} : void 0,
|
|
1081
|
+
browserConfig: generateBrowserConfig$1({
|
|
1082
|
+
...story,
|
|
1083
|
+
parameters: {
|
|
1084
|
+
...story.parameters,
|
|
1085
|
+
viewport: { width: breakpoint }
|
|
1086
|
+
}
|
|
1087
|
+
})
|
|
1088
|
+
};
|
|
1089
|
+
});
|
|
1090
|
+
}) ?? [];
|
|
1091
|
+
return [...shotItems, ...extraShots];
|
|
1092
|
+
});
|
|
1093
|
+
};
|
|
1094
|
+
//#endregion
|
|
1095
|
+
//#region src/crawler/pageScreenshots.ts
|
|
1096
|
+
const generateBrowserConfig = (page) => {
|
|
1097
|
+
const browserConfig = config.configureBrowser?.({
|
|
1098
|
+
...page,
|
|
1099
|
+
shotMode: "page"
|
|
1100
|
+
});
|
|
1101
|
+
if (page.viewport && browserConfig) {
|
|
1102
|
+
browserConfig.viewport ??= {
|
|
1103
|
+
width: 1280,
|
|
1104
|
+
height: 720
|
|
1105
|
+
};
|
|
1106
|
+
browserConfig.viewport = {
|
|
1107
|
+
...browserConfig.viewport,
|
|
1108
|
+
...page.viewport
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
return browserConfig;
|
|
1112
|
+
};
|
|
1113
|
+
const generatePageShotItems = (pages, baseUrl, mask, modeBreakpoints, browser) => {
|
|
1114
|
+
const names = pages.map((page) => page.name);
|
|
1115
|
+
const uniqueNames = new Set(names);
|
|
1116
|
+
if (names.length !== uniqueNames.size) throw new Error("Error: Page names must be unique");
|
|
1117
|
+
return pages.flatMap((page) => {
|
|
1118
|
+
const shotName = config.shotNameGenerator?.({
|
|
1119
|
+
...page,
|
|
1120
|
+
shotMode: "page"
|
|
1121
|
+
}) ?? page.name;
|
|
1122
|
+
let label = generateLabel({ browser });
|
|
1123
|
+
let fileNameWithExt = `${shotName}${label}.png`;
|
|
1124
|
+
const baseShotItem = {
|
|
1125
|
+
shotMode: "page",
|
|
1126
|
+
id: `${shotName}${label}`,
|
|
1127
|
+
shotName: `${shotName}${label}`,
|
|
1128
|
+
url: path.join(baseUrl, page.path),
|
|
1129
|
+
filePathBaseline: isPlatformModeConfig(config) ? notSupported : path.join(config.imagePathBaseline, fileNameWithExt),
|
|
1130
|
+
filePathCurrent: path.join(config.imagePathCurrent, fileNameWithExt),
|
|
1131
|
+
filePathDifference: isPlatformModeConfig(config) ? notSupported : path.join(config.imagePathDifference, fileNameWithExt),
|
|
1132
|
+
browserConfig: generateBrowserConfig(page),
|
|
1133
|
+
threshold: page.threshold ?? config.threshold,
|
|
1134
|
+
waitBeforeScreenshot: page.waitBeforeScreenshot ?? config.waitBeforeScreenshot,
|
|
1135
|
+
mask: [...mask ?? [], ...page.mask ?? []],
|
|
1136
|
+
waitForSelector: config?.pageShots?.waitForSelector,
|
|
1137
|
+
componentPath: void 0,
|
|
1138
|
+
storyName: void 0
|
|
1139
|
+
};
|
|
1140
|
+
const breakpoints = selectBreakpoints(config.breakpoints, modeBreakpoints, page.breakpoints);
|
|
1141
|
+
if (breakpoints.length === 0) return [baseShotItem];
|
|
1142
|
+
return breakpoints.map((breakpoint) => {
|
|
1143
|
+
label = generateLabel({
|
|
1144
|
+
breakpoint,
|
|
1145
|
+
browser
|
|
1146
|
+
});
|
|
1147
|
+
fileNameWithExt = `${shotName}${label}.png`;
|
|
1148
|
+
return {
|
|
1149
|
+
...baseShotItem,
|
|
1150
|
+
id: `${shotName}${label}`,
|
|
1151
|
+
shotName: `${shotName}${label}`,
|
|
1152
|
+
breakpoint,
|
|
1153
|
+
breakpointGroup: page.name,
|
|
1154
|
+
url: path.join(baseUrl, page.path),
|
|
1155
|
+
filePathBaseline: isPlatformModeConfig(config) ? notSupported : path.join(config.imagePathBaseline, fileNameWithExt),
|
|
1156
|
+
filePathCurrent: path.join(config.imagePathCurrent, fileNameWithExt),
|
|
1157
|
+
filePathDifference: isPlatformModeConfig(config) ? notSupported : path.join(config.imagePathDifference, fileNameWithExt),
|
|
1158
|
+
viewport: { width: breakpoint },
|
|
1159
|
+
browserConfig: generateBrowserConfig({
|
|
1160
|
+
...page,
|
|
1161
|
+
viewport: { width: breakpoint }
|
|
1162
|
+
})
|
|
1163
|
+
};
|
|
1164
|
+
});
|
|
1165
|
+
});
|
|
1166
|
+
};
|
|
1167
|
+
const isValidHttpUrl = (string) => {
|
|
1168
|
+
let url;
|
|
1169
|
+
try {
|
|
1170
|
+
url = new URL(string);
|
|
1171
|
+
} catch {
|
|
1172
|
+
return false;
|
|
1173
|
+
}
|
|
1174
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
1175
|
+
};
|
|
1176
|
+
const getPagesFromExternalLoader = async () => {
|
|
1177
|
+
try {
|
|
1178
|
+
if (!config.pageShots?.pagesJsonUrl) return [];
|
|
1179
|
+
log.process("info", "general", `⏬ Loading pages from ${config.pageShots.pagesJsonUrl}`);
|
|
1180
|
+
let pages;
|
|
1181
|
+
if (isValidHttpUrl(config.pageShots.pagesJsonUrl)) {
|
|
1182
|
+
log.process("info", "general", `🕸️ Trying to fetch from URL`);
|
|
1183
|
+
pages = (await axios.get(config.pageShots.pagesJsonUrl)).data;
|
|
1184
|
+
} else {
|
|
1185
|
+
log.process("info", "general", `⏬ Trying to fetch from local file`);
|
|
1186
|
+
const fileContents = await fs.readFile(config.pageShots.pagesJsonUrl, "utf8");
|
|
1187
|
+
pages = JSON.parse(fileContents);
|
|
1188
|
+
}
|
|
1189
|
+
const validatePages = z.array(z.object({
|
|
1190
|
+
path: z.string(),
|
|
1191
|
+
name: z.string(),
|
|
1192
|
+
waitBeforeScreenshot: z.number().optional(),
|
|
1193
|
+
threshold: z.number().optional(),
|
|
1194
|
+
mask: z.array(z.object({ selector: z.string() })).optional(),
|
|
1195
|
+
viewport: z.object({
|
|
1196
|
+
width: z.string(),
|
|
1197
|
+
height: z.string()
|
|
1198
|
+
}).optional()
|
|
1199
|
+
})).safeParse(pages);
|
|
1200
|
+
if (validatePages.success) {
|
|
1201
|
+
log.process("info", "general", `✅ Successfully validated pages structure & loaded ${pages.length} pages from JSON file.`);
|
|
1202
|
+
return pages;
|
|
1203
|
+
}
|
|
1204
|
+
log.process("error", "general", "❌ Error validating the loaded pages structure");
|
|
1205
|
+
log.process("error", "general", validatePages.error);
|
|
1206
|
+
return [];
|
|
1207
|
+
} catch (error) {
|
|
1208
|
+
if (isAxiosError(error) || error instanceof Error) log.process("error", "network", `❌ Error when fetching data: ${error.message}`);
|
|
1209
|
+
return [];
|
|
1210
|
+
}
|
|
1211
|
+
};
|
|
1212
|
+
//#endregion
|
|
1213
|
+
//#region src/shots/shots.ts
|
|
1214
|
+
const takeScreenShot = async ({ browser, shotItem, logger }) => {
|
|
1215
|
+
const context = await browser.newContext(shotItem.browserConfig);
|
|
1216
|
+
const page = await context.newPage();
|
|
1217
|
+
let success = false;
|
|
1218
|
+
page.on("pageerror", (exception) => {
|
|
1219
|
+
logger.browser("error", "general", "Uncaught exception:", exception);
|
|
1220
|
+
});
|
|
1221
|
+
page.on("console", async (message) => {
|
|
1222
|
+
const values = [];
|
|
1223
|
+
try {
|
|
1224
|
+
for (const arg of message.args()) values.push(await arg.jsonValue());
|
|
1225
|
+
} catch (error) {
|
|
1226
|
+
logger.browser("error", "console", "Error while collecting console output", error);
|
|
1227
|
+
}
|
|
1228
|
+
logger.browser("info", "console", String(values.shift()), ...values);
|
|
1229
|
+
});
|
|
1230
|
+
try {
|
|
1231
|
+
await page.goto(shotItem.url);
|
|
1232
|
+
} catch (error) {
|
|
1233
|
+
if (error instanceof Error && error.name === "TimeoutError") logger.process("error", "timeout", `Timeout while loading page: ${shotItem.url}`);
|
|
1234
|
+
else logger.process("error", "general", "Page loading failed", error);
|
|
1235
|
+
}
|
|
1236
|
+
try {
|
|
1237
|
+
await page.waitForLoadState("load", { timeout: config.timeouts.loadState });
|
|
1238
|
+
} catch (error) {
|
|
1239
|
+
logger.process("error", "timeout", `Timeout while waiting for page load state: ${shotItem.url}`, error);
|
|
1240
|
+
}
|
|
1241
|
+
if (shotItem.waitForSelector) try {
|
|
1242
|
+
await page.waitForSelector(shotItem.waitForSelector, {
|
|
1243
|
+
state: "attached",
|
|
1244
|
+
timeout: config.timeouts.loadState
|
|
1245
|
+
});
|
|
1246
|
+
} catch (error) {
|
|
1247
|
+
logger.process("error", "timeout", `Timeout while waiting for Selector ('${shotItem.waitForSelector}') to appear: ${shotItem.url}`, error);
|
|
1248
|
+
}
|
|
1249
|
+
try {
|
|
1250
|
+
await waitForNetworkRequests({
|
|
1251
|
+
page,
|
|
1252
|
+
logger,
|
|
1253
|
+
ignoreUrls: ["/__webpack_hmr"]
|
|
1254
|
+
});
|
|
1255
|
+
} catch (error) {
|
|
1256
|
+
logger.process("error", "timeout", `Timeout while waiting for all network requests: ${shotItem.url}`, error);
|
|
1257
|
+
}
|
|
1258
|
+
if (config.beforeScreenshot) await config.beforeScreenshot(page, {
|
|
1259
|
+
shotMode: shotItem.shotMode,
|
|
1260
|
+
id: shotItem.id,
|
|
1261
|
+
shotName: shotItem.shotName
|
|
1262
|
+
});
|
|
1263
|
+
let fullScreenMode = true;
|
|
1264
|
+
await sleep(shotItem?.waitBeforeScreenshot ?? config.waitBeforeScreenshot);
|
|
1265
|
+
try {
|
|
1266
|
+
if (shotItem.viewport) {
|
|
1267
|
+
const currentViewport = page.viewportSize();
|
|
1268
|
+
await page.setViewportSize({
|
|
1269
|
+
width: shotItem.viewport.width,
|
|
1270
|
+
height: currentViewport?.height ?? 500
|
|
1271
|
+
});
|
|
1272
|
+
fullScreenMode = true;
|
|
1273
|
+
} else {
|
|
1274
|
+
await resizeViewportToFullscreen({ page });
|
|
1275
|
+
fullScreenMode = false;
|
|
1276
|
+
}
|
|
1277
|
+
} catch (error) {
|
|
1278
|
+
logger.process("error", "general", `Could not resize viewport to fullscreen: ${shotItem.shotName}`, error);
|
|
1279
|
+
}
|
|
1280
|
+
let retryCount = 0;
|
|
1281
|
+
let lastShotHash;
|
|
1282
|
+
try {
|
|
1283
|
+
while (retryCount <= config.flakynessRetries) {
|
|
1284
|
+
const { elementLocator } = shotItem;
|
|
1285
|
+
let screenshotOptions = {
|
|
1286
|
+
path: shotItem.filePathCurrent,
|
|
1287
|
+
animations: "disabled",
|
|
1288
|
+
mask: shotItem.mask ? shotItem.mask.map((mask) => page.locator(mask.selector)) : []
|
|
1289
|
+
};
|
|
1290
|
+
if (elementLocator) await page.locator(elementLocator).screenshot(screenshotOptions);
|
|
1291
|
+
else if (shotItem.shotMode === "storybook") if (await page.evaluate(() => {
|
|
1292
|
+
for (const sel of [
|
|
1293
|
+
"[data-radix-portal]",
|
|
1294
|
+
"[data-radix-popper-content-wrapper]",
|
|
1295
|
+
"[role='dialog']",
|
|
1296
|
+
"[role='alertdialog']",
|
|
1297
|
+
".ReactModal__Overlay"
|
|
1298
|
+
]) {
|
|
1299
|
+
const el = document.querySelector(sel);
|
|
1300
|
+
if (el && el.offsetHeight > 0) return true;
|
|
1301
|
+
}
|
|
1302
|
+
return false;
|
|
1303
|
+
})) {
|
|
1304
|
+
const clip = await page.evaluate(() => {
|
|
1305
|
+
const elements = document.querySelectorAll("#storybook-root, #storybook-root *, [data-radix-portal], [data-radix-portal] *, [role='dialog'], [role='dialog'] *");
|
|
1306
|
+
let minX = Infinity;
|
|
1307
|
+
let minY = Infinity;
|
|
1308
|
+
let maxX = 0;
|
|
1309
|
+
let maxY = 0;
|
|
1310
|
+
for (const el of elements) {
|
|
1311
|
+
const rect = el.getBoundingClientRect();
|
|
1312
|
+
if (rect.width === 0 || rect.height === 0) continue;
|
|
1313
|
+
minX = Math.min(minX, rect.left);
|
|
1314
|
+
minY = Math.min(minY, rect.top);
|
|
1315
|
+
maxX = Math.max(maxX, rect.right);
|
|
1316
|
+
maxY = Math.max(maxY, rect.bottom);
|
|
1317
|
+
}
|
|
1318
|
+
if (minX === Infinity) return null;
|
|
1319
|
+
return {
|
|
1320
|
+
x: Math.max(0, Math.floor(minX)),
|
|
1321
|
+
y: Math.max(0, Math.floor(minY)),
|
|
1322
|
+
width: Math.ceil(maxX - Math.max(0, minX)),
|
|
1323
|
+
height: Math.ceil(maxY - Math.max(0, minY))
|
|
1324
|
+
};
|
|
1325
|
+
});
|
|
1326
|
+
if (clip && clip.width > 0 && clip.height > 0) await page.screenshot({
|
|
1327
|
+
...screenshotOptions,
|
|
1328
|
+
clip
|
|
1329
|
+
});
|
|
1330
|
+
else await page.screenshot({
|
|
1331
|
+
...screenshotOptions,
|
|
1332
|
+
fullPage: fullScreenMode
|
|
1333
|
+
});
|
|
1334
|
+
} else {
|
|
1335
|
+
const storybookRoot = page.locator("#storybook-root");
|
|
1336
|
+
if (await storybookRoot.count() > 0) await storybookRoot.screenshot(screenshotOptions);
|
|
1337
|
+
else await page.screenshot({
|
|
1338
|
+
...screenshotOptions,
|
|
1339
|
+
fullPage: fullScreenMode
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
else {
|
|
1343
|
+
screenshotOptions = {
|
|
1344
|
+
...screenshotOptions,
|
|
1345
|
+
fullPage: fullScreenMode
|
|
1346
|
+
};
|
|
1347
|
+
await page.screenshot(screenshotOptions);
|
|
1348
|
+
}
|
|
1349
|
+
const currentShotHash = hashFile(shotItem.filePathCurrent);
|
|
1350
|
+
if (lastShotHash) {
|
|
1351
|
+
logger.process("info", "general", `Screenshot of '${shotItem.shotName}' taken (Retry ${retryCount}). Hash: ${currentShotHash} - Previous hash: ${lastShotHash}`);
|
|
1352
|
+
if (lastShotHash === currentShotHash) break;
|
|
1353
|
+
}
|
|
1354
|
+
lastShotHash = currentShotHash;
|
|
1355
|
+
if (retryCount < config.flakynessRetries) await sleep(config.waitBetweenFlakynessRetries);
|
|
1356
|
+
retryCount++;
|
|
1357
|
+
}
|
|
1358
|
+
success = true;
|
|
1359
|
+
try {
|
|
1360
|
+
const domHtml = await page.content();
|
|
1361
|
+
writeFileSync(shotItem.filePathCurrent.replace(/\.png$/, ".html"), domHtml);
|
|
1362
|
+
} catch (domError) {
|
|
1363
|
+
logger.process("error", "general", "Failed to capture DOM snapshot", domError);
|
|
1364
|
+
}
|
|
1365
|
+
} catch (error) {
|
|
1366
|
+
logger.process("error", "general", "Error when taking screenshot", error);
|
|
1367
|
+
}
|
|
1368
|
+
if (config.afterScreenshot) await config.afterScreenshot(page, shotItem);
|
|
1369
|
+
await context.close();
|
|
1370
|
+
const videoPath = await page.video()?.path();
|
|
1371
|
+
if (videoPath) {
|
|
1372
|
+
const dirname = path.dirname(videoPath);
|
|
1373
|
+
const ext = videoPath.split(".").pop() ?? "webm";
|
|
1374
|
+
const newVideoPath = `${dirname}/${shotItem.shotName}.${ext}`;
|
|
1375
|
+
await page.video()?.saveAs(newVideoPath);
|
|
1376
|
+
await page.video()?.delete();
|
|
1377
|
+
logger.process("info", "general", `Video of '${shotItem.shotName}' recorded and saved to '${newVideoPath}`);
|
|
1378
|
+
}
|
|
1379
|
+
return success;
|
|
1380
|
+
};
|
|
1381
|
+
const takeScreenShots = async (shotItems, _browser) => {
|
|
1382
|
+
const browser = await launchBrowser(_browser);
|
|
1383
|
+
const total = shotItems.length;
|
|
1384
|
+
await mapLimit(shotItems.entries(), config.shotConcurrency, async (item) => {
|
|
1385
|
+
const [index, shotItem] = item;
|
|
1386
|
+
const logger = log.item({
|
|
1387
|
+
shotMode: shotItem.shotMode,
|
|
1388
|
+
uniqueItemId: shotItem.shotName,
|
|
1389
|
+
itemIndex: index,
|
|
1390
|
+
totalItems: total
|
|
1391
|
+
});
|
|
1392
|
+
logger.process("info", "general", `Taking screenshot of '${shotItem.shotName} ${shotItem.breakpoint ? `[${shotItem.breakpoint}]` : ""}'`);
|
|
1393
|
+
const startTime = Date.now();
|
|
1394
|
+
const result = await takeScreenShot({
|
|
1395
|
+
browser,
|
|
1396
|
+
shotItem,
|
|
1397
|
+
logger
|
|
1398
|
+
});
|
|
1399
|
+
const elapsedTime = Number((Date.now() - startTime) / 1e3).toFixed(3);
|
|
1400
|
+
if (result) logger.process("info", "general", `Screenshot of '${shotItem.shotName}' taken and saved to '${shotItem.filePathCurrent}' in ${elapsedTime}s`);
|
|
1401
|
+
else logger.process("info", "general", `Screenshot of '${shotItem.shotName}' failed and took ${elapsedTime}s`);
|
|
1402
|
+
});
|
|
1403
|
+
await browser.close();
|
|
1404
|
+
};
|
|
1405
|
+
//#endregion
|
|
1406
|
+
//#region src/crawler/utils.ts
|
|
1407
|
+
const launchStaticWebServer = async (basePath) => {
|
|
1408
|
+
const port = await getPort({ random: true });
|
|
1409
|
+
const server = http.createServer(async (request, response) => {
|
|
1410
|
+
return handler(request, response, {
|
|
1411
|
+
public: basePath.startsWith("file://") ? basePath.slice(7) : basePath,
|
|
1412
|
+
cleanUrls: false
|
|
1413
|
+
});
|
|
1414
|
+
});
|
|
1415
|
+
server.listen(port);
|
|
1416
|
+
return {
|
|
1417
|
+
server,
|
|
1418
|
+
port,
|
|
1419
|
+
url: `http://localhost:${port}`
|
|
1420
|
+
};
|
|
1421
|
+
};
|
|
1422
|
+
//#endregion
|
|
1423
|
+
//#region src/crawler/histoireScreenshots.ts
|
|
1424
|
+
const generateShotItemsForStory = (story, baseUrl, browser) => {
|
|
1425
|
+
const shotItems = [];
|
|
1426
|
+
const variants = story.variants ?? [story];
|
|
1427
|
+
for (const variant of variants) {
|
|
1428
|
+
const shotName = config.shotNameGenerator?.({
|
|
1429
|
+
...variant,
|
|
1430
|
+
shotMode: "histoire"
|
|
1431
|
+
}) ?? `${story.id}_${variant.title}`;
|
|
1432
|
+
const label = generateLabel({ browser });
|
|
1433
|
+
const fileNameWithExt = `${shotName}${label}.png`;
|
|
1434
|
+
shotItems.push({
|
|
1435
|
+
shotMode: "histoire",
|
|
1436
|
+
id: `${story.id}_${variant.id}${label}`,
|
|
1437
|
+
shotName: `${shotName}${label}`,
|
|
1438
|
+
url: `${baseUrl}/__sandbox.html?storyId=${story.id}&variantId=${variant.id}`,
|
|
1439
|
+
filePathBaseline: isPlatformModeConfig(config) ? notSupported : path.join(config.imagePathBaseline, fileNameWithExt),
|
|
1440
|
+
filePathCurrent: path.join(config.imagePathCurrent, fileNameWithExt),
|
|
1441
|
+
filePathDifference: isPlatformModeConfig(config) ? notSupported : path.join(config.imagePathDifference, fileNameWithExt),
|
|
1442
|
+
threshold: config.threshold,
|
|
1443
|
+
waitForSelector: config?.histoireShots?.waitForSelector,
|
|
1444
|
+
componentPath: story.id,
|
|
1445
|
+
storyName: variant.title
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
return shotItems.filter((story) => story.id !== "full-config");
|
|
1449
|
+
};
|
|
1450
|
+
const generateHistoireShotItems = (baseUrl, stories, browser) => {
|
|
1451
|
+
return stories.flatMap((story) => generateShotItemsForStory(story, baseUrl, browser));
|
|
1452
|
+
};
|
|
1453
|
+
const collectHistoireStories = async (histoireUrl) => {
|
|
1454
|
+
const jsonUrl = `${histoireUrl}/histoire.json`;
|
|
1455
|
+
log.process("info", "general", `\n=== [Histoire Mode] ${jsonUrl} ===\n`);
|
|
1456
|
+
return (await axios.get(jsonUrl)).data.stories;
|
|
1457
|
+
};
|
|
1458
|
+
//#endregion
|
|
1459
|
+
//#region src/createShots.ts
|
|
1460
|
+
const createShots = async () => {
|
|
1461
|
+
const { ladleShots, histoireShots, storybookShots, pageShots, customShots, imagePathCurrent } = config;
|
|
1462
|
+
let storybookShotItems = [];
|
|
1463
|
+
let ladleShotItems = [];
|
|
1464
|
+
let histoireShotItems = [];
|
|
1465
|
+
let pageShotItems = [];
|
|
1466
|
+
let customShotItems = [];
|
|
1467
|
+
removeFilesInFolder(imagePathCurrent);
|
|
1468
|
+
if (!isPlatformModeConfig(config)) removeFilesInFolder(config.imagePathDifference);
|
|
1469
|
+
const browsers = getBrowsers();
|
|
1470
|
+
if (ladleShots) {
|
|
1471
|
+
const { ladleUrl, mask } = ladleShots;
|
|
1472
|
+
log.process("info", "general", `\n=== [Ladle Mode] ${ladleUrl} ===\n`);
|
|
1473
|
+
let ladleWebUrl = ladleUrl;
|
|
1474
|
+
let localServer;
|
|
1475
|
+
if (!ladleUrl.startsWith("http://") && !ladleUrl.startsWith("https://")) {
|
|
1476
|
+
const staticWebServer = await launchStaticWebServer(ladleUrl);
|
|
1477
|
+
ladleWebUrl = staticWebServer.url;
|
|
1478
|
+
localServer = staticWebServer.server;
|
|
1479
|
+
}
|
|
1480
|
+
try {
|
|
1481
|
+
const collection = await collectLadleStories(ladleWebUrl);
|
|
1482
|
+
if (!collection || collection.length === 0) throw new Error("Error: Stories not found");
|
|
1483
|
+
log.process("info", "general", `Found ${collection.length} ladle stories`);
|
|
1484
|
+
await mapLimit(browsers, 1, async (browser) => {
|
|
1485
|
+
const shotItems = generateLadleShotItems(ladleWebUrl, Boolean(localServer), collection, mask, ladleShots.breakpoints, browsers.length > 1 ? browser : void 0);
|
|
1486
|
+
const filterItemsToCheck = "filterItemsToCheck" in config ? config.filterItemsToCheck : void 0;
|
|
1487
|
+
const filteredShotItems = filterItemsToCheck ? shotItems.filter((item) => filterItemsToCheck(item)) : shotItems;
|
|
1488
|
+
ladleShotItems = shotItems;
|
|
1489
|
+
log.process("info", "general", `Prepared ${filteredShotItems.length} ladle stories for screenshots on ${browser.name()}`);
|
|
1490
|
+
await takeScreenShots(filteredShotItems, browser);
|
|
1491
|
+
});
|
|
1492
|
+
localServer?.close();
|
|
1493
|
+
} catch (error) {
|
|
1494
|
+
localServer?.close();
|
|
1495
|
+
throw error;
|
|
1496
|
+
}
|
|
1497
|
+
log.process("info", "general", "Screenshots done!");
|
|
1498
|
+
}
|
|
1499
|
+
if (histoireShots) {
|
|
1500
|
+
const { histoireUrl } = histoireShots;
|
|
1501
|
+
let localServer;
|
|
1502
|
+
let histoireWebUrl;
|
|
1503
|
+
if (!histoireUrl.startsWith("http://") && !histoireUrl.startsWith("https://")) {
|
|
1504
|
+
const staticWebServer = await launchStaticWebServer(histoireUrl);
|
|
1505
|
+
histoireWebUrl = staticWebServer.url;
|
|
1506
|
+
localServer = staticWebServer.server;
|
|
1507
|
+
}
|
|
1508
|
+
if (!histoireWebUrl) throw new Error("Error: Histoire web url not found");
|
|
1509
|
+
log.process("info", "general", `\n=== [Histoire Mode] ${histoireUrl} ===\n`);
|
|
1510
|
+
try {
|
|
1511
|
+
const collection = await collectHistoireStories(histoireWebUrl);
|
|
1512
|
+
if (!collection || collection.length === 0) throw new Error("Error: Stories not found");
|
|
1513
|
+
log.process("info", "general", `Found ${collection.length} Histoire stories`);
|
|
1514
|
+
await mapLimit(browsers, 1, async (browser) => {
|
|
1515
|
+
const shotItems = generateHistoireShotItems(histoireWebUrl, collection, browsers.length > 1 ? browser : void 0);
|
|
1516
|
+
histoireShotItems = shotItems;
|
|
1517
|
+
log.process("info", "general", `Prepared ${shotItems.length} Histoire stories for screenshots on ${browser.name()}`);
|
|
1518
|
+
await takeScreenShots(shotItems, browser);
|
|
1519
|
+
});
|
|
1520
|
+
localServer?.close();
|
|
1521
|
+
} catch (error) {
|
|
1522
|
+
localServer?.close();
|
|
1523
|
+
throw error;
|
|
1524
|
+
}
|
|
1525
|
+
log.process("info", "general", "Screenshots done!");
|
|
1526
|
+
}
|
|
1527
|
+
if (storybookShots) {
|
|
1528
|
+
const { storybookUrl, mask } = storybookShots;
|
|
1529
|
+
log.process("info", "general", `\n=== [Storybook Mode] ${storybookUrl} ===\n`);
|
|
1530
|
+
let storybookWebUrl = storybookUrl;
|
|
1531
|
+
let localServer;
|
|
1532
|
+
if (!storybookUrl.startsWith("http://") && !storybookUrl.startsWith("https://")) {
|
|
1533
|
+
const staticWebServer = await launchStaticWebServer(storybookUrl);
|
|
1534
|
+
storybookWebUrl = staticWebServer.url;
|
|
1535
|
+
localServer = staticWebServer.server;
|
|
1536
|
+
}
|
|
1537
|
+
try {
|
|
1538
|
+
const collection = await collectStories(storybookWebUrl);
|
|
1539
|
+
if (!collection?.stories || collection.stories.length === 0) throw new Error("Error: Stories not found");
|
|
1540
|
+
log.process("info", "general", `Found ${collection.stories.length} stories`);
|
|
1541
|
+
await mapLimit(browsers, 1, async (browser) => {
|
|
1542
|
+
const shotItems = generateStorybookShotItems(storybookWebUrl, collection.stories, mask, storybookShots.breakpoints, browsers.length > 1 ? browser : void 0);
|
|
1543
|
+
const filterItemsToCheck = "filterItemsToCheck" in config ? config.filterItemsToCheck : void 0;
|
|
1544
|
+
const filteredShotItems = filterItemsToCheck ? shotItems.filter((item) => filterItemsToCheck(item)) : shotItems;
|
|
1545
|
+
storybookShotItems = shotItems;
|
|
1546
|
+
log.process("info", "general", `Prepared ${filteredShotItems.length} stories for screenshots on ${browser.name()}`);
|
|
1547
|
+
await takeScreenShots(filteredShotItems, browser);
|
|
1548
|
+
});
|
|
1549
|
+
localServer?.close();
|
|
1550
|
+
} catch (error) {
|
|
1551
|
+
localServer?.close();
|
|
1552
|
+
throw error;
|
|
1553
|
+
}
|
|
1554
|
+
log.process("info", "general", "Screenshots done!");
|
|
1555
|
+
}
|
|
1556
|
+
if (pageShots) {
|
|
1557
|
+
const { pages: pagesFromConfig, baseUrl, mask, breakpoints } = pageShots;
|
|
1558
|
+
const pagesFromLoader = await getPagesFromExternalLoader();
|
|
1559
|
+
let jsonPages = pagesFromLoader || [];
|
|
1560
|
+
if (config.pageShots?.pagesJsonRefiner) {
|
|
1561
|
+
log.process("info", "general", `🧬 Refining pages received in json with function provided in pagesJsonRefiner`);
|
|
1562
|
+
jsonPages = config.pageShots.pagesJsonRefiner(pagesFromLoader || []);
|
|
1563
|
+
}
|
|
1564
|
+
if (jsonPages.length > 0) log.process("info", "general", `Found ${jsonPages.length} pages from external loader`);
|
|
1565
|
+
const pages = [...pagesFromConfig || [], ...jsonPages || []];
|
|
1566
|
+
log.process("info", "general", `\n=== [Page Mode] ${baseUrl} ===\n`);
|
|
1567
|
+
await mapLimit(browsers, 1, async (browser) => {
|
|
1568
|
+
const shotItems = generatePageShotItems(pages, baseUrl, mask, breakpoints, browsers.length > 1 ? browser : void 0);
|
|
1569
|
+
pageShotItems = shotItems;
|
|
1570
|
+
log.process("info", "general", `Prepared ${shotItems.length} pages for screenshots on ${browser.name()}`);
|
|
1571
|
+
await takeScreenShots(shotItems, browser);
|
|
1572
|
+
});
|
|
1573
|
+
log.process("info", "general", "Screenshots done!");
|
|
1574
|
+
}
|
|
1575
|
+
if (customShots) {
|
|
1576
|
+
const { currentShotsPath } = customShots;
|
|
1577
|
+
log.process("info", "general", `\n=== [Custom Mode] ${currentShotsPath} ===\n`);
|
|
1578
|
+
customShotItems = readDirIntoShotItems(currentShotsPath);
|
|
1579
|
+
log.process("info", "general", `Found ${customShotItems.length} custom shots`);
|
|
1580
|
+
}
|
|
1581
|
+
return [
|
|
1582
|
+
...storybookShotItems,
|
|
1583
|
+
...pageShotItems,
|
|
1584
|
+
...ladleShotItems,
|
|
1585
|
+
...histoireShotItems,
|
|
1586
|
+
...customShotItems
|
|
1587
|
+
];
|
|
1588
|
+
};
|
|
1589
|
+
//#endregion
|
|
1590
|
+
//#region ../shared/dist/client.js
|
|
1591
|
+
/**
|
|
1592
|
+
* Typed oRPC client factory.
|
|
1593
|
+
* Used by CLI and frontend to create a type-safe client.
|
|
1594
|
+
*
|
|
1595
|
+
* Uses RPCLink which speaks the oRPC RPC wire format.
|
|
1596
|
+
* The server mounts both OpenAPIHandler (for .route()-defined REST endpoints)
|
|
1597
|
+
* and RPCHandler (fallback for all procedures). The RPCLink client always
|
|
1598
|
+
* hits the RPCHandler path, ensuring compatibility during the OpenAPI migration.
|
|
1599
|
+
*/
|
|
1600
|
+
const resolveBaseUrl = (url) => {
|
|
1601
|
+
if (url.startsWith("http")) return url;
|
|
1602
|
+
if (typeof window !== "undefined") return `${window.location.origin}${url}`;
|
|
1603
|
+
return url;
|
|
1604
|
+
};
|
|
1605
|
+
const isBrowser = typeof window !== "undefined";
|
|
1606
|
+
const createTypedClient = (options) => {
|
|
1607
|
+
return createORPCClient(new RPCLink({
|
|
1608
|
+
url: () => `${resolveBaseUrl(options.url)}/rpc`,
|
|
1609
|
+
fetch: (input, init) => fetch(input, {
|
|
1610
|
+
...init,
|
|
1611
|
+
...isBrowser ? { credentials: "include" } : {}
|
|
1612
|
+
}),
|
|
1613
|
+
headers: () => {
|
|
1614
|
+
const headers = {};
|
|
1615
|
+
if (options.apiToken) headers.authorization = `Bearer ${options.apiToken}`;
|
|
1616
|
+
if (options.apiKey) headers["x-api-key"] = options.apiKey;
|
|
1617
|
+
return headers;
|
|
1618
|
+
}
|
|
1619
|
+
}));
|
|
1620
|
+
};
|
|
1621
|
+
//#endregion
|
|
1622
|
+
//#region src/api.ts
|
|
1623
|
+
const createClient = (platformUrl, apiKey, apiToken) => createTypedClient({
|
|
1624
|
+
url: platformUrl,
|
|
1625
|
+
apiKey,
|
|
1626
|
+
apiToken
|
|
1627
|
+
});
|
|
1628
|
+
const withRetry = async (action, fn, logger = log.process) => {
|
|
1629
|
+
logger("info", "api", `Sending to API [${action}]`);
|
|
1630
|
+
const result = await retry({
|
|
1631
|
+
times: 3,
|
|
1632
|
+
interval(retryCount) {
|
|
1633
|
+
const delay = Math.round(2 ** retryCount * 3e3 * Math.random());
|
|
1634
|
+
logger("info", "api", `Retry attempt ${retryCount} in ${delay}ms [${action}]`);
|
|
1635
|
+
return delay;
|
|
1636
|
+
}
|
|
1637
|
+
}, async () => fn());
|
|
1638
|
+
logger("info", "api", `Successfully sent to API [${action}]`);
|
|
1639
|
+
return result;
|
|
1640
|
+
};
|
|
1641
|
+
const getApiToken = async (config) => {
|
|
1642
|
+
const client = createClient(config.thirdEyePlatform, config.apiKey);
|
|
1643
|
+
try {
|
|
1644
|
+
return await withRetry("getApiToken", () => client.auth.getApiToken({
|
|
1645
|
+
projectId: config.thirdEyeProjectId,
|
|
1646
|
+
orgId: config.thirdEyeOrgId
|
|
1647
|
+
}));
|
|
1648
|
+
} catch (error) {
|
|
1649
|
+
if (error instanceof Error) log.process("error", "api", error.message);
|
|
1650
|
+
else log.process("error", "api", error);
|
|
1651
|
+
process.exit(1);
|
|
1652
|
+
}
|
|
1653
|
+
};
|
|
1654
|
+
const sendInitToAPI = async (config, apiToken) => {
|
|
1655
|
+
const client = createClient(config.thirdEyePlatform, void 0, apiToken);
|
|
1656
|
+
return withRetry("init", () => client.orgs.projects.builds.init({
|
|
1657
|
+
orgId: config.thirdEyeOrgId,
|
|
1658
|
+
projectId: config.thirdEyeProjectId,
|
|
1659
|
+
commit: config.commitHash,
|
|
1660
|
+
branchName: config.commitRefName,
|
|
1661
|
+
buildNumber: config.ciBuildNumber,
|
|
1662
|
+
baseBranch: config.baseBranch || void 0,
|
|
1663
|
+
prNumber: config.prNumber
|
|
1664
|
+
}));
|
|
1665
|
+
};
|
|
1666
|
+
const sendFinalizeToAPI = async (config, apiToken) => {
|
|
1667
|
+
const client = createClient(config.thirdEyePlatform, void 0, apiToken);
|
|
1668
|
+
return withRetry("finalize", () => client.orgs.projects.builds.finalize({
|
|
1669
|
+
orgId: config.thirdEyeOrgId,
|
|
1670
|
+
projectId: config.thirdEyeProjectId,
|
|
1671
|
+
branchName: config.commitRefName,
|
|
1672
|
+
commit: config.commitHash,
|
|
1673
|
+
buildNumber: config.ciBuildNumber
|
|
1674
|
+
}));
|
|
1675
|
+
};
|
|
1676
|
+
const sendCheckCacheToAPI = async (config, apiToken, cacheKey) => {
|
|
1677
|
+
const client = createClient(config.thirdEyePlatform, void 0, apiToken);
|
|
1678
|
+
return withRetry("checkCache", () => client.orgs.projects.builds.checkCache({
|
|
1679
|
+
orgId: config.thirdEyeOrgId,
|
|
1680
|
+
projectId: config.thirdEyeProjectId,
|
|
1681
|
+
cacheKey
|
|
1682
|
+
}));
|
|
1683
|
+
};
|
|
1684
|
+
const prepareUpload = async (config, apiToken, shotNamesWithHashes, cacheKey) => {
|
|
1685
|
+
const client = createClient(config.thirdEyePlatform, void 0, apiToken);
|
|
1686
|
+
return withRetry("prepareUpload", () => client.orgs.projects.uploads.prepare({
|
|
1687
|
+
orgId: config.thirdEyeOrgId,
|
|
1688
|
+
projectId: config.thirdEyeProjectId,
|
|
1689
|
+
branchName: config.commitRefName,
|
|
1690
|
+
commit: config.commitHash,
|
|
1691
|
+
buildNumber: config.ciBuildNumber,
|
|
1692
|
+
currentShots: shotNamesWithHashes,
|
|
1693
|
+
cacheKey
|
|
1694
|
+
}));
|
|
1695
|
+
};
|
|
1696
|
+
const uploadShot = async ({ config, apiToken, uploadToken, name, file, shotMode, componentPath, storyName, storyId, importPath, metadata, logger }) => {
|
|
1697
|
+
const client = createClient(config.thirdEyePlatform, void 0, apiToken);
|
|
1698
|
+
const logFn = logger?.process ?? log.process;
|
|
1699
|
+
const fileData = readFileSync(file).toString("base64");
|
|
1700
|
+
return withRetry("uploadShot", () => client.orgs.projects.uploads.uploadShot({
|
|
1701
|
+
orgId: config.thirdEyeOrgId,
|
|
1702
|
+
uploadToken,
|
|
1703
|
+
name,
|
|
1704
|
+
fileData,
|
|
1705
|
+
shotMode,
|
|
1706
|
+
componentPath,
|
|
1707
|
+
storyName,
|
|
1708
|
+
storyId,
|
|
1709
|
+
importPath,
|
|
1710
|
+
metadata
|
|
1711
|
+
}), logFn);
|
|
1712
|
+
};
|
|
1713
|
+
const processShots = async (config, apiToken, uploadToken, shotsConfig, cacheKey) => {
|
|
1714
|
+
const client = createClient(config.thirdEyePlatform, void 0, apiToken);
|
|
1715
|
+
return withRetry("processShots", () => client.orgs.projects.builds.processShots({
|
|
1716
|
+
orgId: config.thirdEyeOrgId,
|
|
1717
|
+
uploadToken,
|
|
1718
|
+
config: {
|
|
1719
|
+
shots: shotsConfig,
|
|
1720
|
+
threshold: config.threshold
|
|
1721
|
+
},
|
|
1722
|
+
log: logMemory,
|
|
1723
|
+
cacheKey
|
|
1724
|
+
}));
|
|
1725
|
+
};
|
|
1726
|
+
const sendRecordLogsToAPI = async (_config, _apiToken) => {
|
|
1727
|
+
log.process("info", "api", "Logs recorded with build");
|
|
1728
|
+
};
|
|
1729
|
+
const getAffectedStories = async (config, apiToken, changedFiles) => {
|
|
1730
|
+
const client = createClient(config.thirdEyePlatform, void 0, apiToken);
|
|
1731
|
+
return withRetry("getAffectedStories", () => client.orgs.projects.builds.getAffectedStories({
|
|
1732
|
+
orgId: config.thirdEyeOrgId,
|
|
1733
|
+
projectId: config.thirdEyeProjectId,
|
|
1734
|
+
changedFiles
|
|
1735
|
+
}));
|
|
1736
|
+
};
|
|
1737
|
+
const uploadStorybookArchive = async (config, apiToken, projectId, buildId, archive) => {
|
|
1738
|
+
const client = createClient(config.thirdEyePlatform, void 0, apiToken);
|
|
1739
|
+
return withRetry("uploadStorybookArchive", () => client.orgs.projects.storybook.uploadArchive({
|
|
1740
|
+
orgId: config.thirdEyeOrgId,
|
|
1741
|
+
projectId,
|
|
1742
|
+
buildId,
|
|
1743
|
+
archive
|
|
1744
|
+
}));
|
|
1745
|
+
};
|
|
1746
|
+
//#endregion
|
|
1747
|
+
//#region src/upload.ts
|
|
1748
|
+
const uploadRequiredShots = async ({ config, apiToken, uploadToken, requiredFileHashes, extendedShotItems }) => {
|
|
1749
|
+
if (requiredFileHashes.length > 0) {
|
|
1750
|
+
log.process("info", "api", "Uploading shots");
|
|
1751
|
+
const uploadStart = process.hrtime();
|
|
1752
|
+
const requiredShotItems = extendedShotItems.filter((shotItem) => requiredFileHashes.includes(shotItem.hash));
|
|
1753
|
+
await mapLimit(requiredShotItems.entries(), 10, async ([index, shotItem]) => {
|
|
1754
|
+
const logger = log.item({
|
|
1755
|
+
shotMode: shotItem.shotMode,
|
|
1756
|
+
uniqueItemId: shotItem.shotName,
|
|
1757
|
+
itemIndex: index,
|
|
1758
|
+
totalItems: requiredShotItems.length
|
|
1759
|
+
});
|
|
1760
|
+
const htmlPath = shotItem.filePathCurrent.replace(/\.png$/, ".html");
|
|
1761
|
+
const domHtml = existsSync(htmlPath) ? readFileSync(htmlPath).toString("base64") : void 0;
|
|
1762
|
+
await uploadShot({
|
|
1763
|
+
config,
|
|
1764
|
+
apiToken,
|
|
1765
|
+
uploadToken,
|
|
1766
|
+
name: `${shotItem.shotMode}/${shotItem.shotName}`,
|
|
1767
|
+
file: shotItem.filePathCurrent,
|
|
1768
|
+
shotMode: shotItem.shotMode,
|
|
1769
|
+
componentPath: shotItem.componentPath,
|
|
1770
|
+
storyName: shotItem.storyName,
|
|
1771
|
+
storyId: shotItem.storyId,
|
|
1772
|
+
importPath: shotItem.importPath,
|
|
1773
|
+
metadata: {
|
|
1774
|
+
...shotItem.storyArgs ? { args: shotItem.storyArgs } : {},
|
|
1775
|
+
...shotItem.tags ? { tags: shotItem.tags } : {},
|
|
1776
|
+
...shotItem.viewport ? { viewport: shotItem.viewport } : {},
|
|
1777
|
+
...shotItem.breakpoint !== void 0 ? { breakpoint: shotItem.breakpoint } : {},
|
|
1778
|
+
...domHtml ? { dom_html: domHtml } : {}
|
|
1779
|
+
},
|
|
1780
|
+
logger
|
|
1781
|
+
});
|
|
1782
|
+
});
|
|
1783
|
+
const uploadStop = process.hrtime(uploadStart);
|
|
1784
|
+
log.process("info", "api", `Uploading shots took ${parseHrtimeToSeconds(uploadStop)} seconds`);
|
|
1785
|
+
}
|
|
1786
|
+
return true;
|
|
1787
|
+
};
|
|
1788
|
+
//#endregion
|
|
1789
|
+
//#region src/runner.ts
|
|
1790
|
+
/**
|
|
1791
|
+
* Get the list of files changed between the current HEAD and a base ref
|
|
1792
|
+
* using git diff. Returns an empty array if git is unavailable or fails.
|
|
1793
|
+
*/
|
|
1794
|
+
const getChangedFiles = (baseRef) => {
|
|
1795
|
+
try {
|
|
1796
|
+
return execSync(`git diff --name-only ${baseRef}...HEAD`, {
|
|
1797
|
+
encoding: "utf-8",
|
|
1798
|
+
timeout: 3e4
|
|
1799
|
+
}).trim().split("\n").filter((f) => f.length > 0);
|
|
1800
|
+
} catch (error) {
|
|
1801
|
+
if (error instanceof Error) log.process("warn", "general", `Failed to get changed files: ${error.message}`);
|
|
1802
|
+
return [];
|
|
1803
|
+
}
|
|
1804
|
+
};
|
|
1805
|
+
const runner = async (config) => {
|
|
1806
|
+
const executionStart = process.hrtime();
|
|
1807
|
+
try {
|
|
1808
|
+
if (isUpdateMode()) log.process("info", "general", "Running third-eye in update mode. Baseline screenshots will be updated");
|
|
1809
|
+
log.process("info", "general", "📂 Creating shot folders");
|
|
1810
|
+
const createShotsStart = process.hrtime();
|
|
1811
|
+
createShotsFolders();
|
|
1812
|
+
log.process("info", "general", "📸 Creating shots");
|
|
1813
|
+
const shotItems = await createShots();
|
|
1814
|
+
const createShotsStop = process.hrtime(createShotsStart);
|
|
1815
|
+
log.process("info", "general", `Creating shots took ${parseHrtimeToSeconds(createShotsStop)} seconds`);
|
|
1816
|
+
if (config.generateOnly && shotItems.length === 0) {
|
|
1817
|
+
log.process("info", "general", `👋 Exiting process with nothing to compare.`);
|
|
1818
|
+
await exitProcess({ shotsNumber: shotItems.length });
|
|
1819
|
+
}
|
|
1820
|
+
log.process("info", "general", "🔍 Checking differences");
|
|
1821
|
+
const checkDifferenceStart = process.hrtime();
|
|
1822
|
+
const { filterItemsToCheck } = config;
|
|
1823
|
+
const { aboveThresholdDifferenceItems, noBaselinesItems } = await checkDifferences(filterItemsToCheck ? shotItems.filter((item) => filterItemsToCheck(item)) : shotItems);
|
|
1824
|
+
if (isUpdateMode()) {
|
|
1825
|
+
removeFilesInFolder(config.imagePathBaseline, shotItems.map((shotItem) => shotItem.filePathBaseline));
|
|
1826
|
+
for (const noBaselineItem of noBaselinesItems) fs.copySync(noBaselineItem.filePathCurrent, noBaselineItem.filePathBaseline);
|
|
1827
|
+
for (const aboveThresholdDifferenceItem of aboveThresholdDifferenceItems) fs.copySync(aboveThresholdDifferenceItem.filePathCurrent, aboveThresholdDifferenceItem.filePathBaseline);
|
|
1828
|
+
}
|
|
1829
|
+
if ((aboveThresholdDifferenceItems.length > 0 || noBaselinesItems.length > 0) && config.failOnDifference) {
|
|
1830
|
+
log.process("info", "general", `👋 Exiting process with ${aboveThresholdDifferenceItems.length} found differences & ${noBaselinesItems.length} baselines to update`);
|
|
1831
|
+
if (config.generateOnly) await exitProcess({ shotsNumber: shotItems.length });
|
|
1832
|
+
}
|
|
1833
|
+
const checkDifferenceStop = process.hrtime(checkDifferenceStart);
|
|
1834
|
+
log.process("info", "general", `⏱ Checking differences took ${parseHrtimeToSeconds(checkDifferenceStop)} seconds`);
|
|
1835
|
+
const executionStop = process.hrtime(executionStart);
|
|
1836
|
+
log.process("info", "general", `⏱ Lost Pixel run took ${parseHrtimeToSeconds(executionStop)} seconds`);
|
|
1837
|
+
await exitProcess({
|
|
1838
|
+
shotsNumber: shotItems.length,
|
|
1839
|
+
runDuration: Number(parseHrtimeToSeconds(executionStop)),
|
|
1840
|
+
exitCode: 0
|
|
1841
|
+
});
|
|
1842
|
+
} catch (error) {
|
|
1843
|
+
const executionStop = process.hrtime(executionStart);
|
|
1844
|
+
if (error instanceof Error) log.process("error", "general", error.message);
|
|
1845
|
+
else log.process("error", "general", error);
|
|
1846
|
+
await exitProcess({
|
|
1847
|
+
runDuration: Number(parseHrtimeToSeconds(executionStop)),
|
|
1848
|
+
error
|
|
1849
|
+
});
|
|
1850
|
+
}
|
|
1851
|
+
};
|
|
1852
|
+
const getPlatformApiToken = async (config) => {
|
|
1853
|
+
if (!config.apiKey) {
|
|
1854
|
+
log.process("error", "general", `Running Lost Pixel in 'platform' mode requires an API key`);
|
|
1855
|
+
process.exit(1);
|
|
1856
|
+
}
|
|
1857
|
+
if (isUpdateMode()) {
|
|
1858
|
+
log.process("error", "general", `Running Lost Pixel in 'update' mode requires the 'generateOnly' option to be set to true`);
|
|
1859
|
+
process.exit(1);
|
|
1860
|
+
}
|
|
1861
|
+
try {
|
|
1862
|
+
return (await getApiToken(config)).apiToken;
|
|
1863
|
+
} catch (error) {
|
|
1864
|
+
if (error instanceof Error) log.process("error", "general", error.message);
|
|
1865
|
+
else log.process("error", "general", error);
|
|
1866
|
+
process.exit(1);
|
|
1867
|
+
}
|
|
1868
|
+
};
|
|
1869
|
+
const checkForCachedBuild = async (config, apiToken) => {
|
|
1870
|
+
if (process.env.THIRD_EYE_CACHE_KEY) {
|
|
1871
|
+
log.process("info", "general", `♻️ Using cache key ${process.env.THIRD_EYE_CACHE_KEY}`);
|
|
1872
|
+
const { cacheExists } = await sendCheckCacheToAPI(config, apiToken, process.env.THIRD_EYE_CACHE_KEY);
|
|
1873
|
+
if (cacheExists) {
|
|
1874
|
+
log.process("info", "general", `♻️ Cache hit for key ${process.env.THIRD_EYE_CACHE_KEY} - Skipping shot creation`);
|
|
1875
|
+
const { uploadToken } = await prepareUpload(config, apiToken, [], process.env.THIRD_EYE_CACHE_KEY);
|
|
1876
|
+
await processShots(config, apiToken, uploadToken, [], process.env.THIRD_EYE_CACHE_KEY);
|
|
1877
|
+
return true;
|
|
1878
|
+
}
|
|
1879
|
+
log.process("info", "general", `♻️ Cache miss for key ${process.env.THIRD_EYE_CACHE_KEY}`);
|
|
1880
|
+
}
|
|
1881
|
+
return false;
|
|
1882
|
+
};
|
|
1883
|
+
const platformRunner = async (config, apiToken) => {
|
|
1884
|
+
const executionStart = process.hrtime();
|
|
1885
|
+
try {
|
|
1886
|
+
log.process("info", "general", [
|
|
1887
|
+
"📀 Using details:",
|
|
1888
|
+
`ciBuildId = ${config.ciBuildId}`,
|
|
1889
|
+
`ciBuildNumber = ${config.ciBuildNumber}`,
|
|
1890
|
+
`repository = ${config.repository}`,
|
|
1891
|
+
`commitRefName = ${config.commitRefName}`,
|
|
1892
|
+
`commitHash = ${config.commitHash}`
|
|
1893
|
+
].join("\n - "));
|
|
1894
|
+
await sendInitToAPI(config, apiToken);
|
|
1895
|
+
if (!await checkForCachedBuild(config, apiToken)) {
|
|
1896
|
+
log.process("info", "general", "📂 Creating shot folders");
|
|
1897
|
+
const createShotsStart = process.hrtime();
|
|
1898
|
+
createShotsFolders();
|
|
1899
|
+
log.process("info", "general", "📸 Creating shots");
|
|
1900
|
+
let shotItems = await createShots();
|
|
1901
|
+
if (config.turboSnap && config.baseBranch) {
|
|
1902
|
+
log.process("info", "general", `⚡ TurboSnap enabled, checking changed files against ${config.baseBranch}`);
|
|
1903
|
+
const changedFiles = getChangedFiles(config.baseBranch);
|
|
1904
|
+
if (changedFiles.length > 0) {
|
|
1905
|
+
log.process("info", "general", `Found ${changedFiles.length} changed file(s)`);
|
|
1906
|
+
try {
|
|
1907
|
+
const turboResult = await getAffectedStories(config, apiToken, changedFiles);
|
|
1908
|
+
log.process("info", "general", `TurboSnap: ${turboResult.affectedCount} affected, ${turboResult.skippedCount} skipped`);
|
|
1909
|
+
if (turboResult.affected.length > 0) {
|
|
1910
|
+
const affectedSet = new Set(turboResult.affected);
|
|
1911
|
+
shotItems = shotItems.filter((item) => affectedSet.has(item.shotName) || affectedSet.has(`${item.shotMode}/${item.shotName}`));
|
|
1912
|
+
}
|
|
1913
|
+
} catch (error) {
|
|
1914
|
+
if (error instanceof Error) log.process("warn", "general", `TurboSnap filtering failed, capturing all stories: ${error.message}`);
|
|
1915
|
+
}
|
|
1916
|
+
} else log.process("info", "general", "TurboSnap: no changed files detected, capturing all stories");
|
|
1917
|
+
}
|
|
1918
|
+
const shotNames = shotItems.map((shotItem) => shotItem.shotName);
|
|
1919
|
+
const uniqueShotNames = new Set(shotNames);
|
|
1920
|
+
if (shotNames.length !== uniqueShotNames.size) {
|
|
1921
|
+
const duplicates = shotNames.filter((shotName) => shotNames.filter((item) => item === shotName).length > 1);
|
|
1922
|
+
throw new Error(`Error: Shot names must be unique (check for duplicate Story names: [ ${[...new Set(duplicates)].join(", ")} ])`);
|
|
1923
|
+
}
|
|
1924
|
+
const createShotsStop = process.hrtime(createShotsStart);
|
|
1925
|
+
log.process("info", "general", `⏱ Creating shots took ${parseHrtimeToSeconds(createShotsStop)} seconds`);
|
|
1926
|
+
const extendedShotItems = shotItems.map((shotItem) => ({
|
|
1927
|
+
...shotItem,
|
|
1928
|
+
uniqueName: `${shotItem.shotMode}/${shotItem.shotName}`,
|
|
1929
|
+
hash: hashFile(shotItem.filePathCurrent)
|
|
1930
|
+
}));
|
|
1931
|
+
const { buildId, requiredFileHashes, uploadToken } = await prepareUpload(config, apiToken, extendedShotItems.map((shotItem) => ({
|
|
1932
|
+
name: shotItem.uniqueName,
|
|
1933
|
+
hash: shotItem.hash,
|
|
1934
|
+
storyId: shotItem.storyId
|
|
1935
|
+
})));
|
|
1936
|
+
log.process("info", "general", [
|
|
1937
|
+
`🏙 `,
|
|
1938
|
+
`${shotItems.length} shot(s) in total.`,
|
|
1939
|
+
`${shotItems.length - requiredFileHashes.length} shot(s) already exist on platform.`,
|
|
1940
|
+
`${requiredFileHashes.length} shot(s) will be uploaded.`
|
|
1941
|
+
].join(" "));
|
|
1942
|
+
await uploadRequiredShots({
|
|
1943
|
+
config,
|
|
1944
|
+
apiToken,
|
|
1945
|
+
uploadToken,
|
|
1946
|
+
requiredFileHashes,
|
|
1947
|
+
extendedShotItems
|
|
1948
|
+
});
|
|
1949
|
+
await processShots(config, apiToken, uploadToken, shotItems.map((shotItem) => ({
|
|
1950
|
+
name: `${shotItem.shotMode}/${shotItem.shotName}`,
|
|
1951
|
+
threshold: shotItem.threshold
|
|
1952
|
+
})), process.env.THIRD_EYE_CACHE_KEY);
|
|
1953
|
+
if (config.storybookStaticDir && existsSync(config.storybookStaticDir)) {
|
|
1954
|
+
log.process("info", "general", "Uploading Storybook archive...");
|
|
1955
|
+
const archive = execSync(`tar -czf - -C ${JSON.stringify(config.storybookStaticDir)} .`, { maxBuffer: 50 * 1024 * 1024 }).toString("base64");
|
|
1956
|
+
const result = await uploadStorybookArchive(config, apiToken, config.thirdEyeProjectId, buildId, archive);
|
|
1957
|
+
log.process("info", "general", `Storybook uploaded (${result.fileCount} files, ${Math.round(result.totalSizeBytes / 1024)} KB)`);
|
|
1958
|
+
log.process("info", "general", `Storybook URL: ${result.url}`);
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
const executionStop = process.hrtime(executionStart);
|
|
1962
|
+
log.process("info", "general", `⏱ Lost Pixel run took ${parseHrtimeToSeconds(executionStop)} seconds`);
|
|
1963
|
+
} catch (error) {
|
|
1964
|
+
if (error instanceof Error) log.process("error", "general", error.message);
|
|
1965
|
+
else log.process("error", "general", error);
|
|
1966
|
+
log.process("info", "general", "🪵 Sending logs to platform.");
|
|
1967
|
+
await sendRecordLogsToAPI(config, apiToken);
|
|
1968
|
+
process.exit(1);
|
|
1969
|
+
}
|
|
1970
|
+
};
|
|
1971
|
+
//#endregion
|
|
1972
|
+
//#region src/docker-runner/utils.ts
|
|
1973
|
+
const executeDockerRun = async ({ version }) => {
|
|
1974
|
+
const isUpdateModeEnabled = isUpdateMode();
|
|
1975
|
+
const isGenerateMetaEnabled = shallGenerateMeta();
|
|
1976
|
+
const isLocalDebugModeEnabled = isLocalDebugMode();
|
|
1977
|
+
const argv = yargs(hideBin(process.argv)).parseSync();
|
|
1978
|
+
return execa("docker", [
|
|
1979
|
+
"run",
|
|
1980
|
+
"--rm",
|
|
1981
|
+
`-v ${process.cwd()}:${process.cwd()}`,
|
|
1982
|
+
`-e WORKSPACE=${process.cwd()}`,
|
|
1983
|
+
"-e DOCKER=1",
|
|
1984
|
+
`-e THIRD_EYE_DISABLE_TELEMETRY=${process.env.THIRD_EYE_DISABLE_TELEMETRY}`,
|
|
1985
|
+
argv.configDir ? `-e THIRD_EYE_CONFIG_DIR=${argv.configDir}` : "",
|
|
1986
|
+
isUpdateModeEnabled ? "-e THIRD_EYE_MODE=update" : "",
|
|
1987
|
+
isGenerateMetaEnabled ? "-e THIRD_EYE_GENERATE_META=true" : "",
|
|
1988
|
+
isLocalDebugModeEnabled ? "-e THIRD_EYE_LOCAL=true" : "",
|
|
1989
|
+
`thirdeye/third-eye:v${version}`
|
|
1990
|
+
], {
|
|
1991
|
+
shell: true,
|
|
1992
|
+
stdio: "inherit"
|
|
1993
|
+
});
|
|
1994
|
+
};
|
|
1995
|
+
//#endregion
|
|
1996
|
+
//#region src/docker-runner/index.ts
|
|
1997
|
+
const runInDocker = async () => {
|
|
1998
|
+
const version = getVersion();
|
|
1999
|
+
if (version) {
|
|
2000
|
+
log.process("info", "general", `Running in docker: third-eye:${version}`);
|
|
2001
|
+
try {
|
|
2002
|
+
await executeDockerRun({ version });
|
|
2003
|
+
} catch (error) {
|
|
2004
|
+
log.process("error", "general", error);
|
|
2005
|
+
}
|
|
2006
|
+
} else log.process("error", "config", "Seems like third-eye is missing in your package.json. Running third-eye@latest");
|
|
2007
|
+
};
|
|
2008
|
+
//#endregion
|
|
2009
|
+
//#region src/generatePagesFromSitemap.ts
|
|
2010
|
+
async function fetchSitemap(url) {
|
|
2011
|
+
if (url.startsWith("http")) return (await axios.get(url)).data;
|
|
2012
|
+
return readFileSync(url, "utf8");
|
|
2013
|
+
}
|
|
2014
|
+
async function parseSitemap(sitemapContent) {
|
|
2015
|
+
const result = new XMLParser({ isArray: (_tagName, jPath) => typeof jPath === "string" && (jPath === "urlset.url" || jPath === "urlset.url.loc" || jPath === "urlset.url.lastmod") }).parse(sitemapContent);
|
|
2016
|
+
if (!result.urlset || !Array.isArray(result.urlset.url)) throw new Error("Invalid sitemap format");
|
|
2017
|
+
return result.urlset.url.filter((urlEntry) => urlEntry.loc && urlEntry.loc.length > 0).map((urlEntry) => urlEntry.loc[0]);
|
|
2018
|
+
}
|
|
2019
|
+
async function generatePagesFileFromSitemap(url, options) {
|
|
2020
|
+
try {
|
|
2021
|
+
const pages = (await parseSitemap(await fetchSitemap(url))).map((url) => {
|
|
2022
|
+
return PageScreenshotParameterSchema.parse({
|
|
2023
|
+
path: new URL(url).pathname,
|
|
2024
|
+
name: url.replace(/^https?:\/\/(www\.)?|^www\./g, "").replace(/\//g, "_")
|
|
2025
|
+
});
|
|
2026
|
+
});
|
|
2027
|
+
writeFileSync(options.outputPath, JSON.stringify(pages, null, 2));
|
|
2028
|
+
log.process("info", "general", "✅ Pages file generated successfully at", options.outputPath);
|
|
2029
|
+
} catch (error) {
|
|
2030
|
+
log.process("error", "general", "❌ Pages file generation errored out. Please check the error message below", error);
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
const generatePagesFromSitemap = async () => {
|
|
2034
|
+
const { sitemapUrl, outputPath } = await yargs(hideBin(process.argv)).usage("Usage: $0 <command> <sitemapUrl> <outputPath>").command("page-sitemap-gen <sitemapUrl> <outputPath>", "Generate pages file from sitemap").demandCommand(1).argv;
|
|
2035
|
+
if (!sitemapUrl || typeof sitemapUrl !== "string" || !outputPath || typeof outputPath !== "string") {
|
|
2036
|
+
log.process("error", "general", "❌ sitemapUrl and outputPath are required");
|
|
2037
|
+
return;
|
|
2038
|
+
}
|
|
2039
|
+
log.process("info", "general", `🧬 Running third-eye in sitemap-page-gen mode. Pages file will be generated from provided sitemap on ${sitemapUrl}`);
|
|
2040
|
+
await generatePagesFileFromSitemap(sitemapUrl, { outputPath });
|
|
2041
|
+
};
|
|
2042
|
+
//#endregion
|
|
2043
|
+
//#region src/bin.ts
|
|
2044
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
2045
|
+
const commandArgs = yargs(hideBin(process.argv)).parseSync()._;
|
|
2046
|
+
const version = getVersion();
|
|
2047
|
+
if (version) log.process("info", "general", `Version: ${version}`);
|
|
2048
|
+
(async () => {
|
|
2049
|
+
if (isSitemapPageGenMode()) {
|
|
2050
|
+
await generatePagesFromSitemap();
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
if (isDockerMode()) await runInDocker();
|
|
2054
|
+
else if (commandArgs.includes("init-js")) {
|
|
2055
|
+
log.process("info", "general", "Initializing javascript third-eye config");
|
|
2056
|
+
await fs.copy(path.join(__dirname, "..", "config-templates", "example.thirdeye.config.js"), path.join(process.cwd(), "./thirdeye.config.js"));
|
|
2057
|
+
log.process("info", "general", "✅ Config successfully initialized");
|
|
2058
|
+
} else if (commandArgs.includes("init-ts")) {
|
|
2059
|
+
log.process("info", "general", "Initializing typescript third-eye config");
|
|
2060
|
+
const modifiedFile = fs.readFileSync(path.join(__dirname, "..", "config-templates", "example.thirdeye.config.ts")).toString().replace("../src/config", "third-eye");
|
|
2061
|
+
fs.writeFileSync(path.join(process.cwd(), "./thirdeye.config.ts"), modifiedFile);
|
|
2062
|
+
log.process("info", "general", "✅ Config successfully initialized");
|
|
2063
|
+
} else {
|
|
2064
|
+
if (process.env.GITHUB_EVENT_PATH && !process.env.THIRD_EYE_PR_NUMBER) try {
|
|
2065
|
+
const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf-8"));
|
|
2066
|
+
const prNumber = event.pull_request?.number ?? event.number;
|
|
2067
|
+
if (prNumber) process.env.THIRD_EYE_PR_NUMBER = String(prNumber);
|
|
2068
|
+
} catch {}
|
|
2069
|
+
await configure({ localDebugMode: isLocalDebugMode() });
|
|
2070
|
+
if (isPlatformModeConfig(config)) {
|
|
2071
|
+
log.process("info", "general", `🚀 Starting Lost Pixel in 'platform' mode`);
|
|
2072
|
+
const apiToken = await getPlatformApiToken(config);
|
|
2073
|
+
if (commandArgs.includes("finalize")) await sendFinalizeToAPI(config, apiToken);
|
|
2074
|
+
else await platformRunner(config, apiToken);
|
|
2075
|
+
} else {
|
|
2076
|
+
log.process("info", "general", `🚀 Starting Lost Pixel in 'generateOnly' mode`);
|
|
2077
|
+
await runner(config);
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
})();
|
|
2081
|
+
//#endregion
|
|
2082
|
+
export {};
|
|
2083
|
+
|
|
2084
|
+
//# sourceMappingURL=bin.mjs.map
|