snapdrift 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -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/provider.mjs +2 -2
- package/lib/snap-provider.mjs +334 -35
- package/package.json +2 -2
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
|
*
|
|
@@ -607,8 +890,35 @@ export class SnapProvider {
|
|
|
607
890
|
continue;
|
|
608
891
|
}
|
|
609
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
|
+
|
|
610
908
|
if (capture.status === 'diffed') {
|
|
611
|
-
if (capture.
|
|
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) {
|
|
612
922
|
changedScreenshots++;
|
|
613
923
|
changed.push({
|
|
614
924
|
id: capture.routeId || capture.id,
|
|
@@ -626,17 +936,6 @@ export class SnapProvider {
|
|
|
626
936
|
} else {
|
|
627
937
|
matchedScreenshots++;
|
|
628
938
|
}
|
|
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
939
|
}
|
|
641
940
|
}
|
|
642
941
|
|
|
@@ -662,7 +961,7 @@ export class SnapProvider {
|
|
|
662
961
|
missing,
|
|
663
962
|
errors,
|
|
664
963
|
dimensionChanges: [],
|
|
665
|
-
dashboardUrl: `${this.#apiUrl}/
|
|
964
|
+
dashboardUrl: `${this.#apiUrl}/dashboard/visual/${this.#projectId}/runs/${run.id}`
|
|
666
965
|
};
|
|
667
966
|
|
|
668
967
|
summary.status = determineDriftStatus(summary);
|
|
@@ -731,4 +1030,4 @@ export class SnapSkipError extends Error {
|
|
|
731
1030
|
super(message);
|
|
732
1031
|
this.name = 'SnapSkipError';
|
|
733
1032
|
}
|
|
734
|
-
}
|
|
1033
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "snapdrift",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "SnapDrift captures, compares, and reports UI drift for Playwright-based app checks.",
|
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
"@snapdrift/adapter-fs": "^1.0.0",
|
|
99
99
|
"@snapdrift/adapter-report-md": "^1.1.0",
|
|
100
100
|
"@snapdrift/compare-core": "^1.0.0",
|
|
101
|
-
"@snapdrift/manifest": "^1.
|
|
101
|
+
"@snapdrift/manifest": "^1.2.0",
|
|
102
102
|
"js-yaml": "^4.1.0",
|
|
103
103
|
"playwright": "^1.59.1",
|
|
104
104
|
"pngjs": "^7.0.0"
|