snapdrift 0.8.1 → 0.8.2

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.
@@ -0,0 +1,245 @@
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
+ * Whether the current capture leaves real PNGs on the runner's filesystem.
46
+ *
47
+ * `LocalProvider.diff` reads screenshots off disk, so this is the precondition
48
+ * for handing it a capture. A remote Snap capture writes only run metadata and
49
+ * a zero-dimension manifest, which would otherwise diff as a dimension change
50
+ * for every route.
51
+ *
52
+ * @param {string} providerName - Effective provider that produced the capture
53
+ * @param {VisualRegressionConfig} [config]
54
+ * @returns {boolean}
55
+ */
56
+ export function hasLocalScreenshots(providerName, config) {
57
+ if (providerName !== 'snap') {
58
+ return true;
59
+ }
60
+ return isLocalBaseUrl(config?.baseUrl);
61
+ }
62
+
63
+ /**
64
+ * Capture under the configured outage policy.
65
+ *
66
+ * @param {{
67
+ * provider: VisualProvider,
68
+ * providerName?: string,
69
+ * captureOptions: CaptureOptions,
70
+ * config?: VisualRegressionConfig,
71
+ * createLocalProvider?: () => VisualProvider,
72
+ * onSkip?: (error: SnapSkipError) => void,
73
+ * onFallback?: (error: SnapFallbackError) => void
74
+ * }} options
75
+ * @returns {Promise<{
76
+ * outcome: 'captured' | 'skipped',
77
+ * providerName: string,
78
+ * result?: CaptureResult,
79
+ * localScreenshots: boolean
80
+ * }>}
81
+ */
82
+ export async function captureWithPolicy(options) {
83
+ const providerName = options.providerName || 'local';
84
+ const createLocalProvider = options.createLocalProvider || (() => createProvider('local'));
85
+ const onSkip = options.onSkip || noop;
86
+ const onFallback = options.onFallback || noop;
87
+
88
+ try {
89
+ const result = await options.provider.capture(options.captureOptions);
90
+ return {
91
+ outcome: 'captured',
92
+ providerName,
93
+ result,
94
+ localScreenshots: hasLocalScreenshots(providerName, options.config)
95
+ };
96
+ } catch (error) {
97
+ if (error instanceof SnapSkipError) {
98
+ onSkip(error);
99
+ return { outcome: 'skipped', providerName, localScreenshots: false };
100
+ }
101
+ if (error instanceof SnapFallbackError) {
102
+ onFallback(error);
103
+ const result = await createLocalProvider().capture(options.captureOptions);
104
+ // The rest of the pipeline must be told the *effective* provider, not the
105
+ // configured one — otherwise it rebuilds SnapProvider around local output.
106
+ return { outcome: 'captured', providerName: 'local', result, localScreenshots: true };
107
+ }
108
+ throw error;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Diff under the configured outage policy.
114
+ *
115
+ * `fallback-local` is only a real fallback when the local engine has something
116
+ * to compare. Two preconditions have to hold, and neither is guaranteed at the
117
+ * point Snap goes down mid-run:
118
+ *
119
+ * 1. A baseline must exist on disk. Without one the local diff throws on a
120
+ * missing results file, so we report `baseline-unavailable` and let the
121
+ * caller write the documented missing-baseline summary instead.
122
+ * 2. The current capture must have local screenshots. A remote Snap capture
123
+ * does not, so we recapture locally first rather than diffing a manifest of
124
+ * zero-dimension placeholders.
125
+ *
126
+ * @param {{
127
+ * provider: VisualProvider,
128
+ * providerName?: string,
129
+ * diffOptions: DiffOptions,
130
+ * captureOptions?: CaptureOptions,
131
+ * localScreenshots?: boolean,
132
+ * baselineAvailable?: boolean,
133
+ * createLocalProvider?: () => VisualProvider,
134
+ * onSkip?: (error: SnapSkipError) => void,
135
+ * onFallback?: (error: SnapFallbackError) => void,
136
+ * onRecapture?: () => void,
137
+ * onBaselineUnavailable?: () => void
138
+ * }} options
139
+ * @returns {Promise<{
140
+ * outcome: 'diffed' | 'skipped' | 'baseline-unavailable',
141
+ * providerName: string,
142
+ * result?: DiffResult,
143
+ * recapture?: CaptureResult
144
+ * }>}
145
+ */
146
+ export async function diffWithPolicy(options) {
147
+ const providerName = options.providerName || 'local';
148
+ const createLocalProvider = options.createLocalProvider || (() => createProvider('local'));
149
+ const onSkip = options.onSkip || noop;
150
+ const onFallback = options.onFallback || noop;
151
+ const onRecapture = options.onRecapture || noop;
152
+ const onBaselineUnavailable = options.onBaselineUnavailable || noop;
153
+
154
+ try {
155
+ const result = await options.provider.diff(options.diffOptions);
156
+ return { outcome: 'diffed', providerName, result };
157
+ } catch (error) {
158
+ if (error instanceof SnapSkipError) {
159
+ onSkip(error);
160
+ return { outcome: 'skipped', providerName };
161
+ }
162
+ if (!(error instanceof SnapFallbackError)) {
163
+ throw error;
164
+ }
165
+
166
+ onFallback(error);
167
+
168
+ const baselineAvailable = options.baselineAvailable !== undefined
169
+ ? options.baselineAvailable
170
+ : Boolean(options.diffOptions.baselineResultsPath);
171
+ if (!baselineAvailable) {
172
+ onBaselineUnavailable();
173
+ return { outcome: 'baseline-unavailable', providerName: 'local' };
174
+ }
175
+
176
+ const localProvider = createLocalProvider();
177
+ let diffOptions = options.diffOptions;
178
+ /** @type {CaptureResult | undefined} */
179
+ let recapture;
180
+
181
+ if (options.localScreenshots === false) {
182
+ if (!options.captureOptions) {
183
+ throw new Error(
184
+ 'Cannot fall back to a local diff: the current capture has no screenshots on disk ' +
185
+ 'and no captureOptions were supplied to recapture with.',
186
+ { cause: error }
187
+ );
188
+ }
189
+ onRecapture();
190
+ recapture = await localProvider.capture(options.captureOptions);
191
+ diffOptions = {
192
+ ...options.diffOptions,
193
+ currentResultsPath: recapture.resultsPath,
194
+ currentManifestPath: recapture.manifestPath,
195
+ currentRunDir: recapture.screenshotsRoot
196
+ };
197
+ }
198
+
199
+ const result = await localProvider.diff(diffOptions);
200
+ return { outcome: 'diffed', providerName: 'local', result, recapture };
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Publish a baseline under the configured outage policy.
206
+ *
207
+ * Snap can go down *after* the capture submission succeeds — while polling the
208
+ * render or posting the baseline — and the user's `onUnavailable` choice has to
209
+ * hold for that window too. `fallback-local` recaptures locally so the caller
210
+ * still ends up with a baseline bundle it can stage and upload.
211
+ *
212
+ * @param {{
213
+ * provider: VisualProvider,
214
+ * publishOptions: PublishBaselineOptions,
215
+ * captureOptions?: CaptureOptions,
216
+ * createLocalProvider?: () => VisualProvider,
217
+ * onSkip?: (error: SnapSkipError) => void,
218
+ * onFallback?: (error: SnapFallbackError) => void
219
+ * }} options
220
+ * @returns {Promise<{ outcome: 'published' | 'skipped' | 'fell-back', recapture?: CaptureResult }>}
221
+ */
222
+ export async function publishBaselineWithPolicy(options) {
223
+ const createLocalProvider = options.createLocalProvider || (() => createProvider('local'));
224
+ const onSkip = options.onSkip || noop;
225
+ const onFallback = options.onFallback || noop;
226
+
227
+ try {
228
+ await options.provider.publishBaseline(options.publishOptions);
229
+ return { outcome: 'published' };
230
+ } catch (error) {
231
+ if (error instanceof SnapSkipError) {
232
+ onSkip(error);
233
+ return { outcome: 'skipped' };
234
+ }
235
+ if (error instanceof SnapFallbackError) {
236
+ onFallback(error);
237
+ if (!options.captureOptions) {
238
+ return { outcome: 'fell-back' };
239
+ }
240
+ const recapture = await createLocalProvider().capture(options.captureOptions);
241
+ return { outcome: 'fell-back', recapture };
242
+ }
243
+ throw error;
244
+ }
245
+ }