snapdrift 0.2.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/CHANGELOG.md +43 -0
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/actions/baseline/action.yml +142 -0
- package/actions/capture/action.yml +78 -0
- package/actions/comment/action.yml +104 -0
- package/actions/compare/action.yml +163 -0
- package/actions/enforce/action.yml +50 -0
- package/actions/pr-diff/action.yml +612 -0
- package/actions/resolve-baseline/action.yml +165 -0
- package/actions/scope/action.yml +107 -0
- package/actions/stage/action.yml +116 -0
- package/bin/snapdrift.mjs +14 -0
- package/docs/contracts.md +233 -0
- package/docs/integration-guide.md +222 -0
- package/docs/local-cli.md +174 -0
- package/lib/capture-routes.mjs +370 -0
- package/lib/cli.mjs +241 -0
- package/lib/compare-results.mjs +643 -0
- package/lib/drift-summary.mjs +156 -0
- package/lib/pr-comment.mjs +147 -0
- package/lib/report.mjs +268 -0
- package/lib/snapdrift-config.mjs +280 -0
- package/lib/stage-artifacts.mjs +125 -0
- package/package.json +94 -0
- package/types/visual-diff-types.d.ts +137 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import { chromium } from 'playwright';
|
|
7
|
+
import pngjs from 'pngjs';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
loadSnapdriftConfig,
|
|
11
|
+
readFirstDefinedEnv,
|
|
12
|
+
resolveFromWorkingDirectory,
|
|
13
|
+
selectConfiguredRoutes,
|
|
14
|
+
splitCommaList,
|
|
15
|
+
SNAPDRIFT_VIEWPORT_PRESETS,
|
|
16
|
+
SNAPDRIFT_NAVIGATION_TIMEOUT_MS,
|
|
17
|
+
SNAPDRIFT_SETTLE_DELAY_MS
|
|
18
|
+
} from './snapdrift-config.mjs';
|
|
19
|
+
|
|
20
|
+
const { PNG } = pngjs;
|
|
21
|
+
|
|
22
|
+
/** @typedef {import('../types/visual-diff-types').VisualBaselineResults} BaselineResults */
|
|
23
|
+
/** @typedef {import('../types/visual-diff-types').VisualBaselineRouteResult} BaselineRouteResult */
|
|
24
|
+
/** @typedef {import('../types/visual-diff-types').VisualRegressionRouteConfig} SnapdriftRouteConfig */
|
|
25
|
+
/** @typedef {import('../types/visual-diff-types').VisualScreenshotManifest} ScreenshotManifest */
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} targetPath
|
|
29
|
+
* @returns {Promise<void>}
|
|
30
|
+
*/
|
|
31
|
+
async function ensureParentDirectory(targetPath) {
|
|
32
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} targetPath
|
|
37
|
+
* @returns {Promise<void>}
|
|
38
|
+
*/
|
|
39
|
+
async function ensureDirectory(targetPath) {
|
|
40
|
+
await fs.mkdir(targetPath, { recursive: true });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Strips characters that could cause path traversal or produce invalid filenames.
|
|
45
|
+
* @param {string} id
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
function sanitizeRouteId(id) {
|
|
49
|
+
return id
|
|
50
|
+
.replace(/\.\./g, '_')
|
|
51
|
+
.replace(/[/\\]/g, '_')
|
|
52
|
+
// eslint-disable-next-line no-control-regex
|
|
53
|
+
.replace(/[\x00-\x1f\x7f]/g, '');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @typedef {import('../types/visual-diff-types').VisualViewport} VisualViewport */
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Returns a stable string key for a viewport value (preset name or custom dimensions).
|
|
60
|
+
* @param {VisualViewport} viewport
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
function viewportKey(viewport) {
|
|
64
|
+
return typeof viewport === 'string' ? viewport : `custom:${viewport.width}x${viewport.height}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Returns a human-readable label for a viewport.
|
|
69
|
+
* @param {VisualViewport} viewport
|
|
70
|
+
* @returns {string}
|
|
71
|
+
*/
|
|
72
|
+
function viewportLabel(viewport) {
|
|
73
|
+
return typeof viewport === 'string' ? viewport : `${viewport.width}x${viewport.height}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Returns Playwright context options for a viewport.
|
|
78
|
+
* @param {VisualViewport} viewport
|
|
79
|
+
* @returns {import('playwright').BrowserContextOptions}
|
|
80
|
+
*/
|
|
81
|
+
function viewportContextOptions(viewport) {
|
|
82
|
+
if (typeof viewport === 'string') {
|
|
83
|
+
const preset = SNAPDRIFT_VIEWPORT_PRESETS[viewport];
|
|
84
|
+
return {
|
|
85
|
+
viewport: { width: preset.width, height: preset.height },
|
|
86
|
+
deviceScaleFactor: preset.deviceScaleFactor,
|
|
87
|
+
isMobile: preset.isMobile,
|
|
88
|
+
hasTouch: preset.hasTouch
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
viewport: { width: viewport.width, height: viewport.height },
|
|
93
|
+
deviceScaleFactor: 1,
|
|
94
|
+
isMobile: false,
|
|
95
|
+
hasTouch: false
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {import('playwright').Browser} browser
|
|
101
|
+
* @returns {Promise<Map<'desktop' | 'mobile', import('playwright').BrowserContext>>}
|
|
102
|
+
*/
|
|
103
|
+
async function createViewportContexts(browser) {
|
|
104
|
+
return new Map([
|
|
105
|
+
['desktop', await browser.newContext({
|
|
106
|
+
viewport: { width: SNAPDRIFT_VIEWPORT_PRESETS.desktop.width, height: SNAPDRIFT_VIEWPORT_PRESETS.desktop.height },
|
|
107
|
+
deviceScaleFactor: SNAPDRIFT_VIEWPORT_PRESETS.desktop.deviceScaleFactor,
|
|
108
|
+
isMobile: SNAPDRIFT_VIEWPORT_PRESETS.desktop.isMobile,
|
|
109
|
+
hasTouch: SNAPDRIFT_VIEWPORT_PRESETS.desktop.hasTouch
|
|
110
|
+
})],
|
|
111
|
+
['mobile', await browser.newContext({
|
|
112
|
+
viewport: { width: SNAPDRIFT_VIEWPORT_PRESETS.mobile.width, height: SNAPDRIFT_VIEWPORT_PRESETS.mobile.height },
|
|
113
|
+
deviceScaleFactor: SNAPDRIFT_VIEWPORT_PRESETS.mobile.deviceScaleFactor,
|
|
114
|
+
isMobile: SNAPDRIFT_VIEWPORT_PRESETS.mobile.isMobile,
|
|
115
|
+
hasTouch: SNAPDRIFT_VIEWPORT_PRESETS.mobile.hasTouch
|
|
116
|
+
})]
|
|
117
|
+
]);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param {import('playwright').BrowserContext} context
|
|
122
|
+
* @param {SnapdriftRouteConfig} route
|
|
123
|
+
* @param {string} baseUrl
|
|
124
|
+
* @param {string} screenshotsRoot
|
|
125
|
+
* @returns {Promise<BaselineRouteResult & { manifestEntry?: ScreenshotManifest['screenshots'][number] }>}
|
|
126
|
+
*/
|
|
127
|
+
async function captureRoute(context, route, baseUrl, screenshotsRoot) {
|
|
128
|
+
const startedAt = Date.now();
|
|
129
|
+
|
|
130
|
+
const page = await context.newPage();
|
|
131
|
+
try {
|
|
132
|
+
const targetUrl = new URL(route.path, baseUrl).toString();
|
|
133
|
+
await page.goto(targetUrl, {
|
|
134
|
+
waitUntil: 'networkidle',
|
|
135
|
+
timeout: SNAPDRIFT_NAVIGATION_TIMEOUT_MS
|
|
136
|
+
});
|
|
137
|
+
await page.waitForTimeout(SNAPDRIFT_SETTLE_DELAY_MS);
|
|
138
|
+
|
|
139
|
+
const imagePath = path.join('screenshots', `${sanitizeRouteId(route.id)}.png`);
|
|
140
|
+
const absoluteImagePath = path.join(screenshotsRoot, imagePath);
|
|
141
|
+
await ensureParentDirectory(absoluteImagePath);
|
|
142
|
+
const screenshotBuffer = await page.screenshot({
|
|
143
|
+
path: absoluteImagePath,
|
|
144
|
+
fullPage: true
|
|
145
|
+
});
|
|
146
|
+
const screenshot = PNG.sync.read(screenshotBuffer);
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
id: route.id,
|
|
150
|
+
path: route.path,
|
|
151
|
+
viewport: route.viewport,
|
|
152
|
+
status: 'passed',
|
|
153
|
+
durationMs: Date.now() - startedAt,
|
|
154
|
+
imagePath,
|
|
155
|
+
width: screenshot.width,
|
|
156
|
+
height: screenshot.height,
|
|
157
|
+
manifestEntry: {
|
|
158
|
+
id: route.id,
|
|
159
|
+
path: route.path,
|
|
160
|
+
viewport: route.viewport,
|
|
161
|
+
imagePath,
|
|
162
|
+
width: screenshot.width,
|
|
163
|
+
height: screenshot.height
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
} catch (error) {
|
|
167
|
+
return {
|
|
168
|
+
id: route.id,
|
|
169
|
+
path: route.path,
|
|
170
|
+
viewport: route.viewport,
|
|
171
|
+
status: 'failed',
|
|
172
|
+
durationMs: Date.now() - startedAt,
|
|
173
|
+
error: error instanceof Error ? error.message : String(error)
|
|
174
|
+
};
|
|
175
|
+
} finally {
|
|
176
|
+
await page.close();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const CAPTURE_MAX_RETRIES = 1;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* @param {import('playwright').BrowserContext} context
|
|
184
|
+
* @param {SnapdriftRouteConfig} route
|
|
185
|
+
* @param {string} baseUrl
|
|
186
|
+
* @param {string} screenshotsRoot
|
|
187
|
+
* @returns {Promise<BaselineRouteResult & { manifestEntry?: ScreenshotManifest['screenshots'][number] }>}
|
|
188
|
+
*/
|
|
189
|
+
async function captureRouteWithRetry(context, route, baseUrl, screenshotsRoot) {
|
|
190
|
+
let result = await captureRoute(context, route, baseUrl, screenshotsRoot);
|
|
191
|
+
for (let attempt = 1; attempt <= CAPTURE_MAX_RETRIES && result.status !== 'passed'; attempt++) {
|
|
192
|
+
console.log(`[SnapDrift] Retrying route ${route.id} (attempt ${attempt + 1}/${CAPTURE_MAX_RETRIES + 1})...`);
|
|
193
|
+
result = await captureRoute(context, route, baseUrl, screenshotsRoot);
|
|
194
|
+
}
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Captures all routes for a single viewport context sequentially, logging progress.
|
|
200
|
+
*
|
|
201
|
+
* @param {import('playwright').BrowserContext} context
|
|
202
|
+
* @param {Array<{ route: SnapdriftRouteConfig, originalIndex: number }>} entries
|
|
203
|
+
* @param {string} baseUrl
|
|
204
|
+
* @param {string} screenshotsRoot
|
|
205
|
+
* @param {number} totalRoutes
|
|
206
|
+
* @param {Array<BaselineRouteResult & { manifestEntry?: ScreenshotManifest['screenshots'][number] }>} out
|
|
207
|
+
* @returns {Promise<void>}
|
|
208
|
+
*/
|
|
209
|
+
async function captureViewportRoutes(context, entries, baseUrl, screenshotsRoot, totalRoutes, out) {
|
|
210
|
+
for (const { route, originalIndex } of entries) {
|
|
211
|
+
console.log(`[SnapDrift] Capturing route ${originalIndex + 1}/${totalRoutes}: ${route.id} (${viewportLabel(route.viewport)})`);
|
|
212
|
+
out[originalIndex] = await captureRouteWithRetry(context, route, baseUrl, screenshotsRoot);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* @param {{
|
|
218
|
+
* configPath?: string,
|
|
219
|
+
* routeIds?: Iterable<string>,
|
|
220
|
+
* outDir?: string
|
|
221
|
+
* }} [options]
|
|
222
|
+
* @returns {Promise<{ resultsPath: string, manifestPath: string, screenshotsRoot: string, selectedRouteIds: string[] }>}
|
|
223
|
+
*/
|
|
224
|
+
export async function runBaselineCapture(options = {}) {
|
|
225
|
+
const requestedRouteIds = [...(
|
|
226
|
+
options.routeIds || splitCommaList(readFirstDefinedEnv(['SNAPDRIFT_ROUTE_IDS']))
|
|
227
|
+
)];
|
|
228
|
+
const { config, configPath } = await loadSnapdriftConfig(options.configPath);
|
|
229
|
+
const { routes, selectedRouteIds } = selectConfiguredRoutes(config, requestedRouteIds);
|
|
230
|
+
|
|
231
|
+
// When outDir is provided (e.g. by the local CLI), store all outputs flat inside that directory
|
|
232
|
+
// using just the basename of each configured path. Otherwise, use the config-resolved paths.
|
|
233
|
+
const localOutDir = options.outDir ? path.resolve(options.outDir) : null;
|
|
234
|
+
const resultsPath = localOutDir
|
|
235
|
+
? path.join(localOutDir, path.basename(config.resultsFile))
|
|
236
|
+
: resolveFromWorkingDirectory(config, config.resultsFile);
|
|
237
|
+
const manifestPath = localOutDir
|
|
238
|
+
? path.join(localOutDir, path.basename(config.manifestFile))
|
|
239
|
+
: resolveFromWorkingDirectory(config, config.manifestFile);
|
|
240
|
+
const screenshotsRoot = localOutDir || resolveFromWorkingDirectory(config, config.screenshotsRoot);
|
|
241
|
+
|
|
242
|
+
// Screenshots are written to a `screenshots/` subdirectory inside screenshotsRoot,
|
|
243
|
+
// i.e. the actual PNG files land at `{screenshotsRoot}/screenshots/{id}.png`.
|
|
244
|
+
await Promise.all([
|
|
245
|
+
ensureParentDirectory(resultsPath),
|
|
246
|
+
ensureParentDirectory(manifestPath),
|
|
247
|
+
ensureDirectory(path.join(screenshotsRoot, 'screenshots'))
|
|
248
|
+
]);
|
|
249
|
+
|
|
250
|
+
/** @type {BaselineResults} */
|
|
251
|
+
const results = {
|
|
252
|
+
startedAt: new Date().toISOString(),
|
|
253
|
+
baseUrl: config.baseUrl,
|
|
254
|
+
suite: 'snapdrift-capture',
|
|
255
|
+
configPath: path.relative(path.resolve('.'), configPath),
|
|
256
|
+
manifestPath: path.relative(path.resolve('.'), manifestPath),
|
|
257
|
+
screenshotsRoot: path.relative(path.resolve('.'), screenshotsRoot),
|
|
258
|
+
routes: []
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
/** @type {ScreenshotManifest} */
|
|
262
|
+
const manifest = {
|
|
263
|
+
generatedAt: new Date().toISOString(),
|
|
264
|
+
baseUrl: config.baseUrl,
|
|
265
|
+
screenshots: []
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const browser = await chromium.launch({ headless: true });
|
|
269
|
+
// Pre-create contexts for the two standard presets; custom viewports are created on demand below.
|
|
270
|
+
const presetContexts = await createViewportContexts(browser);
|
|
271
|
+
/** @type {Map<string, import('playwright').BrowserContext>} */
|
|
272
|
+
const allContexts = new Map(presetContexts);
|
|
273
|
+
let failures = 0;
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
// Group routes by viewport key, preserving each route's original index for result ordering.
|
|
277
|
+
/** @type {Map<string, Array<{ route: SnapdriftRouteConfig, originalIndex: number }>>} */
|
|
278
|
+
const byViewport = new Map();
|
|
279
|
+
for (const [i, route] of routes.entries()) {
|
|
280
|
+
const key = viewportKey(route.viewport);
|
|
281
|
+
const existing = byViewport.get(key);
|
|
282
|
+
if (existing) {
|
|
283
|
+
existing.push({ route, originalIndex: i });
|
|
284
|
+
} else {
|
|
285
|
+
byViewport.set(key, [{ route, originalIndex: i }]);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Create browser contexts for any custom viewports not covered by presets.
|
|
290
|
+
for (const [key, entries] of byViewport) {
|
|
291
|
+
if (!allContexts.has(key)) {
|
|
292
|
+
try {
|
|
293
|
+
allContexts.set(key, await browser.newContext(viewportContextOptions(entries[0].route.viewport)));
|
|
294
|
+
} catch (err) {
|
|
295
|
+
const message = `Failed to create browser context: ${err instanceof Error ? err.message : String(err)}`;
|
|
296
|
+
for (const { route } of entries) {
|
|
297
|
+
results.routes.push({ id: route.id, path: route.path, viewport: route.viewport, status: 'failed', durationMs: 0, error: message });
|
|
298
|
+
failures += 1;
|
|
299
|
+
}
|
|
300
|
+
throw err;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Pre-allocate results array; captureViewportRoutes fills slots by originalIndex.
|
|
306
|
+
/** @type {Array<BaselineRouteResult & { manifestEntry?: ScreenshotManifest['screenshots'][number] }>} */
|
|
307
|
+
const captureResults = new Array(routes.length);
|
|
308
|
+
|
|
309
|
+
// Run each viewport group concurrently; routes within a group stay sequential.
|
|
310
|
+
await Promise.all([...byViewport.entries()].map(([key, entries]) => {
|
|
311
|
+
const context = allContexts.get(key);
|
|
312
|
+
if (!context) {
|
|
313
|
+
throw new Error(`No browser context for viewport: ${key}`);
|
|
314
|
+
}
|
|
315
|
+
return captureViewportRoutes(context, entries, config.baseUrl, screenshotsRoot, routes.length, captureResults);
|
|
316
|
+
}));
|
|
317
|
+
|
|
318
|
+
// Merge results in original route order.
|
|
319
|
+
for (const capture of captureResults) {
|
|
320
|
+
results.routes.push({
|
|
321
|
+
id: capture.id,
|
|
322
|
+
path: capture.path,
|
|
323
|
+
viewport: capture.viewport,
|
|
324
|
+
status: capture.status,
|
|
325
|
+
durationMs: capture.durationMs,
|
|
326
|
+
imagePath: capture.imagePath,
|
|
327
|
+
width: capture.width,
|
|
328
|
+
height: capture.height,
|
|
329
|
+
error: capture.error
|
|
330
|
+
});
|
|
331
|
+
if (capture.manifestEntry) {
|
|
332
|
+
manifest.screenshots.push(capture.manifestEntry);
|
|
333
|
+
}
|
|
334
|
+
if (capture.status !== 'passed') {
|
|
335
|
+
failures += 1;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
} finally {
|
|
339
|
+
await Promise.all([...allContexts.values()].map((context) => context.close()));
|
|
340
|
+
await browser.close();
|
|
341
|
+
results.finishedAt = new Date().toISOString();
|
|
342
|
+
results.passed = failures === 0;
|
|
343
|
+
manifest.generatedAt = new Date().toISOString();
|
|
344
|
+
|
|
345
|
+
await Promise.all([
|
|
346
|
+
fs.writeFile(resultsPath, JSON.stringify(results, null, 2)),
|
|
347
|
+
fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2))
|
|
348
|
+
]);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (failures > 0) {
|
|
352
|
+
throw new Error(`SnapDrift capture failed for ${failures} route(s).`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
resultsPath,
|
|
357
|
+
manifestPath,
|
|
358
|
+
screenshotsRoot,
|
|
359
|
+
selectedRouteIds
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const isDirectRun = process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url;
|
|
364
|
+
|
|
365
|
+
if (isDirectRun) {
|
|
366
|
+
runBaselineCapture().catch((error) => {
|
|
367
|
+
console.error(error);
|
|
368
|
+
process.exitCode = 1;
|
|
369
|
+
});
|
|
370
|
+
}
|
package/lib/cli.mjs
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { execSync } from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { runBaselineCapture } from './capture-routes.mjs';
|
|
8
|
+
import { generateDriftReport, shouldFailDriftCheck } from './compare-results.mjs';
|
|
9
|
+
import { generateHtmlReport } from './report.mjs';
|
|
10
|
+
import { loadSnapdriftConfig } from './snapdrift-config.mjs';
|
|
11
|
+
|
|
12
|
+
/** @typedef {import('../types/visual-diff-types').VisualDiffSummary} DriftSummary */
|
|
13
|
+
|
|
14
|
+
export const LOCAL_SNAPDRIFT_DIR = '.snapdrift';
|
|
15
|
+
export const LOCAL_BASELINE_SUBDIR = 'baseline';
|
|
16
|
+
export const LOCAL_CURRENT_SUBDIR = 'current';
|
|
17
|
+
export const LOCAL_DIFF_SUBDIR = 'diff';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {{
|
|
21
|
+
* command: string,
|
|
22
|
+
* open: boolean,
|
|
23
|
+
* configPath?: string,
|
|
24
|
+
* routes: string[],
|
|
25
|
+
* baselineDir: string,
|
|
26
|
+
* currentDir: string,
|
|
27
|
+
* diffDir: string
|
|
28
|
+
* }} CliOptions
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse CLI arguments from process.argv (or a provided argv array).
|
|
33
|
+
* Supported commands: capture, diff
|
|
34
|
+
* Supported flags:
|
|
35
|
+
* --open Open the HTML report after diff
|
|
36
|
+
* --config <path> Path to snapdrift.json (default: .github/snapdrift.json)
|
|
37
|
+
* --routes <ids> Comma-separated route IDs to run
|
|
38
|
+
* --baseline-dir <p> Override local baseline directory (default: .snapdrift/baseline)
|
|
39
|
+
* --current-dir <p> Override local current-capture directory (default: .snapdrift/current)
|
|
40
|
+
* --diff-dir <p> Override local diff-output directory (default: .snapdrift/diff)
|
|
41
|
+
*
|
|
42
|
+
* @param {string[]} argv - process.argv
|
|
43
|
+
* @returns {CliOptions}
|
|
44
|
+
*/
|
|
45
|
+
export function parseArgs(argv) {
|
|
46
|
+
const args = argv.slice(2);
|
|
47
|
+
const command = args[0] || 'diff';
|
|
48
|
+
let open = false;
|
|
49
|
+
/** @type {string | undefined} */
|
|
50
|
+
let configPath;
|
|
51
|
+
/** @type {string[]} */
|
|
52
|
+
const routes = [];
|
|
53
|
+
let baselineDir = path.resolve(LOCAL_SNAPDRIFT_DIR, LOCAL_BASELINE_SUBDIR);
|
|
54
|
+
let currentDir = path.resolve(LOCAL_SNAPDRIFT_DIR, LOCAL_CURRENT_SUBDIR);
|
|
55
|
+
let diffDir = path.resolve(LOCAL_SNAPDRIFT_DIR, LOCAL_DIFF_SUBDIR);
|
|
56
|
+
|
|
57
|
+
for (let i = 1; i < args.length; i++) {
|
|
58
|
+
const arg = args[i];
|
|
59
|
+
if (arg === '--open') {
|
|
60
|
+
open = true;
|
|
61
|
+
} else if (arg === '--config' && i + 1 < args.length) {
|
|
62
|
+
configPath = args[++i];
|
|
63
|
+
} else if (arg === '--routes' && i + 1 < args.length) {
|
|
64
|
+
routes.push(
|
|
65
|
+
...args[++i]
|
|
66
|
+
.split(',')
|
|
67
|
+
.map((r) => r.trim())
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
);
|
|
70
|
+
} else if (arg === '--baseline-dir' && i + 1 < args.length) {
|
|
71
|
+
baselineDir = path.resolve(args[++i]);
|
|
72
|
+
} else if (arg === '--current-dir' && i + 1 < args.length) {
|
|
73
|
+
currentDir = path.resolve(args[++i]);
|
|
74
|
+
} else if (arg === '--diff-dir' && i + 1 < args.length) {
|
|
75
|
+
diffDir = path.resolve(args[++i]);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { command, open, configPath, routes, baselineDir, currentDir, diffDir };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Format and print a drift summary to stdout.
|
|
84
|
+
*
|
|
85
|
+
* @param {DriftSummary} summary
|
|
86
|
+
* @returns {void}
|
|
87
|
+
*/
|
|
88
|
+
export function printSummary(summary) {
|
|
89
|
+
const total = summary.totalScreenshots ?? summary.selectedRoutes?.length ?? 0;
|
|
90
|
+
const matched = summary.matchedScreenshots ?? 0;
|
|
91
|
+
const changed = summary.changedScreenshots ?? 0;
|
|
92
|
+
const missing = (summary.missingInBaseline ?? 0) + (summary.missingInCurrent ?? 0);
|
|
93
|
+
const errors = summary.errors?.length ?? 0;
|
|
94
|
+
const dimensionChanges = summary.dimensionChanges?.length ?? 0;
|
|
95
|
+
|
|
96
|
+
const statusIcon =
|
|
97
|
+
summary.status === 'clean'
|
|
98
|
+
? '\u2705' // ✅
|
|
99
|
+
: summary.status === 'changes-detected'
|
|
100
|
+
? '\uD83D\uDFE1' // 🟡
|
|
101
|
+
: '\u274C'; // ❌
|
|
102
|
+
const statusLabel =
|
|
103
|
+
summary.status === 'clean'
|
|
104
|
+
? 'Clean'
|
|
105
|
+
: summary.status === 'changes-detected'
|
|
106
|
+
? 'Drift detected'
|
|
107
|
+
: summary.status ?? 'Unknown';
|
|
108
|
+
|
|
109
|
+
process.stdout.write(`\n${statusIcon} SnapDrift \u2014 ${statusLabel}\n`);
|
|
110
|
+
process.stdout.write(` Routes: ${total}\n`);
|
|
111
|
+
process.stdout.write(` Matched: ${matched}\n`);
|
|
112
|
+
if (changed > 0) process.stdout.write(` Changed: ${changed}\n`);
|
|
113
|
+
if (missing > 0) process.stdout.write(` Missing: ${missing}\n`);
|
|
114
|
+
if (errors > 0) process.stdout.write(` Errors: ${errors}\n`);
|
|
115
|
+
if (dimensionChanges > 0) process.stdout.write(` Dim diff: ${dimensionChanges}\n`);
|
|
116
|
+
|
|
117
|
+
if (summary.changed?.length) {
|
|
118
|
+
process.stdout.write('\n Changed routes:\n');
|
|
119
|
+
for (const item of summary.changed) {
|
|
120
|
+
const pct = ((item.mismatchRatio ?? 0) * 100).toFixed(2);
|
|
121
|
+
process.stdout.write(` \u2022 ${item.id} (${pct}% diff)\n`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
process.stdout.write('\n');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Open a file with the OS default viewer.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} filePath
|
|
132
|
+
* @returns {void}
|
|
133
|
+
*/
|
|
134
|
+
function openFile(filePath) {
|
|
135
|
+
const cmd =
|
|
136
|
+
process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
137
|
+
try {
|
|
138
|
+
execSync(`${cmd} "${filePath}"`);
|
|
139
|
+
} catch {
|
|
140
|
+
process.stderr.write(`Could not open file: ${filePath}\n`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Run the `capture` command — capture screenshots to a local baseline directory.
|
|
146
|
+
*
|
|
147
|
+
* @param {CliOptions} opts
|
|
148
|
+
* @returns {Promise<void>}
|
|
149
|
+
*/
|
|
150
|
+
export async function runCaptureCommand(opts) {
|
|
151
|
+
process.stdout.write(`Capturing baseline to ${opts.baselineDir} ...\n`);
|
|
152
|
+
const { selectedRouteIds } = await runBaselineCapture({
|
|
153
|
+
configPath: opts.configPath,
|
|
154
|
+
routeIds: opts.routes.length > 0 ? opts.routes : undefined,
|
|
155
|
+
outDir: opts.baselineDir
|
|
156
|
+
});
|
|
157
|
+
process.stdout.write(`Captured ${selectedRouteIds.length} route(s) to ${opts.baselineDir}\n`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Run the `diff` command — capture current screenshots, compare against the local baseline,
|
|
162
|
+
* write a summary + HTML report, and optionally open it.
|
|
163
|
+
*
|
|
164
|
+
* @param {CliOptions} opts
|
|
165
|
+
* @returns {Promise<void>}
|
|
166
|
+
*/
|
|
167
|
+
export async function runDiffCommand(opts) {
|
|
168
|
+
process.stdout.write(`Capturing current screenshots to ${opts.currentDir} ...\n`);
|
|
169
|
+
const { config } = await loadSnapdriftConfig(opts.configPath);
|
|
170
|
+
|
|
171
|
+
const currentCapture = await runBaselineCapture({
|
|
172
|
+
configPath: opts.configPath,
|
|
173
|
+
routeIds: opts.routes.length > 0 ? opts.routes : undefined,
|
|
174
|
+
outDir: opts.currentDir
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
process.stdout.write('Comparing against baseline ...\n');
|
|
178
|
+
|
|
179
|
+
const baselineResultsPath = path.join(opts.baselineDir, path.basename(config.resultsFile));
|
|
180
|
+
const baselineManifestPath = path.join(opts.baselineDir, path.basename(config.manifestFile));
|
|
181
|
+
|
|
182
|
+
await fs.mkdir(opts.diffDir, { recursive: true });
|
|
183
|
+
const summaryPath = path.join(opts.diffDir, 'summary.json');
|
|
184
|
+
const markdownPath = path.join(opts.diffDir, 'summary.md');
|
|
185
|
+
const htmlPath = path.join(opts.diffDir, 'report.html');
|
|
186
|
+
|
|
187
|
+
const { summary, markdown } = await generateDriftReport({
|
|
188
|
+
configPath: opts.configPath,
|
|
189
|
+
routeIds: opts.routes.length > 0 ? opts.routes : undefined,
|
|
190
|
+
baselineResultsPath,
|
|
191
|
+
baselineManifestPath,
|
|
192
|
+
currentResultsPath: currentCapture.resultsPath,
|
|
193
|
+
currentManifestPath: currentCapture.manifestPath,
|
|
194
|
+
baselineRunDir: opts.baselineDir,
|
|
195
|
+
currentRunDir: currentCapture.screenshotsRoot
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const html = await generateHtmlReport(summary, {
|
|
199
|
+
baselineRunDir: opts.baselineDir,
|
|
200
|
+
currentRunDir: currentCapture.screenshotsRoot
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await Promise.all([
|
|
204
|
+
fs.writeFile(summaryPath, JSON.stringify(summary, null, 2)),
|
|
205
|
+
fs.writeFile(markdownPath, markdown),
|
|
206
|
+
fs.writeFile(htmlPath, html)
|
|
207
|
+
]);
|
|
208
|
+
|
|
209
|
+
printSummary(summary);
|
|
210
|
+
|
|
211
|
+
if (opts.open) {
|
|
212
|
+
openFile(htmlPath);
|
|
213
|
+
} else if (summary.status !== 'clean') {
|
|
214
|
+
process.stdout.write(`Report: ${htmlPath}\n`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (shouldFailDriftCheck(summary)) {
|
|
218
|
+
process.exitCode = 1;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Main CLI entry point — parse args and dispatch to the appropriate command.
|
|
224
|
+
*
|
|
225
|
+
* @param {string[]} argv
|
|
226
|
+
* @returns {Promise<void>}
|
|
227
|
+
*/
|
|
228
|
+
export async function main(argv) {
|
|
229
|
+
const opts = parseArgs(argv);
|
|
230
|
+
|
|
231
|
+
if (opts.command === 'capture') {
|
|
232
|
+
await runCaptureCommand(opts);
|
|
233
|
+
} else if (opts.command === 'diff') {
|
|
234
|
+
await runDiffCommand(opts);
|
|
235
|
+
} else {
|
|
236
|
+
process.stderr.write(
|
|
237
|
+
`Unknown command: ${opts.command}\nUsage: snapdrift <capture|diff> [options]\n`
|
|
238
|
+
);
|
|
239
|
+
process.exitCode = 1;
|
|
240
|
+
}
|
|
241
|
+
}
|