snapdrift 0.2.0 → 0.3.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 +37 -1
- package/README.md +3 -3
- package/actions/baseline/action.yml +1 -1
- package/actions/comment/action.yml +15 -1
- package/actions/pr-diff/action.yml +17 -3
- package/actions/resolve-baseline/action.yml +1 -1
- package/docs/contracts.md +5 -1
- package/docs/integration-guide.md +20 -4
- package/docs/workflow-templates/refresh-baseline-on-merge.yml +82 -0
- package/lib/capture-routes.mjs +2 -368
- package/lib/cli.mjs +8 -9
- package/lib/compare-results.mjs +21 -642
- package/lib/drift-summary.mjs +3 -154
- package/lib/pr-comment.mjs +2 -145
- package/lib/report.mjs +17 -246
- package/lib/snapdrift-config.mjs +20 -279
- package/lib/stage-artifacts.mjs +2 -123
- package/package.json +10 -2
- package/types/visual-diff-types.d.ts +1 -0
package/lib/compare-results.mjs
CHANGED
|
@@ -1,643 +1,22 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
/** @typedef {import('../types/visual-diff-types').VisualRegressionConfig['diff']['mode']} DriftMode */
|
|
24
|
-
/** @typedef {import('../types/visual-diff-types').VisualScreenshotManifest} ScreenshotManifest */
|
|
25
|
-
/** @typedef {import('../types/visual-diff-types').VisualScreenshotManifestEntry} ScreenshotManifestEntry */
|
|
26
|
-
|
|
27
|
-
const baselineResultsPath = readFirstDefinedEnv(['SNAPDRIFT_BASELINE_RESULTS_PATH']);
|
|
28
|
-
const baselineManifestPath = readFirstDefinedEnv(['SNAPDRIFT_BASELINE_MANIFEST_PATH']);
|
|
29
|
-
const currentResultsPath = readFirstDefinedEnv(['SNAPDRIFT_CURRENT_RESULTS_PATH']);
|
|
30
|
-
const currentManifestPath = readFirstDefinedEnv(['SNAPDRIFT_CURRENT_MANIFEST_PATH']);
|
|
31
|
-
const baselineRunDir = path.resolve(readFirstDefinedEnv(['SNAPDRIFT_BASELINE_RUN_DIR']) || 'baseline');
|
|
32
|
-
const currentRunDirValue = readFirstDefinedEnv(['SNAPDRIFT_CURRENT_RUN_DIR']);
|
|
33
|
-
const currentRunDir = currentRunDirValue ? path.resolve(currentRunDirValue) : '';
|
|
34
|
-
const outDir = path.resolve(
|
|
35
|
-
readFirstDefinedEnv(['SNAPDRIFT_DRIFT_OUT_DIR']) || path.join('qa-artifacts', 'snapdrift', 'drift', 'current')
|
|
36
|
-
);
|
|
37
|
-
const summaryPath = path.resolve(
|
|
38
|
-
readFirstDefinedEnv(['SNAPDRIFT_SUMMARY_PATH']) || path.join(outDir, 'summary.json')
|
|
39
|
-
);
|
|
40
|
-
const markdownPath = path.resolve(
|
|
41
|
-
readFirstDefinedEnv(['SNAPDRIFT_SUMMARY_MARKDOWN_PATH']) || path.join(outDir, 'summary.md')
|
|
42
|
-
);
|
|
43
|
-
const baselineArtifactName = readFirstDefinedEnv(['SNAPDRIFT_BASELINE_ARTIFACT_NAME']) || '';
|
|
44
|
-
const baselineSourceSha = readFirstDefinedEnv(['SNAPDRIFT_BASELINE_SOURCE_SHA']) || '';
|
|
45
|
-
const shouldEnforceOutcomeFromEnv = readFirstDefinedEnv(['SNAPDRIFT_ENFORCE_OUTCOME']) !== '0';
|
|
46
|
-
const DEFAULT_SNAPDRIFT_REPO_URL = 'https://github.com/ranacseruet/snapdrift';
|
|
47
|
-
const DEFAULT_SNAPDRIFT_ICON_URL = 'https://raw.githubusercontent.com/ranacseruet/snapdrift/main/assets/snapdrift-logo-icon.png';
|
|
48
|
-
|
|
49
|
-
const fileIndexCache = new Map();
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* @param {import('../types/visual-diff-types').VisualViewport | undefined} viewport
|
|
53
|
-
* @returns {string}
|
|
54
|
-
*/
|
|
55
|
-
function formatViewport(viewport) {
|
|
56
|
-
if (!viewport) return '';
|
|
57
|
-
return typeof viewport === 'string' ? viewport : `${viewport.width}x${viewport.height}`;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* @param {string} filePath
|
|
62
|
-
* @returns {Promise<boolean>}
|
|
63
|
-
*/
|
|
64
|
-
async function exists(filePath) {
|
|
65
|
-
try {
|
|
66
|
-
await fs.access(filePath);
|
|
67
|
-
return true;
|
|
68
|
-
} catch {
|
|
69
|
-
return false;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* @template T
|
|
75
|
-
* @param {string} filePath
|
|
76
|
-
* @param {string} label
|
|
77
|
-
* @returns {Promise<T>}
|
|
78
|
-
*/
|
|
79
|
-
async function loadJson(filePath, label) {
|
|
80
|
-
try {
|
|
81
|
-
const raw = await fs.readFile(filePath, 'utf8');
|
|
82
|
-
return JSON.parse(raw);
|
|
83
|
-
} catch (error) {
|
|
84
|
-
throw new Error(`Unable to load ${label} at ${filePath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* @param {BaselineResults} results
|
|
90
|
-
* @returns {Map<string, BaselineResults['routes'][number]>}
|
|
91
|
-
*/
|
|
92
|
-
function indexRouteResults(results) {
|
|
93
|
-
return new Map((results.routes || []).map((route) => [route.id, route]));
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* @param {ScreenshotManifest} manifest
|
|
98
|
-
* @param {string[]} selectedRouteIds
|
|
99
|
-
* @returns {Map<string, ScreenshotManifestEntry>}
|
|
100
|
-
*/
|
|
101
|
-
function indexManifestEntries(manifest, selectedRouteIds) {
|
|
102
|
-
const selected = new Set(selectedRouteIds);
|
|
103
|
-
const entries = new Map();
|
|
104
|
-
for (const screenshot of manifest.screenshots || []) {
|
|
105
|
-
if (!selected.has(screenshot.id)) {
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
if (entries.has(screenshot.id)) {
|
|
109
|
-
throw new Error(`Duplicate screenshot id ${screenshot.id} detected in the SnapDrift screenshot manifest.`);
|
|
110
|
-
}
|
|
111
|
-
entries.set(screenshot.id, screenshot);
|
|
112
|
-
}
|
|
113
|
-
return entries;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* @param {string} rootDir
|
|
118
|
-
* @returns {Promise<Map<string, string[]>>}
|
|
119
|
-
*/
|
|
120
|
-
async function buildFileIndex(rootDir) {
|
|
121
|
-
const cached = fileIndexCache.get(rootDir);
|
|
122
|
-
if (cached) {
|
|
123
|
-
return cached;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** @type {Map<string, string[]>} */
|
|
127
|
-
const filesByBasename = new Map();
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* @param {string} currentDir
|
|
131
|
-
* @returns {Promise<void>}
|
|
132
|
-
*/
|
|
133
|
-
async function visit(currentDir) {
|
|
134
|
-
const entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
135
|
-
for (const entry of entries) {
|
|
136
|
-
const fullPath = path.join(currentDir, entry.name);
|
|
137
|
-
if (entry.isDirectory()) {
|
|
138
|
-
await visit(fullPath);
|
|
139
|
-
continue;
|
|
140
|
-
}
|
|
141
|
-
if (!entry.isFile()) {
|
|
142
|
-
continue;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const list = filesByBasename.get(entry.name) || [];
|
|
146
|
-
list.push(fullPath);
|
|
147
|
-
filesByBasename.set(entry.name, list);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
if (await exists(rootDir)) {
|
|
152
|
-
await visit(rootDir);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
fileIndexCache.set(rootDir, filesByBasename);
|
|
156
|
-
return filesByBasename;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* @param {string} runDir
|
|
161
|
-
* @param {string} relativeImagePath
|
|
162
|
-
* @returns {Promise<string>}
|
|
163
|
-
*/
|
|
164
|
-
export async function resolveImagePath(runDir, relativeImagePath) {
|
|
165
|
-
const directPath = path.resolve(runDir, relativeImagePath);
|
|
166
|
-
if (await exists(directPath)) {
|
|
167
|
-
return directPath;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const fileIndex = await buildFileIndex(runDir);
|
|
171
|
-
const basename = path.basename(relativeImagePath);
|
|
172
|
-
const matches = fileIndex.get(basename) || [];
|
|
173
|
-
|
|
174
|
-
if (matches.length === 1) {
|
|
175
|
-
return matches[0];
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
if (matches.length > 1) {
|
|
179
|
-
const normalizedRelativePath = relativeImagePath.replace(/\\/g, '/');
|
|
180
|
-
const suffixMatches = matches.filter((candidate) => candidate.replace(/\\/g, '/').endsWith(normalizedRelativePath));
|
|
181
|
-
if (suffixMatches.length === 1) {
|
|
182
|
-
return suffixMatches[0];
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
throw new Error(`Unable to locate screenshot ${relativeImagePath} under ${runDir}.`);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* @param {string} baselinePath
|
|
191
|
-
* @param {string} currentPath
|
|
192
|
-
* @returns {Promise<{ width: number, height: number, differentPixels: number, totalPixels: number, mismatchRatio: number }>}
|
|
193
|
-
*/
|
|
194
|
-
async function comparePngs(baselinePath, currentPath) {
|
|
195
|
-
const [baselineBuffer, currentBuffer] = await Promise.all([
|
|
196
|
-
fs.readFile(baselinePath),
|
|
197
|
-
fs.readFile(currentPath)
|
|
198
|
-
]);
|
|
199
|
-
const baselinePng = PNG.sync.read(baselineBuffer);
|
|
200
|
-
const currentPng = PNG.sync.read(currentBuffer);
|
|
201
|
-
|
|
202
|
-
if (baselinePng.width !== currentPng.width || baselinePng.height !== currentPng.height) {
|
|
203
|
-
throw new Error(
|
|
204
|
-
`Dimension mismatch: baseline ${baselinePng.width}x${baselinePng.height}, current ${currentPng.width}x${currentPng.height}.`
|
|
205
|
-
);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
let differentPixels = 0;
|
|
209
|
-
for (let index = 0; index < baselinePng.data.length; index += 4) {
|
|
210
|
-
if (
|
|
211
|
-
baselinePng.data[index] !== currentPng.data[index] ||
|
|
212
|
-
baselinePng.data[index + 1] !== currentPng.data[index + 1] ||
|
|
213
|
-
baselinePng.data[index + 2] !== currentPng.data[index + 2] ||
|
|
214
|
-
baselinePng.data[index + 3] !== currentPng.data[index + 3]
|
|
215
|
-
) {
|
|
216
|
-
differentPixels += 1;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
const totalPixels = baselinePng.width * baselinePng.height;
|
|
221
|
-
return {
|
|
222
|
-
width: baselinePng.width,
|
|
223
|
-
height: baselinePng.height,
|
|
224
|
-
differentPixels,
|
|
225
|
-
totalPixels,
|
|
226
|
-
mismatchRatio: totalPixels === 0 ? 0 : differentPixels / totalPixels
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* @param {DriftSummary} summaryData
|
|
232
|
-
* @returns {'clean' | 'changes-detected' | 'incomplete'}
|
|
233
|
-
*/
|
|
234
|
-
export function determineDriftStatus(summaryData) {
|
|
235
|
-
if (
|
|
236
|
-
summaryData.errors.length > 0 ||
|
|
237
|
-
(summaryData.dimensionChanges || []).length > 0 ||
|
|
238
|
-
summaryData.missingInBaseline > 0 ||
|
|
239
|
-
summaryData.missingInCurrent > 0
|
|
240
|
-
) {
|
|
241
|
-
return 'incomplete';
|
|
242
|
-
}
|
|
243
|
-
if (summaryData.changedScreenshots > 0) {
|
|
244
|
-
return 'changes-detected';
|
|
245
|
-
}
|
|
246
|
-
return 'clean';
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* @param {DriftSummary} summaryData
|
|
251
|
-
* @returns {boolean}
|
|
252
|
-
*/
|
|
253
|
-
export function shouldFailDriftCheck(summaryData) {
|
|
254
|
-
if (summaryData.diffMode === 'report-only') {
|
|
255
|
-
return false;
|
|
256
|
-
}
|
|
257
|
-
if (summaryData.diffMode === 'fail-on-changes') {
|
|
258
|
-
return summaryData.changedScreenshots > 0;
|
|
259
|
-
}
|
|
260
|
-
if (summaryData.diffMode === 'fail-on-incomplete') {
|
|
261
|
-
return (
|
|
262
|
-
summaryData.errors.length > 0 ||
|
|
263
|
-
(summaryData.dimensionChanges || []).length > 0 ||
|
|
264
|
-
summaryData.missingInBaseline > 0 ||
|
|
265
|
-
summaryData.missingInCurrent > 0
|
|
266
|
-
);
|
|
267
|
-
}
|
|
268
|
-
return (
|
|
269
|
-
summaryData.changedScreenshots > 0 ||
|
|
270
|
-
summaryData.errors.length > 0 ||
|
|
271
|
-
(summaryData.dimensionChanges || []).length > 0 ||
|
|
272
|
-
summaryData.missingInBaseline > 0 ||
|
|
273
|
-
summaryData.missingInCurrent > 0
|
|
274
|
-
);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* @param {DriftSummary} summaryData
|
|
279
|
-
* @returns {string}
|
|
280
|
-
*/
|
|
281
|
-
function makeMarkdown(summaryData) {
|
|
282
|
-
const statusIconMap = {
|
|
283
|
-
clean: '✅',
|
|
284
|
-
'changes-detected': '🟡',
|
|
285
|
-
incomplete: '⚠️',
|
|
286
|
-
skipped: '⏭️'
|
|
287
|
-
};
|
|
288
|
-
const statusLabelMap = {
|
|
289
|
-
clean: 'Clean',
|
|
290
|
-
'changes-detected': 'Drift detected',
|
|
291
|
-
incomplete: 'Incomplete',
|
|
292
|
-
skipped: 'Skipped'
|
|
293
|
-
};
|
|
294
|
-
const status = summaryData.status || 'incomplete';
|
|
295
|
-
const statusIcon = statusIconMap[status] || '⚠️';
|
|
296
|
-
const statusLabel = statusLabelMap[status] || status;
|
|
297
|
-
const dimensionChanges = summaryData.dimensionChanges || [];
|
|
298
|
-
const selectedRoutes = summaryData.selectedRoutes?.length || 0;
|
|
299
|
-
|
|
300
|
-
const lines = [
|
|
301
|
-
`<img src="${DEFAULT_SNAPDRIFT_ICON_URL}" alt="SnapDrift" width="24" height="24" />`,
|
|
302
|
-
'',
|
|
303
|
-
`# ${statusIcon} SnapDrift Report — ${statusLabel}`,
|
|
304
|
-
'',
|
|
305
|
-
'| Selected routes | Stable captures | Diff mode | Threshold |',
|
|
306
|
-
'|---------------:|----------------:|:----------|----------:|',
|
|
307
|
-
`| ${selectedRoutes} | ${summaryData.matchedScreenshots} | \`${summaryData.diffMode}\` | ${summaryData.threshold} |`,
|
|
308
|
-
'',
|
|
309
|
-
'| Signal | Count |',
|
|
310
|
-
'|:-------|------:|',
|
|
311
|
-
`| Drift signals | ${summaryData.changedScreenshots} |`,
|
|
312
|
-
`| Missing in baseline | ${summaryData.missingInBaseline} |`,
|
|
313
|
-
`| Missing in current capture | ${summaryData.missingInCurrent} |`,
|
|
314
|
-
`| Dimension shifts | ${dimensionChanges.length} |`,
|
|
315
|
-
`| Errors | ${summaryData.errors.length} |`,
|
|
316
|
-
''
|
|
317
|
-
];
|
|
318
|
-
|
|
319
|
-
const metaItems = [];
|
|
320
|
-
if (summaryData.baselineArtifactName) {
|
|
321
|
-
metaItems.push(`baseline \`${summaryData.baselineArtifactName}\``);
|
|
322
|
-
}
|
|
323
|
-
if (summaryData.baselineSourceSha) {
|
|
324
|
-
metaItems.push(`sha \`${summaryData.baselineSourceSha}\``);
|
|
325
|
-
}
|
|
326
|
-
if (metaItems.length > 0) {
|
|
327
|
-
lines.push('');
|
|
328
|
-
lines.push(`<sub>SnapDrift · ${metaItems.join(' · ')}</sub>`);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
lines.push('');
|
|
332
|
-
lines.push('## Drift signals');
|
|
333
|
-
|
|
334
|
-
if (summaryData.changed.length === 0) {
|
|
335
|
-
lines.push('');
|
|
336
|
-
lines.push('None');
|
|
337
|
-
} else {
|
|
338
|
-
lines.push('');
|
|
339
|
-
lines.push('| Route | Viewport | Mismatch | Pixels changed |');
|
|
340
|
-
lines.push('|:------|:---------|:---------|:---------------|');
|
|
341
|
-
for (const item of summaryData.changed) {
|
|
342
|
-
lines.push(`| ${item.id} | ${formatViewport(item.viewport)} | ${(item.mismatchRatio * 100).toFixed(2)}% | ${item.differentPixels}/${item.totalPixels} |`);
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
lines.push('');
|
|
347
|
-
lines.push('## Capture gaps');
|
|
348
|
-
if (summaryData.missing.length === 0) {
|
|
349
|
-
lines.push('');
|
|
350
|
-
lines.push('None');
|
|
351
|
-
} else {
|
|
352
|
-
lines.push('');
|
|
353
|
-
lines.push('| Route | Reason |');
|
|
354
|
-
lines.push('|:------|:-------|');
|
|
355
|
-
for (const item of summaryData.missing) {
|
|
356
|
-
lines.push(`| ${item.id} | ${item.reason} |`);
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
lines.push('');
|
|
361
|
-
lines.push('## Dimension shifts');
|
|
362
|
-
if (dimensionChanges.length === 0) {
|
|
363
|
-
lines.push('');
|
|
364
|
-
lines.push('None');
|
|
365
|
-
} else {
|
|
366
|
-
lines.push('');
|
|
367
|
-
lines.push('> SnapDrift detected a dimension shift between the baseline and current capture. Pixel comparison was skipped for these routes.');
|
|
368
|
-
lines.push('>');
|
|
369
|
-
lines.push('> **Next step:** refresh the baseline after this change lands so SnapDrift can compare like-for-like frames.');
|
|
370
|
-
lines.push('');
|
|
371
|
-
lines.push('| Route | Viewport | Baseline | Current |');
|
|
372
|
-
lines.push('|:------|:---------|:---------|:--------|');
|
|
373
|
-
for (const item of dimensionChanges) {
|
|
374
|
-
lines.push(`| ${item.id} | ${formatViewport(item.viewport)} | ${item.baselineWidth}×${item.baselineHeight} | ${item.currentWidth}×${item.currentHeight} |`);
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
lines.push('');
|
|
379
|
-
lines.push('## Comparison errors');
|
|
380
|
-
if (summaryData.errors.length === 0) {
|
|
381
|
-
lines.push('');
|
|
382
|
-
lines.push('None');
|
|
383
|
-
} else {
|
|
384
|
-
lines.push('');
|
|
385
|
-
lines.push('| Route | Error |');
|
|
386
|
-
lines.push('|:------|:------|');
|
|
387
|
-
for (const item of summaryData.errors) {
|
|
388
|
-
lines.push(`| ${item.id} | ${item.message} |`);
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
lines.push('');
|
|
393
|
-
lines.push(`<sub>SnapDrift · baseline results \`${summaryData.baselineResultsPath}\` · current results \`${summaryData.currentResultsPath}\`</sub>`);
|
|
394
|
-
lines.push('');
|
|
395
|
-
lines.push(`<div align="right"><sub>Powered by <a href="${DEFAULT_SNAPDRIFT_REPO_URL}">SnapDrift</a></sub></div>`);
|
|
396
|
-
|
|
397
|
-
return lines.join('\n') + '\n';
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
/**
|
|
401
|
-
* @param {DriftMode} diffMode
|
|
402
|
-
* @param {{ changedScreenshots?: number }} summary
|
|
403
|
-
* @returns {string}
|
|
404
|
-
*/
|
|
405
|
-
export function formatDriftFailureMessage(diffMode, summary) {
|
|
406
|
-
if (diffMode === 'fail-on-changes') {
|
|
407
|
-
return `SnapDrift detected drift in ${summary.changedScreenshots} capture(s), above the configured threshold.`;
|
|
408
|
-
}
|
|
409
|
-
if (diffMode === 'fail-on-incomplete') {
|
|
410
|
-
return 'SnapDrift stopped the run because the comparison finished incomplete.';
|
|
411
|
-
}
|
|
412
|
-
return 'SnapDrift strict mode detected drift or incomplete comparisons.';
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
/**
|
|
416
|
-
* @param {{
|
|
417
|
-
* configPath?: string,
|
|
418
|
-
* baselineResultsPath?: string,
|
|
419
|
-
* baselineManifestPath?: string,
|
|
420
|
-
* currentResultsPath?: string,
|
|
421
|
-
* currentManifestPath?: string,
|
|
422
|
-
* baselineRunDir?: string,
|
|
423
|
-
* currentRunDir?: string,
|
|
424
|
-
* routeIds?: Iterable<string>,
|
|
425
|
-
* baselineArtifactName?: string,
|
|
426
|
-
* baselineSourceSha?: string
|
|
427
|
-
* }} [options]
|
|
428
|
-
* @returns {Promise<{ summary: DriftSummary, markdown: string }>}
|
|
429
|
-
*/
|
|
430
|
-
export async function generateDriftReport(options = {}) {
|
|
431
|
-
fileIndexCache.clear();
|
|
432
|
-
|
|
433
|
-
const { config } = await loadSnapdriftConfig(options.configPath);
|
|
434
|
-
const selectedRouteIds = selectConfiguredRoutes(
|
|
435
|
-
config,
|
|
436
|
-
options.routeIds || splitCommaList(readFirstDefinedEnv(['SNAPDRIFT_ROUTE_IDS']))
|
|
437
|
-
).selectedRouteIds;
|
|
438
|
-
|
|
439
|
-
const resolvedBaselineResultsPath = path.resolve(
|
|
440
|
-
options.baselineResultsPath || baselineResultsPath || resolveFromWorkingDirectory(config, config.resultsFile)
|
|
441
|
-
);
|
|
442
|
-
const resolvedBaselineManifestPath = path.resolve(
|
|
443
|
-
options.baselineManifestPath || baselineManifestPath || resolveFromWorkingDirectory(config, config.manifestFile)
|
|
444
|
-
);
|
|
445
|
-
const resolvedCurrentResultsPath = path.resolve(
|
|
446
|
-
options.currentResultsPath || currentResultsPath || resolveFromWorkingDirectory(config, config.resultsFile)
|
|
447
|
-
);
|
|
448
|
-
const resolvedCurrentManifestPath = path.resolve(
|
|
449
|
-
options.currentManifestPath || currentManifestPath || resolveFromWorkingDirectory(config, config.manifestFile)
|
|
450
|
-
);
|
|
451
|
-
const resolvedBaselineRunDir = path.resolve(options.baselineRunDir || baselineRunDir);
|
|
452
|
-
const resolvedCurrentRunDir = path.resolve(
|
|
453
|
-
options.currentRunDir || currentRunDir || resolveFromWorkingDirectory(config, config.screenshotsRoot)
|
|
454
|
-
);
|
|
455
|
-
|
|
456
|
-
const [baselineResults, currentResults, baselineManifest, currentManifest] = await Promise.all([
|
|
457
|
-
loadJson(resolvedBaselineResultsPath, 'baseline SnapDrift results'),
|
|
458
|
-
loadJson(resolvedCurrentResultsPath, 'current SnapDrift results'),
|
|
459
|
-
loadJson(resolvedBaselineManifestPath, 'baseline screenshot manifest'),
|
|
460
|
-
loadJson(resolvedCurrentManifestPath, 'current screenshot manifest')
|
|
461
|
-
]);
|
|
462
|
-
|
|
463
|
-
const baselineRouteResults = indexRouteResults(/** @type {BaselineResults} */ (baselineResults));
|
|
464
|
-
const currentRouteResults = indexRouteResults(/** @type {BaselineResults} */ (currentResults));
|
|
465
|
-
const baselineEntries = indexManifestEntries(/** @type {ScreenshotManifest} */ (baselineManifest), selectedRouteIds);
|
|
466
|
-
const currentEntries = indexManifestEntries(/** @type {ScreenshotManifest} */ (currentManifest), selectedRouteIds);
|
|
467
|
-
|
|
468
|
-
/** @type {DriftSummary} */
|
|
469
|
-
const summary = {
|
|
470
|
-
startedAt: new Date().toISOString(),
|
|
471
|
-
baselineResultsPath: resolvedBaselineResultsPath,
|
|
472
|
-
currentResultsPath: resolvedCurrentResultsPath,
|
|
473
|
-
baselineManifestPath: resolvedBaselineManifestPath,
|
|
474
|
-
currentManifestPath: resolvedCurrentManifestPath,
|
|
475
|
-
diffMode: config.diff.mode,
|
|
476
|
-
threshold: config.diff.threshold,
|
|
477
|
-
totalScreenshots: selectedRouteIds.length,
|
|
478
|
-
matchedScreenshots: 0,
|
|
479
|
-
changedScreenshots: 0,
|
|
480
|
-
missingInBaseline: 0,
|
|
481
|
-
missingInCurrent: 0,
|
|
482
|
-
changed: [],
|
|
483
|
-
missing: [],
|
|
484
|
-
errors: [],
|
|
485
|
-
dimensionChanges: [],
|
|
486
|
-
selectedRoutes: selectedRouteIds,
|
|
487
|
-
baselineArtifactName: options.baselineArtifactName || baselineArtifactName || undefined,
|
|
488
|
-
baselineSourceSha: options.baselineSourceSha || baselineSourceSha || undefined,
|
|
489
|
-
baselineAvailable: true
|
|
490
|
-
};
|
|
491
|
-
|
|
492
|
-
for (const routeId of selectedRouteIds) {
|
|
493
|
-
const routeConfig = config.routes.find((route) => route.id === routeId);
|
|
494
|
-
const baselineEntry = baselineEntries.get(routeId);
|
|
495
|
-
const currentEntry = currentEntries.get(routeId);
|
|
496
|
-
const baselineRouteResult = baselineRouteResults.get(routeId);
|
|
497
|
-
const currentRouteResult = currentRouteResults.get(routeId);
|
|
498
|
-
|
|
499
|
-
if (!baselineEntry && !currentEntry) {
|
|
500
|
-
/** @type {DriftErrorItem} */
|
|
501
|
-
const errorRecord = {
|
|
502
|
-
id: routeId,
|
|
503
|
-
path: routeConfig?.path,
|
|
504
|
-
viewport: routeConfig?.viewport,
|
|
505
|
-
status: 'error',
|
|
506
|
-
message: [
|
|
507
|
-
'Screenshot missing from both manifests.',
|
|
508
|
-
baselineRouteResult?.status === 'failed' ? `Baseline capture failed: ${baselineRouteResult.error || 'unknown error'}` : '',
|
|
509
|
-
currentRouteResult?.status === 'failed' ? `Current capture failed: ${currentRouteResult.error || 'unknown error'}` : ''
|
|
510
|
-
]
|
|
511
|
-
.filter(Boolean)
|
|
512
|
-
.join(' ')
|
|
513
|
-
};
|
|
514
|
-
summary.errors.push(errorRecord);
|
|
515
|
-
continue;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
if (!baselineEntry) {
|
|
519
|
-
summary.missingInBaseline += 1;
|
|
520
|
-
summary.missing.push({
|
|
521
|
-
id: routeId,
|
|
522
|
-
path: currentEntry?.path || routeConfig?.path,
|
|
523
|
-
viewport: currentEntry?.viewport || routeConfig?.viewport,
|
|
524
|
-
location: 'baseline',
|
|
525
|
-
reason: 'missing baseline capture'
|
|
526
|
-
});
|
|
527
|
-
continue;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
if (!currentEntry) {
|
|
531
|
-
summary.missingInCurrent += 1;
|
|
532
|
-
summary.missing.push({
|
|
533
|
-
id: routeId,
|
|
534
|
-
path: baselineEntry.path || routeConfig?.path,
|
|
535
|
-
viewport: baselineEntry.viewport || routeConfig?.viewport,
|
|
536
|
-
location: 'current',
|
|
537
|
-
reason: 'missing current capture'
|
|
538
|
-
});
|
|
539
|
-
continue;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
try {
|
|
543
|
-
if (baselineEntry.width !== currentEntry.width || baselineEntry.height !== currentEntry.height) {
|
|
544
|
-
/** @type {DriftDimensionItem} */
|
|
545
|
-
const dimensionRecord = {
|
|
546
|
-
id: routeId,
|
|
547
|
-
path: currentEntry.path || baselineEntry.path || routeConfig?.path,
|
|
548
|
-
viewport: currentEntry.viewport || baselineEntry.viewport || routeConfig?.viewport,
|
|
549
|
-
baselineWidth: baselineEntry.width,
|
|
550
|
-
baselineHeight: baselineEntry.height,
|
|
551
|
-
currentWidth: currentEntry.width,
|
|
552
|
-
currentHeight: currentEntry.height,
|
|
553
|
-
status: 'dimension-changed'
|
|
554
|
-
};
|
|
555
|
-
summary.dimensionChanges.push(dimensionRecord);
|
|
556
|
-
continue;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
const [resolvedBaselineImagePath, resolvedCurrentImagePath] = await Promise.all([
|
|
560
|
-
resolveImagePath(resolvedBaselineRunDir, baselineEntry.imagePath),
|
|
561
|
-
resolveImagePath(resolvedCurrentRunDir, currentEntry.imagePath)
|
|
562
|
-
]);
|
|
563
|
-
const comparison = await comparePngs(resolvedBaselineImagePath, resolvedCurrentImagePath);
|
|
564
|
-
|
|
565
|
-
if (comparison.mismatchRatio <= config.diff.threshold) {
|
|
566
|
-
summary.matchedScreenshots += 1;
|
|
567
|
-
continue;
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
/** @type {DriftChangedItem} */
|
|
571
|
-
const changedRecord = {
|
|
572
|
-
id: routeId,
|
|
573
|
-
path: currentEntry.path,
|
|
574
|
-
viewport: currentEntry.viewport,
|
|
575
|
-
baselineImagePath: baselineEntry.imagePath,
|
|
576
|
-
currentImagePath: currentEntry.imagePath,
|
|
577
|
-
width: comparison.width,
|
|
578
|
-
height: comparison.height,
|
|
579
|
-
differentPixels: comparison.differentPixels,
|
|
580
|
-
totalPixels: comparison.totalPixels,
|
|
581
|
-
mismatchRatio: Number(comparison.mismatchRatio.toFixed(6)),
|
|
582
|
-
status: 'changed'
|
|
583
|
-
};
|
|
584
|
-
summary.changedScreenshots += 1;
|
|
585
|
-
summary.changed.push(changedRecord);
|
|
586
|
-
} catch (error) {
|
|
587
|
-
/** @type {DriftErrorItem} */
|
|
588
|
-
const errorRecord = {
|
|
589
|
-
id: routeId,
|
|
590
|
-
path: currentEntry.path || baselineEntry.path || routeConfig?.path,
|
|
591
|
-
viewport: currentEntry.viewport || baselineEntry.viewport || routeConfig?.viewport,
|
|
592
|
-
status: 'error',
|
|
593
|
-
message: error instanceof Error ? error.message : String(error)
|
|
594
|
-
};
|
|
595
|
-
summary.errors.push(errorRecord);
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
summary.status = determineDriftStatus(summary);
|
|
600
|
-
summary.finishedAt = new Date().toISOString();
|
|
601
|
-
summary.completed = true;
|
|
602
|
-
|
|
603
|
-
return {
|
|
604
|
-
summary,
|
|
605
|
-
markdown: makeMarkdown(summary)
|
|
606
|
-
};
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
/**
|
|
610
|
-
* @param {Parameters<typeof generateDriftReport>[0] & {
|
|
611
|
-
* outDir?: string,
|
|
612
|
-
* summaryPath?: string,
|
|
613
|
-
* markdownPath?: string,
|
|
614
|
-
* enforceOutcome?: boolean
|
|
615
|
-
* }} [options]
|
|
616
|
-
* @returns {Promise<void>}
|
|
617
|
-
*/
|
|
618
|
-
export async function runDriftCheckCli(options = {}) {
|
|
619
|
-
const resolvedOutDir = path.resolve(options.outDir || outDir);
|
|
620
|
-
const resolvedSummaryPath = path.resolve(options.summaryPath || summaryPath);
|
|
621
|
-
const resolvedMarkdownPath = path.resolve(options.markdownPath || markdownPath);
|
|
622
|
-
const shouldEnforceOutcome = options.enforceOutcome ?? shouldEnforceOutcomeFromEnv;
|
|
623
|
-
|
|
624
|
-
await fs.mkdir(resolvedOutDir, { recursive: true });
|
|
625
|
-
const { summary, markdown } = await generateDriftReport(options);
|
|
626
|
-
await Promise.all([
|
|
627
|
-
fs.writeFile(resolvedSummaryPath, JSON.stringify(summary, null, 2)),
|
|
628
|
-
fs.writeFile(resolvedMarkdownPath, markdown)
|
|
629
|
-
]);
|
|
630
|
-
|
|
631
|
-
if (shouldEnforceOutcome && shouldFailDriftCheck(summary)) {
|
|
632
|
-
throw new Error(formatDriftFailureMessage(summary.diffMode, summary));
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
const isDirectRun = process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url;
|
|
637
|
-
|
|
638
|
-
if (isDirectRun) {
|
|
639
|
-
runDriftCheckCli().catch((error) => {
|
|
640
|
-
console.error(error);
|
|
641
|
-
process.exitCode = 1;
|
|
642
|
-
});
|
|
643
|
-
}
|
|
2
|
+
// Re-exports from packages — this module is a thin shim.
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
determineDriftStatus,
|
|
6
|
+
shouldFailDriftCheck,
|
|
7
|
+
indexManifestEntries,
|
|
8
|
+
indexRouteResults
|
|
9
|
+
} from '@snapdrift/manifest';
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
comparePngs,
|
|
13
|
+
resolveImagePath,
|
|
14
|
+
generateDriftReport,
|
|
15
|
+
runDriftCheckCli
|
|
16
|
+
} from '@snapdrift/adapter-fs';
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
makeMarkdown,
|
|
20
|
+
formatDriftFailureMessage,
|
|
21
|
+
formatViewport
|
|
22
|
+
} from '@snapdrift/adapter-report-md';
|