snapdrift 0.3.0 → 0.5.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 +38 -0
- package/README.md +17 -7
- package/actions/baseline/action.yml +83 -13
- package/actions/comment/action.yml +31 -3
- package/actions/pr-diff/action.yml +110 -23
- package/actions/scope/action.yml +9 -0
- package/docs/contracts.md +237 -1
- package/docs/integration-guide.md +94 -26
- package/docs/local-cli.md +107 -4
- package/docs/workflow-templates/refresh-baseline-on-merge.yml +1 -1
- package/lib/cli.mjs +161 -26
- package/lib/init-from-action.mjs +244 -0
- package/lib/migrate-baselines.mjs +199 -0
- package/lib/provider.mjs +106 -0
- package/lib/snap-provider.mjs +1033 -0
- package/lib/snapdrift-config.mjs +3 -1
- package/package.json +9 -5
- package/types/visual-diff-types.d.ts +65 -0
|
@@ -0,0 +1,1033 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import fsSync from 'node:fs';
|
|
6
|
+
import net from 'node:net';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
import { makeMarkdown, buildReportCommentBody } from '@snapdrift/adapter-report-md';
|
|
11
|
+
import { loadSnapdriftConfig, runBaselineCapture } from '@snapdrift/adapter-fs';
|
|
12
|
+
import { selectConfiguredRoutes, splitCommaList, determineDriftStatus, VIEWPORT_PRESETS } from '@snapdrift/manifest';
|
|
13
|
+
|
|
14
|
+
/** @typedef {import('../types/visual-diff-types').VisualProvider} VisualProvider */
|
|
15
|
+
/** @typedef {import('../types/visual-diff-types').ProviderCaptureOptions} CaptureOptions */
|
|
16
|
+
/** @typedef {import('../types/visual-diff-types').ProviderCaptureResult} CaptureResult */
|
|
17
|
+
/** @typedef {import('../types/visual-diff-types').ProviderDiffOptions} DiffOptions */
|
|
18
|
+
/** @typedef {import('../types/visual-diff-types').ProviderDiffResult} DiffResult */
|
|
19
|
+
/** @typedef {import('../types/visual-diff-types').ProviderPublishBaselineOptions} PublishBaselineOptions */
|
|
20
|
+
/** @typedef {import('../types/visual-diff-types').ProviderPublishBaselineResult} PublishBaselineResult */
|
|
21
|
+
/** @typedef {import('../types/visual-diff-types').ProviderFetchBaselineOptions} FetchBaselineOptions */
|
|
22
|
+
/** @typedef {import('../types/visual-diff-types').ProviderBaselineData} BaselineData */
|
|
23
|
+
/** @typedef {import('../types/visual-diff-types').SnapConfig} SnapConfig */
|
|
24
|
+
/** @typedef {import('../types/visual-diff-types').VisualRegressionConfig} VisualRegressionConfig */
|
|
25
|
+
|
|
26
|
+
const DEFAULT_API_URL = 'https://snap.i2dev.com';
|
|
27
|
+
const MAX_RETRIES = 3;
|
|
28
|
+
const INITIAL_RETRY_DELAY_MS = 1000;
|
|
29
|
+
const RETRY_MULTIPLIER = 2;
|
|
30
|
+
const MAX_RETRY_TOTAL_MS = 30000;
|
|
31
|
+
const POLL_INTERVAL_MS = 2000;
|
|
32
|
+
const MAX_POLL_DURATION_MS = 10 * 60 * 1000;
|
|
33
|
+
|
|
34
|
+
// Snap reports a run as "new" when it has no baseline to compare against (first
|
|
35
|
+
// capture for a project, or any baseline-publish run that intentionally omits a
|
|
36
|
+
// baseline). It is a terminal state — the run never advances past it — so the
|
|
37
|
+
// client must stop polling on it just like pass/fail/error.
|
|
38
|
+
const TERMINAL_RUN_STATUSES = new Set(['pass', 'fail', 'error', 'new']);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {string | undefined} baseUrl
|
|
42
|
+
* @returns {boolean}
|
|
43
|
+
*/
|
|
44
|
+
export function isLocalBaseUrl(baseUrl) {
|
|
45
|
+
if (!baseUrl) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let hostname;
|
|
50
|
+
try {
|
|
51
|
+
hostname = new URL(baseUrl).hostname.toLowerCase();
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const normalized = hostname.replace(/^\[/, '').replace(/\]$/, '');
|
|
57
|
+
if (normalized === 'localhost' || normalized.endsWith('.localhost')) {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
if (normalized === '::1' || normalized === '0.0.0.0') {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
return net.isIP(normalized) === 4 && normalized.startsWith('127.');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {string} repoSlug
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
function repoSlugToProjectId(repoSlug) {
|
|
71
|
+
return repoSlug.replace(/[/.]/g, '--');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read the package version from package.json.
|
|
76
|
+
* @returns {string}
|
|
77
|
+
*/
|
|
78
|
+
function getPackageVersion() {
|
|
79
|
+
try {
|
|
80
|
+
const pkgUrl = new URL('../package.json', import.meta.url);
|
|
81
|
+
const pkg = JSON.parse(fsSync.readFileSync(pkgUrl, 'utf-8'));
|
|
82
|
+
return pkg.version || '0.0.0';
|
|
83
|
+
} catch {
|
|
84
|
+
return '0.0.0';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* SnapProvider — VisualProvider that delegates capture + diff to Snap's
|
|
90
|
+
* hosted /v1/visual/* API.
|
|
91
|
+
*
|
|
92
|
+
* @implements {VisualProvider}
|
|
93
|
+
*/
|
|
94
|
+
export class SnapProvider {
|
|
95
|
+
/** @type {SnapConfig} */
|
|
96
|
+
#snapConfig;
|
|
97
|
+
/** @type {typeof globalThis.fetch} */
|
|
98
|
+
#fetchFn;
|
|
99
|
+
/** @type {(ms: number) => Promise<void>} */
|
|
100
|
+
#sleepFn;
|
|
101
|
+
/** @type {(options?: { configPath?: string, routeIds?: Iterable<string>, outDir?: string }) => Promise<CaptureResult>} */
|
|
102
|
+
#localCaptureFn;
|
|
103
|
+
/** @type {string} */
|
|
104
|
+
#apiKey;
|
|
105
|
+
/** @type {string} */
|
|
106
|
+
#apiUrl;
|
|
107
|
+
/** @type {string} */
|
|
108
|
+
#projectId;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {SnapConfig} snapConfig — validated snap section from config
|
|
112
|
+
* @param {{
|
|
113
|
+
* fetchFn?: typeof globalThis.fetch,
|
|
114
|
+
* sleepFn?: (ms: number) => Promise<void>,
|
|
115
|
+
* localCaptureFn?: (options?: { configPath?: string, routeIds?: Iterable<string>, outDir?: string }) => Promise<CaptureResult>
|
|
116
|
+
* }} [options]
|
|
117
|
+
*/
|
|
118
|
+
constructor(snapConfig, options = {}) {
|
|
119
|
+
this.#snapConfig = snapConfig;
|
|
120
|
+
this.#fetchFn = options.fetchFn ?? globalThis.fetch;
|
|
121
|
+
this.#sleepFn = options.sleepFn ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
122
|
+
this.#localCaptureFn = options.localCaptureFn ?? runBaselineCapture;
|
|
123
|
+
this.#apiUrl = (snapConfig.apiUrl || DEFAULT_API_URL).replace(/\/+$/, '');
|
|
124
|
+
this.#apiKey = this.#resolveApiKey(snapConfig);
|
|
125
|
+
this.#projectId = this.#resolveProjectId(snapConfig);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// VisualProvider interface
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Submit routes to Snap for server-side rendering.
|
|
134
|
+
*
|
|
135
|
+
* Creates a run via POST /v1/visual/projects/:id/runs, then submits each
|
|
136
|
+
* route as a capture via POST /v1/visual/runs/:run_id/captures. Writes run
|
|
137
|
+
* metadata to a temp file so diff() can pick it up.
|
|
138
|
+
*
|
|
139
|
+
* @param {CaptureOptions} options
|
|
140
|
+
* @returns {Promise<CaptureResult>}
|
|
141
|
+
*/
|
|
142
|
+
async capture(options) {
|
|
143
|
+
const { config } = await loadSnapdriftConfig(options.configPath);
|
|
144
|
+
const requestedRouteIds = options.routeIds
|
|
145
|
+
? [...options.routeIds]
|
|
146
|
+
: splitCommaList(process.env.SNAPDRIFT_ROUTE_IDS);
|
|
147
|
+
const { routes, selectedRouteIds } = selectConfiguredRoutes(config, requestedRouteIds);
|
|
148
|
+
const purpose = options.purpose ?? 'diff';
|
|
149
|
+
|
|
150
|
+
if (isLocalBaseUrl(config.baseUrl)) {
|
|
151
|
+
return this.#captureLocalAndUpload({
|
|
152
|
+
configPath: options.configPath,
|
|
153
|
+
routeIds: requestedRouteIds,
|
|
154
|
+
outDir: options.outDir,
|
|
155
|
+
config,
|
|
156
|
+
routes,
|
|
157
|
+
selectedRouteIds,
|
|
158
|
+
purpose
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const idempotencyKey = this.#generateIdempotencyKey();
|
|
163
|
+
const runId = `run_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
|
|
164
|
+
|
|
165
|
+
// For a diff run, resolve the latest accepted baseline so the Snap backend
|
|
166
|
+
// can attach it to this run's captures. Without a baselineId the server has
|
|
167
|
+
// nothing to diff against and short-circuits every capture to "diffed" with
|
|
168
|
+
// no comparison data, leaving the dashboard baseline + diff panes empty.
|
|
169
|
+
//
|
|
170
|
+
// For a baseline run we deliberately omit the baselineId: the run is
|
|
171
|
+
// establishing new ground truth, not comparing against the old baseline.
|
|
172
|
+
// Attaching one would make the backend diff the fresh captures against the
|
|
173
|
+
// prior baseline — wasteful at best, and on a dimension mismatch it errors
|
|
174
|
+
// the run and fails the publish.
|
|
175
|
+
const baselineId = purpose === 'baseline' ? null : await this.#resolveLatestBaselineId();
|
|
176
|
+
|
|
177
|
+
// We intentionally do NOT send captureProfileJson on run creation. SnapDrift
|
|
178
|
+
// submits routes for server-side rendering, so the render environment
|
|
179
|
+
// (browser/platform/fonts/viewport) is owned by Snap's render worker, not
|
|
180
|
+
// this client. When a baselineId is attached, the run-creation endpoint
|
|
181
|
+
// compares the run's capture profile against the baseline's, dereferencing
|
|
182
|
+
// nested fields (browser, platform, fonts, viewport) that our minimal
|
|
183
|
+
// profile does not populate — which crashes the server with a 500. The
|
|
184
|
+
// render worker already defaults locale/timezone/viewport when no profile
|
|
185
|
+
// is present, so omitting it here is behaviour-preserving.
|
|
186
|
+
await this.#request('POST', `/v1/visual/projects/${this.#projectId}/runs`, {
|
|
187
|
+
id: runId,
|
|
188
|
+
baseUrl: config.baseUrl,
|
|
189
|
+
trigger: 'ci',
|
|
190
|
+
...(baselineId ? { baselineId } : {}),
|
|
191
|
+
// A baseline run must not be diffed. Suppress the server's
|
|
192
|
+
// auto-resolve-by-branch so no prior baseline is attached.
|
|
193
|
+
...(purpose === 'baseline' ? { skipBaselineResolution: true } : {}),
|
|
194
|
+
...this.#gitRunContext(),
|
|
195
|
+
}, idempotencyKey);
|
|
196
|
+
|
|
197
|
+
// Submit each route as a capture with its own idempotency key
|
|
198
|
+
for (const route of routes) {
|
|
199
|
+
const captureKey = this.#generateIdempotencyKey();
|
|
200
|
+
const captureId = `cap_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
|
|
201
|
+
const viewportDescriptor = typeof route.viewport === 'string'
|
|
202
|
+
? (VIEWPORT_PRESETS[route.viewport] ?? { width: 1280, height: 720 })
|
|
203
|
+
: route.viewport;
|
|
204
|
+
await this.#request('POST', `/v1/visual/runs/${runId}/captures`, {
|
|
205
|
+
id: captureId,
|
|
206
|
+
routeId: route.id,
|
|
207
|
+
routePath: route.path,
|
|
208
|
+
viewportDescriptorJson: JSON.stringify(viewportDescriptor),
|
|
209
|
+
}, captureKey);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Write run metadata to a temp file so diff() can thread the run_id
|
|
213
|
+
const outDir = options.outDir
|
|
214
|
+
? path.resolve(options.outDir)
|
|
215
|
+
: await fs.mkdtemp(path.join(os.tmpdir(), 'snapdrift-snap-'));
|
|
216
|
+
|
|
217
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
218
|
+
|
|
219
|
+
const resultsPath = path.join(outDir, 'results.json');
|
|
220
|
+
const manifestPath = path.join(outDir, 'manifest.json');
|
|
221
|
+
|
|
222
|
+
const runMetadata = { runId, projectId: this.#projectId, startedAt: new Date().toISOString() };
|
|
223
|
+
await Promise.all([
|
|
224
|
+
fs.writeFile(resultsPath, JSON.stringify(runMetadata, null, 2)),
|
|
225
|
+
fs.writeFile(manifestPath, JSON.stringify({
|
|
226
|
+
generatedAt: new Date().toISOString(),
|
|
227
|
+
baseUrl: config.baseUrl,
|
|
228
|
+
screenshots: routes.map((r) => ({
|
|
229
|
+
id: r.id,
|
|
230
|
+
path: r.path,
|
|
231
|
+
viewport: r.viewport,
|
|
232
|
+
imagePath: `screenshots/${r.id}.png`,
|
|
233
|
+
width: 0,
|
|
234
|
+
height: 0
|
|
235
|
+
}))
|
|
236
|
+
}, null, 2))
|
|
237
|
+
]);
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
resultsPath,
|
|
241
|
+
manifestPath,
|
|
242
|
+
screenshotsRoot: outDir,
|
|
243
|
+
selectedRouteIds
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Poll Snap for run completion and map the result to a drift summary.
|
|
249
|
+
*
|
|
250
|
+
* @param {DiffOptions} options
|
|
251
|
+
* @returns {Promise<DiffResult>}
|
|
252
|
+
*/
|
|
253
|
+
async diff(options) {
|
|
254
|
+
// Read run metadata from the capture step
|
|
255
|
+
const currentResultsPath = options.currentResultsPath;
|
|
256
|
+
if (!currentResultsPath) {
|
|
257
|
+
throw new Error('SnapProvider.diff requires currentResultsPath from a prior capture() call.');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const metadata = JSON.parse(await fs.readFile(currentResultsPath, 'utf-8'));
|
|
261
|
+
const runId = metadata.runId;
|
|
262
|
+
|
|
263
|
+
// Poll until the run reaches a terminal state
|
|
264
|
+
const run = await this.#pollRun(runId);
|
|
265
|
+
|
|
266
|
+
const { config } = await loadSnapdriftConfig(options.configPath);
|
|
267
|
+
const summary = this.#mapRunToSummary(run, config);
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
summary,
|
|
271
|
+
markdown: makeMarkdown(summary)
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Publish a baseline to Snap.
|
|
277
|
+
*
|
|
278
|
+
* When called with a `resultsPath` that contains a `runId` (written by
|
|
279
|
+
* `capture()`), this method polls the run until all renders complete, then
|
|
280
|
+
* creates a snap baseline from the captured S3 keys. This is the primary
|
|
281
|
+
* path used by the baseline action with `provider: "snap"`.
|
|
282
|
+
*
|
|
283
|
+
* @param {PublishBaselineOptions} options
|
|
284
|
+
* @returns {Promise<PublishBaselineResult>}
|
|
285
|
+
*/
|
|
286
|
+
async publishBaseline(options) {
|
|
287
|
+
const resultsPath = options.resultsPath
|
|
288
|
+
?? (options.bundleDir ? path.join(path.resolve(options.bundleDir), 'results.json') : undefined);
|
|
289
|
+
|
|
290
|
+
// Snap-native baseline: poll the run that capture() submitted, then
|
|
291
|
+
// create a baseline from the rendered S3 keys.
|
|
292
|
+
if (resultsPath) {
|
|
293
|
+
let metadata;
|
|
294
|
+
try {
|
|
295
|
+
metadata = JSON.parse(await fs.readFile(resultsPath, 'utf-8'));
|
|
296
|
+
} catch { /* fall through to legacy path */ }
|
|
297
|
+
|
|
298
|
+
if (metadata?.runId) {
|
|
299
|
+
const { runId } = metadata;
|
|
300
|
+
const idempotencyKey = `baseline-${runId}`;
|
|
301
|
+
|
|
302
|
+
// Poll until the run reaches a terminal state (renders + auto-diffs finish)
|
|
303
|
+
const run = await this.#pollRun(runId);
|
|
304
|
+
|
|
305
|
+
if (run.status === 'error') {
|
|
306
|
+
throw new Error(
|
|
307
|
+
`Snap baseline run ${runId} ended with status "error" — cannot publish baseline. ` +
|
|
308
|
+
`Check the snap dashboard for capture error details.`
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const captures = run.captures ?? [];
|
|
313
|
+
const rendered = captures.filter((c) => c.currentObjectKey);
|
|
314
|
+
|
|
315
|
+
if (rendered.length === 0) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
`Snap baseline run ${runId} completed but no captures produced a screenshot ` +
|
|
318
|
+
`(${captures.length} capture(s) found, 0 rendered). Cannot publish an empty baseline.`
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (rendered.length < captures.length) {
|
|
323
|
+
const failed = captures.length - rendered.length;
|
|
324
|
+
process.stderr.write(
|
|
325
|
+
`[SnapDrift] Warning: ${failed} of ${captures.length} capture(s) did not produce ` +
|
|
326
|
+
`a screenshot and will be excluded from the baseline.\n`
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Build a manifest from the rendered captures
|
|
331
|
+
const routes = rendered.map((c) => ({
|
|
332
|
+
routeId: c.routeId,
|
|
333
|
+
routePath: c.routePath,
|
|
334
|
+
viewportDescriptorJson: c.viewportDescriptorJson,
|
|
335
|
+
objectKey: c.currentObjectKey
|
|
336
|
+
}));
|
|
337
|
+
|
|
338
|
+
const manifestJson = JSON.stringify({
|
|
339
|
+
schemaVersion: 1,
|
|
340
|
+
sourceRunId: runId,
|
|
341
|
+
routes
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const baselineId = `bsl_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
|
|
345
|
+
await this.#request(
|
|
346
|
+
'POST',
|
|
347
|
+
`/v1/visual/projects/${this.#projectId}/baselines`,
|
|
348
|
+
{
|
|
349
|
+
id: baselineId,
|
|
350
|
+
refBranch: process.env.GITHUB_REF_NAME || process.env.GITHUB_HEAD_REF || 'main',
|
|
351
|
+
refSha: process.env.GITHUB_SHA || 'unknown',
|
|
352
|
+
manifestJson,
|
|
353
|
+
captureProfileJson: JSON.stringify(this.#buildCaptureProfile())
|
|
354
|
+
},
|
|
355
|
+
idempotencyKey
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapdrift-snap-baseline-'));
|
|
359
|
+
return { bundleDir };
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Legacy / migration path: bundle dir with pre-built results/manifest
|
|
364
|
+
const idempotencyKey = this.#generateIdempotencyKey();
|
|
365
|
+
const body = { idempotencyKey };
|
|
366
|
+
|
|
367
|
+
if (options.bundleDir) {
|
|
368
|
+
const bundleDir = path.resolve(options.bundleDir);
|
|
369
|
+
const bResultsPath = options.resultsPath || path.join(bundleDir, 'results.json');
|
|
370
|
+
const manifestPath = options.manifestPath || path.join(bundleDir, 'manifest.json');
|
|
371
|
+
|
|
372
|
+
try {
|
|
373
|
+
const results = JSON.parse(await fs.readFile(bResultsPath, 'utf-8'));
|
|
374
|
+
body.results = results;
|
|
375
|
+
} catch { /* results.json may not exist */ }
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
|
|
379
|
+
body.manifest = manifest;
|
|
380
|
+
} catch { /* manifest.json may not exist */ }
|
|
381
|
+
|
|
382
|
+
if (body.results?.runId) {
|
|
383
|
+
body.idempotencyKey = `baseline-${body.results.runId}`;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
await this.#request('POST', `/v1/visual/projects/${this.#projectId}/baselines`, body, idempotencyKey);
|
|
388
|
+
|
|
389
|
+
const bundleDir = options.bundleDir
|
|
390
|
+
? path.resolve(options.bundleDir)
|
|
391
|
+
: await fs.mkdtemp(path.join(os.tmpdir(), 'snapdrift-snap-baseline-'));
|
|
392
|
+
|
|
393
|
+
return { bundleDir };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Fetch the latest baseline from Snap.
|
|
398
|
+
*
|
|
399
|
+
* @param {FetchBaselineOptions} _options
|
|
400
|
+
* @returns {Promise<BaselineData | null>}
|
|
401
|
+
*/
|
|
402
|
+
async fetchLatestBaseline(_options) {
|
|
403
|
+
try {
|
|
404
|
+
const baseline = await this.#request('GET', `/v1/visual/projects/${this.#projectId}/baselines/latest`);
|
|
405
|
+
|
|
406
|
+
const runDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapdrift-snap-baseline-'));
|
|
407
|
+
const resultsPath = path.join(runDir, 'results.json');
|
|
408
|
+
const manifestPath = path.join(runDir, 'manifest.json');
|
|
409
|
+
|
|
410
|
+
if (baseline.results) {
|
|
411
|
+
await fs.writeFile(resultsPath, JSON.stringify(baseline.results, null, 2));
|
|
412
|
+
}
|
|
413
|
+
if (baseline.manifest) {
|
|
414
|
+
await fs.writeFile(manifestPath, JSON.stringify(baseline.manifest, null, 2));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
resultsPath,
|
|
419
|
+
manifestPath,
|
|
420
|
+
runDir,
|
|
421
|
+
screenshotsDir: path.join(runDir, 'screenshots'),
|
|
422
|
+
artifactName: baseline.artifactName || 'snap-baseline',
|
|
423
|
+
headSha: baseline.headSha || ''
|
|
424
|
+
};
|
|
425
|
+
} catch (error) {
|
|
426
|
+
if (error instanceof SnapApiError && error.status === 404) {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
throw error;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
// PR comment
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Build a PR comment body from a diff summary.
|
|
439
|
+
* SnapProvider includes the dashboard URL from the summary when available.
|
|
440
|
+
*
|
|
441
|
+
* @param {Record<string, unknown>} summary
|
|
442
|
+
* @param {import('../types/visual-diff-types').ProviderCommentMeta} [meta]
|
|
443
|
+
* @returns {string}
|
|
444
|
+
*/
|
|
445
|
+
buildCommentBody(summary, meta = {}) {
|
|
446
|
+
const dashboardUrl = summary.dashboardUrl || meta.dashboardUrl;
|
|
447
|
+
return buildReportCommentBody(summary, { ...meta, dashboardUrl });
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
// Migration methods
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Check whether a baseline already exists for a given commit SHA.
|
|
456
|
+
*
|
|
457
|
+
* @param {string} headSha
|
|
458
|
+
* @returns {Promise<object | null>} Baseline data if found, null on 404
|
|
459
|
+
*/
|
|
460
|
+
async checkBaselineExists(headSha) {
|
|
461
|
+
try {
|
|
462
|
+
const baseline = await this.#request('GET', `/v1/visual/projects/${this.#projectId}/baselines/latest?sha=${encodeURIComponent(headSha)}`);
|
|
463
|
+
return baseline;
|
|
464
|
+
} catch (error) {
|
|
465
|
+
if (error instanceof SnapApiError && error.status === 404) {
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
throw error;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Upload local baselines to Snap as the initial accepted baseline.
|
|
474
|
+
* Used by `snapdrift migrate-baselines --to snap`.
|
|
475
|
+
*
|
|
476
|
+
* Reads local baseline files and POSTs them to the Snap baselines API.
|
|
477
|
+
* Screenshots are base64-encoded and included in the request body.
|
|
478
|
+
* Idempotent: uses a commit-SHA-derived idempotency key.
|
|
479
|
+
*
|
|
480
|
+
* @param {{
|
|
481
|
+
* results: object,
|
|
482
|
+
* manifest: object,
|
|
483
|
+
* screenshots: Array<{ filename: string, data: string }>,
|
|
484
|
+
* headSha: string
|
|
485
|
+
* }} options
|
|
486
|
+
* @returns {Promise<{ uploaded: number, skipped: number, baselineId: string }>}
|
|
487
|
+
*/
|
|
488
|
+
async migrateBaselineFromLocal(options) {
|
|
489
|
+
const { results, manifest, screenshots, headSha } = options;
|
|
490
|
+
const idempotencyKey = `baseline-migrate-${headSha}`;
|
|
491
|
+
|
|
492
|
+
const body = {
|
|
493
|
+
idempotencyKey,
|
|
494
|
+
results,
|
|
495
|
+
manifest,
|
|
496
|
+
screenshots,
|
|
497
|
+
headSha,
|
|
498
|
+
captureProfile: this.#buildCaptureProfile()
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const response = await this.#request('POST', `/v1/visual/projects/${this.#projectId}/baselines`, body, idempotencyKey);
|
|
502
|
+
|
|
503
|
+
const screenshotCount = screenshots?.length ?? 0;
|
|
504
|
+
return {
|
|
505
|
+
uploaded: screenshotCount,
|
|
506
|
+
skipped: 0,
|
|
507
|
+
baselineId: response.id || idempotencyKey
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Export baselines from Snap for local import.
|
|
513
|
+
* Used by `snapdrift migrate-baselines --to local --from snap`.
|
|
514
|
+
*
|
|
515
|
+
* @param {{ tag?: string }} _options
|
|
516
|
+
* @returns {Promise<{
|
|
517
|
+
* results: object,
|
|
518
|
+
* manifest: object,
|
|
519
|
+
* screenshots: Array<{ filename: string, data: Buffer }>,
|
|
520
|
+
* engine: { name: string, version: string }
|
|
521
|
+
* }>}
|
|
522
|
+
* @throws {Error} The Snap export endpoint is not yet available.
|
|
523
|
+
*/
|
|
524
|
+
async exportBaselines(_options) {
|
|
525
|
+
throw new Error(
|
|
526
|
+
'The Snap export endpoint is not yet available. ' +
|
|
527
|
+
'This command will be functional once the Snap API adds GET /v1/visual/projects/:id/export. ' +
|
|
528
|
+
'See Snap repo issue #482 for tracking.'
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ---------------------------------------------------------------------------
|
|
533
|
+
// Private helpers
|
|
534
|
+
// ---------------------------------------------------------------------------
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Resolve API key from config.
|
|
538
|
+
* @param {SnapConfig} snapConfig
|
|
539
|
+
* @returns {string}
|
|
540
|
+
*/
|
|
541
|
+
#resolveApiKey(snapConfig) {
|
|
542
|
+
if (snapConfig.apiKeyEnv) {
|
|
543
|
+
const value = process.env[snapConfig.apiKeyEnv];
|
|
544
|
+
if (!value) {
|
|
545
|
+
throw new Error(`Snap API key not found in environment variable: ${snapConfig.apiKeyEnv}`);
|
|
546
|
+
}
|
|
547
|
+
return value;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (snapConfig.apiKey) {
|
|
551
|
+
return snapConfig.apiKey.replace(/\$\{(\w+)\}/g, (_, varName) => {
|
|
552
|
+
const value = process.env[varName];
|
|
553
|
+
if (!value) {
|
|
554
|
+
throw new Error(`Snap API key interpolation failed: environment variable ${varName} is not set.`);
|
|
555
|
+
}
|
|
556
|
+
return value;
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Should not reach here if config was validated, but defensive
|
|
561
|
+
throw new Error('Snap provider requires exactly one of snap.apiKeyEnv or snap.apiKey.');
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Resolve project ID from config.
|
|
566
|
+
* @param {SnapConfig} snapConfig
|
|
567
|
+
* @returns {string}
|
|
568
|
+
*/
|
|
569
|
+
#resolveProjectId(snapConfig) {
|
|
570
|
+
const projectId = snapConfig.projectId || 'auto';
|
|
571
|
+
if (projectId === 'auto') {
|
|
572
|
+
const repo = process.env.GITHUB_REPOSITORY;
|
|
573
|
+
if (!repo) {
|
|
574
|
+
throw new Error(
|
|
575
|
+
'Snap project ID is set to "auto" but GITHUB_REPOSITORY is not set. ' +
|
|
576
|
+
'Set the GITHUB_REPOSITORY environment variable or provide an explicit snap.projectId.'
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
return repoSlugToProjectId(repo);
|
|
580
|
+
}
|
|
581
|
+
return projectId;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Build a capture profile for the Snap API.
|
|
586
|
+
* @returns {object}
|
|
587
|
+
*/
|
|
588
|
+
#buildCaptureProfile() {
|
|
589
|
+
return {
|
|
590
|
+
schemaVersion: 1,
|
|
591
|
+
engine: {
|
|
592
|
+
name: 'snapdrift-local',
|
|
593
|
+
version: `v${getPackageVersion().split('.')[0]}`
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Resolve the id of the latest accepted baseline for this project so it can
|
|
600
|
+
* be attached to a new run. Returns null when no baseline exists yet (the
|
|
601
|
+
* legitimate first-run case), so capture can still proceed.
|
|
602
|
+
*
|
|
603
|
+
* @returns {Promise<string | null>}
|
|
604
|
+
*/
|
|
605
|
+
async #resolveLatestBaselineId() {
|
|
606
|
+
try {
|
|
607
|
+
const baseline = await this.#request('GET', `/v1/visual/projects/${this.#projectId}/baselines/latest`);
|
|
608
|
+
return baseline?.id ?? null;
|
|
609
|
+
} catch (error) {
|
|
610
|
+
if (error instanceof SnapApiError && error.status === 404) {
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
throw error;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Collect git ref context from the GitHub Actions environment so the Snap
|
|
619
|
+
* dashboard can attribute the run to a branch. Uses GITHUB_HEAD_REF (the
|
|
620
|
+
* source branch on pull_request events) and falls back to GITHUB_REF_NAME
|
|
621
|
+
* (the branch on push events).
|
|
622
|
+
*
|
|
623
|
+
* @returns {{ branch?: string }}
|
|
624
|
+
*/
|
|
625
|
+
#gitRunContext() {
|
|
626
|
+
const branch = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME;
|
|
627
|
+
return branch ? { branch } : {};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* @returns {string}
|
|
632
|
+
*/
|
|
633
|
+
#generateIdempotencyKey() {
|
|
634
|
+
return crypto.randomUUID();
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* @param {{
|
|
639
|
+
* configPath?: string,
|
|
640
|
+
* routeIds: string[],
|
|
641
|
+
* outDir?: string,
|
|
642
|
+
* config: VisualRegressionConfig,
|
|
643
|
+
* routes: VisualRegressionConfig['routes'],
|
|
644
|
+
* selectedRouteIds: string[],
|
|
645
|
+
* purpose: 'baseline' | 'diff'
|
|
646
|
+
* }} options
|
|
647
|
+
* @returns {Promise<CaptureResult>}
|
|
648
|
+
*/
|
|
649
|
+
async #captureLocalAndUpload(options) {
|
|
650
|
+
const localCapture = await this.#localCaptureFn({
|
|
651
|
+
configPath: options.configPath,
|
|
652
|
+
routeIds: options.routeIds,
|
|
653
|
+
outDir: options.outDir
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
const localResults = JSON.parse(await fs.readFile(localCapture.resultsPath, 'utf-8'));
|
|
657
|
+
const manifest = JSON.parse(await fs.readFile(localCapture.manifestPath, 'utf-8'));
|
|
658
|
+
// Route ids are unique per the v1 schema (one viewport per route), so keying
|
|
659
|
+
// the manifest by id is an unambiguous join against the configured routes.
|
|
660
|
+
const manifestById = new Map((manifest.screenshots || []).map((entry) => [entry.id, entry]));
|
|
661
|
+
|
|
662
|
+
// Resolve every selected route to its screenshot up front, before any server
|
|
663
|
+
// call. A missing local capture then fails fast instead of leaving an
|
|
664
|
+
// orphaned run on Snap that can never complete.
|
|
665
|
+
const uploads = options.routes.map((route) => {
|
|
666
|
+
const manifestEntry = manifestById.get(route.id);
|
|
667
|
+
if (!manifestEntry) {
|
|
668
|
+
throw new Error(`Local Snap capture did not produce a screenshot for route "${route.id}".`);
|
|
669
|
+
}
|
|
670
|
+
return { route, manifestEntry };
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
const runId = `run_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
|
|
674
|
+
// A baseline run establishes new ground truth and must not be diffed, so we
|
|
675
|
+
// omit the baselineId. Captures then settle to "new" server-side (no
|
|
676
|
+
// comparison), and publishBaseline harvests their object keys. A diff run
|
|
677
|
+
// attaches the latest baseline so the backend can compare against it.
|
|
678
|
+
const baselineId = options.purpose === 'baseline' ? null : await this.#resolveLatestBaselineId();
|
|
679
|
+
|
|
680
|
+
await this.#request('POST', `/v1/visual/projects/${this.#projectId}/runs`, {
|
|
681
|
+
id: runId,
|
|
682
|
+
baseUrl: options.config.baseUrl,
|
|
683
|
+
trigger: 'ci',
|
|
684
|
+
...(baselineId ? { baselineId } : {}),
|
|
685
|
+
// A baseline run must not be diffed. Suppress the server's
|
|
686
|
+
// auto-resolve-by-branch so no prior baseline is attached.
|
|
687
|
+
...(options.purpose === 'baseline' ? { skipBaselineResolution: true } : {}),
|
|
688
|
+
...this.#gitRunContext()
|
|
689
|
+
}, this.#generateIdempotencyKey());
|
|
690
|
+
|
|
691
|
+
for (const { route, manifestEntry } of uploads) {
|
|
692
|
+
const captureId = `cap_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`;
|
|
693
|
+
const viewportDescriptor = typeof route.viewport === 'string'
|
|
694
|
+
? (VIEWPORT_PRESETS[route.viewport] ?? { width: 1280, height: 720 })
|
|
695
|
+
: route.viewport;
|
|
696
|
+
|
|
697
|
+
await this.#request('POST', `/v1/visual/runs/${runId}/captures`, {
|
|
698
|
+
id: captureId,
|
|
699
|
+
routeId: route.id,
|
|
700
|
+
routePath: route.path,
|
|
701
|
+
viewportDescriptorJson: JSON.stringify(viewportDescriptor),
|
|
702
|
+
// The screenshot is captured locally and uploaded below, not rendered by
|
|
703
|
+
// Snap. This flag tells the backend to keep the capture out of the render
|
|
704
|
+
// worker's queue — without it the worker would try to render baseUrl
|
|
705
|
+
// (a client-only/loopback address it can't reach) and error the run.
|
|
706
|
+
localCapture: true,
|
|
707
|
+
}, this.#generateIdempotencyKey());
|
|
708
|
+
|
|
709
|
+
const imagePath = path.resolve(localCapture.screenshotsRoot, manifestEntry.imagePath);
|
|
710
|
+
const imageBytes = await fs.readFile(imagePath);
|
|
711
|
+
|
|
712
|
+
await this.#request('POST', `/v1/visual/captures/${captureId}/local-result`, {
|
|
713
|
+
imageBase64: imageBytes.toString('base64'),
|
|
714
|
+
width: manifestEntry.width,
|
|
715
|
+
height: manifestEntry.height
|
|
716
|
+
}, this.#generateIdempotencyKey());
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const snapResults = {
|
|
720
|
+
...localResults,
|
|
721
|
+
provider: 'snap',
|
|
722
|
+
captureMode: 'local-upload',
|
|
723
|
+
runId,
|
|
724
|
+
projectId: this.#projectId,
|
|
725
|
+
snapStartedAt: new Date().toISOString()
|
|
726
|
+
};
|
|
727
|
+
await fs.writeFile(localCapture.resultsPath, JSON.stringify(snapResults, null, 2));
|
|
728
|
+
|
|
729
|
+
return localCapture;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Make an HTTP request to the Snap API with retry logic.
|
|
734
|
+
*
|
|
735
|
+
* @param {string} method
|
|
736
|
+
* @param {string} path
|
|
737
|
+
* @param {object} [body]
|
|
738
|
+
* @param {string} [idempotencyKey]
|
|
739
|
+
* @returns {Promise<any>} Parsed JSON response
|
|
740
|
+
*/
|
|
741
|
+
async #request(method, path, body, idempotencyKey) {
|
|
742
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
743
|
+
try {
|
|
744
|
+
const headers = {
|
|
745
|
+
'Authorization': `Bearer ${this.#apiKey}`,
|
|
746
|
+
'Content-Type': 'application/json',
|
|
747
|
+
'Accept': 'application/json'
|
|
748
|
+
};
|
|
749
|
+
if (idempotencyKey && method === 'POST') {
|
|
750
|
+
headers['Idempotency-Key'] = idempotencyKey;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const url = `${this.#apiUrl}${path}`;
|
|
754
|
+
const response = await this.#fetchFn(url, {
|
|
755
|
+
method,
|
|
756
|
+
headers,
|
|
757
|
+
body: body ? JSON.stringify(body) : undefined
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
if (response.ok) {
|
|
761
|
+
const text = await response.text();
|
|
762
|
+
return text ? JSON.parse(text) : {};
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// 4xx — never retry
|
|
766
|
+
if (response.status >= 400 && response.status < 500) {
|
|
767
|
+
const errorBody = await response.text().catch(() => '');
|
|
768
|
+
let detail = '';
|
|
769
|
+
try {
|
|
770
|
+
const parsed = JSON.parse(errorBody);
|
|
771
|
+
detail = parsed.error || parsed.message || '';
|
|
772
|
+
} catch { /* use default message */ }
|
|
773
|
+
const errorMessage = detail ? `Snap API ${response.status}: ${detail}` : `Snap API ${response.status}`;
|
|
774
|
+
throw new SnapApiError(response.status, errorMessage, path);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// 5xx — retry with backoff (unless this is the last attempt)
|
|
778
|
+
if (attempt < MAX_RETRIES) {
|
|
779
|
+
const delay = Math.min(INITIAL_RETRY_DELAY_MS * (RETRY_MULTIPLIER ** (attempt - 1)), MAX_RETRY_TOTAL_MS);
|
|
780
|
+
await this.#sleepFn(delay);
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Exhausted retries
|
|
785
|
+
const errorBody = await response.text().catch(() => '');
|
|
786
|
+
let detail = '';
|
|
787
|
+
try {
|
|
788
|
+
const parsed = JSON.parse(errorBody);
|
|
789
|
+
detail = parsed.error || parsed.message || '';
|
|
790
|
+
} catch { /* use default message */ }
|
|
791
|
+
const errorMessage = detail ? `Snap API ${response.status}: ${detail}` : `Snap API ${response.status}`;
|
|
792
|
+
return this.#handleUnavailable(new SnapApiError(response.status, errorMessage, path));
|
|
793
|
+
|
|
794
|
+
} catch (error) {
|
|
795
|
+
if (error instanceof SnapApiError) {
|
|
796
|
+
throw error;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Network error — retry
|
|
800
|
+
if (attempt < MAX_RETRIES) {
|
|
801
|
+
const delay = Math.min(INITIAL_RETRY_DELAY_MS * (RETRY_MULTIPLIER ** (attempt - 1)), MAX_RETRY_TOTAL_MS);
|
|
802
|
+
await this.#sleepFn(delay);
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
return this.#handleUnavailable(new SnapUnavailableError(error instanceof Error ? error.message : String(error)));
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Unreachable, but satisfies the type checker
|
|
811
|
+
return this.#handleUnavailable(new SnapUnavailableError('Max retries exceeded'));
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Handle unavailable behavior based on onUnavailable config.
|
|
816
|
+
* @param {Error} error
|
|
817
|
+
* @returns {never | object}
|
|
818
|
+
*/
|
|
819
|
+
#handleUnavailable(error) {
|
|
820
|
+
const mode = this.#snapConfig.onUnavailable || 'fail';
|
|
821
|
+
|
|
822
|
+
if (mode === 'fail') {
|
|
823
|
+
throw error;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (mode === 'warn-and-skip') {
|
|
827
|
+
process.stderr.write(`[SnapDrift] Snap unavailable: ${error.message}. Skipping visual regression.\n`);
|
|
828
|
+
throw new SnapSkipError(error.message);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (mode === 'fallback-local') {
|
|
832
|
+
process.stderr.write(`[SnapDrift] Snap unavailable: ${error.message}. Falling back to local provider.\n`);
|
|
833
|
+
throw new SnapFallbackError(error.message);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
throw error;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Poll a run until it reaches a terminal state.
|
|
841
|
+
* @param {string} runId
|
|
842
|
+
* @returns {Promise<object>}
|
|
843
|
+
*/
|
|
844
|
+
async #pollRun(runId) {
|
|
845
|
+
const startTime = Date.now();
|
|
846
|
+
|
|
847
|
+
while (Date.now() - startTime < MAX_POLL_DURATION_MS) {
|
|
848
|
+
const run = await this.#request('GET', `/v1/visual/runs/${runId}`);
|
|
849
|
+
|
|
850
|
+
if (TERMINAL_RUN_STATUSES.has(run.status)) {
|
|
851
|
+
return run;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
await this.#sleepFn(POLL_INTERVAL_MS);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
throw new Error(`Snap run ${runId} did not complete within 10 minutes.`);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Map a Snap run result to a VisualDiffSummary.
|
|
862
|
+
* @param {object} run
|
|
863
|
+
* @param {VisualRegressionConfig} config
|
|
864
|
+
* @returns {import('../types/visual-diff-types').VisualDiffSummary}
|
|
865
|
+
*/
|
|
866
|
+
#mapRunToSummary(run, config) {
|
|
867
|
+
const captures = run.captures || [];
|
|
868
|
+
const totalScreenshots = captures.length;
|
|
869
|
+
let matchedScreenshots = 0;
|
|
870
|
+
let changedScreenshots = 0;
|
|
871
|
+
let missingInBaseline = 0;
|
|
872
|
+
let missingInCurrent = 0;
|
|
873
|
+
|
|
874
|
+
/** @type {import('../types/visual-diff-types').VisualDiffChangedItem[]} */
|
|
875
|
+
const changed = [];
|
|
876
|
+
/** @type {import('../types/visual-diff-types').VisualDiffMissingItem[]} */
|
|
877
|
+
const missing = [];
|
|
878
|
+
/** @type {import('../types/visual-diff-types').VisualDiffErrorItem[]} */
|
|
879
|
+
const errors = [];
|
|
880
|
+
|
|
881
|
+
for (const capture of captures) {
|
|
882
|
+
if (capture.status === 'error') {
|
|
883
|
+
errors.push({
|
|
884
|
+
id: capture.routeId || capture.id,
|
|
885
|
+
path: capture.routePath,
|
|
886
|
+
viewport: capture.viewport,
|
|
887
|
+
status: 'error',
|
|
888
|
+
message: capture.errorDetails?.message || capture.errorCode || 'Unknown error'
|
|
889
|
+
});
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
if (capture.status === 'new') {
|
|
894
|
+
// The backend rendered this capture but had no baseline to diff against
|
|
895
|
+
// (first run for the route). Surface it as a missing baseline rather than
|
|
896
|
+
// a silent match — there is nothing to compare yet.
|
|
897
|
+
missingInBaseline++;
|
|
898
|
+
missing.push({
|
|
899
|
+
id: capture.routeId || capture.id,
|
|
900
|
+
reason: 'no baseline capture found on Snap',
|
|
901
|
+
path: capture.routePath,
|
|
902
|
+
viewport: capture.viewport,
|
|
903
|
+
location: 'baseline'
|
|
904
|
+
});
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (capture.status === 'diffed') {
|
|
909
|
+
if (!capture.baselineObjectKey) {
|
|
910
|
+
// No baseline was attached to this capture: the backend short-circuits
|
|
911
|
+
// to "diffed" with no comparison data (no diffPct / diff image). Surface
|
|
912
|
+
// it as a missing baseline instead of silently counting it as a match.
|
|
913
|
+
missingInBaseline++;
|
|
914
|
+
missing.push({
|
|
915
|
+
id: capture.routeId || capture.id,
|
|
916
|
+
reason: 'no baseline capture found on Snap',
|
|
917
|
+
path: capture.routePath,
|
|
918
|
+
viewport: capture.viewport,
|
|
919
|
+
location: 'baseline'
|
|
920
|
+
});
|
|
921
|
+
} else if (capture.diffPct != null && capture.diffPct > config.diff.threshold) {
|
|
922
|
+
changedScreenshots++;
|
|
923
|
+
changed.push({
|
|
924
|
+
id: capture.routeId || capture.id,
|
|
925
|
+
path: capture.routePath || '',
|
|
926
|
+
viewport: capture.viewport,
|
|
927
|
+
baselineImagePath: capture.baselineObjectKey || '',
|
|
928
|
+
currentImagePath: capture.currentObjectKey || '',
|
|
929
|
+
width: capture.width || 0,
|
|
930
|
+
height: capture.height || 0,
|
|
931
|
+
differentPixels: capture.diffPixels || 0,
|
|
932
|
+
totalPixels: (capture.width || 0) * (capture.height || 0),
|
|
933
|
+
mismatchRatio: capture.diffPct,
|
|
934
|
+
status: 'changed'
|
|
935
|
+
});
|
|
936
|
+
} else {
|
|
937
|
+
matchedScreenshots++;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/** @type {import('../types/visual-diff-types').VisualDiffSummary} */
|
|
943
|
+
const summary = {
|
|
944
|
+
startedAt: run.startedAt || new Date().toISOString(),
|
|
945
|
+
finishedAt: run.finishedAt || new Date().toISOString(),
|
|
946
|
+
completed: true,
|
|
947
|
+
status: run.status === 'pass' ? 'clean' : run.status === 'fail' ? 'changes-detected' : 'incomplete',
|
|
948
|
+
selectedRoutes: captures.map((c) => c.routeId || c.id),
|
|
949
|
+
baselineManifestPath: '',
|
|
950
|
+
currentManifestPath: '',
|
|
951
|
+
diffMode: config.diff.mode,
|
|
952
|
+
threshold: config.diff.threshold,
|
|
953
|
+
baselineResultsPath: '',
|
|
954
|
+
currentResultsPath: '',
|
|
955
|
+
totalScreenshots,
|
|
956
|
+
matchedScreenshots,
|
|
957
|
+
changedScreenshots,
|
|
958
|
+
missingInBaseline,
|
|
959
|
+
missingInCurrent,
|
|
960
|
+
changed,
|
|
961
|
+
missing,
|
|
962
|
+
errors,
|
|
963
|
+
dimensionChanges: [],
|
|
964
|
+
dashboardUrl: `${this.#apiUrl}/dashboard/visual/${this.#projectId}/runs/${run.id}`
|
|
965
|
+
};
|
|
966
|
+
|
|
967
|
+
summary.status = determineDriftStatus(summary);
|
|
968
|
+
return summary;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Error thrown when the Snap API returns a non-retryable HTTP error.
|
|
974
|
+
*/
|
|
975
|
+
export class SnapApiError extends Error {
|
|
976
|
+
/** @type {number} */
|
|
977
|
+
status;
|
|
978
|
+
/** @type {string} */
|
|
979
|
+
path;
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* @param {number} status
|
|
983
|
+
* @param {string} message
|
|
984
|
+
* @param {string} path
|
|
985
|
+
*/
|
|
986
|
+
constructor(status, message, path) {
|
|
987
|
+
super(message);
|
|
988
|
+
this.name = 'SnapApiError';
|
|
989
|
+
this.status = status;
|
|
990
|
+
this.path = path;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Error thrown when the Snap API is unreachable after all retries.
|
|
996
|
+
*/
|
|
997
|
+
export class SnapUnavailableError extends Error {
|
|
998
|
+
/**
|
|
999
|
+
* @param {string} message
|
|
1000
|
+
*/
|
|
1001
|
+
constructor(message) {
|
|
1002
|
+
super(message);
|
|
1003
|
+
this.name = 'SnapUnavailableError';
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Error thrown when Snap is unavailable and onUnavailable is "fallback-local".
|
|
1009
|
+
* The caller should catch this and delegate to LocalProvider.
|
|
1010
|
+
*/
|
|
1011
|
+
export class SnapFallbackError extends Error {
|
|
1012
|
+
/**
|
|
1013
|
+
* @param {string} message
|
|
1014
|
+
*/
|
|
1015
|
+
constructor(message) {
|
|
1016
|
+
super(message);
|
|
1017
|
+
this.name = 'SnapFallbackError';
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* Error thrown when Snap is unavailable and onUnavailable is "warn-and-skip".
|
|
1023
|
+
* The caller should catch this and exit cleanly (skip visual regression).
|
|
1024
|
+
*/
|
|
1025
|
+
export class SnapSkipError extends Error {
|
|
1026
|
+
/**
|
|
1027
|
+
* @param {string} message
|
|
1028
|
+
*/
|
|
1029
|
+
constructor(message) {
|
|
1030
|
+
super(message);
|
|
1031
|
+
this.name = 'SnapSkipError';
|
|
1032
|
+
}
|
|
1033
|
+
}
|