snapdrift 0.2.1 → 0.4.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/lib/cli.mjs CHANGED
@@ -4,10 +4,11 @@ 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 { loadSnapdriftConfig } from '@snapdrift/adapter-fs';
8
+ import { shouldFailDriftCheck } from '@snapdrift/manifest';
9
+ import { createProvider } from './provider.mjs';
10
+ import { SnapFallbackError, SnapSkipError } from './snap-provider.mjs';
9
11
  import { generateHtmlReport } from './report.mjs';
10
- import { loadSnapdriftConfig } from './snapdrift-config.mjs';
11
12
 
12
13
  /** @typedef {import('../types/visual-diff-types').VisualDiffSummary} DriftSummary */
13
14
 
@@ -24,13 +25,17 @@ export const LOCAL_DIFF_SUBDIR = 'diff';
24
25
  * routes: string[],
25
26
  * baselineDir: string,
26
27
  * currentDir: string,
27
- * diffDir: string
28
+ * diffDir: string,
29
+ * to?: 'snap' | 'local',
30
+ * from?: 'snap',
31
+ * acceptCrossEngine?: boolean,
32
+ * fromSnapAction?: string
28
33
  * }} CliOptions
29
34
  */
30
35
 
31
36
  /**
32
37
  * Parse CLI arguments from process.argv (or a provided argv array).
33
- * Supported commands: capture, diff
38
+ * Supported commands: capture, diff, migrate-baselines, init
34
39
  * Supported flags:
35
40
  * --open Open the HTML report after diff
36
41
  * --config <path> Path to snapdrift.json (default: .github/snapdrift.json)
@@ -38,6 +43,10 @@ export const LOCAL_DIFF_SUBDIR = 'diff';
38
43
  * --baseline-dir <p> Override local baseline directory (default: .snapdrift/baseline)
39
44
  * --current-dir <p> Override local current-capture directory (default: .snapdrift/current)
40
45
  * --diff-dir <p> Override local diff-output directory (default: .snapdrift/diff)
46
+ * --to <snap|local> Migration target (migrate-baselines command)
47
+ * --from <snap> Migration source (migrate-baselines --to local)
48
+ * --accept-cross-engine Allow importing baselines from a different capture engine
49
+ * --from-snap-action <path> Snap action workflow YAML path (init command)
41
50
  *
42
51
  * @param {string[]} argv - process.argv
43
52
  * @returns {CliOptions}
@@ -53,6 +62,13 @@ export function parseArgs(argv) {
53
62
  let baselineDir = path.resolve(LOCAL_SNAPDRIFT_DIR, LOCAL_BASELINE_SUBDIR);
54
63
  let currentDir = path.resolve(LOCAL_SNAPDRIFT_DIR, LOCAL_CURRENT_SUBDIR);
55
64
  let diffDir = path.resolve(LOCAL_SNAPDRIFT_DIR, LOCAL_DIFF_SUBDIR);
65
+ /** @type {'snap' | 'local' | undefined} */
66
+ let to;
67
+ /** @type {'snap' | undefined} */
68
+ let from;
69
+ let acceptCrossEngine = false;
70
+ /** @type {string | undefined} */
71
+ let fromSnapAction;
56
72
 
57
73
  for (let i = 1; i < args.length; i++) {
58
74
  const arg = args[i];
@@ -73,10 +89,24 @@ export function parseArgs(argv) {
73
89
  currentDir = path.resolve(args[++i]);
74
90
  } else if (arg === '--diff-dir' && i + 1 < args.length) {
75
91
  diffDir = path.resolve(args[++i]);
92
+ } else if (arg === '--to' && i + 1 < args.length) {
93
+ const toVal = args[++i];
94
+ if (toVal === 'snap' || toVal === 'local') {
95
+ to = toVal;
96
+ }
97
+ } else if (arg === '--from' && i + 1 < args.length) {
98
+ const fromVal = args[++i];
99
+ if (fromVal === 'snap') {
100
+ from = fromVal;
101
+ }
102
+ } else if (arg === '--accept-cross-engine') {
103
+ acceptCrossEngine = true;
104
+ } else if (arg === '--from-snap-action' && i + 1 < args.length) {
105
+ fromSnapAction = args[++i];
76
106
  }
77
107
  }
78
108
 
79
- return { command, open, configPath, routes, baselineDir, currentDir, diffDir };
109
+ return { command, open, configPath, routes, baselineDir, currentDir, diffDir, to, from, acceptCrossEngine, fromSnapAction };
80
110
  }
81
111
 
82
112
  /**
@@ -95,10 +125,10 @@ export function printSummary(summary) {
95
125
 
96
126
  const statusIcon =
97
127
  summary.status === 'clean'
98
- ? '\u2705' // ✅
128
+ ? '✅' // ✅
99
129
  : summary.status === 'changes-detected'
100
- ? '\uD83D\uDFE1' // 🟡
101
- : '\u274C'; // ❌
130
+ ? '🟡' // 🟡
131
+ : '❌'; // ❌
102
132
  const statusLabel =
103
133
  summary.status === 'clean'
104
134
  ? 'Clean'
@@ -106,7 +136,7 @@ export function printSummary(summary) {
106
136
  ? 'Drift detected'
107
137
  : summary.status ?? 'Unknown';
108
138
 
109
- process.stdout.write(`\n${statusIcon} SnapDrift \u2014 ${statusLabel}\n`);
139
+ process.stdout.write(`\n${statusIcon} SnapDrift — ${statusLabel}\n`);
110
140
  process.stdout.write(` Routes: ${total}\n`);
111
141
  process.stdout.write(` Matched: ${matched}\n`);
112
142
  if (changed > 0) process.stdout.write(` Changed: ${changed}\n`);
@@ -118,7 +148,7 @@ export function printSummary(summary) {
118
148
  process.stdout.write('\n Changed routes:\n');
119
149
  for (const item of summary.changed) {
120
150
  const pct = ((item.mismatchRatio ?? 0) * 100).toFixed(2);
121
- process.stdout.write(` \u2022 ${item.id} (${pct}% diff)\n`);
151
+ process.stdout.write(` • ${item.id} (${pct}% diff)\n`);
122
152
  }
123
153
  }
124
154
 
@@ -149,12 +179,29 @@ function openFile(filePath) {
149
179
  */
150
180
  export async function runCaptureCommand(opts) {
151
181
  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`);
182
+ const { config } = await loadSnapdriftConfig(opts.configPath);
183
+ const provider = createProvider(config.provider ?? 'local', config);
184
+ try {
185
+ const { selectedRouteIds } = await provider.capture({
186
+ configPath: opts.configPath,
187
+ routeIds: opts.routes.length > 0 ? opts.routes : undefined,
188
+ outDir: opts.baselineDir
189
+ });
190
+ process.stdout.write(`Captured ${selectedRouteIds.length} route(s) to ${opts.baselineDir}\n`);
191
+ } catch (error) {
192
+ if (error instanceof SnapFallbackError) {
193
+ process.stdout.write('Falling back to local provider for capture...\n');
194
+ const localProvider = createProvider('local');
195
+ const { selectedRouteIds } = await localProvider.capture({
196
+ configPath: opts.configPath,
197
+ routeIds: opts.routes.length > 0 ? opts.routes : undefined,
198
+ outDir: opts.baselineDir
199
+ });
200
+ process.stdout.write(`Captured ${selectedRouteIds.length} route(s) to ${opts.baselineDir}\n`);
201
+ return;
202
+ }
203
+ throw error;
204
+ }
158
205
  }
159
206
 
160
207
  /**
@@ -167,12 +214,30 @@ export async function runCaptureCommand(opts) {
167
214
  export async function runDiffCommand(opts) {
168
215
  process.stdout.write(`Capturing current screenshots to ${opts.currentDir} ...\n`);
169
216
  const { config } = await loadSnapdriftConfig(opts.configPath);
217
+ const provider = createProvider(config.provider ?? 'local', config);
170
218
 
171
- const currentCapture = await runBaselineCapture({
172
- configPath: opts.configPath,
173
- routeIds: opts.routes.length > 0 ? opts.routes : undefined,
174
- outDir: opts.currentDir
175
- });
219
+ let currentCapture;
220
+ let usedLocalFallback = false;
221
+ try {
222
+ currentCapture = await provider.capture({
223
+ configPath: opts.configPath,
224
+ routeIds: opts.routes.length > 0 ? opts.routes : undefined,
225
+ outDir: opts.currentDir
226
+ });
227
+ } catch (error) {
228
+ if (error instanceof SnapFallbackError) {
229
+ process.stdout.write('Falling back to local provider for capture...\n');
230
+ const localProvider = createProvider('local');
231
+ currentCapture = await localProvider.capture({
232
+ configPath: opts.configPath,
233
+ routeIds: opts.routes.length > 0 ? opts.routes : undefined,
234
+ outDir: opts.currentDir
235
+ });
236
+ usedLocalFallback = true;
237
+ } else {
238
+ throw error;
239
+ }
240
+ }
176
241
 
177
242
  process.stdout.write('Comparing against baseline ...\n');
178
243
 
@@ -184,16 +249,28 @@ export async function runDiffCommand(opts) {
184
249
  const markdownPath = path.join(opts.diffDir, 'summary.md');
185
250
  const htmlPath = path.join(opts.diffDir, 'report.html');
186
251
 
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
- });
252
+ const diffProvider = usedLocalFallback ? createProvider('local') : provider;
253
+ let diffResult;
254
+ try {
255
+ diffResult = await diffProvider.diff({
256
+ configPath: opts.configPath,
257
+ routeIds: opts.routes.length > 0 ? opts.routes : undefined,
258
+ baselineResultsPath,
259
+ baselineManifestPath,
260
+ currentResultsPath: currentCapture.resultsPath,
261
+ currentManifestPath: currentCapture.manifestPath,
262
+ baselineRunDir: opts.baselineDir,
263
+ currentRunDir: currentCapture.screenshotsRoot
264
+ });
265
+ } catch (error) {
266
+ if (error instanceof SnapSkipError) {
267
+ process.stdout.write('Visual regression skipped (Snap unavailable, warn-and-skip mode).\n');
268
+ return;
269
+ }
270
+ throw error;
271
+ }
272
+
273
+ const { summary, markdown } = diffResult;
197
274
 
198
275
  const html = await generateHtmlReport(summary, {
199
276
  baselineRunDir: opts.baselineDir,
@@ -219,6 +296,58 @@ export async function runDiffCommand(opts) {
219
296
  }
220
297
  }
221
298
 
299
+ /**
300
+ * Run the `migrate-baselines` command — migrate baselines between local and Snap.
301
+ *
302
+ * @param {CliOptions} opts
303
+ * @returns {Promise<void>}
304
+ */
305
+ export async function runMigrateBaselinesCommand(opts) {
306
+ if (!opts.to || (opts.to !== 'snap' && opts.to !== 'local')) {
307
+ process.stderr.write('Usage: snapdrift migrate-baselines --to <snap|local> [--from snap] [--accept-cross-engine] [--config <path>] [--baseline-dir <dir>]\n');
308
+ process.exitCode = 1;
309
+ return;
310
+ }
311
+
312
+ if (opts.to === 'local' && opts.from !== 'snap') {
313
+ process.stderr.write('Error: --to local requires --from snap\n');
314
+ process.exitCode = 1;
315
+ return;
316
+ }
317
+
318
+ if (opts.acceptCrossEngine && opts.to !== 'local') {
319
+ process.stderr.write('Error: --accept-cross-engine is only valid with --to local\n');
320
+ process.exitCode = 1;
321
+ return;
322
+ }
323
+
324
+ const { runMigrateToSnap, runMigrateToLocal } = await import('./migrate-baselines.mjs');
325
+ const { config } = await loadSnapdriftConfig(opts.configPath);
326
+
327
+ if (opts.to === 'snap') {
328
+ await runMigrateToSnap(config, opts);
329
+ } else {
330
+ await runMigrateToLocal(config, opts);
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Run the `init` command — initialize snapdrift config from a Snap action workflow.
336
+ *
337
+ * @param {CliOptions} opts
338
+ * @returns {Promise<void>}
339
+ */
340
+ export async function runInitCommand(opts) {
341
+ if (!opts.fromSnapAction) {
342
+ process.stderr.write('Usage: snapdrift init --from-snap-action <workflow-yaml-path>\n');
343
+ process.exitCode = 1;
344
+ return;
345
+ }
346
+
347
+ const { runInitFromAction } = await import('./init-from-action.mjs');
348
+ await runInitFromAction(opts.fromSnapAction);
349
+ }
350
+
222
351
  /**
223
352
  * Main CLI entry point — parse args and dispatch to the appropriate command.
224
353
  *
@@ -232,10 +361,14 @@ export async function main(argv) {
232
361
  await runCaptureCommand(opts);
233
362
  } else if (opts.command === 'diff') {
234
363
  await runDiffCommand(opts);
364
+ } else if (opts.command === 'migrate-baselines') {
365
+ await runMigrateBaselinesCommand(opts);
366
+ } else if (opts.command === 'init') {
367
+ await runInitCommand(opts);
235
368
  } else {
236
369
  process.stderr.write(
237
- `Unknown command: ${opts.command}\nUsage: snapdrift <capture|diff> [options]\n`
370
+ `Unknown command: ${opts.command}\nUsage: snapdrift <capture|diff|migrate-baselines|init> [options]\n`
238
371
  );
239
372
  process.exitCode = 1;
240
373
  }
241
- }
374
+ }