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/CHANGELOG.md +37 -1
- package/actions/comment/action.yml +22 -3
- package/actions/pr-diff/action.yml +16 -3
- package/docs/contracts.md +126 -1
- package/lib/capture-routes.mjs +2 -368
- package/lib/cli.mjs +167 -34
- package/lib/compare-results.mjs +21 -642
- package/lib/drift-summary.mjs +3 -154
- package/lib/init-from-action.mjs +244 -0
- package/lib/migrate-baselines.mjs +199 -0
- package/lib/pr-comment.mjs +2 -147
- package/lib/provider.mjs +106 -0
- package/lib/report.mjs +17 -246
- package/lib/snap-provider.mjs +734 -0
- package/lib/snapdrift-config.mjs +22 -284
- package/lib/stage-artifacts.mjs +2 -123
- package/package.json +16 -4
- package/types/visual-diff-types.d.ts +65 -0
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 {
|
|
8
|
-
import {
|
|
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
|
-
? '
|
|
128
|
+
? '✅' // ✅
|
|
99
129
|
: summary.status === 'changes-detected'
|
|
100
|
-
? '
|
|
101
|
-
: '
|
|
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
|
|
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(`
|
|
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 {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
+
}
|