snapdrift 0.3.0 → 0.5.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,8 +4,10 @@ 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, generateDriftReport, loadSnapdriftConfig } from '@snapdrift/adapter-fs';
7
+ import { loadSnapdriftConfig } from '@snapdrift/adapter-fs';
8
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
12
 
11
13
  /** @typedef {import('../types/visual-diff-types').VisualDiffSummary} DriftSummary */
@@ -23,13 +25,17 @@ export const LOCAL_DIFF_SUBDIR = 'diff';
23
25
  * routes: string[],
24
26
  * baselineDir: string,
25
27
  * currentDir: string,
26
- * diffDir: string
28
+ * diffDir: string,
29
+ * to?: 'snap' | 'local',
30
+ * from?: 'snap',
31
+ * acceptCrossEngine?: boolean,
32
+ * fromSnapAction?: string
27
33
  * }} CliOptions
28
34
  */
29
35
 
30
36
  /**
31
37
  * Parse CLI arguments from process.argv (or a provided argv array).
32
- * Supported commands: capture, diff
38
+ * Supported commands: capture, diff, migrate-baselines, init
33
39
  * Supported flags:
34
40
  * --open Open the HTML report after diff
35
41
  * --config <path> Path to snapdrift.json (default: .github/snapdrift.json)
@@ -37,6 +43,10 @@ export const LOCAL_DIFF_SUBDIR = 'diff';
37
43
  * --baseline-dir <p> Override local baseline directory (default: .snapdrift/baseline)
38
44
  * --current-dir <p> Override local current-capture directory (default: .snapdrift/current)
39
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)
40
50
  *
41
51
  * @param {string[]} argv - process.argv
42
52
  * @returns {CliOptions}
@@ -52,6 +62,13 @@ export function parseArgs(argv) {
52
62
  let baselineDir = path.resolve(LOCAL_SNAPDRIFT_DIR, LOCAL_BASELINE_SUBDIR);
53
63
  let currentDir = path.resolve(LOCAL_SNAPDRIFT_DIR, LOCAL_CURRENT_SUBDIR);
54
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;
55
72
 
56
73
  for (let i = 1; i < args.length; i++) {
57
74
  const arg = args[i];
@@ -72,10 +89,24 @@ export function parseArgs(argv) {
72
89
  currentDir = path.resolve(args[++i]);
73
90
  } else if (arg === '--diff-dir' && i + 1 < args.length) {
74
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];
75
106
  }
76
107
  }
77
108
 
78
- return { command, open, configPath, routes, baselineDir, currentDir, diffDir };
109
+ return { command, open, configPath, routes, baselineDir, currentDir, diffDir, to, from, acceptCrossEngine, fromSnapAction };
79
110
  }
80
111
 
81
112
  /**
@@ -148,12 +179,30 @@ function openFile(filePath) {
148
179
  */
149
180
  export async function runCaptureCommand(opts) {
150
181
  process.stdout.write(`Capturing baseline to ${opts.baselineDir} ...\n`);
151
- const { selectedRouteIds } = await runBaselineCapture({
152
- configPath: opts.configPath,
153
- routeIds: opts.routes.length > 0 ? opts.routes : undefined,
154
- outDir: opts.baselineDir
155
- });
156
- 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
+ purpose: 'baseline'
190
+ });
191
+ process.stdout.write(`Captured ${selectedRouteIds.length} route(s) to ${opts.baselineDir}\n`);
192
+ } catch (error) {
193
+ if (error instanceof SnapFallbackError) {
194
+ process.stdout.write('Falling back to local provider for capture...\n');
195
+ const localProvider = createProvider('local');
196
+ const { selectedRouteIds } = await localProvider.capture({
197
+ configPath: opts.configPath,
198
+ routeIds: opts.routes.length > 0 ? opts.routes : undefined,
199
+ outDir: opts.baselineDir
200
+ });
201
+ process.stdout.write(`Captured ${selectedRouteIds.length} route(s) to ${opts.baselineDir}\n`);
202
+ return;
203
+ }
204
+ throw error;
205
+ }
157
206
  }
158
207
 
159
208
  /**
@@ -166,12 +215,30 @@ export async function runCaptureCommand(opts) {
166
215
  export async function runDiffCommand(opts) {
167
216
  process.stdout.write(`Capturing current screenshots to ${opts.currentDir} ...\n`);
168
217
  const { config } = await loadSnapdriftConfig(opts.configPath);
218
+ const provider = createProvider(config.provider ?? 'local', config);
169
219
 
170
- const currentCapture = await runBaselineCapture({
171
- configPath: opts.configPath,
172
- routeIds: opts.routes.length > 0 ? opts.routes : undefined,
173
- outDir: opts.currentDir
174
- });
220
+ let currentCapture;
221
+ let usedLocalFallback = false;
222
+ try {
223
+ currentCapture = await provider.capture({
224
+ configPath: opts.configPath,
225
+ routeIds: opts.routes.length > 0 ? opts.routes : undefined,
226
+ outDir: opts.currentDir
227
+ });
228
+ } catch (error) {
229
+ if (error instanceof SnapFallbackError) {
230
+ process.stdout.write('Falling back to local provider for capture...\n');
231
+ const localProvider = createProvider('local');
232
+ currentCapture = await localProvider.capture({
233
+ configPath: opts.configPath,
234
+ routeIds: opts.routes.length > 0 ? opts.routes : undefined,
235
+ outDir: opts.currentDir
236
+ });
237
+ usedLocalFallback = true;
238
+ } else {
239
+ throw error;
240
+ }
241
+ }
175
242
 
176
243
  process.stdout.write('Comparing against baseline ...\n');
177
244
 
@@ -183,16 +250,28 @@ export async function runDiffCommand(opts) {
183
250
  const markdownPath = path.join(opts.diffDir, 'summary.md');
184
251
  const htmlPath = path.join(opts.diffDir, 'report.html');
185
252
 
186
- const { summary, markdown } = await generateDriftReport({
187
- configPath: opts.configPath,
188
- routeIds: opts.routes.length > 0 ? opts.routes : undefined,
189
- baselineResultsPath,
190
- baselineManifestPath,
191
- currentResultsPath: currentCapture.resultsPath,
192
- currentManifestPath: currentCapture.manifestPath,
193
- baselineRunDir: opts.baselineDir,
194
- currentRunDir: currentCapture.screenshotsRoot
195
- });
253
+ const diffProvider = usedLocalFallback ? createProvider('local') : provider;
254
+ let diffResult;
255
+ try {
256
+ diffResult = await diffProvider.diff({
257
+ configPath: opts.configPath,
258
+ routeIds: opts.routes.length > 0 ? opts.routes : undefined,
259
+ baselineResultsPath,
260
+ baselineManifestPath,
261
+ currentResultsPath: currentCapture.resultsPath,
262
+ currentManifestPath: currentCapture.manifestPath,
263
+ baselineRunDir: opts.baselineDir,
264
+ currentRunDir: currentCapture.screenshotsRoot
265
+ });
266
+ } catch (error) {
267
+ if (error instanceof SnapSkipError) {
268
+ process.stdout.write('Visual regression skipped (Snap unavailable, warn-and-skip mode).\n');
269
+ return;
270
+ }
271
+ throw error;
272
+ }
273
+
274
+ const { summary, markdown } = diffResult;
196
275
 
197
276
  const html = await generateHtmlReport(summary, {
198
277
  baselineRunDir: opts.baselineDir,
@@ -218,6 +297,58 @@ export async function runDiffCommand(opts) {
218
297
  }
219
298
  }
220
299
 
300
+ /**
301
+ * Run the `migrate-baselines` command — migrate baselines between local and Snap.
302
+ *
303
+ * @param {CliOptions} opts
304
+ * @returns {Promise<void>}
305
+ */
306
+ export async function runMigrateBaselinesCommand(opts) {
307
+ if (!opts.to || (opts.to !== 'snap' && opts.to !== 'local')) {
308
+ process.stderr.write('Usage: snapdrift migrate-baselines --to <snap|local> [--from snap] [--accept-cross-engine] [--config <path>] [--baseline-dir <dir>]\n');
309
+ process.exitCode = 1;
310
+ return;
311
+ }
312
+
313
+ if (opts.to === 'local' && opts.from !== 'snap') {
314
+ process.stderr.write('Error: --to local requires --from snap\n');
315
+ process.exitCode = 1;
316
+ return;
317
+ }
318
+
319
+ if (opts.acceptCrossEngine && opts.to !== 'local') {
320
+ process.stderr.write('Error: --accept-cross-engine is only valid with --to local\n');
321
+ process.exitCode = 1;
322
+ return;
323
+ }
324
+
325
+ const { runMigrateToSnap, runMigrateToLocal } = await import('./migrate-baselines.mjs');
326
+ const { config } = await loadSnapdriftConfig(opts.configPath);
327
+
328
+ if (opts.to === 'snap') {
329
+ await runMigrateToSnap(config, opts);
330
+ } else {
331
+ await runMigrateToLocal(config, opts);
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Run the `init` command — initialize snapdrift config from a Snap action workflow.
337
+ *
338
+ * @param {CliOptions} opts
339
+ * @returns {Promise<void>}
340
+ */
341
+ export async function runInitCommand(opts) {
342
+ if (!opts.fromSnapAction) {
343
+ process.stderr.write('Usage: snapdrift init --from-snap-action <workflow-yaml-path>\n');
344
+ process.exitCode = 1;
345
+ return;
346
+ }
347
+
348
+ const { runInitFromAction } = await import('./init-from-action.mjs');
349
+ await runInitFromAction(opts.fromSnapAction);
350
+ }
351
+
221
352
  /**
222
353
  * Main CLI entry point — parse args and dispatch to the appropriate command.
223
354
  *
@@ -231,9 +362,13 @@ export async function main(argv) {
231
362
  await runCaptureCommand(opts);
232
363
  } else if (opts.command === 'diff') {
233
364
  await runDiffCommand(opts);
365
+ } else if (opts.command === 'migrate-baselines') {
366
+ await runMigrateBaselinesCommand(opts);
367
+ } else if (opts.command === 'init') {
368
+ await runInitCommand(opts);
234
369
  } else {
235
370
  process.stderr.write(
236
- `Unknown command: ${opts.command}\nUsage: snapdrift <capture|diff> [options]\n`
371
+ `Unknown command: ${opts.command}\nUsage: snapdrift <capture|diff|migrate-baselines|init> [options]\n`
237
372
  );
238
373
  process.exitCode = 1;
239
374
  }
@@ -0,0 +1,244 @@
1
+ // @ts-check
2
+
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import yaml from 'js-yaml';
6
+
7
+ /** @typedef {import('../types/visual-diff-types').InitWarning} InitWarning */
8
+
9
+ const DEFAULT_OUTPUT_DIR = '.github';
10
+ const DEFAULT_CONFIG_FILENAME = 'snapdrift.json';
11
+ const MIGRATION_NOTES_FILENAME = 'MIGRATION_NOTES.md';
12
+
13
+ /**
14
+ * Find the step that uses the Snap visual testing action.
15
+ *
16
+ * @param {object} workflow - Parsed YAML workflow
17
+ * @returns {{ step: object, jobName: string, stepIndex: number } | null}
18
+ */
19
+ function findSnapActionStep(workflow) {
20
+ const jobs = workflow.jobs || {};
21
+ for (const [jobName, job] of Object.entries(jobs)) {
22
+ const steps = job.steps || [];
23
+ for (let i = 0; i < steps.length; i++) {
24
+ const step = steps[i];
25
+ const uses = step.uses || '';
26
+ if ((uses.includes('snap/github-action') || uses.includes('i2dev-com/snap')) && uses.includes('action')) {
27
+ return { step, jobName, stepIndex: i };
28
+ }
29
+ }
30
+ }
31
+ return null;
32
+ }
33
+
34
+ /**
35
+ * Map Snap action enforcement mode to snapdrift diff.mode.
36
+ *
37
+ * @param {object} inputs
38
+ * @returns {string}
39
+ */
40
+ function mapDiffMode(inputs) {
41
+ const failOnChanges = inputs['fail-on-changes'] || inputs.fail_on_changes;
42
+ if (failOnChanges === 'true' || failOnChanges === true) {
43
+ return 'fail-on-changes';
44
+ }
45
+ const failOnIncomplete = inputs['fail-on-incomplete'] || inputs.fail_on_incomplete;
46
+ if (failOnIncomplete === 'true' || failOnIncomplete === true) {
47
+ return 'fail-on-incomplete';
48
+ }
49
+ return 'report-only';
50
+ }
51
+
52
+ /**
53
+ * Extract Snap action inputs and translate to snapdrift config fields.
54
+ *
55
+ * @param {object} step - The workflow step using the Snap action
56
+ * @returns {{ config: object, warnings: InitWarning[] }}
57
+ */
58
+ function translateStepToConfig(step) {
59
+ /** @type {InitWarning[]} */
60
+ const warnings = [];
61
+ const inputs = step.with || {};
62
+
63
+ // Diff settings
64
+ const threshold = parseFloat(inputs.threshold ?? inputs['diff-threshold'] ?? '0.01');
65
+ const diffMode = mapDiffMode(inputs);
66
+
67
+ // Format warning
68
+ const format = inputs.format;
69
+ if (format && format !== 'png') {
70
+ warnings.push({
71
+ field: 'format',
72
+ originalValue: format,
73
+ message: `SnapDrift only supports PNG screenshots. The Snap action's "format: ${format}" setting has no snapdrift equivalent and will be ignored.`,
74
+ severity: 'warning'
75
+ });
76
+ }
77
+
78
+ // Baseline tag warning
79
+ const baselineTag = inputs.baseline_tag || inputs['baseline-tag'];
80
+ if (baselineTag) {
81
+ warnings.push({
82
+ field: 'baseline_tag',
83
+ originalValue: baselineTag,
84
+ message: `SnapDrift uses commit-based baselines. The Snap action's "baseline_tag: ${baselineTag}" is a semantic gap that cannot be automatically translated. SnapDrift always uses the latest baseline from the target branch.`,
85
+ severity: 'warning'
86
+ });
87
+ }
88
+
89
+ // Snap provider config (if present in inputs)
90
+ /** @type {object | undefined} */
91
+ let snap;
92
+ const snapApiUrl = inputs['snap-api-url'] || inputs.snap_api_url;
93
+ const snapApiKeyEnv = inputs['snap-api-key-env'] || inputs.snap_api_key_env;
94
+ const snapProjectId = inputs['snap-project-id'] || inputs.snap_project_id;
95
+
96
+ if (snapApiKeyEnv || snapProjectId) {
97
+ snap = {};
98
+ if (snapApiUrl) snap.apiUrl = snapApiUrl;
99
+ if (snapApiKeyEnv) snap.apiKeyEnv = snapApiKeyEnv;
100
+ if (snapProjectId) snap.projectId = snapProjectId;
101
+ }
102
+
103
+ const config = {
104
+ baselineArtifactName: 'snapdrift-baseline',
105
+ workingDirectory: '.',
106
+ baseUrl: 'http://localhost:3000',
107
+ resultsFile: 'qa-artifacts/snapdrift/baseline/current/results.json',
108
+ manifestFile: 'qa-artifacts/snapdrift/baseline/current/manifest.json',
109
+ screenshotsRoot: 'qa-artifacts/snapdrift/baseline/current',
110
+ routes: [],
111
+ diff: {
112
+ threshold: isNaN(threshold) ? 0.01 : threshold,
113
+ mode: diffMode
114
+ }
115
+ };
116
+
117
+ if (snap) {
118
+ config.provider = 'snap';
119
+ config.snap = snap;
120
+ }
121
+
122
+ // Note about routes — cannot be auto-translated from action inputs
123
+ warnings.push({
124
+ field: 'routes',
125
+ originalValue: '',
126
+ message: 'Routes cannot be automatically translated from the Snap action. You must fill in the routes array in snapdrift.json manually, matching the pages you want to test.',
127
+ severity: 'note'
128
+ });
129
+
130
+ // Note about baseUrl — cannot be auto-translated
131
+ warnings.push({
132
+ field: 'baseUrl',
133
+ originalValue: '',
134
+ message: 'The baseUrl was set to a placeholder (http://localhost:3000). Update it to match your app URL before running snapdrift.',
135
+ severity: 'note'
136
+ });
137
+
138
+ return { config, warnings };
139
+ }
140
+
141
+ /**
142
+ * Generate MIGRATION_NOTES.md content.
143
+ *
144
+ * @param {InitWarning[]} warnings
145
+ * @param {string} sourceYamlPath
146
+ * @returns {string}
147
+ */
148
+ function generateMigrationNotes(warnings, sourceYamlPath) {
149
+ const lines = [
150
+ '# Migration Notes',
151
+ '',
152
+ `Generated from: \`${sourceYamlPath}\``,
153
+ '',
154
+ '## Warnings',
155
+ ''
156
+ ];
157
+
158
+ const warningsList = warnings.filter((w) => w.severity === 'warning');
159
+ if (warningsList.length === 0) {
160
+ lines.push('No warnings.');
161
+ } else {
162
+ for (const w of warningsList) {
163
+ lines.push(`- **${w.field}**: ${w.message}`);
164
+ }
165
+ }
166
+
167
+ lines.push('');
168
+ lines.push('## Notes');
169
+ lines.push('');
170
+
171
+ const notesList = warnings.filter((w) => w.severity === 'note');
172
+ for (const n of notesList) {
173
+ lines.push(`- **${n.field}**: ${n.message}`);
174
+ }
175
+
176
+ lines.push('');
177
+ return lines.join('\n');
178
+ }
179
+
180
+ /**
181
+ * Run the init --from-snap-action codemod.
182
+ *
183
+ * Reads a Snap action workflow YAML, translates it into snapdrift.json,
184
+ * and emits MIGRATION_NOTES.md with warnings.
185
+ *
186
+ * @param {string} actionYamlPath - Path to the Snap action workflow YAML
187
+ * @returns {Promise<{ configPath: string, warningsCount: number }>}
188
+ */
189
+ export async function runInitFromAction(actionYamlPath) {
190
+ // Read and parse YAML
191
+ let workflowContent;
192
+ try {
193
+ workflowContent = await fs.readFile(actionYamlPath, 'utf-8');
194
+ } catch {
195
+ throw new Error(`Cannot read workflow file: ${actionYamlPath}`);
196
+ }
197
+
198
+ const workflow = yaml.load(workflowContent);
199
+ if (!workflow || typeof workflow !== 'object') {
200
+ throw new Error(`Invalid workflow YAML: ${actionYamlPath}`);
201
+ }
202
+
203
+ // Find the Snap action step
204
+ const snapStep = findSnapActionStep(workflow);
205
+ if (!snapStep) {
206
+ throw new Error(
207
+ `No Snap action step found in ${actionYamlPath}. ` +
208
+ `Looked for steps with "uses" containing "snap" and "action".`
209
+ );
210
+ }
211
+
212
+ // Translate
213
+ const { config, warnings } = translateStepToConfig(snapStep.step);
214
+
215
+ // Ensure output directory exists
216
+ const outputDir = DEFAULT_OUTPUT_DIR;
217
+ await fs.mkdir(outputDir, { recursive: true });
218
+
219
+ const configPath = path.join(outputDir, DEFAULT_CONFIG_FILENAME);
220
+ const notesPath = path.join(outputDir, MIGRATION_NOTES_FILENAME);
221
+
222
+ // Idempotency: refuse to overwrite existing snapdrift.json
223
+ const configExists = await fs.access(configPath).then(() => true, () => false);
224
+ if (configExists) {
225
+ throw new Error(
226
+ `${configPath} already exists. Remove it manually and re-run to regenerate.`
227
+ );
228
+ }
229
+
230
+ // Write snapdrift.json
231
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2) + '\n');
232
+
233
+ // Write MIGRATION_NOTES.md
234
+ const notesContent = generateMigrationNotes(warnings, actionYamlPath);
235
+ await fs.writeFile(notesPath, notesContent);
236
+
237
+ process.stdout.write(`Created ${configPath}\n`);
238
+ process.stdout.write(`Created ${notesPath}\n`);
239
+ if (warnings.length > 0) {
240
+ process.stdout.write(`${warnings.length} warning(s)/note(s) — review ${notesPath}\n`);
241
+ }
242
+
243
+ return { configPath, warningsCount: warnings.length };
244
+ }