snapdrift 0.8.2 → 0.10.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 +74 -0
- package/README.md +8 -3
- package/action.yml +10 -2
- package/actions/comment/action.yml +6 -0
- package/actions/compare/action.yml +6 -0
- package/actions/pr-diff/action.yml +102 -55
- package/actions/resolve-baseline/action.yml +36 -50
- package/actions/scope/action.yml +24 -1
- package/actions/stage/action.yml +7 -1
- package/bin/snapdrift.mjs +6 -3
- package/docs/contracts.md +125 -9
- package/docs/integration-guide.md +61 -12
- package/docs/local-cli.md +15 -1
- package/docs/workflow-templates/refresh-baseline-on-merge.yml +1 -1
- package/lib/cli.mjs +140 -52
- package/lib/init-from-action.mjs +24 -13
- package/lib/outage-policy.mjs +35 -2
- package/lib/provider.mjs +39 -12
- package/lib/report.mjs +3 -2
- package/lib/resolve-baseline-artifact.mjs +147 -0
- package/lib/resolve-baseline-paths.mjs +40 -0
- package/lib/snap-provider.mjs +1164 -481
- package/package.json +15 -11
- package/types/visual-diff-types.d.ts +30 -1
package/lib/cli.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
|
|
|
@@ -49,6 +49,11 @@ export const LOCAL_DIFF_SUBDIR = 'diff';
|
|
|
49
49
|
* --accept-cross-engine Allow importing baselines from a different capture engine
|
|
50
50
|
* --from-snap-action <path> Snap action workflow YAML path (init command)
|
|
51
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
|
+
*
|
|
52
57
|
* @param {string[]} argv - process.argv
|
|
53
58
|
* @returns {CliOptions}
|
|
54
59
|
*/
|
|
@@ -71,41 +76,105 @@ export function parseArgs(argv) {
|
|
|
71
76
|
/** @type {string | undefined} */
|
|
72
77
|
let fromSnapAction;
|
|
73
78
|
|
|
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
|
-
|
|
108
|
-
|
|
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
|
+
}
|
|
109
178
|
|
|
110
179
|
return { command, open, configPath, routes, baselineDir, currentDir, diffDir, to, from, acceptCrossEngine, fromSnapAction };
|
|
111
180
|
}
|
|
@@ -163,15 +232,30 @@ export function printSummary(summary) {
|
|
|
163
232
|
* @returns {void}
|
|
164
233
|
*/
|
|
165
234
|
function openFile(filePath) {
|
|
166
|
-
const cmd =
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
execSync(`${cmd} "${filePath}"`);
|
|
170
|
-
} catch {
|
|
235
|
+
const { cmd, args } = buildOpenCommand(filePath);
|
|
236
|
+
const result = spawnSync(cmd, args);
|
|
237
|
+
if (result.error) {
|
|
171
238
|
process.stderr.write(`Could not open file: ${filePath}\n`);
|
|
172
239
|
}
|
|
173
240
|
}
|
|
174
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
|
+
|
|
175
259
|
/**
|
|
176
260
|
* Run the `capture` command — capture screenshots to a local baseline directory.
|
|
177
261
|
*
|
|
@@ -346,21 +430,24 @@ export async function runDiffCommand(opts) {
|
|
|
346
430
|
const markdownPath = path.join(opts.diffDir, 'summary.md');
|
|
347
431
|
const htmlPath = path.join(opts.diffDir, 'report.html');
|
|
348
432
|
|
|
433
|
+
const diffOptions = /** @type {import('../types/visual-diff-types').ProviderDiffOptions & { diffImagesDir: string }} */ ({
|
|
434
|
+
configPath: opts.configPath,
|
|
435
|
+
routeIds: opts.routes.length > 0 ? opts.routes : undefined,
|
|
436
|
+
baselineResultsPath,
|
|
437
|
+
baselineManifestPath,
|
|
438
|
+
currentResultsPath: currentCapture.resultsPath,
|
|
439
|
+
currentManifestPath: currentCapture.manifestPath,
|
|
440
|
+
baselineRunDir: opts.baselineDir,
|
|
441
|
+
currentRunDir: currentCapture.screenshotsRoot,
|
|
442
|
+
diffImagesDir: path.join(opts.diffDir, 'diffs')
|
|
443
|
+
});
|
|
444
|
+
|
|
349
445
|
const diffed = await diffWithPolicy({
|
|
350
446
|
provider: captured.providerName === 'local' ? createProvider('local') : provider,
|
|
351
447
|
providerName: captured.providerName,
|
|
352
448
|
localScreenshots: captured.localScreenshots,
|
|
353
449
|
captureOptions,
|
|
354
|
-
diffOptions
|
|
355
|
-
configPath: opts.configPath,
|
|
356
|
-
routeIds: opts.routes.length > 0 ? opts.routes : undefined,
|
|
357
|
-
baselineResultsPath,
|
|
358
|
-
baselineManifestPath,
|
|
359
|
-
currentResultsPath: currentCapture.resultsPath,
|
|
360
|
-
currentManifestPath: currentCapture.manifestPath,
|
|
361
|
-
baselineRunDir: opts.baselineDir,
|
|
362
|
-
currentRunDir: currentCapture.screenshotsRoot
|
|
363
|
-
},
|
|
450
|
+
diffOptions,
|
|
364
451
|
onSkip: () =>
|
|
365
452
|
process.stdout.write('Visual regression skipped (Snap unavailable, warn-and-skip mode).\n'),
|
|
366
453
|
onFallback: () => process.stdout.write('Falling back to local provider for the diff...\n'),
|
|
@@ -377,7 +464,8 @@ export async function runDiffCommand(opts) {
|
|
|
377
464
|
|
|
378
465
|
const html = await generateHtmlReport(summary, {
|
|
379
466
|
baselineRunDir: opts.baselineDir,
|
|
380
|
-
currentRunDir
|
|
467
|
+
currentRunDir,
|
|
468
|
+
diffRunDir: opts.diffDir
|
|
381
469
|
});
|
|
382
470
|
|
|
383
471
|
await Promise.all([
|
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',
|
package/lib/outage-policy.mjs
CHANGED
|
@@ -41,6 +41,25 @@ export const MISSING_BASELINE_REASON = 'missing_main_baseline_artifact';
|
|
|
41
41
|
/** @returns {void} */
|
|
42
42
|
function noop() {}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* @param {unknown} outageError
|
|
46
|
+
* @param {'capture' | 'diff'} phase
|
|
47
|
+
* @param {string | undefined} lookupMessage
|
|
48
|
+
* @returns {Error}
|
|
49
|
+
*/
|
|
50
|
+
function createBaselineLookupFallbackError(outageError, phase, lookupMessage) {
|
|
51
|
+
const lookupDetail = typeof lookupMessage === 'string' && lookupMessage.trim().length > 0
|
|
52
|
+
? ` ${lookupMessage.trim()}`
|
|
53
|
+
: '';
|
|
54
|
+
const outageDetail = outageError instanceof Error && outageError.message
|
|
55
|
+
? ` Snap fallback reason: ${outageError.message}`
|
|
56
|
+
: '';
|
|
57
|
+
return new Error(
|
|
58
|
+
`Cannot fall back to a local ${phase} because the GitHub baseline lookup failed.${lookupDetail}${outageDetail}`,
|
|
59
|
+
{ cause: outageError }
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
44
63
|
/**
|
|
45
64
|
* Whether the current capture leaves real PNGs on the runner's filesystem.
|
|
46
65
|
*
|
|
@@ -70,7 +89,9 @@ export function hasLocalScreenshots(providerName, config) {
|
|
|
70
89
|
* config?: VisualRegressionConfig,
|
|
71
90
|
* createLocalProvider?: () => VisualProvider,
|
|
72
91
|
* onSkip?: (error: SnapSkipError) => void,
|
|
73
|
-
* onFallback?: (error: SnapFallbackError) => void
|
|
92
|
+
* onFallback?: (error: SnapFallbackError) => void,
|
|
93
|
+
* baselineResolutionStatus?: string,
|
|
94
|
+
* baselineResolutionMessage?: string
|
|
74
95
|
* }} options
|
|
75
96
|
* @returns {Promise<{
|
|
76
97
|
* outcome: 'captured' | 'skipped',
|
|
@@ -100,6 +121,9 @@ export async function captureWithPolicy(options) {
|
|
|
100
121
|
}
|
|
101
122
|
if (error instanceof SnapFallbackError) {
|
|
102
123
|
onFallback(error);
|
|
124
|
+
if (options.baselineResolutionStatus === 'error') {
|
|
125
|
+
throw createBaselineLookupFallbackError(error, 'capture', options.baselineResolutionMessage);
|
|
126
|
+
}
|
|
103
127
|
const result = await createLocalProvider().capture(options.captureOptions);
|
|
104
128
|
// The rest of the pipeline must be told the *effective* provider, not the
|
|
105
129
|
// configured one — otherwise it rebuilds SnapProvider around local output.
|
|
@@ -134,7 +158,9 @@ export async function captureWithPolicy(options) {
|
|
|
134
158
|
* onSkip?: (error: SnapSkipError) => void,
|
|
135
159
|
* onFallback?: (error: SnapFallbackError) => void,
|
|
136
160
|
* onRecapture?: () => void,
|
|
137
|
-
* onBaselineUnavailable?: () => void
|
|
161
|
+
* onBaselineUnavailable?: () => void,
|
|
162
|
+
* baselineResolutionStatus?: string,
|
|
163
|
+
* baselineResolutionMessage?: string
|
|
138
164
|
* }} options
|
|
139
165
|
* @returns {Promise<{
|
|
140
166
|
* outcome: 'diffed' | 'skipped' | 'baseline-unavailable',
|
|
@@ -170,9 +196,16 @@ export async function diffWithPolicy(options) {
|
|
|
170
196
|
: Boolean(options.diffOptions.baselineResultsPath);
|
|
171
197
|
if (!baselineAvailable) {
|
|
172
198
|
onBaselineUnavailable();
|
|
199
|
+
if (options.baselineResolutionStatus === 'error') {
|
|
200
|
+
throw createBaselineLookupFallbackError(error, 'diff', options.baselineResolutionMessage);
|
|
201
|
+
}
|
|
173
202
|
return { outcome: 'baseline-unavailable', providerName: 'local' };
|
|
174
203
|
}
|
|
175
204
|
|
|
205
|
+
if (options.baselineResolutionStatus === 'error') {
|
|
206
|
+
throw createBaselineLookupFallbackError(error, 'diff', options.baselineResolutionMessage);
|
|
207
|
+
}
|
|
208
|
+
|
|
176
209
|
const localProvider = createLocalProvider();
|
|
177
210
|
let diffOptions = options.diffOptions;
|
|
178
211
|
/** @type {CaptureResult | undefined} */
|
package/lib/provider.mjs
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import { runBaselineCapture, generateDriftReport, stageArtifacts } from '@snapdrift/adapter-fs';
|
|
3
|
+
import { loadSnapdriftConfig, runBaselineCapture, generateDriftReport, stageArtifacts } from '@snapdrift/adapter-fs';
|
|
4
4
|
import { buildReportCommentBody } from '@snapdrift/adapter-report-md';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
SnapProvider,
|
|
7
|
+
SnapApiError,
|
|
8
|
+
SnapUpgradeRequiredError,
|
|
9
|
+
SnapUnavailableError,
|
|
10
|
+
SnapFallbackError,
|
|
11
|
+
SnapSkipError,
|
|
12
|
+
isLocalBaseUrl
|
|
13
|
+
} from './snap-provider.mjs';
|
|
6
14
|
|
|
7
15
|
/** @typedef {import('../types/visual-diff-types').VisualProvider} VisualProvider */
|
|
8
16
|
/** @typedef {import('../types/visual-diff-types').ProviderCaptureOptions} CaptureOptions */
|
|
@@ -35,7 +43,12 @@ export class LocalProvider {
|
|
|
35
43
|
* @returns {Promise<DiffResult>}
|
|
36
44
|
*/
|
|
37
45
|
async diff(options) {
|
|
38
|
-
|
|
46
|
+
const { config } = await loadSnapdriftConfig(options.configPath);
|
|
47
|
+
const comparisonPolicy = config.diff.comparisonPolicy ?? {
|
|
48
|
+
version: 1,
|
|
49
|
+
threshold: config.diff.threshold
|
|
50
|
+
};
|
|
51
|
+
return generateDriftReport({ ...options, comparisonPolicy });
|
|
39
52
|
}
|
|
40
53
|
|
|
41
54
|
/**
|
|
@@ -55,8 +68,7 @@ export class LocalProvider {
|
|
|
55
68
|
*/
|
|
56
69
|
async fetchLatestBaseline(_options) {
|
|
57
70
|
throw new Error(
|
|
58
|
-
'LocalProvider.fetchLatestBaseline is not yet implemented. ' +
|
|
59
|
-
'Use the pr-diff action for baseline resolution, or wait for a future release.'
|
|
71
|
+
'LocalProvider.fetchLatestBaseline is not yet implemented. ' + 'Use the pr-diff action for baseline resolution, or wait for a future release.'
|
|
60
72
|
);
|
|
61
73
|
}
|
|
62
74
|
|
|
@@ -64,7 +76,7 @@ export class LocalProvider {
|
|
|
64
76
|
* Build a PR comment body from a diff summary.
|
|
65
77
|
* LocalProvider omits the dashboard link (no dashboard exists).
|
|
66
78
|
*
|
|
67
|
-
* @param {
|
|
79
|
+
* @param {import('@snapdrift/manifest').VisualReportSummary} summary
|
|
68
80
|
* @param {import('../types/visual-diff-types').ProviderCommentMeta} [meta]
|
|
69
81
|
* @returns {string}
|
|
70
82
|
*/
|
|
@@ -73,7 +85,7 @@ export class LocalProvider {
|
|
|
73
85
|
}
|
|
74
86
|
}
|
|
75
87
|
|
|
76
|
-
/** @type {
|
|
88
|
+
/** @type {{ local: (config?: VisualRegressionConfig) => LocalProvider, snap: (config: VisualRegressionConfig) => SnapProvider }} */
|
|
77
89
|
const PROVIDER_FACTORIES = {
|
|
78
90
|
local: () => new LocalProvider(),
|
|
79
91
|
snap: (config) => {
|
|
@@ -84,6 +96,24 @@ const PROVIDER_FACTORIES = {
|
|
|
84
96
|
}
|
|
85
97
|
};
|
|
86
98
|
|
|
99
|
+
/**
|
|
100
|
+
* @overload
|
|
101
|
+
* @param {'snap'} providerName
|
|
102
|
+
* @param {VisualRegressionConfig} config
|
|
103
|
+
* @returns {SnapProvider}
|
|
104
|
+
*/
|
|
105
|
+
/**
|
|
106
|
+
* @overload
|
|
107
|
+
* @param {'local'} providerName
|
|
108
|
+
* @param {VisualRegressionConfig} [config]
|
|
109
|
+
* @returns {LocalProvider}
|
|
110
|
+
*/
|
|
111
|
+
/**
|
|
112
|
+
* @overload
|
|
113
|
+
* @param {string} providerName
|
|
114
|
+
* @param {VisualRegressionConfig} [config]
|
|
115
|
+
* @returns {VisualProvider}
|
|
116
|
+
*/
|
|
87
117
|
/**
|
|
88
118
|
* Create a VisualProvider instance by name.
|
|
89
119
|
*
|
|
@@ -95,12 +125,9 @@ const PROVIDER_FACTORIES = {
|
|
|
95
125
|
export function createProvider(providerName, config) {
|
|
96
126
|
const factory = PROVIDER_FACTORIES[providerName];
|
|
97
127
|
if (!factory) {
|
|
98
|
-
throw new Error(
|
|
99
|
-
`Unknown SnapDrift provider: "${providerName}". ` +
|
|
100
|
-
`Available providers: ${Object.keys(PROVIDER_FACTORIES).join(', ')}.`
|
|
101
|
-
);
|
|
128
|
+
throw new Error(`Unknown SnapDrift provider: "${providerName}". ` + `Available providers: ${Object.keys(PROVIDER_FACTORIES).join(', ')}.`);
|
|
102
129
|
}
|
|
103
130
|
return factory(config);
|
|
104
131
|
}
|
|
105
132
|
|
|
106
|
-
export { SnapProvider, SnapApiError, SnapUnavailableError, SnapFallbackError, SnapSkipError, isLocalBaseUrl };
|
|
133
|
+
export { SnapProvider, SnapApiError, SnapUpgradeRequiredError, SnapUnavailableError, SnapFallbackError, SnapSkipError, isLocalBaseUrl };
|
package/lib/report.mjs
CHANGED
|
@@ -26,14 +26,15 @@ async function defaultImageReader(runDir, imagePath) {
|
|
|
26
26
|
* @param {{
|
|
27
27
|
* baselineRunDir?: string,
|
|
28
28
|
* currentRunDir?: string,
|
|
29
|
+
* diffRunDir?: string,
|
|
29
30
|
* imageReader?: (runDir: string, imagePath: string) => Promise<string | null>
|
|
30
31
|
* }} [options]
|
|
31
32
|
* @returns {Promise<string>}
|
|
32
33
|
*/
|
|
33
34
|
export async function generateHtmlReport(summary, options = {}) {
|
|
34
35
|
const enrichedOptions = { ...options };
|
|
35
|
-
if (!enrichedOptions.imageReader && (options.baselineRunDir || options.currentRunDir)) {
|
|
36
|
+
if (!enrichedOptions.imageReader && (options.baselineRunDir || options.currentRunDir || options.diffRunDir)) {
|
|
36
37
|
enrichedOptions.imageReader = defaultImageReader;
|
|
37
38
|
}
|
|
38
39
|
return _generateHtmlReport(summary, enrichedOptions);
|
|
39
|
-
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {{
|
|
5
|
+
* found: boolean,
|
|
6
|
+
* resolutionStatus: 'found' | 'missing',
|
|
7
|
+
* artifactName: string,
|
|
8
|
+
* headSha?: string,
|
|
9
|
+
* runId?: string,
|
|
10
|
+
* message: string
|
|
11
|
+
* }} BaselineResolution
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the most recent successful, non-expired SnapDrift baseline artifact.
|
|
16
|
+
*
|
|
17
|
+
* The GitHub API calls are kept here so the standalone resolver and the PR
|
|
18
|
+
* wrapper share the same response validation and pagination behavior.
|
|
19
|
+
*
|
|
20
|
+
* @param {{
|
|
21
|
+
* github: {
|
|
22
|
+
* paginate: Function,
|
|
23
|
+
* rest: { actions: {
|
|
24
|
+
* listWorkflowRuns: Function,
|
|
25
|
+
* listWorkflowRunArtifacts: Function
|
|
26
|
+
* }}
|
|
27
|
+
* },
|
|
28
|
+
* repository: string,
|
|
29
|
+
* workflowId: string,
|
|
30
|
+
* branch: string,
|
|
31
|
+
* artifactName: string
|
|
32
|
+
* }} options
|
|
33
|
+
* @returns {Promise<BaselineResolution>}
|
|
34
|
+
*/
|
|
35
|
+
export async function resolveBaselineArtifact({
|
|
36
|
+
github,
|
|
37
|
+
repository,
|
|
38
|
+
workflowId,
|
|
39
|
+
branch,
|
|
40
|
+
artifactName
|
|
41
|
+
}) {
|
|
42
|
+
const { owner, repo } = parseBaselineRepository(repository);
|
|
43
|
+
if (typeof artifactName !== 'string' || artifactName.trim().length === 0) {
|
|
44
|
+
throw new Error('Invalid baseline artifact name. Expected a non-empty name.');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Search the complete successful-run history so an older, still-valid
|
|
48
|
+
// baseline remains discoverable; 100 keeps that intentional search to the
|
|
49
|
+
// fewest API pages GitHub allows.
|
|
50
|
+
const runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
|
|
51
|
+
owner,
|
|
52
|
+
repo,
|
|
53
|
+
workflow_id: workflowId,
|
|
54
|
+
branch,
|
|
55
|
+
event: 'push',
|
|
56
|
+
per_page: 100
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (!Array.isArray(runs)) {
|
|
60
|
+
throw new Error('GitHub returned an invalid workflow run list.');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const run of runs) {
|
|
64
|
+
validateWorkflowRun(run);
|
|
65
|
+
|
|
66
|
+
// GitHub returns a null conclusion for queued and in-progress runs. They
|
|
67
|
+
// are valid records and must be ignored before inspecting terminal fields.
|
|
68
|
+
if (run.status !== 'completed') {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (typeof run.conclusion !== 'string') {
|
|
72
|
+
throw new Error(`GitHub returned a malformed completed workflow run record for run ${run.id}.`);
|
|
73
|
+
}
|
|
74
|
+
if (run.conclusion !== 'success') {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
|
|
79
|
+
owner,
|
|
80
|
+
repo,
|
|
81
|
+
run_id: run.id,
|
|
82
|
+
per_page: 100
|
|
83
|
+
});
|
|
84
|
+
if (!Array.isArray(artifacts)) {
|
|
85
|
+
throw new Error(`GitHub returned a malformed artifact list for workflow run ${run.id}.`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Only the artifact SnapDrift depends on needs strict field validation.
|
|
89
|
+
// Other artifacts in the same run belong to the consumer workflow and may
|
|
90
|
+
// legitimately have a different shape.
|
|
91
|
+
const namedArtifacts = artifacts.filter((artifact) => (
|
|
92
|
+
artifact && typeof artifact === 'object' && artifact.name === artifactName
|
|
93
|
+
));
|
|
94
|
+
for (const artifact of namedArtifacts) {
|
|
95
|
+
if (typeof artifact.expired !== 'boolean') {
|
|
96
|
+
throw new Error(`GitHub returned a malformed baseline artifact record for workflow run ${run.id}.`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const match = namedArtifacts.find((artifact) => !artifact.expired);
|
|
101
|
+
if (match) {
|
|
102
|
+
return {
|
|
103
|
+
found: true,
|
|
104
|
+
resolutionStatus: 'found',
|
|
105
|
+
artifactName: match.name,
|
|
106
|
+
headSha: run.head_sha,
|
|
107
|
+
runId: String(run.id),
|
|
108
|
+
message: ''
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
found: false,
|
|
115
|
+
resolutionStatus: 'missing',
|
|
116
|
+
artifactName,
|
|
117
|
+
message: `No non-expired SnapDrift baseline artifact named ${artifactName} was found.`
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @param {string} repository
|
|
123
|
+
* @returns {{owner: string, repo: string}}
|
|
124
|
+
*/
|
|
125
|
+
export function parseBaselineRepository(repository) {
|
|
126
|
+
const value = String(repository || '').trim();
|
|
127
|
+
const parts = value.split('/');
|
|
128
|
+
if (parts.length !== 2 || parts.some((part) => part.trim().length === 0)) {
|
|
129
|
+
throw new Error(`Invalid baseline repository "${value}". Expected owner/name.`);
|
|
130
|
+
}
|
|
131
|
+
return { owner: parts[0].trim(), repo: parts[1].trim() };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {unknown} run
|
|
136
|
+
* @returns {asserts run is {id: number, status: string, head_sha: string, conclusion?: unknown}}
|
|
137
|
+
*/
|
|
138
|
+
function validateWorkflowRun(run) {
|
|
139
|
+
if (!run || typeof run !== 'object') {
|
|
140
|
+
throw new Error('GitHub returned a malformed workflow run record.');
|
|
141
|
+
}
|
|
142
|
+
const record = /** @type {Record<string, unknown>} */ (run);
|
|
143
|
+
if (typeof record.id !== 'number' || !Number.isInteger(record.id) || record.id <= 0 ||
|
|
144
|
+
typeof record.status !== 'string' || typeof record.head_sha !== 'string' || record.head_sha.length === 0) {
|
|
145
|
+
throw new Error('GitHub returned a malformed workflow run record.');
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {{
|
|
7
|
+
* runDir: string,
|
|
8
|
+
* resultsFile: string,
|
|
9
|
+
* manifestFile: string,
|
|
10
|
+
* screenshotsDir: string,
|
|
11
|
+
* }} BaselinePaths
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {{
|
|
16
|
+
* workspace: string,
|
|
17
|
+
* downloadPath?: string,
|
|
18
|
+
* }} ResolveBaselinePathsParams
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const DEFAULT_DOWNLOAD_PATH = 'snapdrift-baseline-download';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the baseline download paths for the resolve-baseline composite action.
|
|
25
|
+
*
|
|
26
|
+
* `downloadPath` may be absolute (used directly) or relative to the Actions
|
|
27
|
+
* workspace (prefixed with `workspace`). Returns the run dir plus the results,
|
|
28
|
+
* manifest, and screenshots paths derived from it.
|
|
29
|
+
*
|
|
30
|
+
* @param {ResolveBaselinePathsParams} params
|
|
31
|
+
* @returns {BaselinePaths}
|
|
32
|
+
*/
|
|
33
|
+
export function resolveBaselinePaths({ workspace, downloadPath }) {
|
|
34
|
+
const rel = downloadPath || DEFAULT_DOWNLOAD_PATH;
|
|
35
|
+
const runDir = path.isAbsolute(rel) ? rel : path.join(workspace, rel);
|
|
36
|
+
const resultsFile = path.join(runDir, 'results.json');
|
|
37
|
+
const manifestFile = path.join(runDir, 'manifest.json');
|
|
38
|
+
const screenshotsDir = path.join(path.dirname(manifestFile), 'screenshots');
|
|
39
|
+
return { runDir, resultsFile, manifestFile, screenshotsDir };
|
|
40
|
+
}
|