snapdrift 0.4.0 → 0.5.1
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 +31 -0
- package/README.md +17 -7
- package/actions/baseline/action.yml +83 -13
- package/actions/comment/action.yml +9 -0
- package/actions/pr-diff/action.yml +94 -20
- package/actions/scope/action.yml +9 -0
- package/docs/contracts.md +126 -12
- 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 +2 -1
- package/lib/init-from-action.mjs +1 -1
- package/lib/provider.mjs +2 -2
- package/lib/snap-provider.mjs +364 -35
- package/package.json +5 -5
package/lib/snap-provider.mjs
CHANGED
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
4
|
import fs from 'node:fs/promises';
|
|
5
5
|
import fsSync from 'node:fs';
|
|
6
|
+
import net from 'node:net';
|
|
6
7
|
import os from 'node:os';
|
|
7
8
|
import path from 'node:path';
|
|
8
9
|
|
|
9
10
|
import { makeMarkdown, buildReportCommentBody } from '@snapdrift/adapter-report-md';
|
|
10
|
-
import { loadSnapdriftConfig } from '@snapdrift/adapter-fs';
|
|
11
|
-
import { selectConfiguredRoutes, splitCommaList, determineDriftStatus } from '@snapdrift/manifest';
|
|
11
|
+
import { loadSnapdriftConfig, runBaselineCapture } from '@snapdrift/adapter-fs';
|
|
12
|
+
import { selectConfiguredRoutes, splitCommaList, determineDriftStatus, VIEWPORT_PRESETS } from '@snapdrift/manifest';
|
|
12
13
|
|
|
13
14
|
/** @typedef {import('../types/visual-diff-types').VisualProvider} VisualProvider */
|
|
14
15
|
/** @typedef {import('../types/visual-diff-types').ProviderCaptureOptions} CaptureOptions */
|
|
@@ -30,7 +31,37 @@ const MAX_RETRY_TOTAL_MS = 30000;
|
|
|
30
31
|
const POLL_INTERVAL_MS = 2000;
|
|
31
32
|
const MAX_POLL_DURATION_MS = 10 * 60 * 1000;
|
|
32
33
|
|
|
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
|
+
}
|
|
34
65
|
|
|
35
66
|
/**
|
|
36
67
|
* @param {string} repoSlug
|
|
@@ -67,6 +98,8 @@ export class SnapProvider {
|
|
|
67
98
|
#fetchFn;
|
|
68
99
|
/** @type {(ms: number) => Promise<void>} */
|
|
69
100
|
#sleepFn;
|
|
101
|
+
/** @type {(options?: { configPath?: string, routeIds?: Iterable<string>, outDir?: string }) => Promise<CaptureResult>} */
|
|
102
|
+
#localCaptureFn;
|
|
70
103
|
/** @type {string} */
|
|
71
104
|
#apiKey;
|
|
72
105
|
/** @type {string} */
|
|
@@ -76,12 +109,17 @@ export class SnapProvider {
|
|
|
76
109
|
|
|
77
110
|
/**
|
|
78
111
|
* @param {SnapConfig} snapConfig — validated snap section from config
|
|
79
|
-
* @param {{
|
|
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]
|
|
80
117
|
*/
|
|
81
118
|
constructor(snapConfig, options = {}) {
|
|
82
119
|
this.#snapConfig = snapConfig;
|
|
83
120
|
this.#fetchFn = options.fetchFn ?? globalThis.fetch;
|
|
84
121
|
this.#sleepFn = options.sleepFn ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
122
|
+
this.#localCaptureFn = options.localCaptureFn ?? runBaselineCapture;
|
|
85
123
|
this.#apiUrl = (snapConfig.apiUrl || DEFAULT_API_URL).replace(/\/+$/, '');
|
|
86
124
|
this.#apiKey = this.#resolveApiKey(snapConfig);
|
|
87
125
|
this.#projectId = this.#resolveProjectId(snapConfig);
|
|
@@ -107,29 +145,67 @@ export class SnapProvider {
|
|
|
107
145
|
? [...options.routeIds]
|
|
108
146
|
: splitCommaList(process.env.SNAPDRIFT_ROUTE_IDS);
|
|
109
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
|
+
}
|
|
110
161
|
|
|
111
162
|
const idempotencyKey = this.#generateIdempotencyKey();
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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(),
|
|
121
195
|
}, idempotencyKey);
|
|
122
196
|
|
|
123
|
-
const runId = runResponse.id;
|
|
124
|
-
|
|
125
197
|
// Submit each route as a capture with its own idempotency key
|
|
126
198
|
for (const route of routes) {
|
|
127
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;
|
|
128
204
|
await this.#request('POST', `/v1/visual/runs/${runId}/captures`, {
|
|
129
|
-
|
|
205
|
+
id: captureId,
|
|
130
206
|
routeId: route.id,
|
|
131
207
|
routePath: route.path,
|
|
132
|
-
|
|
208
|
+
viewportDescriptorJson: JSON.stringify(viewportDescriptor),
|
|
133
209
|
}, captureKey);
|
|
134
210
|
}
|
|
135
211
|
|
|
@@ -199,21 +275,102 @@ export class SnapProvider {
|
|
|
199
275
|
/**
|
|
200
276
|
* Publish a baseline to Snap.
|
|
201
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
|
+
*
|
|
202
283
|
* @param {PublishBaselineOptions} options
|
|
203
284
|
* @returns {Promise<PublishBaselineResult>}
|
|
204
285
|
*/
|
|
205
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
|
|
206
364
|
const idempotencyKey = this.#generateIdempotencyKey();
|
|
207
365
|
const body = { idempotencyKey };
|
|
208
366
|
|
|
209
|
-
// If a bundle directory is provided, read files from it
|
|
210
367
|
if (options.bundleDir) {
|
|
211
368
|
const bundleDir = path.resolve(options.bundleDir);
|
|
212
|
-
const
|
|
369
|
+
const bResultsPath = options.resultsPath || path.join(bundleDir, 'results.json');
|
|
213
370
|
const manifestPath = options.manifestPath || path.join(bundleDir, 'manifest.json');
|
|
214
371
|
|
|
215
372
|
try {
|
|
216
|
-
const results = JSON.parse(await fs.readFile(
|
|
373
|
+
const results = JSON.parse(await fs.readFile(bResultsPath, 'utf-8'));
|
|
217
374
|
body.results = results;
|
|
218
375
|
} catch { /* results.json may not exist */ }
|
|
219
376
|
|
|
@@ -222,7 +379,6 @@ export class SnapProvider {
|
|
|
222
379
|
body.manifest = manifest;
|
|
223
380
|
} catch { /* manifest.json may not exist */ }
|
|
224
381
|
|
|
225
|
-
// If results contain a runId, use it for idempotency
|
|
226
382
|
if (body.results?.runId) {
|
|
227
383
|
body.idempotencyKey = `baseline-${body.results.runId}`;
|
|
228
384
|
}
|
|
@@ -439,6 +595,38 @@ export class SnapProvider {
|
|
|
439
595
|
};
|
|
440
596
|
}
|
|
441
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
|
+
|
|
442
630
|
/**
|
|
443
631
|
* @returns {string}
|
|
444
632
|
*/
|
|
@@ -446,6 +634,101 @@ export class SnapProvider {
|
|
|
446
634
|
return crypto.randomUUID();
|
|
447
635
|
}
|
|
448
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
|
+
|
|
449
732
|
/**
|
|
450
733
|
* Make an HTTP request to the Snap API with retry logic.
|
|
451
734
|
*
|
|
@@ -587,6 +870,11 @@ export class SnapProvider {
|
|
|
587
870
|
let changedScreenshots = 0;
|
|
588
871
|
let missingInBaseline = 0;
|
|
589
872
|
let missingInCurrent = 0;
|
|
873
|
+
// Track real comparisons (a baseline was attached) and how many came back
|
|
874
|
+
// pixel-identical (0% drift) so we can flag the "captured a stale/wrong
|
|
875
|
+
// page" pattern below. See issue #93.
|
|
876
|
+
let comparedWithBaseline = 0;
|
|
877
|
+
let exactZeroMatches = 0;
|
|
590
878
|
|
|
591
879
|
/** @type {import('../types/visual-diff-types').VisualDiffChangedItem[]} */
|
|
592
880
|
const changed = [];
|
|
@@ -607,8 +895,36 @@ export class SnapProvider {
|
|
|
607
895
|
continue;
|
|
608
896
|
}
|
|
609
897
|
|
|
898
|
+
if (capture.status === 'new') {
|
|
899
|
+
// The backend rendered this capture but had no baseline to diff against
|
|
900
|
+
// (first run for the route). Surface it as a missing baseline rather than
|
|
901
|
+
// a silent match — there is nothing to compare yet.
|
|
902
|
+
missingInBaseline++;
|
|
903
|
+
missing.push({
|
|
904
|
+
id: capture.routeId || capture.id,
|
|
905
|
+
reason: 'no baseline capture found on Snap',
|
|
906
|
+
path: capture.routePath,
|
|
907
|
+
viewport: capture.viewport,
|
|
908
|
+
location: 'baseline'
|
|
909
|
+
});
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
|
|
610
913
|
if (capture.status === 'diffed') {
|
|
611
|
-
if (capture.
|
|
914
|
+
if (!capture.baselineObjectKey) {
|
|
915
|
+
// No baseline was attached to this capture: the backend short-circuits
|
|
916
|
+
// to "diffed" with no comparison data (no diffPct / diff image). Surface
|
|
917
|
+
// it as a missing baseline instead of silently counting it as a match.
|
|
918
|
+
missingInBaseline++;
|
|
919
|
+
missing.push({
|
|
920
|
+
id: capture.routeId || capture.id,
|
|
921
|
+
reason: 'no baseline capture found on Snap',
|
|
922
|
+
path: capture.routePath,
|
|
923
|
+
viewport: capture.viewport,
|
|
924
|
+
location: 'baseline'
|
|
925
|
+
});
|
|
926
|
+
} else if (capture.diffPct != null && capture.diffPct > config.diff.threshold) {
|
|
927
|
+
comparedWithBaseline++;
|
|
612
928
|
changedScreenshots++;
|
|
613
929
|
changed.push({
|
|
614
930
|
id: capture.routeId || capture.id,
|
|
@@ -624,22 +940,35 @@ export class SnapProvider {
|
|
|
624
940
|
status: 'changed'
|
|
625
941
|
});
|
|
626
942
|
} else {
|
|
943
|
+
comparedWithBaseline++;
|
|
944
|
+
if (capture.diffPct === 0) {
|
|
945
|
+
exactZeroMatches++;
|
|
946
|
+
}
|
|
627
947
|
matchedScreenshots++;
|
|
628
948
|
}
|
|
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
949
|
}
|
|
641
950
|
}
|
|
642
951
|
|
|
952
|
+
// False-negative guard: when a diff run compared every route against a real
|
|
953
|
+
// baseline yet every single comparison came back pixel-identical (0% drift),
|
|
954
|
+
// the most likely explanation is that the client captured a stale or wrong
|
|
955
|
+
// page (baseUrl pointing at production instead of the PR preview, the
|
|
956
|
+
// preview not being deployed yet, or a caching layer) rather than a genuine
|
|
957
|
+
// "nothing changed". Warn so a real regression isn't silently missed. We
|
|
958
|
+
// only warn (never fail): a PR with no visual change legitimately produces
|
|
959
|
+
// this same shape. We require more than one compared route — a single
|
|
960
|
+
// pixel-identical route is indistinguishable from an ordinary clean diff,
|
|
961
|
+
// whereas several independent routes all landing at exactly 0% is the
|
|
962
|
+
// tell-tale signature of a single stale source page. See issue #93.
|
|
963
|
+
if (comparedWithBaseline > 1 && exactZeroMatches === comparedWithBaseline) {
|
|
964
|
+
process.stderr.write(
|
|
965
|
+
`[SnapDrift] Warning: all ${comparedWithBaseline} compared route(s) were pixel-identical ` +
|
|
966
|
+
`to the baseline (0% drift). If you expected a visual change, the captured page may be ` +
|
|
967
|
+
`stale or wrong — verify the resolved baseUrl points at the PR preview and that the ` +
|
|
968
|
+
`preview was deployed before capture ran. See issue #93.\n`
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
|
|
643
972
|
/** @type {import('../types/visual-diff-types').VisualDiffSummary} */
|
|
644
973
|
const summary = {
|
|
645
974
|
startedAt: run.startedAt || new Date().toISOString(),
|
|
@@ -662,7 +991,7 @@ export class SnapProvider {
|
|
|
662
991
|
missing,
|
|
663
992
|
errors,
|
|
664
993
|
dimensionChanges: [],
|
|
665
|
-
dashboardUrl: `${this.#apiUrl}/
|
|
994
|
+
dashboardUrl: `${this.#apiUrl}/dashboard/visual/${this.#projectId}/runs/${run.id}`
|
|
666
995
|
};
|
|
667
996
|
|
|
668
997
|
summary.status = determineDriftStatus(summary);
|
|
@@ -731,4 +1060,4 @@ export class SnapSkipError extends Error {
|
|
|
731
1060
|
super(message);
|
|
732
1061
|
this.name = 'SnapSkipError';
|
|
733
1062
|
}
|
|
734
|
-
}
|
|
1063
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "snapdrift",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "SnapDrift captures, compares, and reports UI drift for Playwright-based app checks.",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"@eslint/js": "^10.0.1",
|
|
42
42
|
"@types/jest": "^30.0.0",
|
|
43
43
|
"@types/js-yaml": "^4.0.9",
|
|
44
|
-
"@types/node": "^
|
|
44
|
+
"@types/node": "^26.1.0",
|
|
45
45
|
"@types/pngjs": "^6.0.5",
|
|
46
46
|
"eslint": "^10.2.0",
|
|
47
47
|
"globals": "^17.4.0",
|
|
@@ -95,11 +95,11 @@
|
|
|
95
95
|
"registry": "https://registry.npmjs.org"
|
|
96
96
|
},
|
|
97
97
|
"dependencies": {
|
|
98
|
-
"@snapdrift/adapter-fs": "^1.0.
|
|
98
|
+
"@snapdrift/adapter-fs": "^1.0.1",
|
|
99
99
|
"@snapdrift/adapter-report-md": "^1.1.0",
|
|
100
100
|
"@snapdrift/compare-core": "^1.0.0",
|
|
101
|
-
"@snapdrift/manifest": "^1.
|
|
102
|
-
"js-yaml": "^
|
|
101
|
+
"@snapdrift/manifest": "^1.2.0",
|
|
102
|
+
"js-yaml": "^5.2.1",
|
|
103
103
|
"playwright": "^1.59.1",
|
|
104
104
|
"pngjs": "^7.0.0"
|
|
105
105
|
}
|