snapdrift 0.11.0 → 0.13.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(),
@@ -46,7 +46,6 @@ const BINARY_REQUEST_TIMEOUT_MS = 120 * 1000;
46
46
  const POLL_INTERVAL_MS = 2000;
47
47
  const MAX_OPERATION_DURATION_MS = 10 * 60 * 1000;
48
48
  const MAX_STABLE_INCOMPLETE_POLLS = 30;
49
- const MAX_COMPARISON_PIXELS = 32 * 1024 * 1024;
50
49
  const BASELINE_RUN_SUCCESS_STATUS = 'new';
51
50
  const BASELINE_CAPTURE_SUCCESS_STATUS = 'new';
52
51
  const TERMINAL_CAPTURE_STATUSES = new Set(['new', 'diffed', 'error']);
@@ -178,8 +177,7 @@ function isComparisonMetadata(value) {
178
177
  canvas.height === Math.max(baseline.height, current.height) &&
179
178
  Number.isInteger(comparison.totalPixels) &&
180
179
  comparison.totalPixels >= 0 &&
181
- comparison.totalPixels <= canvas.width * canvas.height &&
182
- canvas.width * canvas.height <= MAX_COMPARISON_PIXELS
180
+ comparison.totalPixels <= canvas.width * canvas.height
183
181
  );
184
182
  }
185
183
 
@@ -950,6 +948,7 @@ export class SnapProvider {
950
948
  resultsPath,
951
949
  manifestPath,
952
950
  screenshotsRoot: outDir,
951
+ artifacts: { localScreenshots: false, artifactsRoot: undefined },
953
952
  selectedRouteIds
954
953
  };
955
954
  }
@@ -1965,7 +1964,7 @@ export class SnapProvider {
1965
1964
  };
1966
1965
  await fs.writeFile(localCapture.resultsPath, JSON.stringify(snapResults, null, 2));
1967
1966
 
1968
- return localCapture;
1967
+ return { ...localCapture, artifacts: { localScreenshots: true, artifactsRoot: localCapture.screenshotsRoot } };
1969
1968
  }
1970
1969
 
1971
1970
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "snapdrift",
3
- "version": "0.11.0",
3
+ "version": "0.13.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.0",
107
- "@snapdrift/compare-core": "^1.2.0",
108
- "@snapdrift/manifest": "^1.4.0",
106
+ "@snapdrift/adapter-fs": "^1.4.0",
107
+ "@snapdrift/adapter-report-md": "^1.3.0",
108
+ "@snapdrift/compare-core": "^1.4.0",
109
+ "@snapdrift/manifest": "^1.5.0",
109
110
  "js-yaml": "^5.4.0",
110
111
  "playwright": "^1.59.1",
111
112
  "pngjs": "^7.0.0"
@@ -192,6 +192,7 @@ export interface VisualDiffSummary {
192
192
  changed: VisualDiffChangedItem[];
193
193
  missing: VisualDiffMissingItem[];
194
194
  errors: VisualDiffErrorItem[];
195
+ captureCompatibility?: import('@snapdrift/manifest').CaptureCompatibility;
195
196
  dimensionChanges: VisualDiffDimensionItem[];
196
197
  comparisonPolicy?: ComparisonPolicy;
197
198
  message?: string;