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.
@@ -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
- const TERMINAL_RUN_STATUSES = new Set(['pass', 'fail', 'error']);
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 {{ fetchFn?: typeof globalThis.fetch, sleepFn?: (ms: number) => Promise<void> }} [options]
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 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()
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
- idempotencyKey: captureKey,
205
+ id: captureId,
130
206
  routeId: route.id,
131
207
  routePath: route.path,
132
- viewport: route.viewport
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 resultsPath = options.resultsPath || path.join(bundleDir, 'results.json');
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(resultsPath, 'utf-8'));
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.diffPct !== null && capture.diffPct > config.diff.threshold) {
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}/projects/${this.#projectId}/runs/${run.id}`
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.4.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": "^25.3.5",
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.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.1.0",
102
- "js-yaml": "^4.1.0",
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
  }