snapdrift 0.2.0 → 0.3.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.
@@ -1,370 +1,4 @@
1
1
  // @ts-check
2
+ // Re-exports from packages — this module is a thin shim.
2
3
 
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
- }
4
+ export { runBaselineCapture } from '@snapdrift/adapter-fs';
package/lib/cli.mjs CHANGED
@@ -4,10 +4,9 @@ import { execSync } from 'node:child_process';
4
4
  import fs from 'node:fs/promises';
5
5
  import path from 'node:path';
6
6
 
7
- import { runBaselineCapture } from './capture-routes.mjs';
8
- import { generateDriftReport, shouldFailDriftCheck } from './compare-results.mjs';
7
+ import { runBaselineCapture, generateDriftReport, loadSnapdriftConfig } from '@snapdrift/adapter-fs';
8
+ import { shouldFailDriftCheck } from '@snapdrift/manifest';
9
9
  import { generateHtmlReport } from './report.mjs';
10
- import { loadSnapdriftConfig } from './snapdrift-config.mjs';
11
10
 
12
11
  /** @typedef {import('../types/visual-diff-types').VisualDiffSummary} DriftSummary */
13
12
 
@@ -95,10 +94,10 @@ export function printSummary(summary) {
95
94
 
96
95
  const statusIcon =
97
96
  summary.status === 'clean'
98
- ? '\u2705' // ✅
97
+ ? '✅' // ✅
99
98
  : summary.status === 'changes-detected'
100
- ? '\uD83D\uDFE1' // 🟡
101
- : '\u274C'; // ❌
99
+ ? '🟡' // 🟡
100
+ : '❌'; // ❌
102
101
  const statusLabel =
103
102
  summary.status === 'clean'
104
103
  ? 'Clean'
@@ -106,7 +105,7 @@ export function printSummary(summary) {
106
105
  ? 'Drift detected'
107
106
  : summary.status ?? 'Unknown';
108
107
 
109
- process.stdout.write(`\n${statusIcon} SnapDrift \u2014 ${statusLabel}\n`);
108
+ process.stdout.write(`\n${statusIcon} SnapDrift — ${statusLabel}\n`);
110
109
  process.stdout.write(` Routes: ${total}\n`);
111
110
  process.stdout.write(` Matched: ${matched}\n`);
112
111
  if (changed > 0) process.stdout.write(` Changed: ${changed}\n`);
@@ -118,7 +117,7 @@ export function printSummary(summary) {
118
117
  process.stdout.write('\n Changed routes:\n');
119
118
  for (const item of summary.changed) {
120
119
  const pct = ((item.mismatchRatio ?? 0) * 100).toFixed(2);
121
- process.stdout.write(` \u2022 ${item.id} (${pct}% diff)\n`);
120
+ process.stdout.write(` • ${item.id} (${pct}% diff)\n`);
122
121
  }
123
122
  }
124
123
 
@@ -238,4 +237,4 @@ export async function main(argv) {
238
237
  );
239
238
  process.exitCode = 1;
240
239
  }
241
- }
240
+ }