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.
@@ -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
+ }