snapdrift 0.8.1 → 0.9.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 +61 -0
- package/README.md +12 -4
- package/action.yml +16 -9
- package/actions/baseline/action.yml +68 -47
- package/actions/pr-diff/action.yml +208 -107
- package/actions/resolve-baseline/action.yml +36 -50
- package/actions/scope/action.yml +24 -1
- package/bin/snapdrift.mjs +6 -3
- package/docs/contracts.md +141 -9
- package/docs/integration-guide.md +54 -13
- package/docs/local-cli.md +6 -5
- package/docs/workflow-templates/refresh-baseline-on-merge.yml +25 -13
- package/lib/cli.mjs +243 -168
- package/lib/init-from-action.mjs +24 -13
- package/lib/outage-policy.mjs +278 -0
- package/lib/provider.mjs +20 -2
- package/lib/resolve-baseline-artifact.mjs +147 -0
- package/lib/resolve-baseline-paths.mjs +40 -0
- package/lib/snap-provider.mjs +1246 -303
- package/package.json +15 -9
- package/types/visual-diff-types.d.ts +28 -0
package/lib/cli.mjs
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
4
|
import fs from 'node:fs/promises';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
7
|
import { loadSnapdriftConfig } from '@snapdrift/adapter-fs';
|
|
8
8
|
import { shouldFailDriftCheck } from '@snapdrift/manifest';
|
|
9
9
|
import { createProvider } from './provider.mjs';
|
|
10
|
-
import {
|
|
10
|
+
import { captureWithPolicy, diffWithPolicy, publishBaselineWithPolicy } from './outage-policy.mjs';
|
|
11
11
|
import { generateHtmlReport } from './report.mjs';
|
|
12
12
|
|
|
13
13
|
/** @typedef {import('../types/visual-diff-types').VisualDiffSummary} DriftSummary */
|
|
@@ -39,7 +39,8 @@ export const LOCAL_DIFF_SUBDIR = 'diff';
|
|
|
39
39
|
* Supported flags:
|
|
40
40
|
* --open Open the HTML report after diff
|
|
41
41
|
* --config <path> Path to snapdrift.json (default: .github/snapdrift.json)
|
|
42
|
-
* --routes <ids> Comma-separated route IDs
|
|
42
|
+
* --routes <ids> Comma-separated route IDs (diff/capture/local baseline;
|
|
43
|
+
* hosted baselines require the complete configured set)
|
|
43
44
|
* --baseline-dir <p> Override local baseline directory (default: .snapdrift/baseline)
|
|
44
45
|
* --current-dir <p> Override local current-capture directory (default: .snapdrift/current)
|
|
45
46
|
* --diff-dir <p> Override local diff-output directory (default: .snapdrift/diff)
|
|
@@ -48,6 +49,11 @@ export const LOCAL_DIFF_SUBDIR = 'diff';
|
|
|
48
49
|
* --accept-cross-engine Allow importing baselines from a different capture engine
|
|
49
50
|
* --from-snap-action <path> Snap action workflow YAML path (init command)
|
|
50
51
|
*
|
|
52
|
+
* Values may be given inline as `--flag=value` (so values beginning with a hyphen,
|
|
53
|
+
* e.g. `--routes=-mobile`, work) or after the `--` end-of-options marker. Unknown
|
|
54
|
+
* flags and missing option values are rejected with a non-zero error; invalid
|
|
55
|
+
* *values* for --to/--from remain silently ignored (validated downstream).
|
|
56
|
+
*
|
|
51
57
|
* @param {string[]} argv - process.argv
|
|
52
58
|
* @returns {CliOptions}
|
|
53
59
|
*/
|
|
@@ -70,41 +76,105 @@ export function parseArgs(argv) {
|
|
|
70
76
|
/** @type {string | undefined} */
|
|
71
77
|
let fromSnapAction;
|
|
72
78
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
79
|
+
// Flags that consume the following token as their value. Unknown flags and
|
|
80
|
+
// missing option values are rejected rather than silently ignored, so a typo
|
|
81
|
+
// cannot fall back to the default config/directories. Invalid *values* for
|
|
82
|
+
// --to/--from stay silently ignored to preserve the migrate-baselines contract
|
|
83
|
+
// (their validation happens downstream). Values that begin with a hyphen (e.g.
|
|
84
|
+
// a route id like `-mobile`) may be supplied inline as `--flag=value` or bare.
|
|
85
|
+
const VALUE_FLAGS = new Set([
|
|
86
|
+
'--config',
|
|
87
|
+
'--routes',
|
|
88
|
+
'--baseline-dir',
|
|
89
|
+
'--current-dir',
|
|
90
|
+
'--diff-dir',
|
|
91
|
+
'--to',
|
|
92
|
+
'--from',
|
|
93
|
+
'--from-snap-action'
|
|
94
|
+
]);
|
|
95
|
+
const BOOLEAN_FLAGS = new Set(['--open', '--accept-cross-engine']);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Assign a parsed value (from an inline `--flag=value` or a following token)
|
|
99
|
+
* to the option named by `flag`.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} flag
|
|
102
|
+
* @param {string} value
|
|
103
|
+
*/
|
|
104
|
+
function assignFlag(flag, value) {
|
|
105
|
+
if (flag === '--routes') {
|
|
106
|
+
routes.push(...value.split(',').map((r) => r.trim()).filter(Boolean));
|
|
107
|
+
} else if (flag === '--config') {
|
|
108
|
+
configPath = value;
|
|
109
|
+
} else if (flag === '--baseline-dir') {
|
|
110
|
+
baselineDir = path.resolve(value);
|
|
111
|
+
} else if (flag === '--current-dir') {
|
|
112
|
+
currentDir = path.resolve(value);
|
|
113
|
+
} else if (flag === '--diff-dir') {
|
|
114
|
+
diffDir = path.resolve(value);
|
|
115
|
+
} else if (flag === '--to') {
|
|
116
|
+
if (value === 'snap' || value === 'local') to = value;
|
|
117
|
+
} else if (flag === '--from') {
|
|
118
|
+
if (value === 'snap') from = value;
|
|
119
|
+
} else if (flag === '--from-snap-action') {
|
|
120
|
+
fromSnapAction = value;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let endOfOptions = false;
|
|
125
|
+
|
|
126
|
+
for (let i = 1; i < args.length; i++) {
|
|
127
|
+
const raw = args[i];
|
|
128
|
+
|
|
129
|
+
if (raw === '--') {
|
|
130
|
+
endOfOptions = true;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// End-of-options: after `--`, tokens are values/positionals, never flags.
|
|
135
|
+
if (endOfOptions) {
|
|
136
|
+
if (VALUE_FLAGS.has(raw)) {
|
|
137
|
+
const value = args[i + 1];
|
|
138
|
+
if (value === undefined) {
|
|
139
|
+
throw new Error(`Missing value for flag ${raw}`);
|
|
140
|
+
}
|
|
141
|
+
assignFlag(raw, value);
|
|
142
|
+
i++; // consume the following token so it is not reprocessed as a flag
|
|
143
|
+
}
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Inline `--flag=value` form (also handles values that begin with a hyphen).
|
|
148
|
+
const eq = raw.indexOf('=');
|
|
149
|
+
if (raw.startsWith('--') && eq > 0) {
|
|
150
|
+
const flag = raw.slice(0, eq);
|
|
151
|
+
if (!VALUE_FLAGS.has(flag)) {
|
|
152
|
+
throw new Error(`Unknown flag: ${flag}`);
|
|
153
|
+
}
|
|
154
|
+
assignFlag(flag, raw.slice(eq + 1));
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (BOOLEAN_FLAGS.has(raw)) {
|
|
159
|
+
if (raw === '--open') {
|
|
160
|
+
open = true;
|
|
161
|
+
} else if (raw === '--accept-cross-engine') {
|
|
162
|
+
acceptCrossEngine = true;
|
|
163
|
+
}
|
|
164
|
+
} else if (VALUE_FLAGS.has(raw)) {
|
|
165
|
+
const value = args[i + 1];
|
|
166
|
+
// A hyphenated following token (e.g. a route id like `-mobile`) is a
|
|
167
|
+
// legitimate value; only a missing token, the `--` end-of-options marker,
|
|
168
|
+
// or a following *recognized flag* means the value is absent.
|
|
169
|
+
if (value === undefined || value === '--' || VALUE_FLAGS.has(value) || BOOLEAN_FLAGS.has(value)) {
|
|
170
|
+
throw new Error(`Missing value for flag ${raw}`);
|
|
171
|
+
}
|
|
172
|
+
assignFlag(raw, value);
|
|
173
|
+
i++; // consume the following token so it is not reprocessed as a flag
|
|
174
|
+
} else if (raw.startsWith('-')) {
|
|
175
|
+
throw new Error(`Unknown flag: ${raw}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
108
178
|
|
|
109
179
|
return { command, open, configPath, routes, baselineDir, currentDir, diffDir, to, from, acceptCrossEngine, fromSnapAction };
|
|
110
180
|
}
|
|
@@ -162,15 +232,30 @@ export function printSummary(summary) {
|
|
|
162
232
|
* @returns {void}
|
|
163
233
|
*/
|
|
164
234
|
function openFile(filePath) {
|
|
165
|
-
const cmd =
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
execSync(`${cmd} "${filePath}"`);
|
|
169
|
-
} catch {
|
|
235
|
+
const { cmd, args } = buildOpenCommand(filePath);
|
|
236
|
+
const result = spawnSync(cmd, args);
|
|
237
|
+
if (result.error) {
|
|
170
238
|
process.stderr.write(`Could not open file: ${filePath}\n`);
|
|
171
239
|
}
|
|
172
240
|
}
|
|
173
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Build the platform-correct invocation to open a file with the OS default
|
|
244
|
+
* viewer. The path is passed as a separate argument (never shell-interpolated)
|
|
245
|
+
* so metacharacters in a local path cannot execute commands. On Windows, `start`
|
|
246
|
+
* is a cmd.exe built-in rather than an executable, so spawnSync (which does not
|
|
247
|
+
* use a shell by default) returns ENOENT; open the registered handler through
|
|
248
|
+
* rundll32, a real executable, keeping the path as a separate argument.
|
|
249
|
+
*
|
|
250
|
+
* @param {string} filePath
|
|
251
|
+
* @returns {{ cmd: string, args: string[] }}
|
|
252
|
+
*/
|
|
253
|
+
export function buildOpenCommand(filePath, platform = process.platform) {
|
|
254
|
+
if (platform === 'darwin') return { cmd: 'open', args: [filePath] };
|
|
255
|
+
if (platform === 'linux') return { cmd: 'xdg-open', args: [filePath] };
|
|
256
|
+
return { cmd: 'rundll32', args: ['url.dll,FileProtocolHandler', filePath] };
|
|
257
|
+
}
|
|
258
|
+
|
|
174
259
|
/**
|
|
175
260
|
* Run the `capture` command — capture screenshots to a local baseline directory.
|
|
176
261
|
*
|
|
@@ -180,36 +265,33 @@ function openFile(filePath) {
|
|
|
180
265
|
export async function runCaptureCommand(opts) {
|
|
181
266
|
process.stdout.write(`Capturing baseline to ${opts.baselineDir} ...\n`);
|
|
182
267
|
const { config } = await loadSnapdriftConfig(opts.configPath);
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
268
|
+
const providerName = config.provider ?? 'local';
|
|
269
|
+
const provider = createProvider(providerName, config);
|
|
270
|
+
|
|
271
|
+
// onUnavailable: warn-and-skip — the user asked not to fail the build when
|
|
272
|
+
// Snap is down. `diff` and `baseline` both honour this; `capture` used to
|
|
273
|
+
// let SnapSkipError reach bin/snapdrift.mjs, which exits 1. See #108.
|
|
274
|
+
const captured = await captureWithPolicy({
|
|
275
|
+
provider,
|
|
276
|
+
providerName,
|
|
277
|
+
config,
|
|
278
|
+
captureOptions: {
|
|
186
279
|
configPath: opts.configPath,
|
|
187
280
|
routeIds: opts.routes.length > 0 ? opts.routes : undefined,
|
|
188
281
|
outDir: opts.baselineDir,
|
|
189
|
-
purpose: '
|
|
190
|
-
}
|
|
191
|
-
process.stdout.write(
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
process.stdout.write('Capture skipped (Snap unavailable, warn-and-skip mode).\n');
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
if (error instanceof SnapFallbackError) {
|
|
201
|
-
process.stdout.write('Falling back to local provider for capture...\n');
|
|
202
|
-
const localProvider = createProvider('local');
|
|
203
|
-
const { selectedRouteIds } = await localProvider.capture({
|
|
204
|
-
configPath: opts.configPath,
|
|
205
|
-
routeIds: opts.routes.length > 0 ? opts.routes : undefined,
|
|
206
|
-
outDir: opts.baselineDir
|
|
207
|
-
});
|
|
208
|
-
process.stdout.write(`Captured ${selectedRouteIds.length} route(s) to ${opts.baselineDir}\n`);
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
throw error;
|
|
282
|
+
purpose: 'capture'
|
|
283
|
+
},
|
|
284
|
+
onSkip: () => process.stdout.write('Capture skipped (Snap unavailable, warn-and-skip mode).\n'),
|
|
285
|
+
onFallback: () => process.stdout.write('Falling back to local provider for capture...\n')
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
if (captured.outcome === 'skipped') {
|
|
289
|
+
return;
|
|
212
290
|
}
|
|
291
|
+
|
|
292
|
+
process.stdout.write(
|
|
293
|
+
`Captured ${captured.result.selectedRouteIds.length} route(s) to ${opts.baselineDir}\n`
|
|
294
|
+
);
|
|
213
295
|
}
|
|
214
296
|
|
|
215
297
|
/**
|
|
@@ -240,79 +322,65 @@ export async function runBaselineCommand(opts) {
|
|
|
240
322
|
: `Capturing baseline to ${opts.baselineDir} ...\n`
|
|
241
323
|
);
|
|
242
324
|
|
|
243
|
-
/**
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
325
|
+
/** @type {import('../types/visual-diff-types').ProviderCaptureOptions} */
|
|
326
|
+
const captureOptions = {
|
|
327
|
+
configPath: opts.configPath,
|
|
328
|
+
routeIds: opts.routes.length > 0 ? opts.routes : undefined,
|
|
329
|
+
outDir: opts.baselineDir,
|
|
330
|
+
purpose: 'baseline'
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const onFallback = () => process.stdout.write('Falling back to local provider for capture...\n');
|
|
334
|
+
|
|
335
|
+
// Snap can go down during *either* phase — the capture submission or the
|
|
336
|
+
// publish that follows it — and the user's `onUnavailable` choice must hold
|
|
337
|
+
// for both, otherwise `warn-and-skip` still exits 1 and `fallback-local`
|
|
338
|
+
// still leaves them with no baseline.
|
|
339
|
+
const captured = await captureWithPolicy({
|
|
340
|
+
provider,
|
|
341
|
+
providerName,
|
|
342
|
+
config,
|
|
343
|
+
captureOptions,
|
|
344
|
+
onSkip: () => process.stdout.write('Baseline skipped (Snap unavailable, warn-and-skip mode).\n'),
|
|
345
|
+
onFallback
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
if (captured.outcome === 'skipped') {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (captured.providerName !== 'snap') {
|
|
353
|
+
const suffix = providerName === 'snap' ? ' (local fallback — nothing published to Snap)' : '';
|
|
252
354
|
process.stdout.write(
|
|
253
|
-
`Captured ${selectedRouteIds.length} route(s) to ${opts.baselineDir}
|
|
355
|
+
`Captured ${captured.result.selectedRouteIds.length} route(s) to ${opts.baselineDir}${suffix}\n`
|
|
254
356
|
);
|
|
357
|
+
return;
|
|
255
358
|
}
|
|
256
359
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
* the user's choice must hold for both, otherwise `warn-and-skip` still exits 1
|
|
261
|
-
* and `fallback-local` still leaves them with no baseline.
|
|
262
|
-
*
|
|
263
|
-
* @param {unknown} error
|
|
264
|
-
* @param {string} skipMessage
|
|
265
|
-
* @returns {Promise<boolean>} true when the error was absorbed
|
|
266
|
-
*/
|
|
267
|
-
async function handleUnavailable(error, skipMessage) {
|
|
268
|
-
if (error instanceof SnapSkipError) {
|
|
269
|
-
process.stdout.write(skipMessage);
|
|
270
|
-
return true;
|
|
271
|
-
}
|
|
272
|
-
if (error instanceof SnapFallbackError) {
|
|
273
|
-
await captureLocalFallback();
|
|
274
|
-
return true;
|
|
275
|
-
}
|
|
276
|
-
return false;
|
|
277
|
-
}
|
|
360
|
+
process.stdout.write(
|
|
361
|
+
`Captured ${captured.result.selectedRouteIds.length} route(s); waiting for Snap to finish rendering ...\n`
|
|
362
|
+
);
|
|
278
363
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
});
|
|
288
|
-
} catch (error) {
|
|
289
|
-
if (await handleUnavailable(error, 'Baseline skipped (Snap unavailable, warn-and-skip mode).\n')) {
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
|
-
throw error;
|
|
293
|
-
}
|
|
364
|
+
const published = await publishBaselineWithPolicy({
|
|
365
|
+
provider,
|
|
366
|
+
publishOptions: { resultsPath: captured.result.resultsPath },
|
|
367
|
+
captureOptions,
|
|
368
|
+
onSkip: () =>
|
|
369
|
+
process.stdout.write('Baseline publish skipped (Snap unavailable, warn-and-skip mode).\n'),
|
|
370
|
+
onFallback
|
|
371
|
+
});
|
|
294
372
|
|
|
295
|
-
if (
|
|
296
|
-
process.stdout.write(`Captured ${captured.selectedRouteIds.length} route(s) to ${opts.baselineDir}\n`);
|
|
373
|
+
if (published.outcome === 'skipped') {
|
|
297
374
|
return;
|
|
298
375
|
}
|
|
299
376
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
} catch (error) {
|
|
306
|
-
if (
|
|
307
|
-
await handleUnavailable(
|
|
308
|
-
error,
|
|
309
|
-
'Baseline publish skipped (Snap unavailable, warn-and-skip mode).\n'
|
|
310
|
-
)
|
|
311
|
-
) {
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
314
|
-
throw error;
|
|
377
|
+
if (published.outcome === 'fell-back') {
|
|
378
|
+
process.stdout.write(
|
|
379
|
+
`Captured ${published.recapture?.selectedRouteIds.length ?? 0} route(s) to ${opts.baselineDir} (local fallback — nothing published to Snap)\n`
|
|
380
|
+
);
|
|
381
|
+
return;
|
|
315
382
|
}
|
|
383
|
+
|
|
316
384
|
process.stdout.write('Snap baseline published successfully.\n');
|
|
317
385
|
}
|
|
318
386
|
|
|
@@ -326,31 +394,32 @@ export async function runBaselineCommand(opts) {
|
|
|
326
394
|
export async function runDiffCommand(opts) {
|
|
327
395
|
process.stdout.write(`Capturing current screenshots to ${opts.currentDir} ...\n`);
|
|
328
396
|
const { config } = await loadSnapdriftConfig(opts.configPath);
|
|
329
|
-
const
|
|
397
|
+
const providerName = config.provider ?? 'local';
|
|
398
|
+
const provider = createProvider(providerName, config);
|
|
330
399
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
throw error;
|
|
351
|
-
}
|
|
400
|
+
/** @type {import('../types/visual-diff-types').ProviderCaptureOptions} */
|
|
401
|
+
const captureOptions = {
|
|
402
|
+
configPath: opts.configPath,
|
|
403
|
+
routeIds: opts.routes.length > 0 ? opts.routes : undefined,
|
|
404
|
+
outDir: opts.currentDir
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const captured = await captureWithPolicy({
|
|
408
|
+
provider,
|
|
409
|
+
providerName,
|
|
410
|
+
config,
|
|
411
|
+
captureOptions,
|
|
412
|
+
onSkip: () =>
|
|
413
|
+
process.stdout.write('Visual regression skipped (Snap unavailable, warn-and-skip mode).\n'),
|
|
414
|
+
onFallback: () => process.stdout.write('Falling back to local provider for capture...\n')
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
if (captured.outcome === 'skipped') {
|
|
418
|
+
return;
|
|
352
419
|
}
|
|
353
420
|
|
|
421
|
+
const currentCapture = captured.result;
|
|
422
|
+
|
|
354
423
|
process.stdout.write('Comparing against baseline ...\n');
|
|
355
424
|
|
|
356
425
|
const baselineResultsPath = path.join(opts.baselineDir, path.basename(config.resultsFile));
|
|
@@ -361,10 +430,12 @@ export async function runDiffCommand(opts) {
|
|
|
361
430
|
const markdownPath = path.join(opts.diffDir, 'summary.md');
|
|
362
431
|
const htmlPath = path.join(opts.diffDir, 'report.html');
|
|
363
432
|
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
433
|
+
const diffed = await diffWithPolicy({
|
|
434
|
+
provider: captured.providerName === 'local' ? createProvider('local') : provider,
|
|
435
|
+
providerName: captured.providerName,
|
|
436
|
+
localScreenshots: captured.localScreenshots,
|
|
437
|
+
captureOptions,
|
|
438
|
+
diffOptions: {
|
|
368
439
|
configPath: opts.configPath,
|
|
369
440
|
routeIds: opts.routes.length > 0 ? opts.routes : undefined,
|
|
370
441
|
baselineResultsPath,
|
|
@@ -373,20 +444,24 @@ export async function runDiffCommand(opts) {
|
|
|
373
444
|
currentManifestPath: currentCapture.manifestPath,
|
|
374
445
|
baselineRunDir: opts.baselineDir,
|
|
375
446
|
currentRunDir: currentCapture.screenshotsRoot
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
447
|
+
},
|
|
448
|
+
onSkip: () =>
|
|
449
|
+
process.stdout.write('Visual regression skipped (Snap unavailable, warn-and-skip mode).\n'),
|
|
450
|
+
onFallback: () => process.stdout.write('Falling back to local provider for the diff...\n'),
|
|
451
|
+
onRecapture: () =>
|
|
452
|
+
process.stdout.write('Recapturing locally so the local diff has screenshots to compare...\n')
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
if (diffed.outcome !== 'diffed') {
|
|
456
|
+
return;
|
|
383
457
|
}
|
|
384
458
|
|
|
385
|
-
const { summary, markdown } =
|
|
459
|
+
const { summary, markdown } = diffed.result;
|
|
460
|
+
const currentRunDir = diffed.recapture?.screenshotsRoot ?? currentCapture.screenshotsRoot;
|
|
386
461
|
|
|
387
462
|
const html = await generateHtmlReport(summary, {
|
|
388
463
|
baselineRunDir: opts.baselineDir,
|
|
389
|
-
currentRunDir
|
|
464
|
+
currentRunDir
|
|
390
465
|
});
|
|
391
466
|
|
|
392
467
|
await Promise.all([
|
|
@@ -485,4 +560,4 @@ export async function main(argv) {
|
|
|
485
560
|
);
|
|
486
561
|
process.exitCode = 1;
|
|
487
562
|
}
|
|
488
|
-
}
|
|
563
|
+
}
|
package/lib/init-from-action.mjs
CHANGED
|
@@ -87,19 +87,30 @@ function translateStepToConfig(step) {
|
|
|
87
87
|
});
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
90
|
+
// Snap provider config (if present in inputs). A hosted Snap provider requires
|
|
91
|
+
// an API-key source; only enable provider: "snap" when one is present. When a
|
|
92
|
+
// project ID exists without an API-key source, keep a valid (local) config and
|
|
93
|
+
// emit a clear migration warning rather than emitting provider: "snap" with no
|
|
94
|
+
// API-key source, which later fails validation.
|
|
95
|
+
/** @type {JsonObject | undefined} */
|
|
96
|
+
let snap;
|
|
97
|
+
const snapApiUrl = inputs['snap-api-url'] || inputs.snap_api_url;
|
|
98
|
+
const snapApiKeyEnv = inputs['snap-api-key-env'] || inputs.snap_api_key_env;
|
|
99
|
+
const snapProjectId = inputs['snap-project-id'] || inputs.snap_project_id;
|
|
100
|
+
|
|
101
|
+
if (snapApiKeyEnv) {
|
|
102
|
+
snap = {};
|
|
103
|
+
if (snapApiUrl) snap.apiUrl = snapApiUrl;
|
|
104
|
+
snap.apiKeyEnv = snapApiKeyEnv;
|
|
105
|
+
if (snapProjectId) snap.projectId = snapProjectId;
|
|
106
|
+
} else if (snapProjectId) {
|
|
107
|
+
warnings.push({
|
|
108
|
+
field: 'snap.projectId',
|
|
109
|
+
originalValue: snapProjectId,
|
|
110
|
+
message: `Snap project ID "${snapProjectId}" is present but no API-key source (snap-api-key-env) was configured. SnapDrift keeps the local provider; add a Snap API key source to enable hosted baselines.`,
|
|
111
|
+
severity: 'warning'
|
|
112
|
+
});
|
|
113
|
+
}
|
|
103
114
|
|
|
104
115
|
const config = {
|
|
105
116
|
baselineArtifactName: 'snapdrift-baseline',
|