snapdrift 0.2.1 → 0.4.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 +37 -1
- package/actions/comment/action.yml +22 -3
- package/actions/pr-diff/action.yml +16 -3
- package/docs/contracts.md +126 -1
- package/lib/capture-routes.mjs +2 -368
- package/lib/cli.mjs +167 -34
- package/lib/compare-results.mjs +21 -642
- package/lib/drift-summary.mjs +3 -154
- package/lib/init-from-action.mjs +244 -0
- package/lib/migrate-baselines.mjs +199 -0
- package/lib/pr-comment.mjs +2 -147
- package/lib/provider.mjs +106 -0
- package/lib/report.mjs +17 -246
- package/lib/snap-provider.mjs +734 -0
- package/lib/snapdrift-config.mjs +22 -284
- package/lib/stage-artifacts.mjs +2 -123
- package/package.json +16 -4
- package/types/visual-diff-types.d.ts +65 -0
|
@@ -0,0 +1,734 @@
|
|
|
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 os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
import { makeMarkdown, buildReportCommentBody } from '@snapdrift/adapter-report-md';
|
|
10
|
+
import { loadSnapdriftConfig } from '@snapdrift/adapter-fs';
|
|
11
|
+
import { selectConfiguredRoutes, splitCommaList, determineDriftStatus } from '@snapdrift/manifest';
|
|
12
|
+
|
|
13
|
+
/** @typedef {import('../types/visual-diff-types').VisualProvider} VisualProvider */
|
|
14
|
+
/** @typedef {import('../types/visual-diff-types').ProviderCaptureOptions} CaptureOptions */
|
|
15
|
+
/** @typedef {import('../types/visual-diff-types').ProviderCaptureResult} CaptureResult */
|
|
16
|
+
/** @typedef {import('../types/visual-diff-types').ProviderDiffOptions} DiffOptions */
|
|
17
|
+
/** @typedef {import('../types/visual-diff-types').ProviderDiffResult} DiffResult */
|
|
18
|
+
/** @typedef {import('../types/visual-diff-types').ProviderPublishBaselineOptions} PublishBaselineOptions */
|
|
19
|
+
/** @typedef {import('../types/visual-diff-types').ProviderPublishBaselineResult} PublishBaselineResult */
|
|
20
|
+
/** @typedef {import('../types/visual-diff-types').ProviderFetchBaselineOptions} FetchBaselineOptions */
|
|
21
|
+
/** @typedef {import('../types/visual-diff-types').ProviderBaselineData} BaselineData */
|
|
22
|
+
/** @typedef {import('../types/visual-diff-types').SnapConfig} SnapConfig */
|
|
23
|
+
/** @typedef {import('../types/visual-diff-types').VisualRegressionConfig} VisualRegressionConfig */
|
|
24
|
+
|
|
25
|
+
const DEFAULT_API_URL = 'https://snap.i2dev.com';
|
|
26
|
+
const MAX_RETRIES = 3;
|
|
27
|
+
const INITIAL_RETRY_DELAY_MS = 1000;
|
|
28
|
+
const RETRY_MULTIPLIER = 2;
|
|
29
|
+
const MAX_RETRY_TOTAL_MS = 30000;
|
|
30
|
+
const POLL_INTERVAL_MS = 2000;
|
|
31
|
+
const MAX_POLL_DURATION_MS = 10 * 60 * 1000;
|
|
32
|
+
|
|
33
|
+
const TERMINAL_RUN_STATUSES = new Set(['pass', 'fail', 'error']);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} repoSlug
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
function repoSlugToProjectId(repoSlug) {
|
|
40
|
+
return repoSlug.replace(/[/.]/g, '--');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read the package version from package.json.
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
function getPackageVersion() {
|
|
48
|
+
try {
|
|
49
|
+
const pkgUrl = new URL('../package.json', import.meta.url);
|
|
50
|
+
const pkg = JSON.parse(fsSync.readFileSync(pkgUrl, 'utf-8'));
|
|
51
|
+
return pkg.version || '0.0.0';
|
|
52
|
+
} catch {
|
|
53
|
+
return '0.0.0';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* SnapProvider — VisualProvider that delegates capture + diff to Snap's
|
|
59
|
+
* hosted /v1/visual/* API.
|
|
60
|
+
*
|
|
61
|
+
* @implements {VisualProvider}
|
|
62
|
+
*/
|
|
63
|
+
export class SnapProvider {
|
|
64
|
+
/** @type {SnapConfig} */
|
|
65
|
+
#snapConfig;
|
|
66
|
+
/** @type {typeof globalThis.fetch} */
|
|
67
|
+
#fetchFn;
|
|
68
|
+
/** @type {(ms: number) => Promise<void>} */
|
|
69
|
+
#sleepFn;
|
|
70
|
+
/** @type {string} */
|
|
71
|
+
#apiKey;
|
|
72
|
+
/** @type {string} */
|
|
73
|
+
#apiUrl;
|
|
74
|
+
/** @type {string} */
|
|
75
|
+
#projectId;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {SnapConfig} snapConfig — validated snap section from config
|
|
79
|
+
* @param {{ fetchFn?: typeof globalThis.fetch, sleepFn?: (ms: number) => Promise<void> }} [options]
|
|
80
|
+
*/
|
|
81
|
+
constructor(snapConfig, options = {}) {
|
|
82
|
+
this.#snapConfig = snapConfig;
|
|
83
|
+
this.#fetchFn = options.fetchFn ?? globalThis.fetch;
|
|
84
|
+
this.#sleepFn = options.sleepFn ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
85
|
+
this.#apiUrl = (snapConfig.apiUrl || DEFAULT_API_URL).replace(/\/+$/, '');
|
|
86
|
+
this.#apiKey = this.#resolveApiKey(snapConfig);
|
|
87
|
+
this.#projectId = this.#resolveProjectId(snapConfig);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// VisualProvider interface
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Submit routes to Snap for server-side rendering.
|
|
96
|
+
*
|
|
97
|
+
* Creates a run via POST /v1/visual/projects/:id/runs, then submits each
|
|
98
|
+
* route as a capture via POST /v1/visual/runs/:run_id/captures. Writes run
|
|
99
|
+
* metadata to a temp file so diff() can pick it up.
|
|
100
|
+
*
|
|
101
|
+
* @param {CaptureOptions} options
|
|
102
|
+
* @returns {Promise<CaptureResult>}
|
|
103
|
+
*/
|
|
104
|
+
async capture(options) {
|
|
105
|
+
const { config } = await loadSnapdriftConfig(options.configPath);
|
|
106
|
+
const requestedRouteIds = options.routeIds
|
|
107
|
+
? [...options.routeIds]
|
|
108
|
+
: splitCommaList(process.env.SNAPDRIFT_ROUTE_IDS);
|
|
109
|
+
const { routes, selectedRouteIds } = selectConfiguredRoutes(config, requestedRouteIds);
|
|
110
|
+
|
|
111
|
+
const idempotencyKey = this.#generateIdempotencyKey();
|
|
112
|
+
const runResponse = await this.#request('POST', `/v1/visual/projects/${this.#projectId}/runs`, {
|
|
113
|
+
idempotencyKey,
|
|
114
|
+
routes: routes.map((r) => ({
|
|
115
|
+
id: r.id,
|
|
116
|
+
path: r.path,
|
|
117
|
+
viewport: r.viewport,
|
|
118
|
+
changePaths: r.changePaths
|
|
119
|
+
})),
|
|
120
|
+
captureProfile: this.#buildCaptureProfile()
|
|
121
|
+
}, idempotencyKey);
|
|
122
|
+
|
|
123
|
+
const runId = runResponse.id;
|
|
124
|
+
|
|
125
|
+
// Submit each route as a capture with its own idempotency key
|
|
126
|
+
for (const route of routes) {
|
|
127
|
+
const captureKey = this.#generateIdempotencyKey();
|
|
128
|
+
await this.#request('POST', `/v1/visual/runs/${runId}/captures`, {
|
|
129
|
+
idempotencyKey: captureKey,
|
|
130
|
+
routeId: route.id,
|
|
131
|
+
routePath: route.path,
|
|
132
|
+
viewport: route.viewport
|
|
133
|
+
}, captureKey);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Write run metadata to a temp file so diff() can thread the run_id
|
|
137
|
+
const outDir = options.outDir
|
|
138
|
+
? path.resolve(options.outDir)
|
|
139
|
+
: await fs.mkdtemp(path.join(os.tmpdir(), 'snapdrift-snap-'));
|
|
140
|
+
|
|
141
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
142
|
+
|
|
143
|
+
const resultsPath = path.join(outDir, 'results.json');
|
|
144
|
+
const manifestPath = path.join(outDir, 'manifest.json');
|
|
145
|
+
|
|
146
|
+
const runMetadata = { runId, projectId: this.#projectId, startedAt: new Date().toISOString() };
|
|
147
|
+
await Promise.all([
|
|
148
|
+
fs.writeFile(resultsPath, JSON.stringify(runMetadata, null, 2)),
|
|
149
|
+
fs.writeFile(manifestPath, JSON.stringify({
|
|
150
|
+
generatedAt: new Date().toISOString(),
|
|
151
|
+
baseUrl: config.baseUrl,
|
|
152
|
+
screenshots: routes.map((r) => ({
|
|
153
|
+
id: r.id,
|
|
154
|
+
path: r.path,
|
|
155
|
+
viewport: r.viewport,
|
|
156
|
+
imagePath: `screenshots/${r.id}.png`,
|
|
157
|
+
width: 0,
|
|
158
|
+
height: 0
|
|
159
|
+
}))
|
|
160
|
+
}, null, 2))
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
resultsPath,
|
|
165
|
+
manifestPath,
|
|
166
|
+
screenshotsRoot: outDir,
|
|
167
|
+
selectedRouteIds
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Poll Snap for run completion and map the result to a drift summary.
|
|
173
|
+
*
|
|
174
|
+
* @param {DiffOptions} options
|
|
175
|
+
* @returns {Promise<DiffResult>}
|
|
176
|
+
*/
|
|
177
|
+
async diff(options) {
|
|
178
|
+
// Read run metadata from the capture step
|
|
179
|
+
const currentResultsPath = options.currentResultsPath;
|
|
180
|
+
if (!currentResultsPath) {
|
|
181
|
+
throw new Error('SnapProvider.diff requires currentResultsPath from a prior capture() call.');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const metadata = JSON.parse(await fs.readFile(currentResultsPath, 'utf-8'));
|
|
185
|
+
const runId = metadata.runId;
|
|
186
|
+
|
|
187
|
+
// Poll until the run reaches a terminal state
|
|
188
|
+
const run = await this.#pollRun(runId);
|
|
189
|
+
|
|
190
|
+
const { config } = await loadSnapdriftConfig(options.configPath);
|
|
191
|
+
const summary = this.#mapRunToSummary(run, config);
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
summary,
|
|
195
|
+
markdown: makeMarkdown(summary)
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Publish a baseline to Snap.
|
|
201
|
+
*
|
|
202
|
+
* @param {PublishBaselineOptions} options
|
|
203
|
+
* @returns {Promise<PublishBaselineResult>}
|
|
204
|
+
*/
|
|
205
|
+
async publishBaseline(options) {
|
|
206
|
+
const idempotencyKey = this.#generateIdempotencyKey();
|
|
207
|
+
const body = { idempotencyKey };
|
|
208
|
+
|
|
209
|
+
// If a bundle directory is provided, read files from it
|
|
210
|
+
if (options.bundleDir) {
|
|
211
|
+
const bundleDir = path.resolve(options.bundleDir);
|
|
212
|
+
const resultsPath = options.resultsPath || path.join(bundleDir, 'results.json');
|
|
213
|
+
const manifestPath = options.manifestPath || path.join(bundleDir, 'manifest.json');
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const results = JSON.parse(await fs.readFile(resultsPath, 'utf-8'));
|
|
217
|
+
body.results = results;
|
|
218
|
+
} catch { /* results.json may not exist */ }
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
|
|
222
|
+
body.manifest = manifest;
|
|
223
|
+
} catch { /* manifest.json may not exist */ }
|
|
224
|
+
|
|
225
|
+
// If results contain a runId, use it for idempotency
|
|
226
|
+
if (body.results?.runId) {
|
|
227
|
+
body.idempotencyKey = `baseline-${body.results.runId}`;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
await this.#request('POST', `/v1/visual/projects/${this.#projectId}/baselines`, body, idempotencyKey);
|
|
232
|
+
|
|
233
|
+
const bundleDir = options.bundleDir
|
|
234
|
+
? path.resolve(options.bundleDir)
|
|
235
|
+
: await fs.mkdtemp(path.join(os.tmpdir(), 'snapdrift-snap-baseline-'));
|
|
236
|
+
|
|
237
|
+
return { bundleDir };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Fetch the latest baseline from Snap.
|
|
242
|
+
*
|
|
243
|
+
* @param {FetchBaselineOptions} _options
|
|
244
|
+
* @returns {Promise<BaselineData | null>}
|
|
245
|
+
*/
|
|
246
|
+
async fetchLatestBaseline(_options) {
|
|
247
|
+
try {
|
|
248
|
+
const baseline = await this.#request('GET', `/v1/visual/projects/${this.#projectId}/baselines/latest`);
|
|
249
|
+
|
|
250
|
+
const runDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapdrift-snap-baseline-'));
|
|
251
|
+
const resultsPath = path.join(runDir, 'results.json');
|
|
252
|
+
const manifestPath = path.join(runDir, 'manifest.json');
|
|
253
|
+
|
|
254
|
+
if (baseline.results) {
|
|
255
|
+
await fs.writeFile(resultsPath, JSON.stringify(baseline.results, null, 2));
|
|
256
|
+
}
|
|
257
|
+
if (baseline.manifest) {
|
|
258
|
+
await fs.writeFile(manifestPath, JSON.stringify(baseline.manifest, null, 2));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
resultsPath,
|
|
263
|
+
manifestPath,
|
|
264
|
+
runDir,
|
|
265
|
+
screenshotsDir: path.join(runDir, 'screenshots'),
|
|
266
|
+
artifactName: baseline.artifactName || 'snap-baseline',
|
|
267
|
+
headSha: baseline.headSha || ''
|
|
268
|
+
};
|
|
269
|
+
} catch (error) {
|
|
270
|
+
if (error instanceof SnapApiError && error.status === 404) {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// PR comment
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Build a PR comment body from a diff summary.
|
|
283
|
+
* SnapProvider includes the dashboard URL from the summary when available.
|
|
284
|
+
*
|
|
285
|
+
* @param {Record<string, unknown>} summary
|
|
286
|
+
* @param {import('../types/visual-diff-types').ProviderCommentMeta} [meta]
|
|
287
|
+
* @returns {string}
|
|
288
|
+
*/
|
|
289
|
+
buildCommentBody(summary, meta = {}) {
|
|
290
|
+
const dashboardUrl = summary.dashboardUrl || meta.dashboardUrl;
|
|
291
|
+
return buildReportCommentBody(summary, { ...meta, dashboardUrl });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// Migration methods
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Check whether a baseline already exists for a given commit SHA.
|
|
300
|
+
*
|
|
301
|
+
* @param {string} headSha
|
|
302
|
+
* @returns {Promise<object | null>} Baseline data if found, null on 404
|
|
303
|
+
*/
|
|
304
|
+
async checkBaselineExists(headSha) {
|
|
305
|
+
try {
|
|
306
|
+
const baseline = await this.#request('GET', `/v1/visual/projects/${this.#projectId}/baselines/latest?sha=${encodeURIComponent(headSha)}`);
|
|
307
|
+
return baseline;
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (error instanceof SnapApiError && error.status === 404) {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Upload local baselines to Snap as the initial accepted baseline.
|
|
318
|
+
* Used by `snapdrift migrate-baselines --to snap`.
|
|
319
|
+
*
|
|
320
|
+
* Reads local baseline files and POSTs them to the Snap baselines API.
|
|
321
|
+
* Screenshots are base64-encoded and included in the request body.
|
|
322
|
+
* Idempotent: uses a commit-SHA-derived idempotency key.
|
|
323
|
+
*
|
|
324
|
+
* @param {{
|
|
325
|
+
* results: object,
|
|
326
|
+
* manifest: object,
|
|
327
|
+
* screenshots: Array<{ filename: string, data: string }>,
|
|
328
|
+
* headSha: string
|
|
329
|
+
* }} options
|
|
330
|
+
* @returns {Promise<{ uploaded: number, skipped: number, baselineId: string }>}
|
|
331
|
+
*/
|
|
332
|
+
async migrateBaselineFromLocal(options) {
|
|
333
|
+
const { results, manifest, screenshots, headSha } = options;
|
|
334
|
+
const idempotencyKey = `baseline-migrate-${headSha}`;
|
|
335
|
+
|
|
336
|
+
const body = {
|
|
337
|
+
idempotencyKey,
|
|
338
|
+
results,
|
|
339
|
+
manifest,
|
|
340
|
+
screenshots,
|
|
341
|
+
headSha,
|
|
342
|
+
captureProfile: this.#buildCaptureProfile()
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const response = await this.#request('POST', `/v1/visual/projects/${this.#projectId}/baselines`, body, idempotencyKey);
|
|
346
|
+
|
|
347
|
+
const screenshotCount = screenshots?.length ?? 0;
|
|
348
|
+
return {
|
|
349
|
+
uploaded: screenshotCount,
|
|
350
|
+
skipped: 0,
|
|
351
|
+
baselineId: response.id || idempotencyKey
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Export baselines from Snap for local import.
|
|
357
|
+
* Used by `snapdrift migrate-baselines --to local --from snap`.
|
|
358
|
+
*
|
|
359
|
+
* @param {{ tag?: string }} _options
|
|
360
|
+
* @returns {Promise<{
|
|
361
|
+
* results: object,
|
|
362
|
+
* manifest: object,
|
|
363
|
+
* screenshots: Array<{ filename: string, data: Buffer }>,
|
|
364
|
+
* engine: { name: string, version: string }
|
|
365
|
+
* }>}
|
|
366
|
+
* @throws {Error} The Snap export endpoint is not yet available.
|
|
367
|
+
*/
|
|
368
|
+
async exportBaselines(_options) {
|
|
369
|
+
throw new Error(
|
|
370
|
+
'The Snap export endpoint is not yet available. ' +
|
|
371
|
+
'This command will be functional once the Snap API adds GET /v1/visual/projects/:id/export. ' +
|
|
372
|
+
'See Snap repo issue #482 for tracking.'
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
// Private helpers
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Resolve API key from config.
|
|
382
|
+
* @param {SnapConfig} snapConfig
|
|
383
|
+
* @returns {string}
|
|
384
|
+
*/
|
|
385
|
+
#resolveApiKey(snapConfig) {
|
|
386
|
+
if (snapConfig.apiKeyEnv) {
|
|
387
|
+
const value = process.env[snapConfig.apiKeyEnv];
|
|
388
|
+
if (!value) {
|
|
389
|
+
throw new Error(`Snap API key not found in environment variable: ${snapConfig.apiKeyEnv}`);
|
|
390
|
+
}
|
|
391
|
+
return value;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (snapConfig.apiKey) {
|
|
395
|
+
return snapConfig.apiKey.replace(/\$\{(\w+)\}/g, (_, varName) => {
|
|
396
|
+
const value = process.env[varName];
|
|
397
|
+
if (!value) {
|
|
398
|
+
throw new Error(`Snap API key interpolation failed: environment variable ${varName} is not set.`);
|
|
399
|
+
}
|
|
400
|
+
return value;
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Should not reach here if config was validated, but defensive
|
|
405
|
+
throw new Error('Snap provider requires exactly one of snap.apiKeyEnv or snap.apiKey.');
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Resolve project ID from config.
|
|
410
|
+
* @param {SnapConfig} snapConfig
|
|
411
|
+
* @returns {string}
|
|
412
|
+
*/
|
|
413
|
+
#resolveProjectId(snapConfig) {
|
|
414
|
+
const projectId = snapConfig.projectId || 'auto';
|
|
415
|
+
if (projectId === 'auto') {
|
|
416
|
+
const repo = process.env.GITHUB_REPOSITORY;
|
|
417
|
+
if (!repo) {
|
|
418
|
+
throw new Error(
|
|
419
|
+
'Snap project ID is set to "auto" but GITHUB_REPOSITORY is not set. ' +
|
|
420
|
+
'Set the GITHUB_REPOSITORY environment variable or provide an explicit snap.projectId.'
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
return repoSlugToProjectId(repo);
|
|
424
|
+
}
|
|
425
|
+
return projectId;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Build a capture profile for the Snap API.
|
|
430
|
+
* @returns {object}
|
|
431
|
+
*/
|
|
432
|
+
#buildCaptureProfile() {
|
|
433
|
+
return {
|
|
434
|
+
schemaVersion: 1,
|
|
435
|
+
engine: {
|
|
436
|
+
name: 'snapdrift-local',
|
|
437
|
+
version: `v${getPackageVersion().split('.')[0]}`
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* @returns {string}
|
|
444
|
+
*/
|
|
445
|
+
#generateIdempotencyKey() {
|
|
446
|
+
return crypto.randomUUID();
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Make an HTTP request to the Snap API with retry logic.
|
|
451
|
+
*
|
|
452
|
+
* @param {string} method
|
|
453
|
+
* @param {string} path
|
|
454
|
+
* @param {object} [body]
|
|
455
|
+
* @param {string} [idempotencyKey]
|
|
456
|
+
* @returns {Promise<any>} Parsed JSON response
|
|
457
|
+
*/
|
|
458
|
+
async #request(method, path, body, idempotencyKey) {
|
|
459
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
460
|
+
try {
|
|
461
|
+
const headers = {
|
|
462
|
+
'Authorization': `Bearer ${this.#apiKey}`,
|
|
463
|
+
'Content-Type': 'application/json',
|
|
464
|
+
'Accept': 'application/json'
|
|
465
|
+
};
|
|
466
|
+
if (idempotencyKey && method === 'POST') {
|
|
467
|
+
headers['Idempotency-Key'] = idempotencyKey;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const url = `${this.#apiUrl}${path}`;
|
|
471
|
+
const response = await this.#fetchFn(url, {
|
|
472
|
+
method,
|
|
473
|
+
headers,
|
|
474
|
+
body: body ? JSON.stringify(body) : undefined
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
if (response.ok) {
|
|
478
|
+
const text = await response.text();
|
|
479
|
+
return text ? JSON.parse(text) : {};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// 4xx — never retry
|
|
483
|
+
if (response.status >= 400 && response.status < 500) {
|
|
484
|
+
const errorBody = await response.text().catch(() => '');
|
|
485
|
+
let detail = '';
|
|
486
|
+
try {
|
|
487
|
+
const parsed = JSON.parse(errorBody);
|
|
488
|
+
detail = parsed.error || parsed.message || '';
|
|
489
|
+
} catch { /* use default message */ }
|
|
490
|
+
const errorMessage = detail ? `Snap API ${response.status}: ${detail}` : `Snap API ${response.status}`;
|
|
491
|
+
throw new SnapApiError(response.status, errorMessage, path);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// 5xx — retry with backoff (unless this is the last attempt)
|
|
495
|
+
if (attempt < MAX_RETRIES) {
|
|
496
|
+
const delay = Math.min(INITIAL_RETRY_DELAY_MS * (RETRY_MULTIPLIER ** (attempt - 1)), MAX_RETRY_TOTAL_MS);
|
|
497
|
+
await this.#sleepFn(delay);
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Exhausted retries
|
|
502
|
+
const errorBody = await response.text().catch(() => '');
|
|
503
|
+
let detail = '';
|
|
504
|
+
try {
|
|
505
|
+
const parsed = JSON.parse(errorBody);
|
|
506
|
+
detail = parsed.error || parsed.message || '';
|
|
507
|
+
} catch { /* use default message */ }
|
|
508
|
+
const errorMessage = detail ? `Snap API ${response.status}: ${detail}` : `Snap API ${response.status}`;
|
|
509
|
+
return this.#handleUnavailable(new SnapApiError(response.status, errorMessage, path));
|
|
510
|
+
|
|
511
|
+
} catch (error) {
|
|
512
|
+
if (error instanceof SnapApiError) {
|
|
513
|
+
throw error;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Network error — retry
|
|
517
|
+
if (attempt < MAX_RETRIES) {
|
|
518
|
+
const delay = Math.min(INITIAL_RETRY_DELAY_MS * (RETRY_MULTIPLIER ** (attempt - 1)), MAX_RETRY_TOTAL_MS);
|
|
519
|
+
await this.#sleepFn(delay);
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return this.#handleUnavailable(new SnapUnavailableError(error instanceof Error ? error.message : String(error)));
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Unreachable, but satisfies the type checker
|
|
528
|
+
return this.#handleUnavailable(new SnapUnavailableError('Max retries exceeded'));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Handle unavailable behavior based on onUnavailable config.
|
|
533
|
+
* @param {Error} error
|
|
534
|
+
* @returns {never | object}
|
|
535
|
+
*/
|
|
536
|
+
#handleUnavailable(error) {
|
|
537
|
+
const mode = this.#snapConfig.onUnavailable || 'fail';
|
|
538
|
+
|
|
539
|
+
if (mode === 'fail') {
|
|
540
|
+
throw error;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (mode === 'warn-and-skip') {
|
|
544
|
+
process.stderr.write(`[SnapDrift] Snap unavailable: ${error.message}. Skipping visual regression.\n`);
|
|
545
|
+
throw new SnapSkipError(error.message);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (mode === 'fallback-local') {
|
|
549
|
+
process.stderr.write(`[SnapDrift] Snap unavailable: ${error.message}. Falling back to local provider.\n`);
|
|
550
|
+
throw new SnapFallbackError(error.message);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
throw error;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Poll a run until it reaches a terminal state.
|
|
558
|
+
* @param {string} runId
|
|
559
|
+
* @returns {Promise<object>}
|
|
560
|
+
*/
|
|
561
|
+
async #pollRun(runId) {
|
|
562
|
+
const startTime = Date.now();
|
|
563
|
+
|
|
564
|
+
while (Date.now() - startTime < MAX_POLL_DURATION_MS) {
|
|
565
|
+
const run = await this.#request('GET', `/v1/visual/runs/${runId}`);
|
|
566
|
+
|
|
567
|
+
if (TERMINAL_RUN_STATUSES.has(run.status)) {
|
|
568
|
+
return run;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
await this.#sleepFn(POLL_INTERVAL_MS);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
throw new Error(`Snap run ${runId} did not complete within 10 minutes.`);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Map a Snap run result to a VisualDiffSummary.
|
|
579
|
+
* @param {object} run
|
|
580
|
+
* @param {VisualRegressionConfig} config
|
|
581
|
+
* @returns {import('../types/visual-diff-types').VisualDiffSummary}
|
|
582
|
+
*/
|
|
583
|
+
#mapRunToSummary(run, config) {
|
|
584
|
+
const captures = run.captures || [];
|
|
585
|
+
const totalScreenshots = captures.length;
|
|
586
|
+
let matchedScreenshots = 0;
|
|
587
|
+
let changedScreenshots = 0;
|
|
588
|
+
let missingInBaseline = 0;
|
|
589
|
+
let missingInCurrent = 0;
|
|
590
|
+
|
|
591
|
+
/** @type {import('../types/visual-diff-types').VisualDiffChangedItem[]} */
|
|
592
|
+
const changed = [];
|
|
593
|
+
/** @type {import('../types/visual-diff-types').VisualDiffMissingItem[]} */
|
|
594
|
+
const missing = [];
|
|
595
|
+
/** @type {import('../types/visual-diff-types').VisualDiffErrorItem[]} */
|
|
596
|
+
const errors = [];
|
|
597
|
+
|
|
598
|
+
for (const capture of captures) {
|
|
599
|
+
if (capture.status === 'error') {
|
|
600
|
+
errors.push({
|
|
601
|
+
id: capture.routeId || capture.id,
|
|
602
|
+
path: capture.routePath,
|
|
603
|
+
viewport: capture.viewport,
|
|
604
|
+
status: 'error',
|
|
605
|
+
message: capture.errorDetails?.message || capture.errorCode || 'Unknown error'
|
|
606
|
+
});
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (capture.status === 'diffed') {
|
|
611
|
+
if (capture.diffPct !== null && capture.diffPct > config.diff.threshold) {
|
|
612
|
+
changedScreenshots++;
|
|
613
|
+
changed.push({
|
|
614
|
+
id: capture.routeId || capture.id,
|
|
615
|
+
path: capture.routePath || '',
|
|
616
|
+
viewport: capture.viewport,
|
|
617
|
+
baselineImagePath: capture.baselineObjectKey || '',
|
|
618
|
+
currentImagePath: capture.currentObjectKey || '',
|
|
619
|
+
width: capture.width || 0,
|
|
620
|
+
height: capture.height || 0,
|
|
621
|
+
differentPixels: capture.diffPixels || 0,
|
|
622
|
+
totalPixels: (capture.width || 0) * (capture.height || 0),
|
|
623
|
+
mismatchRatio: capture.diffPct,
|
|
624
|
+
status: 'changed'
|
|
625
|
+
});
|
|
626
|
+
} else {
|
|
627
|
+
matchedScreenshots++;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (capture.baselineObjectKey === null && capture.diffPct === null) {
|
|
631
|
+
missingInBaseline++;
|
|
632
|
+
missing.push({
|
|
633
|
+
id: capture.routeId || capture.id,
|
|
634
|
+
reason: 'no baseline capture found on Snap',
|
|
635
|
+
path: capture.routePath,
|
|
636
|
+
viewport: capture.viewport,
|
|
637
|
+
location: 'baseline'
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** @type {import('../types/visual-diff-types').VisualDiffSummary} */
|
|
644
|
+
const summary = {
|
|
645
|
+
startedAt: run.startedAt || new Date().toISOString(),
|
|
646
|
+
finishedAt: run.finishedAt || new Date().toISOString(),
|
|
647
|
+
completed: true,
|
|
648
|
+
status: run.status === 'pass' ? 'clean' : run.status === 'fail' ? 'changes-detected' : 'incomplete',
|
|
649
|
+
selectedRoutes: captures.map((c) => c.routeId || c.id),
|
|
650
|
+
baselineManifestPath: '',
|
|
651
|
+
currentManifestPath: '',
|
|
652
|
+
diffMode: config.diff.mode,
|
|
653
|
+
threshold: config.diff.threshold,
|
|
654
|
+
baselineResultsPath: '',
|
|
655
|
+
currentResultsPath: '',
|
|
656
|
+
totalScreenshots,
|
|
657
|
+
matchedScreenshots,
|
|
658
|
+
changedScreenshots,
|
|
659
|
+
missingInBaseline,
|
|
660
|
+
missingInCurrent,
|
|
661
|
+
changed,
|
|
662
|
+
missing,
|
|
663
|
+
errors,
|
|
664
|
+
dimensionChanges: [],
|
|
665
|
+
dashboardUrl: `${this.#apiUrl}/projects/${this.#projectId}/runs/${run.id}`
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
summary.status = determineDriftStatus(summary);
|
|
669
|
+
return summary;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Error thrown when the Snap API returns a non-retryable HTTP error.
|
|
675
|
+
*/
|
|
676
|
+
export class SnapApiError extends Error {
|
|
677
|
+
/** @type {number} */
|
|
678
|
+
status;
|
|
679
|
+
/** @type {string} */
|
|
680
|
+
path;
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* @param {number} status
|
|
684
|
+
* @param {string} message
|
|
685
|
+
* @param {string} path
|
|
686
|
+
*/
|
|
687
|
+
constructor(status, message, path) {
|
|
688
|
+
super(message);
|
|
689
|
+
this.name = 'SnapApiError';
|
|
690
|
+
this.status = status;
|
|
691
|
+
this.path = path;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Error thrown when the Snap API is unreachable after all retries.
|
|
697
|
+
*/
|
|
698
|
+
export class SnapUnavailableError extends Error {
|
|
699
|
+
/**
|
|
700
|
+
* @param {string} message
|
|
701
|
+
*/
|
|
702
|
+
constructor(message) {
|
|
703
|
+
super(message);
|
|
704
|
+
this.name = 'SnapUnavailableError';
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Error thrown when Snap is unavailable and onUnavailable is "fallback-local".
|
|
710
|
+
* The caller should catch this and delegate to LocalProvider.
|
|
711
|
+
*/
|
|
712
|
+
export class SnapFallbackError extends Error {
|
|
713
|
+
/**
|
|
714
|
+
* @param {string} message
|
|
715
|
+
*/
|
|
716
|
+
constructor(message) {
|
|
717
|
+
super(message);
|
|
718
|
+
this.name = 'SnapFallbackError';
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Error thrown when Snap is unavailable and onUnavailable is "warn-and-skip".
|
|
724
|
+
* The caller should catch this and exit cleanly (skip visual regression).
|
|
725
|
+
*/
|
|
726
|
+
export class SnapSkipError extends Error {
|
|
727
|
+
/**
|
|
728
|
+
* @param {string} message
|
|
729
|
+
*/
|
|
730
|
+
constructor(message) {
|
|
731
|
+
super(message);
|
|
732
|
+
this.name = 'SnapSkipError';
|
|
733
|
+
}
|
|
734
|
+
}
|