snapdrift 0.11.0 → 0.13.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 +131 -0
- package/README.md +6 -2
- package/action.yml +2 -2
- package/actions/comment/action.yml +8 -37
- package/actions/pr-diff/action.yml +24 -121
- package/actions/scope/action.yml +15 -73
- package/docs/contracts.md +156 -11
- package/docs/integration-guide.md +9 -11
- package/docs/local-cli.md +57 -2
- package/docs/workflow-templates/refresh-baseline-on-merge.yml +1 -1
- package/lib/cli.mjs +4 -0
- package/lib/github-requests.mjs +134 -0
- package/lib/outage-policy.mjs +49 -14
- package/lib/provider.mjs +19 -1
- package/lib/snap-provider.mjs +3 -4
- package/package.json +6 -5
- package/types/visual-diff-types.d.ts +1 -0
package/lib/outage-policy.mjs
CHANGED
|
@@ -73,10 +73,21 @@ function createBaselineLookupFallbackError(outageError, phase, lookupMessage) {
|
|
|
73
73
|
* @returns {boolean}
|
|
74
74
|
*/
|
|
75
75
|
export function hasLocalScreenshots(providerName, config) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
return describeCaptureArtifacts(providerName, undefined, config).localScreenshots;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Prefer provider-returned artifacts; infer capabilities only for legacy results.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} providerName
|
|
83
|
+
* @param {Partial<CaptureResult>} [result]
|
|
84
|
+
* @param {VisualRegressionConfig} [config]
|
|
85
|
+
* @returns {{ localScreenshots: boolean, artifactsRoot?: string }}
|
|
86
|
+
*/
|
|
87
|
+
export function describeCaptureArtifacts(providerName, result, config) {
|
|
88
|
+
if (result?.artifacts) return result.artifacts;
|
|
89
|
+
const localScreenshots = providerName !== 'snap' || isLocalBaseUrl(config?.baseUrl);
|
|
90
|
+
return { localScreenshots, artifactsRoot: localScreenshots ? result?.screenshotsRoot : undefined };
|
|
80
91
|
}
|
|
81
92
|
|
|
82
93
|
/**
|
|
@@ -97,7 +108,8 @@ export function hasLocalScreenshots(providerName, config) {
|
|
|
97
108
|
* outcome: 'captured' | 'skipped',
|
|
98
109
|
* providerName: string,
|
|
99
110
|
* result?: CaptureResult,
|
|
100
|
-
* localScreenshots: boolean
|
|
111
|
+
* localScreenshots: boolean,
|
|
112
|
+
* artifacts: ReturnType<typeof describeCaptureArtifacts>
|
|
101
113
|
* }>}
|
|
102
114
|
*/
|
|
103
115
|
export async function captureWithPolicy(options) {
|
|
@@ -108,16 +120,18 @@ export async function captureWithPolicy(options) {
|
|
|
108
120
|
|
|
109
121
|
try {
|
|
110
122
|
const result = await options.provider.capture(options.captureOptions);
|
|
123
|
+
const artifacts = describeCaptureArtifacts(providerName, result, options.config);
|
|
111
124
|
return {
|
|
112
125
|
outcome: 'captured',
|
|
113
126
|
providerName,
|
|
114
127
|
result,
|
|
115
|
-
|
|
128
|
+
artifacts,
|
|
129
|
+
localScreenshots: artifacts.localScreenshots
|
|
116
130
|
};
|
|
117
131
|
} catch (error) {
|
|
118
132
|
if (error instanceof SnapSkipError) {
|
|
119
133
|
onSkip(error);
|
|
120
|
-
return { outcome: 'skipped', providerName, localScreenshots: false };
|
|
134
|
+
return { outcome: 'skipped', providerName, localScreenshots: false, artifacts: { localScreenshots: false } };
|
|
121
135
|
}
|
|
122
136
|
if (error instanceof SnapFallbackError) {
|
|
123
137
|
onFallback(error);
|
|
@@ -127,7 +141,8 @@ export async function captureWithPolicy(options) {
|
|
|
127
141
|
const result = await createLocalProvider().capture(options.captureOptions);
|
|
128
142
|
// The rest of the pipeline must be told the *effective* provider, not the
|
|
129
143
|
// configured one — otherwise it rebuilds SnapProvider around local output.
|
|
130
|
-
|
|
144
|
+
const artifacts = describeCaptureArtifacts('local', result, options.config);
|
|
145
|
+
return { outcome: 'captured', providerName: 'local', result, localScreenshots: artifacts.localScreenshots, artifacts };
|
|
131
146
|
}
|
|
132
147
|
throw error;
|
|
133
148
|
}
|
|
@@ -147,12 +162,19 @@ export async function captureWithPolicy(options) {
|
|
|
147
162
|
* does not, so we recapture locally first rather than diffing a manifest of
|
|
148
163
|
* zero-dimension placeholders.
|
|
149
164
|
*
|
|
165
|
+
* Capability precedence: legacy `localScreenshots` overrides `artifacts`, then
|
|
166
|
+
* `captureResult.artifacts`, then provider/config inference. Without any of those
|
|
167
|
+
* inputs (including config), preserve the legacy local-screenshots default.
|
|
168
|
+
*
|
|
150
169
|
* @param {{
|
|
151
170
|
* provider: VisualProvider,
|
|
152
171
|
* providerName?: string,
|
|
153
172
|
* diffOptions: DiffOptions,
|
|
173
|
+
* captureResult?: CaptureResult,
|
|
154
174
|
* captureOptions?: CaptureOptions,
|
|
155
175
|
* localScreenshots?: boolean,
|
|
176
|
+
* artifacts?: ReturnType<typeof describeCaptureArtifacts>,
|
|
177
|
+
* config?: VisualRegressionConfig,
|
|
156
178
|
* baselineAvailable?: boolean,
|
|
157
179
|
* createLocalProvider?: () => VisualProvider,
|
|
158
180
|
* onSkip?: (error: SnapSkipError) => void,
|
|
@@ -166,11 +188,23 @@ export async function captureWithPolicy(options) {
|
|
|
166
188
|
* outcome: 'diffed' | 'skipped' | 'baseline-unavailable',
|
|
167
189
|
* providerName: string,
|
|
168
190
|
* result?: DiffResult,
|
|
169
|
-
* recapture?: CaptureResult
|
|
191
|
+
* recapture?: CaptureResult,
|
|
192
|
+
* localScreenshots: boolean,
|
|
193
|
+
* artifacts: ReturnType<typeof describeCaptureArtifacts>
|
|
170
194
|
* }>}
|
|
171
195
|
*/
|
|
172
196
|
export async function diffWithPolicy(options) {
|
|
173
197
|
const providerName = options.providerName || 'local';
|
|
198
|
+
/** @type {Partial<CaptureResult>} */
|
|
199
|
+
const captureResult = options.captureResult || { screenshotsRoot: options.diffOptions.currentRunDir };
|
|
200
|
+
const described = options.artifacts || (captureResult.artifacts || options.config
|
|
201
|
+
? describeCaptureArtifacts(providerName, captureResult, options.config)
|
|
202
|
+
: { localScreenshots: true, artifactsRoot: captureResult.screenshotsRoot });
|
|
203
|
+
let artifacts = {
|
|
204
|
+
...described,
|
|
205
|
+
localScreenshots: options.localScreenshots ?? described.localScreenshots
|
|
206
|
+
};
|
|
207
|
+
if (!artifacts.localScreenshots) artifacts.artifactsRoot = undefined;
|
|
174
208
|
const createLocalProvider = options.createLocalProvider || (() => createProvider('local'));
|
|
175
209
|
const onSkip = options.onSkip || noop;
|
|
176
210
|
const onFallback = options.onFallback || noop;
|
|
@@ -179,11 +213,11 @@ export async function diffWithPolicy(options) {
|
|
|
179
213
|
|
|
180
214
|
try {
|
|
181
215
|
const result = await options.provider.diff(options.diffOptions);
|
|
182
|
-
return { outcome: 'diffed', providerName, result };
|
|
216
|
+
return { outcome: 'diffed', providerName, result, artifacts, localScreenshots: artifacts.localScreenshots };
|
|
183
217
|
} catch (error) {
|
|
184
218
|
if (error instanceof SnapSkipError) {
|
|
185
219
|
onSkip(error);
|
|
186
|
-
return { outcome: 'skipped', providerName };
|
|
220
|
+
return { outcome: 'skipped', providerName, artifacts, localScreenshots: artifacts.localScreenshots };
|
|
187
221
|
}
|
|
188
222
|
if (!(error instanceof SnapFallbackError)) {
|
|
189
223
|
throw error;
|
|
@@ -199,7 +233,7 @@ export async function diffWithPolicy(options) {
|
|
|
199
233
|
if (options.baselineResolutionStatus === 'error') {
|
|
200
234
|
throw createBaselineLookupFallbackError(error, 'diff', options.baselineResolutionMessage);
|
|
201
235
|
}
|
|
202
|
-
return { outcome: 'baseline-unavailable', providerName: 'local' };
|
|
236
|
+
return { outcome: 'baseline-unavailable', providerName: 'local', artifacts, localScreenshots: artifacts.localScreenshots };
|
|
203
237
|
}
|
|
204
238
|
|
|
205
239
|
if (options.baselineResolutionStatus === 'error') {
|
|
@@ -211,7 +245,7 @@ export async function diffWithPolicy(options) {
|
|
|
211
245
|
/** @type {CaptureResult | undefined} */
|
|
212
246
|
let recapture;
|
|
213
247
|
|
|
214
|
-
if (
|
|
248
|
+
if (!artifacts.localScreenshots) {
|
|
215
249
|
if (!options.captureOptions) {
|
|
216
250
|
throw new Error(
|
|
217
251
|
'Cannot fall back to a local diff: the current capture has no screenshots on disk ' +
|
|
@@ -221,6 +255,7 @@ export async function diffWithPolicy(options) {
|
|
|
221
255
|
}
|
|
222
256
|
onRecapture();
|
|
223
257
|
recapture = await localProvider.capture(options.captureOptions);
|
|
258
|
+
artifacts = describeCaptureArtifacts('local', recapture, options.config);
|
|
224
259
|
diffOptions = {
|
|
225
260
|
...options.diffOptions,
|
|
226
261
|
currentResultsPath: recapture.resultsPath,
|
|
@@ -230,7 +265,7 @@ export async function diffWithPolicy(options) {
|
|
|
230
265
|
}
|
|
231
266
|
|
|
232
267
|
const result = await localProvider.diff(diffOptions);
|
|
233
|
-
return { outcome: 'diffed', providerName: 'local', result, recapture };
|
|
268
|
+
return { outcome: 'diffed', providerName: 'local', result, recapture, artifacts, localScreenshots: artifacts.localScreenshots };
|
|
234
269
|
}
|
|
235
270
|
}
|
|
236
271
|
|
package/lib/provider.mjs
CHANGED
|
@@ -35,7 +35,8 @@ export class LocalProvider {
|
|
|
35
35
|
* @returns {Promise<CaptureResult>}
|
|
36
36
|
*/
|
|
37
37
|
async capture(options) {
|
|
38
|
-
|
|
38
|
+
const result = await runBaselineCapture(options);
|
|
39
|
+
return { ...result, artifacts: { localScreenshots: true, artifactsRoot: result.screenshotsRoot } };
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
/**
|
|
@@ -58,6 +59,7 @@ export class LocalProvider {
|
|
|
58
59
|
/**
|
|
59
60
|
* @param {FetchBaselineOptions} _options
|
|
60
61
|
* @returns {Promise<never>}
|
|
62
|
+
* Guard with providerSupports(providerName, 'fetchLatestBaseline') before calling.
|
|
61
63
|
* @throws {Error} Not yet implemented for LocalProvider.
|
|
62
64
|
* Use the pr-diff action for baseline resolution, or wait for a future release.
|
|
63
65
|
*/
|
|
@@ -80,6 +82,22 @@ export class LocalProvider {
|
|
|
80
82
|
}
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
export const PROVIDER_CAPABILITIES = Object.freeze({
|
|
86
|
+
local: Object.freeze({ capture: true, diff: true, publishBaseline: true, fetchLatestBaseline: false, buildCommentBody: true }),
|
|
87
|
+
snap: Object.freeze({ capture: true, diff: true, publishBaseline: true, fetchLatestBaseline: true, buildCommentBody: true })
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {string} providerName
|
|
92
|
+
* @param {string} capability
|
|
93
|
+
* @returns {boolean}
|
|
94
|
+
*/
|
|
95
|
+
export function providerSupports(providerName, capability) {
|
|
96
|
+
return Object.hasOwn(PROVIDER_CAPABILITIES, providerName) &&
|
|
97
|
+
Object.hasOwn(PROVIDER_CAPABILITIES[providerName], capability) &&
|
|
98
|
+
PROVIDER_CAPABILITIES[providerName][capability] === true;
|
|
99
|
+
}
|
|
100
|
+
|
|
83
101
|
/** @type {{ local: (config?: VisualRegressionConfig) => LocalProvider, snap: (config: VisualRegressionConfig) => SnapProvider }} */
|
|
84
102
|
const PROVIDER_FACTORIES = {
|
|
85
103
|
local: () => new LocalProvider(),
|
package/lib/snap-provider.mjs
CHANGED
|
@@ -46,7 +46,6 @@ const BINARY_REQUEST_TIMEOUT_MS = 120 * 1000;
|
|
|
46
46
|
const POLL_INTERVAL_MS = 2000;
|
|
47
47
|
const MAX_OPERATION_DURATION_MS = 10 * 60 * 1000;
|
|
48
48
|
const MAX_STABLE_INCOMPLETE_POLLS = 30;
|
|
49
|
-
const MAX_COMPARISON_PIXELS = 32 * 1024 * 1024;
|
|
50
49
|
const BASELINE_RUN_SUCCESS_STATUS = 'new';
|
|
51
50
|
const BASELINE_CAPTURE_SUCCESS_STATUS = 'new';
|
|
52
51
|
const TERMINAL_CAPTURE_STATUSES = new Set(['new', 'diffed', 'error']);
|
|
@@ -178,8 +177,7 @@ function isComparisonMetadata(value) {
|
|
|
178
177
|
canvas.height === Math.max(baseline.height, current.height) &&
|
|
179
178
|
Number.isInteger(comparison.totalPixels) &&
|
|
180
179
|
comparison.totalPixels >= 0 &&
|
|
181
|
-
comparison.totalPixels <= canvas.width * canvas.height
|
|
182
|
-
canvas.width * canvas.height <= MAX_COMPARISON_PIXELS
|
|
180
|
+
comparison.totalPixels <= canvas.width * canvas.height
|
|
183
181
|
);
|
|
184
182
|
}
|
|
185
183
|
|
|
@@ -950,6 +948,7 @@ export class SnapProvider {
|
|
|
950
948
|
resultsPath,
|
|
951
949
|
manifestPath,
|
|
952
950
|
screenshotsRoot: outDir,
|
|
951
|
+
artifacts: { localScreenshots: false, artifactsRoot: undefined },
|
|
953
952
|
selectedRouteIds
|
|
954
953
|
};
|
|
955
954
|
}
|
|
@@ -1965,7 +1964,7 @@ export class SnapProvider {
|
|
|
1965
1964
|
};
|
|
1966
1965
|
await fs.writeFile(localCapture.resultsPath, JSON.stringify(snapResults, null, 2));
|
|
1967
1966
|
|
|
1968
|
-
return localCapture;
|
|
1967
|
+
return { ...localCapture, artifacts: { localScreenshots: true, artifactsRoot: localCapture.screenshotsRoot } };
|
|
1969
1968
|
}
|
|
1970
1969
|
|
|
1971
1970
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "snapdrift",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "SnapDrift captures, compares, and reports UI drift for Playwright-based app checks.",
|
|
@@ -81,6 +81,7 @@
|
|
|
81
81
|
"./lib/cli.mjs": "./lib/cli.mjs",
|
|
82
82
|
"./lib/provider.mjs": "./lib/provider.mjs",
|
|
83
83
|
"./lib/outage-policy.mjs": "./lib/outage-policy.mjs",
|
|
84
|
+
"./lib/github-requests.mjs": "./lib/github-requests.mjs",
|
|
84
85
|
"./lib/snap-provider.mjs": "./lib/snap-provider.mjs",
|
|
85
86
|
"./lib/migrate-baselines.mjs": "./lib/migrate-baselines.mjs",
|
|
86
87
|
"./lib/init-from-action.mjs": "./lib/init-from-action.mjs",
|
|
@@ -102,10 +103,10 @@
|
|
|
102
103
|
"registry": "https://registry.npmjs.org"
|
|
103
104
|
},
|
|
104
105
|
"dependencies": {
|
|
105
|
-
"@snapdrift/adapter-fs": "^1.
|
|
106
|
-
"@snapdrift/adapter-report-md": "^1.
|
|
107
|
-
"@snapdrift/compare-core": "^1.
|
|
108
|
-
"@snapdrift/manifest": "^1.
|
|
106
|
+
"@snapdrift/adapter-fs": "^1.4.0",
|
|
107
|
+
"@snapdrift/adapter-report-md": "^1.3.0",
|
|
108
|
+
"@snapdrift/compare-core": "^1.4.0",
|
|
109
|
+
"@snapdrift/manifest": "^1.5.0",
|
|
109
110
|
"js-yaml": "^5.4.0",
|
|
110
111
|
"playwright": "^1.59.1",
|
|
111
112
|
"pngjs": "^7.0.0"
|
|
@@ -192,6 +192,7 @@ export interface VisualDiffSummary {
|
|
|
192
192
|
changed: VisualDiffChangedItem[];
|
|
193
193
|
missing: VisualDiffMissingItem[];
|
|
194
194
|
errors: VisualDiffErrorItem[];
|
|
195
|
+
captureCompatibility?: import('@snapdrift/manifest').CaptureCompatibility;
|
|
195
196
|
dimensionChanges: VisualDiffDimensionItem[];
|
|
196
197
|
comparisonPolicy?: ComparisonPolicy;
|
|
197
198
|
message?: string;
|