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/CHANGELOG.md +38 -0
- package/README.md +17 -7
- package/actions/baseline/action.yml +83 -13
- package/actions/comment/action.yml +31 -3
- package/actions/pr-diff/action.yml +110 -23
- package/actions/scope/action.yml +9 -0
- package/docs/contracts.md +237 -1
- package/docs/integration-guide.md +94 -26
- package/docs/local-cli.md +107 -4
- package/docs/workflow-templates/refresh-baseline-on-merge.yml +1 -1
- package/lib/cli.mjs +161 -26
- package/lib/init-from-action.mjs +244 -0
- package/lib/migrate-baselines.mjs +199 -0
- package/lib/provider.mjs +106 -0
- package/lib/snap-provider.mjs +1033 -0
- package/lib/snapdrift-config.mjs +3 -1
- package/package.json +9 -5
- package/types/visual-diff-types.d.ts +65 -0
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 {
|
|
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 {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
+
}
|