snapdrift 0.12.0 → 0.14.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.
@@ -73,10 +73,21 @@ function createBaselineLookupFallbackError(outageError, phase, lookupMessage) {
73
73
  * @returns {boolean}
74
74
  */
75
75
  export function hasLocalScreenshots(providerName, config) {
76
- if (providerName !== 'snap') {
77
- return true;
78
- }
79
- return isLocalBaseUrl(config?.baseUrl);
76
+ return describeCaptureArtifacts(providerName, undefined, config).localScreenshots;
77
+ }
78
+
79
+ /**
80
+ * Prefer provider-returned artifacts; infer capabilities only for legacy results.
81
+ *
82
+ * @param {string} providerName
83
+ * @param {Partial<CaptureResult>} [result]
84
+ * @param {VisualRegressionConfig} [config]
85
+ * @returns {{ localScreenshots: boolean, artifactsRoot?: string }}
86
+ */
87
+ export function describeCaptureArtifacts(providerName, result, config) {
88
+ if (result?.artifacts) return result.artifacts;
89
+ const localScreenshots = providerName !== 'snap' || isLocalBaseUrl(config?.baseUrl);
90
+ return { localScreenshots, artifactsRoot: localScreenshots ? result?.screenshotsRoot : undefined };
80
91
  }
81
92
 
82
93
  /**
@@ -97,7 +108,8 @@ export function hasLocalScreenshots(providerName, config) {
97
108
  * outcome: 'captured' | 'skipped',
98
109
  * providerName: string,
99
110
  * result?: CaptureResult,
100
- * localScreenshots: boolean
111
+ * localScreenshots: boolean,
112
+ * artifacts: ReturnType<typeof describeCaptureArtifacts>
101
113
  * }>}
102
114
  */
103
115
  export async function captureWithPolicy(options) {
@@ -108,16 +120,18 @@ export async function captureWithPolicy(options) {
108
120
 
109
121
  try {
110
122
  const result = await options.provider.capture(options.captureOptions);
123
+ const artifacts = describeCaptureArtifacts(providerName, result, options.config);
111
124
  return {
112
125
  outcome: 'captured',
113
126
  providerName,
114
127
  result,
115
- localScreenshots: hasLocalScreenshots(providerName, options.config)
128
+ artifacts,
129
+ localScreenshots: artifacts.localScreenshots
116
130
  };
117
131
  } catch (error) {
118
132
  if (error instanceof SnapSkipError) {
119
133
  onSkip(error);
120
- return { outcome: 'skipped', providerName, localScreenshots: false };
134
+ return { outcome: 'skipped', providerName, localScreenshots: false, artifacts: { localScreenshots: false } };
121
135
  }
122
136
  if (error instanceof SnapFallbackError) {
123
137
  onFallback(error);
@@ -127,7 +141,8 @@ export async function captureWithPolicy(options) {
127
141
  const result = await createLocalProvider().capture(options.captureOptions);
128
142
  // The rest of the pipeline must be told the *effective* provider, not the
129
143
  // configured one — otherwise it rebuilds SnapProvider around local output.
130
- return { outcome: 'captured', providerName: 'local', result, localScreenshots: true };
144
+ const artifacts = describeCaptureArtifacts('local', result, options.config);
145
+ return { outcome: 'captured', providerName: 'local', result, localScreenshots: artifacts.localScreenshots, artifacts };
131
146
  }
132
147
  throw error;
133
148
  }
@@ -147,12 +162,19 @@ export async function captureWithPolicy(options) {
147
162
  * does not, so we recapture locally first rather than diffing a manifest of
148
163
  * zero-dimension placeholders.
149
164
  *
165
+ * Capability precedence: legacy `localScreenshots` overrides `artifacts`, then
166
+ * `captureResult.artifacts`, then provider/config inference. Without any of those
167
+ * inputs (including config), preserve the legacy local-screenshots default.
168
+ *
150
169
  * @param {{
151
170
  * provider: VisualProvider,
152
171
  * providerName?: string,
153
172
  * diffOptions: DiffOptions,
173
+ * captureResult?: CaptureResult,
154
174
  * captureOptions?: CaptureOptions,
155
175
  * localScreenshots?: boolean,
176
+ * artifacts?: ReturnType<typeof describeCaptureArtifacts>,
177
+ * config?: VisualRegressionConfig,
156
178
  * baselineAvailable?: boolean,
157
179
  * createLocalProvider?: () => VisualProvider,
158
180
  * onSkip?: (error: SnapSkipError) => void,
@@ -166,11 +188,23 @@ export async function captureWithPolicy(options) {
166
188
  * outcome: 'diffed' | 'skipped' | 'baseline-unavailable',
167
189
  * providerName: string,
168
190
  * result?: DiffResult,
169
- * recapture?: CaptureResult
191
+ * recapture?: CaptureResult,
192
+ * localScreenshots: boolean,
193
+ * artifacts: ReturnType<typeof describeCaptureArtifacts>
170
194
  * }>}
171
195
  */
172
196
  export async function diffWithPolicy(options) {
173
197
  const providerName = options.providerName || 'local';
198
+ /** @type {Partial<CaptureResult>} */
199
+ const captureResult = options.captureResult || { screenshotsRoot: options.diffOptions.currentRunDir };
200
+ const described = options.artifacts || (captureResult.artifacts || options.config
201
+ ? describeCaptureArtifacts(providerName, captureResult, options.config)
202
+ : { localScreenshots: true, artifactsRoot: captureResult.screenshotsRoot });
203
+ let artifacts = {
204
+ ...described,
205
+ localScreenshots: options.localScreenshots ?? described.localScreenshots
206
+ };
207
+ if (!artifacts.localScreenshots) artifacts.artifactsRoot = undefined;
174
208
  const createLocalProvider = options.createLocalProvider || (() => createProvider('local'));
175
209
  const onSkip = options.onSkip || noop;
176
210
  const onFallback = options.onFallback || noop;
@@ -179,11 +213,11 @@ export async function diffWithPolicy(options) {
179
213
 
180
214
  try {
181
215
  const result = await options.provider.diff(options.diffOptions);
182
- return { outcome: 'diffed', providerName, result };
216
+ return { outcome: 'diffed', providerName, result, artifacts, localScreenshots: artifacts.localScreenshots };
183
217
  } catch (error) {
184
218
  if (error instanceof SnapSkipError) {
185
219
  onSkip(error);
186
- return { outcome: 'skipped', providerName };
220
+ return { outcome: 'skipped', providerName, artifacts, localScreenshots: artifacts.localScreenshots };
187
221
  }
188
222
  if (!(error instanceof SnapFallbackError)) {
189
223
  throw error;
@@ -199,7 +233,7 @@ export async function diffWithPolicy(options) {
199
233
  if (options.baselineResolutionStatus === 'error') {
200
234
  throw createBaselineLookupFallbackError(error, 'diff', options.baselineResolutionMessage);
201
235
  }
202
- return { outcome: 'baseline-unavailable', providerName: 'local' };
236
+ return { outcome: 'baseline-unavailable', providerName: 'local', artifacts, localScreenshots: artifacts.localScreenshots };
203
237
  }
204
238
 
205
239
  if (options.baselineResolutionStatus === 'error') {
@@ -211,7 +245,7 @@ export async function diffWithPolicy(options) {
211
245
  /** @type {CaptureResult | undefined} */
212
246
  let recapture;
213
247
 
214
- if (options.localScreenshots === false) {
248
+ if (!artifacts.localScreenshots) {
215
249
  if (!options.captureOptions) {
216
250
  throw new Error(
217
251
  'Cannot fall back to a local diff: the current capture has no screenshots on disk ' +
@@ -221,6 +255,7 @@ export async function diffWithPolicy(options) {
221
255
  }
222
256
  onRecapture();
223
257
  recapture = await localProvider.capture(options.captureOptions);
258
+ artifacts = describeCaptureArtifacts('local', recapture, options.config);
224
259
  diffOptions = {
225
260
  ...options.diffOptions,
226
261
  currentResultsPath: recapture.resultsPath,
@@ -230,7 +265,7 @@ export async function diffWithPolicy(options) {
230
265
  }
231
266
 
232
267
  const result = await localProvider.diff(diffOptions);
233
- return { outcome: 'diffed', providerName: 'local', result, recapture };
268
+ return { outcome: 'diffed', providerName: 'local', result, recapture, artifacts, localScreenshots: artifacts.localScreenshots };
234
269
  }
235
270
  }
236
271
 
package/lib/provider.mjs CHANGED
@@ -35,7 +35,8 @@ export class LocalProvider {
35
35
  * @returns {Promise<CaptureResult>}
36
36
  */
37
37
  async capture(options) {
38
- return runBaselineCapture(options);
38
+ const result = await runBaselineCapture(options);
39
+ return { ...result, artifacts: { localScreenshots: true, artifactsRoot: result.screenshotsRoot } };
39
40
  }
40
41
 
41
42
  /**
@@ -58,6 +59,7 @@ export class LocalProvider {
58
59
  /**
59
60
  * @param {FetchBaselineOptions} _options
60
61
  * @returns {Promise<never>}
62
+ * Guard with providerSupports(providerName, 'fetchLatestBaseline') before calling.
61
63
  * @throws {Error} Not yet implemented for LocalProvider.
62
64
  * Use the pr-diff action for baseline resolution, or wait for a future release.
63
65
  */
@@ -80,6 +82,22 @@ export class LocalProvider {
80
82
  }
81
83
  }
82
84
 
85
+ export const PROVIDER_CAPABILITIES = Object.freeze({
86
+ local: Object.freeze({ capture: true, diff: true, publishBaseline: true, fetchLatestBaseline: false, buildCommentBody: true }),
87
+ snap: Object.freeze({ capture: true, diff: true, publishBaseline: true, fetchLatestBaseline: true, buildCommentBody: true })
88
+ });
89
+
90
+ /**
91
+ * @param {string} providerName
92
+ * @param {string} capability
93
+ * @returns {boolean}
94
+ */
95
+ export function providerSupports(providerName, capability) {
96
+ return Object.hasOwn(PROVIDER_CAPABILITIES, providerName) &&
97
+ Object.hasOwn(PROVIDER_CAPABILITIES[providerName], capability) &&
98
+ PROVIDER_CAPABILITIES[providerName][capability] === true;
99
+ }
100
+
83
101
  /** @type {{ local: (config?: VisualRegressionConfig) => LocalProvider, snap: (config: VisualRegressionConfig) => SnapProvider }} */
84
102
  const PROVIDER_FACTORIES = {
85
103
  local: () => new LocalProvider(),
@@ -10,6 +10,7 @@ import path from 'node:path';
10
10
 
11
11
  import { makeMarkdown, buildReportCommentBody } from '@snapdrift/adapter-report-md';
12
12
  import { loadSnapdriftConfig, runBaselineCapture } from '@snapdrift/adapter-fs';
13
+ import { COMPARISON_FALLBACK_REASONS, COMPARISON_ROW_KINDS } from '@snapdrift/compare-core';
13
14
  import {
14
15
  selectConfiguredRoutes,
15
16
  splitCommaList,
@@ -148,7 +149,7 @@ function hostedDiffError(detail) {
148
149
  function isComparisonPolicy(value) {
149
150
  if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
150
151
  const policy = /** @type {Record<string, any>} */ (value);
151
- return policy.version === 1 && typeof policy.threshold === 'number' && Number.isFinite(policy.threshold) && policy.threshold >= 0 && policy.threshold <= 1;
152
+ return (policy.version === 1 || policy.version === 2) && typeof policy.threshold === 'number' && Number.isFinite(policy.threshold) && policy.threshold >= 0 && policy.threshold <= 1;
152
153
  }
153
154
 
154
155
  /** @param {unknown} value @returns {value is ComparisonMetadata} */
@@ -170,15 +171,66 @@ function isComparisonMetadata(value) {
170
171
  return false;
171
172
  }
172
173
  const [baseline, current, canvas] = dimensions;
173
- return (
174
+ const baseMetadataValid = (
174
175
  typeof comparison.dimensionsChanged === 'boolean' &&
175
176
  comparison.dimensionsChanged === (baseline.width !== current.width || baseline.height !== current.height) &&
176
- canvas.width === Math.max(baseline.width, current.width) &&
177
- canvas.height === Math.max(baseline.height, current.height) &&
178
177
  Number.isInteger(comparison.totalPixels) &&
179
- comparison.totalPixels >= 0 &&
180
- comparison.totalPixels <= canvas.width * canvas.height
178
+ comparison.totalPixels >= 0
181
179
  );
180
+ if (!baseMetadataValid) return false;
181
+ if (comparison.policyVersion !== undefined && comparison.policyVersion !== 2) return false;
182
+
183
+ if (comparison.policyVersion !== 2) {
184
+ if (comparison.mode !== undefined || comparison.rowMapping !== undefined || comparison.fallbackReason !== undefined) {
185
+ return false;
186
+ }
187
+ return (
188
+ canvas.width === Math.max(baseline.width, current.width) &&
189
+ canvas.height === Math.max(baseline.height, current.height) &&
190
+ comparison.totalPixels <= canvas.width * canvas.height
191
+ );
192
+ }
193
+
194
+ if (comparison.mode === 'coordinate-fallback') {
195
+ return (
196
+ comparison.rowMapping === undefined &&
197
+ COMPARISON_FALLBACK_REASONS.includes(comparison.fallbackReason) &&
198
+ canvas.width === Math.max(baseline.width, current.width) &&
199
+ canvas.height === Math.max(baseline.height, current.height) &&
200
+ comparison.totalPixels <= canvas.width * canvas.height
201
+ );
202
+ }
203
+
204
+ if (comparison.mode !== 'vertical-aligned' || comparison.fallbackReason !== undefined || !Array.isArray(comparison.rowMapping) || baseline.width !== current.width || canvas.width !== baseline.width) {
205
+ return false;
206
+ }
207
+ let outputStart = 0;
208
+ let baselineStart = 0;
209
+ let currentStart = 0;
210
+ for (const segment of comparison.rowMapping) {
211
+ if (!segment || !COMPARISON_ROW_KINDS.includes(segment.kind) || !Number.isInteger(segment.outputStart) || segment.outputStart !== outputStart || !Number.isInteger(segment.length) || segment.length <= 0) {
212
+ return false;
213
+ }
214
+ const hasBaseline = segment.kind === 'matched' || segment.kind === 'changed' || segment.kind === 'deleted';
215
+ const hasCurrent = segment.kind === 'matched' || segment.kind === 'changed' || segment.kind === 'inserted';
216
+ if (hasBaseline !== (Number.isInteger(segment.baselineStart)) || hasCurrent !== (Number.isInteger(segment.currentStart))) {
217
+ return false;
218
+ }
219
+ if (
220
+ (hasBaseline && segment.baselineStart !== baselineStart) ||
221
+ (!hasBaseline && segment.baselineStart !== undefined) ||
222
+ (hasCurrent && segment.currentStart !== currentStart) ||
223
+ (!hasCurrent && segment.currentStart !== undefined)
224
+ ) {
225
+ return false;
226
+ }
227
+ if (hasBaseline && (segment.baselineStart < 0 || segment.baselineStart + segment.length > baseline.height)) return false;
228
+ if (hasCurrent && (segment.currentStart < 0 || segment.currentStart + segment.length > current.height)) return false;
229
+ outputStart += segment.length;
230
+ if (hasBaseline) baselineStart += segment.length;
231
+ if (hasCurrent) currentStart += segment.length;
232
+ }
233
+ return canvas.height === outputStart && baselineStart === baseline.height && currentStart === current.height && canvas.height >= Math.max(baseline.height, current.height) && canvas.height <= baseline.height + current.height && comparison.totalPixels === canvas.width * canvas.height;
182
234
  }
183
235
 
184
236
  /**
@@ -221,12 +273,17 @@ function comparisonFromCapture(capture) {
221
273
  return isComparisonMetadata(candidate) ? candidate : undefined;
222
274
  }
223
275
 
276
+ /** @param {ComparisonMetadata} comparison @param {ComparisonPolicy} expected */
277
+ function comparisonMatchesPolicy(comparison, expected) {
278
+ return expected.version === 2 ? comparison.policyVersion === 2 : comparison.policyVersion === undefined;
279
+ }
280
+
224
281
  /** @param {unknown} actual @param {ComparisonPolicy} expected @param {string} context */
225
282
  function assertComparisonPolicyAcknowledged(actual, expected, context) {
226
- if (!isComparisonPolicy(actual) || actual.threshold !== expected.threshold) {
283
+ if (!isComparisonPolicy(actual) || actual.version !== expected.version || actual.threshold !== expected.threshold) {
227
284
  throw new SnapUpgradeRequiredError(
228
- `${context} did not acknowledge comparisonPolicy v1 with threshold ${expected.threshold}. ` +
229
- 'Upgrade the Snap server before enabling unequal-dimension comparisons.'
285
+ `${context} did not acknowledge comparisonPolicy v${expected.version} with threshold ${expected.threshold}. ` +
286
+ 'Upgrade the Snap server before enabling this comparison policy.'
230
287
  );
231
288
  }
232
289
  }
@@ -948,6 +1005,7 @@ export class SnapProvider {
948
1005
  resultsPath,
949
1006
  manifestPath,
950
1007
  screenshotsRoot: outDir,
1008
+ artifacts: { localScreenshots: false, artifactsRoot: undefined },
951
1009
  selectedRouteIds
952
1010
  };
953
1011
  }
@@ -1963,7 +2021,7 @@ export class SnapProvider {
1963
2021
  };
1964
2022
  await fs.writeFile(localCapture.resultsPath, JSON.stringify(snapResults, null, 2));
1965
2023
 
1966
- return localCapture;
2024
+ return { ...localCapture, artifacts: { localScreenshots: true, artifactsRoot: localCapture.screenshotsRoot } };
1967
2025
  }
1968
2026
 
1969
2027
  /**
@@ -2246,7 +2304,18 @@ export class SnapProvider {
2246
2304
  expected.routePath,
2247
2305
  expected.viewport,
2248
2306
  `Snap capture "${expected.routeId}" is missing complete comparison metadata; ` +
2249
- 'upgrade the Snap server and diff Lambda before enabling comparisonPolicy v1.'
2307
+ `upgrade the Snap server and diff Lambda before enabling comparisonPolicy v${comparisonPolicy.version}.`
2308
+ );
2309
+ continue;
2310
+ }
2311
+
2312
+ if (comparisonPolicy && comparison && !comparisonMatchesPolicy(comparison, comparisonPolicy)) {
2313
+ addError(
2314
+ expected.routeId,
2315
+ expected.routePath,
2316
+ expected.viewport,
2317
+ `Snap capture "${expected.routeId}" returned comparison metadata for a different policy than v${comparisonPolicy.version}; ` +
2318
+ 'upgrade the Snap server and diff Lambda before enabling this comparison policy.'
2250
2319
  );
2251
2320
  continue;
2252
2321
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "snapdrift",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "SnapDrift captures, compares, and reports UI drift for Playwright-based app checks.",
@@ -81,6 +81,7 @@
81
81
  "./lib/cli.mjs": "./lib/cli.mjs",
82
82
  "./lib/provider.mjs": "./lib/provider.mjs",
83
83
  "./lib/outage-policy.mjs": "./lib/outage-policy.mjs",
84
+ "./lib/github-requests.mjs": "./lib/github-requests.mjs",
84
85
  "./lib/snap-provider.mjs": "./lib/snap-provider.mjs",
85
86
  "./lib/migrate-baselines.mjs": "./lib/migrate-baselines.mjs",
86
87
  "./lib/init-from-action.mjs": "./lib/init-from-action.mjs",
@@ -102,10 +103,10 @@
102
103
  "registry": "https://registry.npmjs.org"
103
104
  },
104
105
  "dependencies": {
105
- "@snapdrift/adapter-fs": "^1.3.0",
106
- "@snapdrift/adapter-report-md": "^1.2.1",
107
- "@snapdrift/compare-core": "^1.3.0",
108
- "@snapdrift/manifest": "^1.4.0",
106
+ "@snapdrift/adapter-fs": "^1.5.0",
107
+ "@snapdrift/adapter-report-md": "^1.4.0",
108
+ "@snapdrift/compare-core": "^1.5.0",
109
+ "@snapdrift/manifest": "^1.6.0",
109
110
  "js-yaml": "^5.4.0",
110
111
  "playwright": "^1.59.1",
111
112
  "pngjs": "^7.0.0"
@@ -15,11 +15,19 @@ export interface VisualRegressionSelectionConfig {
15
15
  sharedExact?: string[];
16
16
  }
17
17
 
18
- export interface ComparisonPolicy {
18
+ /** @deprecated Coordinate-based comparison policy. Retained for existing configurations; use `ComparisonPolicyV2` for new configurations. */
19
+ export interface ComparisonPolicyV1 {
19
20
  version: 1;
20
21
  threshold: number;
21
22
  }
22
23
 
24
+ export interface ComparisonPolicyV2 {
25
+ version: 2;
26
+ threshold: number;
27
+ }
28
+
29
+ export type ComparisonPolicy = ComparisonPolicyV1 | ComparisonPolicyV2;
30
+
23
31
  export interface ComparisonDimensions {
24
32
  width: number;
25
33
  height: number;
@@ -31,6 +39,22 @@ export interface ComparisonMetadata {
31
39
  canvas: ComparisonDimensions;
32
40
  dimensionsChanged: boolean;
33
41
  totalPixels: number;
42
+ policyVersion?: 2;
43
+ mode?: 'vertical-aligned' | 'coordinate-fallback';
44
+ rowMapping?: ComparisonRowMapping[];
45
+ fallbackReason?: ComparisonFallbackReason;
46
+ }
47
+
48
+ export type ComparisonFallbackReason = 'width-mismatch' | 'alignment-limit' | 'ambiguous' | 'verification-failed' | 'ignore-regions';
49
+ export const COMPARISON_FALLBACK_REASONS: readonly ['width-mismatch', 'alignment-limit', 'ambiguous', 'verification-failed', 'ignore-regions'];
50
+ export type ComparisonRowKind = 'matched' | 'changed' | 'inserted' | 'deleted';
51
+ export const COMPARISON_ROW_KINDS: readonly ['matched', 'changed', 'inserted', 'deleted'];
52
+ export interface ComparisonRowMapping {
53
+ outputStart: number;
54
+ length: number;
55
+ kind: ComparisonRowKind;
56
+ baselineStart?: number;
57
+ currentStart?: number;
34
58
  }
35
59
 
36
60
  export interface VisualRegressionRouteConfig {
@@ -52,6 +76,7 @@ export interface VisualRegressionConfig {
52
76
  diff: {
53
77
  threshold: number;
54
78
  mode: 'report-only' | 'fail-on-changes' | 'fail-on-incomplete' | 'strict';
79
+ /** Deprecated v1 remains the compatibility default; new configurations should use v2. */
55
80
  comparisonPolicy?: ComparisonPolicy;
56
81
  };
57
82
  selection?: VisualRegressionSelectionConfig;
@@ -110,7 +135,7 @@ export interface SnapRunMetadata {
110
135
  runId: string;
111
136
  projectId: string;
112
137
  purpose: 'baseline' | 'capture' | 'diff';
113
- /** Exact comparison policy acknowledged by Snap for updated clients. */
138
+ /** Exact comparison policy acknowledged by Snap; v1 is deprecated for compatibility. */
114
139
  comparisonPolicy?: ComparisonPolicy;
115
140
  /** CI source branch for hosted baseline runs; omitted for ordinary diff runs. */
116
141
  refBranch?: string;
@@ -192,7 +217,9 @@ export interface VisualDiffSummary {
192
217
  changed: VisualDiffChangedItem[];
193
218
  missing: VisualDiffMissingItem[];
194
219
  errors: VisualDiffErrorItem[];
220
+ captureCompatibility?: import('@snapdrift/manifest').CaptureCompatibility;
195
221
  dimensionChanges: VisualDiffDimensionItem[];
222
+ /** The exact policy used for the comparison; v1 is deprecated and retained for compatibility. */
196
223
  comparisonPolicy?: ComparisonPolicy;
197
224
  message?: string;
198
225
  /** Link to the provider's run detail page. Set by SnapProvider during diff(); undefined for LocalProvider. */