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
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared `snap.onUnavailable` policy for every SnapDrift entry point.
|
|
5
|
+
*
|
|
6
|
+
* `warn-and-skip` and `fallback-local` used to be re-implemented independently
|
|
7
|
+
* in `lib/cli.mjs`, `actions/pr-diff` and `actions/baseline`, and the copies
|
|
8
|
+
* drifted apart — see ranacseruet/snapdrift#125:
|
|
9
|
+
*
|
|
10
|
+
* - a wrapper capture that fell back to local still reported `provider=snap`,
|
|
11
|
+
* so the compare step rebuilt `SnapProvider` and handed it local results with
|
|
12
|
+
* no run id;
|
|
13
|
+
* - a diff-time fallback swapped only the diff provider, so `LocalProvider` was
|
|
14
|
+
* asked to pixel-compare a remote Snap capture that has a manifest but no
|
|
15
|
+
* PNGs on disk;
|
|
16
|
+
* - `warn-and-skip` exited the wrapper without writing the documented skipped
|
|
17
|
+
* summary, so the PR comment claimed "Capture Failed";
|
|
18
|
+
* - `actions/baseline` did not apply the policy to `publishBaseline()` at all.
|
|
19
|
+
*
|
|
20
|
+
* Every caller now routes through these three helpers, so the error-mode matrix
|
|
21
|
+
* is defined once.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createProvider } from './provider.mjs';
|
|
25
|
+
import { SnapFallbackError, SnapSkipError, isLocalBaseUrl } from './snap-provider.mjs';
|
|
26
|
+
|
|
27
|
+
/** @typedef {import('../types/visual-diff-types').VisualProvider} VisualProvider */
|
|
28
|
+
/** @typedef {import('../types/visual-diff-types').ProviderCaptureOptions} CaptureOptions */
|
|
29
|
+
/** @typedef {import('../types/visual-diff-types').ProviderCaptureResult} CaptureResult */
|
|
30
|
+
/** @typedef {import('../types/visual-diff-types').ProviderDiffOptions} DiffOptions */
|
|
31
|
+
/** @typedef {import('../types/visual-diff-types').ProviderDiffResult} DiffResult */
|
|
32
|
+
/** @typedef {import('../types/visual-diff-types').ProviderPublishBaselineOptions} PublishBaselineOptions */
|
|
33
|
+
/** @typedef {import('../types/visual-diff-types').VisualRegressionConfig} VisualRegressionConfig */
|
|
34
|
+
|
|
35
|
+
/** Reason recorded on the skipped summary written under `warn-and-skip`. */
|
|
36
|
+
export const SNAP_UNAVAILABLE_REASON = 'snap_unavailable';
|
|
37
|
+
|
|
38
|
+
/** Reason recorded when `fallback-local` has no baseline to compare against. */
|
|
39
|
+
export const MISSING_BASELINE_REASON = 'missing_main_baseline_artifact';
|
|
40
|
+
|
|
41
|
+
/** @returns {void} */
|
|
42
|
+
function noop() {}
|
|
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
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whether the current capture leaves real PNGs on the runner's filesystem.
|
|
65
|
+
*
|
|
66
|
+
* `LocalProvider.diff` reads screenshots off disk, so this is the precondition
|
|
67
|
+
* for handing it a capture. A remote Snap capture writes only run metadata and
|
|
68
|
+
* a zero-dimension manifest, which would otherwise diff as a dimension change
|
|
69
|
+
* for every route.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} providerName - Effective provider that produced the capture
|
|
72
|
+
* @param {VisualRegressionConfig} [config]
|
|
73
|
+
* @returns {boolean}
|
|
74
|
+
*/
|
|
75
|
+
export function hasLocalScreenshots(providerName, config) {
|
|
76
|
+
if (providerName !== 'snap') {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
return isLocalBaseUrl(config?.baseUrl);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Capture under the configured outage policy.
|
|
84
|
+
*
|
|
85
|
+
* @param {{
|
|
86
|
+
* provider: VisualProvider,
|
|
87
|
+
* providerName?: string,
|
|
88
|
+
* captureOptions: CaptureOptions,
|
|
89
|
+
* config?: VisualRegressionConfig,
|
|
90
|
+
* createLocalProvider?: () => VisualProvider,
|
|
91
|
+
* onSkip?: (error: SnapSkipError) => void,
|
|
92
|
+
* onFallback?: (error: SnapFallbackError) => void,
|
|
93
|
+
* baselineResolutionStatus?: string,
|
|
94
|
+
* baselineResolutionMessage?: string
|
|
95
|
+
* }} options
|
|
96
|
+
* @returns {Promise<{
|
|
97
|
+
* outcome: 'captured' | 'skipped',
|
|
98
|
+
* providerName: string,
|
|
99
|
+
* result?: CaptureResult,
|
|
100
|
+
* localScreenshots: boolean
|
|
101
|
+
* }>}
|
|
102
|
+
*/
|
|
103
|
+
export async function captureWithPolicy(options) {
|
|
104
|
+
const providerName = options.providerName || 'local';
|
|
105
|
+
const createLocalProvider = options.createLocalProvider || (() => createProvider('local'));
|
|
106
|
+
const onSkip = options.onSkip || noop;
|
|
107
|
+
const onFallback = options.onFallback || noop;
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const result = await options.provider.capture(options.captureOptions);
|
|
111
|
+
return {
|
|
112
|
+
outcome: 'captured',
|
|
113
|
+
providerName,
|
|
114
|
+
result,
|
|
115
|
+
localScreenshots: hasLocalScreenshots(providerName, options.config)
|
|
116
|
+
};
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (error instanceof SnapSkipError) {
|
|
119
|
+
onSkip(error);
|
|
120
|
+
return { outcome: 'skipped', providerName, localScreenshots: false };
|
|
121
|
+
}
|
|
122
|
+
if (error instanceof SnapFallbackError) {
|
|
123
|
+
onFallback(error);
|
|
124
|
+
if (options.baselineResolutionStatus === 'error') {
|
|
125
|
+
throw createBaselineLookupFallbackError(error, 'capture', options.baselineResolutionMessage);
|
|
126
|
+
}
|
|
127
|
+
const result = await createLocalProvider().capture(options.captureOptions);
|
|
128
|
+
// The rest of the pipeline must be told the *effective* provider, not the
|
|
129
|
+
// configured one — otherwise it rebuilds SnapProvider around local output.
|
|
130
|
+
return { outcome: 'captured', providerName: 'local', result, localScreenshots: true };
|
|
131
|
+
}
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Diff under the configured outage policy.
|
|
138
|
+
*
|
|
139
|
+
* `fallback-local` is only a real fallback when the local engine has something
|
|
140
|
+
* to compare. Two preconditions have to hold, and neither is guaranteed at the
|
|
141
|
+
* point Snap goes down mid-run:
|
|
142
|
+
*
|
|
143
|
+
* 1. A baseline must exist on disk. Without one the local diff throws on a
|
|
144
|
+
* missing results file, so we report `baseline-unavailable` and let the
|
|
145
|
+
* caller write the documented missing-baseline summary instead.
|
|
146
|
+
* 2. The current capture must have local screenshots. A remote Snap capture
|
|
147
|
+
* does not, so we recapture locally first rather than diffing a manifest of
|
|
148
|
+
* zero-dimension placeholders.
|
|
149
|
+
*
|
|
150
|
+
* @param {{
|
|
151
|
+
* provider: VisualProvider,
|
|
152
|
+
* providerName?: string,
|
|
153
|
+
* diffOptions: DiffOptions,
|
|
154
|
+
* captureOptions?: CaptureOptions,
|
|
155
|
+
* localScreenshots?: boolean,
|
|
156
|
+
* baselineAvailable?: boolean,
|
|
157
|
+
* createLocalProvider?: () => VisualProvider,
|
|
158
|
+
* onSkip?: (error: SnapSkipError) => void,
|
|
159
|
+
* onFallback?: (error: SnapFallbackError) => void,
|
|
160
|
+
* onRecapture?: () => void,
|
|
161
|
+
* onBaselineUnavailable?: () => void,
|
|
162
|
+
* baselineResolutionStatus?: string,
|
|
163
|
+
* baselineResolutionMessage?: string
|
|
164
|
+
* }} options
|
|
165
|
+
* @returns {Promise<{
|
|
166
|
+
* outcome: 'diffed' | 'skipped' | 'baseline-unavailable',
|
|
167
|
+
* providerName: string,
|
|
168
|
+
* result?: DiffResult,
|
|
169
|
+
* recapture?: CaptureResult
|
|
170
|
+
* }>}
|
|
171
|
+
*/
|
|
172
|
+
export async function diffWithPolicy(options) {
|
|
173
|
+
const providerName = options.providerName || 'local';
|
|
174
|
+
const createLocalProvider = options.createLocalProvider || (() => createProvider('local'));
|
|
175
|
+
const onSkip = options.onSkip || noop;
|
|
176
|
+
const onFallback = options.onFallback || noop;
|
|
177
|
+
const onRecapture = options.onRecapture || noop;
|
|
178
|
+
const onBaselineUnavailable = options.onBaselineUnavailable || noop;
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const result = await options.provider.diff(options.diffOptions);
|
|
182
|
+
return { outcome: 'diffed', providerName, result };
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (error instanceof SnapSkipError) {
|
|
185
|
+
onSkip(error);
|
|
186
|
+
return { outcome: 'skipped', providerName };
|
|
187
|
+
}
|
|
188
|
+
if (!(error instanceof SnapFallbackError)) {
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
onFallback(error);
|
|
193
|
+
|
|
194
|
+
const baselineAvailable = options.baselineAvailable !== undefined
|
|
195
|
+
? options.baselineAvailable
|
|
196
|
+
: Boolean(options.diffOptions.baselineResultsPath);
|
|
197
|
+
if (!baselineAvailable) {
|
|
198
|
+
onBaselineUnavailable();
|
|
199
|
+
if (options.baselineResolutionStatus === 'error') {
|
|
200
|
+
throw createBaselineLookupFallbackError(error, 'diff', options.baselineResolutionMessage);
|
|
201
|
+
}
|
|
202
|
+
return { outcome: 'baseline-unavailable', providerName: 'local' };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (options.baselineResolutionStatus === 'error') {
|
|
206
|
+
throw createBaselineLookupFallbackError(error, 'diff', options.baselineResolutionMessage);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const localProvider = createLocalProvider();
|
|
210
|
+
let diffOptions = options.diffOptions;
|
|
211
|
+
/** @type {CaptureResult | undefined} */
|
|
212
|
+
let recapture;
|
|
213
|
+
|
|
214
|
+
if (options.localScreenshots === false) {
|
|
215
|
+
if (!options.captureOptions) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
'Cannot fall back to a local diff: the current capture has no screenshots on disk ' +
|
|
218
|
+
'and no captureOptions were supplied to recapture with.',
|
|
219
|
+
{ cause: error }
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
onRecapture();
|
|
223
|
+
recapture = await localProvider.capture(options.captureOptions);
|
|
224
|
+
diffOptions = {
|
|
225
|
+
...options.diffOptions,
|
|
226
|
+
currentResultsPath: recapture.resultsPath,
|
|
227
|
+
currentManifestPath: recapture.manifestPath,
|
|
228
|
+
currentRunDir: recapture.screenshotsRoot
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const result = await localProvider.diff(diffOptions);
|
|
233
|
+
return { outcome: 'diffed', providerName: 'local', result, recapture };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Publish a baseline under the configured outage policy.
|
|
239
|
+
*
|
|
240
|
+
* Snap can go down *after* the capture submission succeeds — while polling the
|
|
241
|
+
* render or posting the baseline — and the user's `onUnavailable` choice has to
|
|
242
|
+
* hold for that window too. `fallback-local` recaptures locally so the caller
|
|
243
|
+
* still ends up with a baseline bundle it can stage and upload.
|
|
244
|
+
*
|
|
245
|
+
* @param {{
|
|
246
|
+
* provider: VisualProvider,
|
|
247
|
+
* publishOptions: PublishBaselineOptions,
|
|
248
|
+
* captureOptions?: CaptureOptions,
|
|
249
|
+
* createLocalProvider?: () => VisualProvider,
|
|
250
|
+
* onSkip?: (error: SnapSkipError) => void,
|
|
251
|
+
* onFallback?: (error: SnapFallbackError) => void
|
|
252
|
+
* }} options
|
|
253
|
+
* @returns {Promise<{ outcome: 'published' | 'skipped' | 'fell-back', recapture?: CaptureResult }>}
|
|
254
|
+
*/
|
|
255
|
+
export async function publishBaselineWithPolicy(options) {
|
|
256
|
+
const createLocalProvider = options.createLocalProvider || (() => createProvider('local'));
|
|
257
|
+
const onSkip = options.onSkip || noop;
|
|
258
|
+
const onFallback = options.onFallback || noop;
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
await options.provider.publishBaseline(options.publishOptions);
|
|
262
|
+
return { outcome: 'published' };
|
|
263
|
+
} catch (error) {
|
|
264
|
+
if (error instanceof SnapSkipError) {
|
|
265
|
+
onSkip(error);
|
|
266
|
+
return { outcome: 'skipped' };
|
|
267
|
+
}
|
|
268
|
+
if (error instanceof SnapFallbackError) {
|
|
269
|
+
onFallback(error);
|
|
270
|
+
if (!options.captureOptions) {
|
|
271
|
+
return { outcome: 'fell-back' };
|
|
272
|
+
}
|
|
273
|
+
const recapture = await createLocalProvider().capture(options.captureOptions);
|
|
274
|
+
return { outcome: 'fell-back', recapture };
|
|
275
|
+
}
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
}
|
package/lib/provider.mjs
CHANGED
|
@@ -64,7 +64,7 @@ export class LocalProvider {
|
|
|
64
64
|
* Build a PR comment body from a diff summary.
|
|
65
65
|
* LocalProvider omits the dashboard link (no dashboard exists).
|
|
66
66
|
*
|
|
67
|
-
* @param {
|
|
67
|
+
* @param {import('@snapdrift/manifest').VisualReportSummary} summary
|
|
68
68
|
* @param {import('../types/visual-diff-types').ProviderCommentMeta} [meta]
|
|
69
69
|
* @returns {string}
|
|
70
70
|
*/
|
|
@@ -73,7 +73,7 @@ export class LocalProvider {
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
/** @type {
|
|
76
|
+
/** @type {{ local: (config?: VisualRegressionConfig) => LocalProvider, snap: (config: VisualRegressionConfig) => SnapProvider }} */
|
|
77
77
|
const PROVIDER_FACTORIES = {
|
|
78
78
|
local: () => new LocalProvider(),
|
|
79
79
|
snap: (config) => {
|
|
@@ -84,6 +84,24 @@ const PROVIDER_FACTORIES = {
|
|
|
84
84
|
}
|
|
85
85
|
};
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* @overload
|
|
89
|
+
* @param {'snap'} providerName
|
|
90
|
+
* @param {VisualRegressionConfig} config
|
|
91
|
+
* @returns {SnapProvider}
|
|
92
|
+
*/
|
|
93
|
+
/**
|
|
94
|
+
* @overload
|
|
95
|
+
* @param {'local'} providerName
|
|
96
|
+
* @param {VisualRegressionConfig} [config]
|
|
97
|
+
* @returns {LocalProvider}
|
|
98
|
+
*/
|
|
99
|
+
/**
|
|
100
|
+
* @overload
|
|
101
|
+
* @param {string} providerName
|
|
102
|
+
* @param {VisualRegressionConfig} [config]
|
|
103
|
+
* @returns {VisualProvider}
|
|
104
|
+
*/
|
|
87
105
|
/**
|
|
88
106
|
* Create a VisualProvider instance by name.
|
|
89
107
|
*
|
|
@@ -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
|
+
}
|