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/drift-summary.mjs
CHANGED
|
@@ -1,156 +1,5 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
+
// Re-exports from packages — this module is a thin shim.
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
import { splitCommaList } from './snapdrift-config.mjs';
|
|
7
|
-
|
|
8
|
-
const DEFAULT_OUT_DIR = path.join('qa-artifacts', 'snapdrift', 'drift', 'current');
|
|
9
|
-
const DEFAULT_SNAPDRIFT_REPO_URL = 'https://github.com/ranacseruet/snapdrift';
|
|
10
|
-
const DEFAULT_SNAPDRIFT_ICON_URL = 'https://raw.githubusercontent.com/ranacseruet/snapdrift/main/assets/snapdrift-logo-icon.png';
|
|
11
|
-
|
|
12
|
-
const STATUS_ICONS = {
|
|
13
|
-
clean: '✅',
|
|
14
|
-
'changes-detected': '🟡',
|
|
15
|
-
incomplete: '⚠️',
|
|
16
|
-
skipped: '⏭️'
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
const STATUS_LABELS = {
|
|
20
|
-
clean: 'Clean',
|
|
21
|
-
'changes-detected': 'Drift detected',
|
|
22
|
-
incomplete: 'Incomplete',
|
|
23
|
-
skipped: 'Skipped'
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* @param {string} reason
|
|
28
|
-
* @returns {{ message: string, markdownReason: string }}
|
|
29
|
-
*/
|
|
30
|
-
function describeReason(reason) {
|
|
31
|
-
if (reason === 'missing_main_baseline_artifact') {
|
|
32
|
-
return {
|
|
33
|
-
message: 'The latest successful `main` SnapDrift baseline was not available.',
|
|
34
|
-
markdownReason: 'latest successful `main` SnapDrift baseline was not available'
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
if (reason === 'no_snapdrift_relevant_changes') {
|
|
39
|
-
return {
|
|
40
|
-
message: 'No drift-relevant changes were detected in this pull request.',
|
|
41
|
-
markdownReason: 'no drift-relevant changes were detected in this pull request'
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const text = reason.replace(/_/g, ' ');
|
|
46
|
-
return {
|
|
47
|
-
message: text,
|
|
48
|
-
markdownReason: text
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* @param {{
|
|
54
|
-
* status?: 'skipped' | 'clean' | 'changes-detected' | 'incomplete',
|
|
55
|
-
* reason: string,
|
|
56
|
-
* message?: string,
|
|
57
|
-
* selectedRouteIds?: string[] | string,
|
|
58
|
-
* currentResultsPath?: string,
|
|
59
|
-
* baselineAvailable?: boolean
|
|
60
|
-
* }} options
|
|
61
|
-
* @returns {{ summary: Record<string, unknown>, markdown: string }}
|
|
62
|
-
*/
|
|
63
|
-
export function buildDriftSummary(options) {
|
|
64
|
-
const description = describeReason(options.reason);
|
|
65
|
-
const status = options.status || 'skipped';
|
|
66
|
-
const selectedRoutes = Array.isArray(options.selectedRouteIds)
|
|
67
|
-
? options.selectedRouteIds
|
|
68
|
-
: splitCommaList(options.selectedRouteIds);
|
|
69
|
-
const message = options.message || description.message;
|
|
70
|
-
const statusIcon = STATUS_ICONS[status] || '⚠️';
|
|
71
|
-
const statusLabel = STATUS_LABELS[status] || status;
|
|
72
|
-
const baselineAvailable = typeof options.baselineAvailable === 'boolean'
|
|
73
|
-
? String(options.baselineAvailable)
|
|
74
|
-
: 'n/a';
|
|
75
|
-
const currentCapture = options.currentResultsPath
|
|
76
|
-
? `\`${options.currentResultsPath}\``
|
|
77
|
-
: 'n/a';
|
|
78
|
-
|
|
79
|
-
/** @type {Record<string, unknown>} */
|
|
80
|
-
const summary = {
|
|
81
|
-
status,
|
|
82
|
-
reason: options.reason,
|
|
83
|
-
message,
|
|
84
|
-
selectedRoutes
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
if (typeof options.baselineAvailable === 'boolean') {
|
|
88
|
-
summary.baselineAvailable = options.baselineAvailable;
|
|
89
|
-
}
|
|
90
|
-
if (options.currentResultsPath) {
|
|
91
|
-
summary.currentResultsPath = options.currentResultsPath;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const lines = [
|
|
95
|
-
`<img src="${DEFAULT_SNAPDRIFT_ICON_URL}" alt="SnapDrift" width="24" height="24" />`,
|
|
96
|
-
'',
|
|
97
|
-
`# ${statusIcon} SnapDrift Report — ${statusLabel}`,
|
|
98
|
-
'',
|
|
99
|
-
'| Selected routes | Baseline available | Current capture |',
|
|
100
|
-
'|---------------:|:-------------------|:----------------|',
|
|
101
|
-
`| ${selectedRoutes.length} | ${baselineAvailable} | ${currentCapture} |`
|
|
102
|
-
];
|
|
103
|
-
|
|
104
|
-
lines.push('');
|
|
105
|
-
lines.push(`> **Reason:** ${description.markdownReason}`);
|
|
106
|
-
lines.push(`> **Details:** ${message}`);
|
|
107
|
-
|
|
108
|
-
if (options.reason === 'missing_main_baseline_artifact') {
|
|
109
|
-
lines.push('');
|
|
110
|
-
lines.push('## Next action');
|
|
111
|
-
lines.push('');
|
|
112
|
-
lines.push('- Publish or refresh a SnapDrift baseline on `main`, then rerun the pull request check.');
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
lines.push('');
|
|
116
|
-
lines.push(`<div align="right"><sub>Powered by <a href="${DEFAULT_SNAPDRIFT_REPO_URL}">SnapDrift</a></sub></div>`);
|
|
117
|
-
|
|
118
|
-
return {
|
|
119
|
-
summary,
|
|
120
|
-
markdown: `${lines.join('\n')}\n`
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* @param {{
|
|
126
|
-
* status?: 'skipped' | 'clean' | 'changes-detected' | 'incomplete',
|
|
127
|
-
* reason: string,
|
|
128
|
-
* message?: string,
|
|
129
|
-
* selectedRouteIds?: string[] | string,
|
|
130
|
-
* currentResultsPath?: string,
|
|
131
|
-
* baselineAvailable?: boolean,
|
|
132
|
-
* outDir?: string,
|
|
133
|
-
* summaryPath?: string,
|
|
134
|
-
* markdownPath?: string
|
|
135
|
-
* }} options
|
|
136
|
-
* @returns {Promise<{ summaryPath: string, markdownPath: string, summary: Record<string, unknown>, markdown: string }>}
|
|
137
|
-
*/
|
|
138
|
-
export async function writeDriftSummary(options) {
|
|
139
|
-
const resolvedOutDir = path.resolve(options.outDir || DEFAULT_OUT_DIR);
|
|
140
|
-
const resolvedSummaryPath = path.resolve(options.summaryPath || path.join(resolvedOutDir, 'summary.json'));
|
|
141
|
-
const resolvedMarkdownPath = path.resolve(options.markdownPath || path.join(resolvedOutDir, 'summary.md'));
|
|
142
|
-
const { summary, markdown } = buildDriftSummary(options);
|
|
143
|
-
|
|
144
|
-
await fs.mkdir(resolvedOutDir, { recursive: true });
|
|
145
|
-
await Promise.all([
|
|
146
|
-
fs.writeFile(resolvedSummaryPath, `${JSON.stringify(summary, null, 2)}\n`),
|
|
147
|
-
fs.writeFile(resolvedMarkdownPath, markdown)
|
|
148
|
-
]);
|
|
149
|
-
|
|
150
|
-
return {
|
|
151
|
-
summaryPath: resolvedSummaryPath,
|
|
152
|
-
markdownPath: resolvedMarkdownPath,
|
|
153
|
-
summary,
|
|
154
|
-
markdown
|
|
155
|
-
};
|
|
156
|
-
}
|
|
4
|
+
export { buildDriftSummary } from '@snapdrift/adapter-report-md';
|
|
5
|
+
export { writeDriftSummary } from '@snapdrift/adapter-fs';
|
package/lib/pr-comment.mjs
CHANGED
|
@@ -1,147 +1,4 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
+
// Re-exports from packages — this module is a thin shim.
|
|
2
3
|
|
|
3
|
-
export
|
|
4
|
-
export const PR_COMMENT_MARKERS = [PR_COMMENT_MARKER];
|
|
5
|
-
export const DEFAULT_SNAPDRIFT_REPO_URL = 'https://github.com/ranacseruet/snapdrift';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Escape characters that could break markdown table cells or inject links.
|
|
9
|
-
* @param {unknown} value
|
|
10
|
-
* @returns {string}
|
|
11
|
-
*/
|
|
12
|
-
function escapeMarkdown(value) {
|
|
13
|
-
return String(value ?? '')
|
|
14
|
-
.replace(/\|/g, '\\|')
|
|
15
|
-
.replace(/\[/g, '\\[')
|
|
16
|
-
.replace(/\]/g, '\\]');
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const STATUS_ICONS = {
|
|
20
|
-
clean: '✅',
|
|
21
|
-
'changes-detected': '🟡',
|
|
22
|
-
incomplete: '⚠️',
|
|
23
|
-
skipped: '⏭️'
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
const STATUS_LABELS = {
|
|
27
|
-
clean: 'Clean',
|
|
28
|
-
'changes-detected': 'Drift detected',
|
|
29
|
-
incomplete: 'Incomplete',
|
|
30
|
-
skipped: 'Skipped'
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* @param {Record<string, unknown>} summary
|
|
35
|
-
* @param {{ artifactName?: string, runUrl?: string }} [meta]
|
|
36
|
-
* @returns {string}
|
|
37
|
-
*/
|
|
38
|
-
export function buildReportCommentBody(summary, meta = {}) {
|
|
39
|
-
const status = /** @type {string} */ (summary.status) || 'incomplete';
|
|
40
|
-
const statusIcon = STATUS_ICONS[status] || '⚠️';
|
|
41
|
-
const statusLabel = STATUS_LABELS[status] || status;
|
|
42
|
-
const dimensionChanges = /** @type {Array<Record<string, unknown>>} */ (summary.dimensionChanges) || [];
|
|
43
|
-
const errors = /** @type {Array<Record<string, unknown>>} */ (summary.errors) || [];
|
|
44
|
-
const errorCount = (/** @type {unknown[]} */ (summary.errors) || []).length;
|
|
45
|
-
|
|
46
|
-
const lines = [
|
|
47
|
-
PR_COMMENT_MARKER,
|
|
48
|
-
`## ${statusIcon} SnapDrift Report — ${statusLabel}`,
|
|
49
|
-
'',
|
|
50
|
-
'| Signal | Count |',
|
|
51
|
-
'|:-------|------:|',
|
|
52
|
-
`| Drift signals | ${summary.changedScreenshots || 0} |`,
|
|
53
|
-
`| Missing in baseline | ${summary.missingInBaseline || 0} |`,
|
|
54
|
-
`| Missing in current capture | ${summary.missingInCurrent || 0} |`,
|
|
55
|
-
`| Dimension shifts | ${dimensionChanges.length} |`
|
|
56
|
-
];
|
|
57
|
-
|
|
58
|
-
if (summary.message) {
|
|
59
|
-
lines.push('');
|
|
60
|
-
lines.push(`> **Note:** ${escapeMarkdown(summary.message)}`);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
if (errors.length > 0) {
|
|
64
|
-
lines.push('');
|
|
65
|
-
lines.push('<details><summary>Error details</summary>');
|
|
66
|
-
lines.push('');
|
|
67
|
-
lines.push('| Route | Viewport | Error |');
|
|
68
|
-
lines.push('|:------|:---------|:------|');
|
|
69
|
-
for (const item of errors.slice(0, 10)) {
|
|
70
|
-
lines.push(`| ${escapeMarkdown(item.id)} | ${escapeMarkdown(item.viewport)} | ${escapeMarkdown(item.message)} |`);
|
|
71
|
-
}
|
|
72
|
-
if (errorCount > 10) {
|
|
73
|
-
lines.push('');
|
|
74
|
-
const runLinkSuffix = meta.runUrl && /^https?:\/\//.test(meta.runUrl)
|
|
75
|
-
? ` — [View full report →](${meta.runUrl})`
|
|
76
|
-
: '';
|
|
77
|
-
lines.push(`*...and ${errorCount - 10} more*${runLinkSuffix}`);
|
|
78
|
-
}
|
|
79
|
-
lines.push('');
|
|
80
|
-
lines.push('</details>');
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const changed = /** @type {Array<Record<string, unknown>>} */ (summary.changed) || [];
|
|
84
|
-
if (changed.length > 0) {
|
|
85
|
-
lines.push('');
|
|
86
|
-
lines.push('<details><summary>Drift signals</summary>');
|
|
87
|
-
lines.push('');
|
|
88
|
-
lines.push('| Route | Viewport | Mismatch |');
|
|
89
|
-
lines.push('|:------|:---------|:---------|');
|
|
90
|
-
for (const item of changed.slice(0, 20)) {
|
|
91
|
-
const percentChanged = typeof item.mismatchRatio === 'number'
|
|
92
|
-
? `${(item.mismatchRatio * 100).toFixed(2)}%`
|
|
93
|
-
: 'n/a';
|
|
94
|
-
lines.push(`| ${escapeMarkdown(item.id)} | ${escapeMarkdown(item.viewport)} | ${percentChanged} |`);
|
|
95
|
-
}
|
|
96
|
-
if (changed.length > 20) {
|
|
97
|
-
lines.push('');
|
|
98
|
-
const runLinkSuffix = meta.runUrl && /^https?:\/\//.test(meta.runUrl)
|
|
99
|
-
? ` — [View full report →](${meta.runUrl})`
|
|
100
|
-
: '';
|
|
101
|
-
lines.push(`*...and ${changed.length - 20} more*${runLinkSuffix}`);
|
|
102
|
-
}
|
|
103
|
-
lines.push('');
|
|
104
|
-
lines.push('</details>');
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
if (dimensionChanges.length > 0) {
|
|
108
|
-
lines.push('');
|
|
109
|
-
lines.push('<details open><summary>Dimension shifts — comparison skipped</summary>');
|
|
110
|
-
lines.push('');
|
|
111
|
-
lines.push('> SnapDrift detected a dimension shift between the baseline and current capture. Pixel comparison was skipped for these routes.');
|
|
112
|
-
lines.push('>');
|
|
113
|
-
lines.push('> **Next step:** refresh the baseline after this change lands so SnapDrift can compare like-for-like frames.');
|
|
114
|
-
lines.push('');
|
|
115
|
-
lines.push('| Route | Viewport | Baseline | Current |');
|
|
116
|
-
lines.push('|:------|:---------|:---------|:--------|');
|
|
117
|
-
for (const item of dimensionChanges) {
|
|
118
|
-
lines.push(`| ${escapeMarkdown(item.id)} | ${escapeMarkdown(item.viewport)} | ${item.baselineWidth}×${item.baselineHeight} | ${item.currentWidth}×${item.currentHeight} |`);
|
|
119
|
-
}
|
|
120
|
-
lines.push('');
|
|
121
|
-
lines.push('</details>');
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const metaItems = [];
|
|
125
|
-
if (meta.artifactName) {
|
|
126
|
-
metaItems.push(`artifact \`${meta.artifactName}\``);
|
|
127
|
-
}
|
|
128
|
-
if (summary.baselineArtifactName) {
|
|
129
|
-
metaItems.push(`baseline \`${summary.baselineArtifactName}\``);
|
|
130
|
-
}
|
|
131
|
-
if (summary.baselineSourceSha) {
|
|
132
|
-
const sha = /** @type {string} */ (summary.baselineSourceSha);
|
|
133
|
-
metaItems.push(`sha \`${sha.slice(0, 7)}\``);
|
|
134
|
-
}
|
|
135
|
-
if (meta.runUrl && /^https?:\/\//.test(meta.runUrl)) {
|
|
136
|
-
metaItems.push(`[View run](${meta.runUrl})`);
|
|
137
|
-
}
|
|
138
|
-
if (metaItems.length > 0) {
|
|
139
|
-
lines.push('');
|
|
140
|
-
lines.push(`<sub>SnapDrift · ${metaItems.join(' · ')}</sub>`);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
lines.push('');
|
|
144
|
-
lines.push(`<div align="right"><sub>Powered by <a href="${DEFAULT_SNAPDRIFT_REPO_URL}">SnapDrift</a></sub></div>`);
|
|
145
|
-
|
|
146
|
-
return lines.join('\n');
|
|
147
|
-
}
|
|
4
|
+
export { buildReportCommentBody, PR_COMMENT_MARKER, PR_COMMENT_MARKERS, escapeMarkdown } from '@snapdrift/adapter-report-md';
|
package/lib/report.mjs
CHANGED
|
@@ -1,40 +1,20 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
+
// Shim: delegates to adapter-report-md with default filesystem imageReader.
|
|
2
3
|
|
|
3
4
|
import fs from 'node:fs/promises';
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
/** @typedef {import('../types/visual-diff-types').VisualDiffSummary} DriftSummary */
|
|
8
|
-
/** @typedef {import('../types/visual-diff-types').VisualViewport} VisualViewport */
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* @param {VisualViewport | undefined} viewport
|
|
12
|
-
* @returns {string}
|
|
13
|
-
*/
|
|
14
|
-
function formatViewport(viewport) {
|
|
15
|
-
if (!viewport) return '';
|
|
16
|
-
return typeof viewport === 'string' ? viewport : `${viewport.width}x${viewport.height}`;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* @param {string} text
|
|
21
|
-
* @returns {string}
|
|
22
|
-
*/
|
|
23
|
-
function escapeHtml(text) {
|
|
24
|
-
return String(text)
|
|
25
|
-
.replace(/&/g, '&')
|
|
26
|
-
.replace(/</g, '<')
|
|
27
|
-
.replace(/>/g, '>')
|
|
28
|
-
.replace(/"/g, '"');
|
|
29
|
-
}
|
|
5
|
+
import { resolveImagePath } from '@snapdrift/adapter-fs';
|
|
6
|
+
import { generateHtmlReport as _generateHtmlReport } from '@snapdrift/adapter-report-md';
|
|
30
7
|
|
|
31
8
|
/**
|
|
32
|
-
*
|
|
9
|
+
* Default filesystem image reader — resolves image path and reads as base64.
|
|
10
|
+
* @param {string} runDir
|
|
11
|
+
* @param {string} imagePath
|
|
33
12
|
* @returns {Promise<string | null>}
|
|
34
13
|
*/
|
|
35
|
-
async function
|
|
14
|
+
async function defaultImageReader(runDir, imagePath) {
|
|
36
15
|
try {
|
|
37
|
-
const
|
|
16
|
+
const resolved = await resolveImagePath(runDir, imagePath);
|
|
17
|
+
const buffer = await fs.readFile(resolved);
|
|
38
18
|
return buffer.toString('base64');
|
|
39
19
|
} catch {
|
|
40
20
|
return null;
|
|
@@ -42,227 +22,18 @@ async function readFileAsBase64(filePath) {
|
|
|
42
22
|
}
|
|
43
23
|
|
|
44
24
|
/**
|
|
45
|
-
* @param {
|
|
46
|
-
* @param {string} alt
|
|
47
|
-
* @returns {string}
|
|
48
|
-
*/
|
|
49
|
-
function imgTag(base64Data, alt) {
|
|
50
|
-
if (!base64Data) return `<span class="no-img">Image unavailable</span>`;
|
|
51
|
-
return `<img src="data:image/png;base64,${base64Data}" alt="${escapeHtml(alt)}" />`;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Generates a self-contained HTML report from a diff summary.
|
|
56
|
-
* Images are embedded as base64 data URIs when screenshot directories are provided.
|
|
57
|
-
*
|
|
58
|
-
* @param {DriftSummary} summary
|
|
25
|
+
* @param {import('../types/visual-diff-types').VisualDiffSummary} summary
|
|
59
26
|
* @param {{
|
|
60
27
|
* baselineRunDir?: string,
|
|
61
|
-
* currentRunDir?: string
|
|
28
|
+
* currentRunDir?: string,
|
|
29
|
+
* imageReader?: (runDir: string, imagePath: string) => Promise<string | null>
|
|
62
30
|
* }} [options]
|
|
63
31
|
* @returns {Promise<string>}
|
|
64
32
|
*/
|
|
65
33
|
export async function generateHtmlReport(summary, options = {}) {
|
|
66
|
-
const {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const statusClass = {
|
|
70
|
-
clean: 'status-clean',
|
|
71
|
-
'changes-detected': 'status-changes',
|
|
72
|
-
incomplete: 'status-incomplete',
|
|
73
|
-
skipped: 'status-skipped'
|
|
74
|
-
}[summary.status || 'incomplete'] || 'status-incomplete';
|
|
75
|
-
|
|
76
|
-
const statusLabel = {
|
|
77
|
-
clean: '✓ Clean',
|
|
78
|
-
'changes-detected': 'Drift detected',
|
|
79
|
-
incomplete: 'Incomplete',
|
|
80
|
-
skipped: 'Skipped'
|
|
81
|
-
}[summary.status || 'incomplete'] || 'Incomplete';
|
|
82
|
-
|
|
83
|
-
// --- Changed routes ---
|
|
84
|
-
let changedHtml;
|
|
85
|
-
if (summary.changed.length === 0) {
|
|
86
|
-
changedHtml = '<p class="none">None</p>';
|
|
87
|
-
} else {
|
|
88
|
-
const rows = await Promise.all(summary.changed.map(async (item) => {
|
|
89
|
-
let baselineImgHtml = '';
|
|
90
|
-
let currentImgHtml = '';
|
|
91
|
-
|
|
92
|
-
if (baselineRunDir) {
|
|
93
|
-
try {
|
|
94
|
-
const resolved = await resolveImagePath(baselineRunDir, item.baselineImagePath);
|
|
95
|
-
baselineImgHtml = imgTag(await readFileAsBase64(resolved), `Baseline: ${item.id}`);
|
|
96
|
-
} catch {
|
|
97
|
-
baselineImgHtml = imgTag(null, `Baseline: ${item.id}`);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
if (currentRunDir) {
|
|
101
|
-
try {
|
|
102
|
-
const resolved = await resolveImagePath(currentRunDir, item.currentImagePath);
|
|
103
|
-
currentImgHtml = imgTag(await readFileAsBase64(resolved), `Current: ${item.id}`);
|
|
104
|
-
} catch {
|
|
105
|
-
currentImgHtml = imgTag(null, `Current: ${item.id}`);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const dataRow = `<tr>
|
|
110
|
-
<td>${escapeHtml(item.id)}</td>
|
|
111
|
-
<td>${escapeHtml(item.path)}</td>
|
|
112
|
-
<td>${escapeHtml(formatViewport(item.viewport))}</td>
|
|
113
|
-
<td>${(item.mismatchRatio * 100).toFixed(2)}%</td>
|
|
114
|
-
<td>${item.differentPixels}/${item.totalPixels}</td>
|
|
115
|
-
</tr>`;
|
|
116
|
-
|
|
117
|
-
const imagesRow = (baselineImgHtml || currentImgHtml) ? `<tr class="images-row"><td colspan="5">
|
|
118
|
-
<div class="image-compare">
|
|
119
|
-
<div class="image-col"><div class="image-label">Baseline</div>${baselineImgHtml || '<span class="no-img">–</span>'}</div>
|
|
120
|
-
<div class="image-col"><div class="image-label">Current</div>${currentImgHtml || '<span class="no-img">–</span>'}</div>
|
|
121
|
-
</div>
|
|
122
|
-
</td></tr>` : '';
|
|
123
|
-
|
|
124
|
-
return dataRow + imagesRow;
|
|
125
|
-
}));
|
|
126
|
-
|
|
127
|
-
changedHtml = `<table>
|
|
128
|
-
<thead><tr><th>Route</th><th>Path</th><th>Viewport</th><th>Mismatch</th><th>Pixels changed</th></tr></thead>
|
|
129
|
-
<tbody>${rows.join('')}</tbody>
|
|
130
|
-
</table>`;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// --- Capture gaps ---
|
|
134
|
-
let missingHtml;
|
|
135
|
-
if (summary.missing.length === 0) {
|
|
136
|
-
missingHtml = '<p class="none">None</p>';
|
|
137
|
-
} else {
|
|
138
|
-
const rows = summary.missing.map((item) => `<tr>
|
|
139
|
-
<td>${escapeHtml(item.id)}</td>
|
|
140
|
-
<td>${escapeHtml(item.location)}</td>
|
|
141
|
-
<td>${escapeHtml(item.reason)}</td>
|
|
142
|
-
</tr>`).join('');
|
|
143
|
-
missingHtml = `<table>
|
|
144
|
-
<thead><tr><th>Route</th><th>Missing from</th><th>Reason</th></tr></thead>
|
|
145
|
-
<tbody>${rows}</tbody>
|
|
146
|
-
</table>`;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// --- Dimension shifts ---
|
|
150
|
-
let dimensionHtml;
|
|
151
|
-
if (dimensionChanges.length === 0) {
|
|
152
|
-
dimensionHtml = '<p class="none">None</p>';
|
|
153
|
-
} else {
|
|
154
|
-
const rows = dimensionChanges.map((item) => `<tr>
|
|
155
|
-
<td>${escapeHtml(item.id)}</td>
|
|
156
|
-
<td>${escapeHtml(formatViewport(item.viewport))}</td>
|
|
157
|
-
<td>${item.baselineWidth}×${item.baselineHeight}</td>
|
|
158
|
-
<td>${item.currentWidth}×${item.currentHeight}</td>
|
|
159
|
-
</tr>`).join('');
|
|
160
|
-
dimensionHtml = `<table>
|
|
161
|
-
<thead><tr><th>Route</th><th>Viewport</th><th>Baseline</th><th>Current</th></tr></thead>
|
|
162
|
-
<tbody>${rows}</tbody>
|
|
163
|
-
</table>`;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// --- Errors ---
|
|
167
|
-
let errorsHtml;
|
|
168
|
-
if (summary.errors.length === 0) {
|
|
169
|
-
errorsHtml = '<p class="none">None</p>';
|
|
170
|
-
} else {
|
|
171
|
-
const rows = summary.errors.map((item) => `<tr>
|
|
172
|
-
<td>${escapeHtml(item.id)}</td>
|
|
173
|
-
<td>${escapeHtml(item.message)}</td>
|
|
174
|
-
</tr>`).join('');
|
|
175
|
-
errorsHtml = `<table>
|
|
176
|
-
<thead><tr><th>Route</th><th>Error</th></tr></thead>
|
|
177
|
-
<tbody>${rows}</tbody>
|
|
178
|
-
</table>`;
|
|
34
|
+
const enrichedOptions = { ...options };
|
|
35
|
+
if (!enrichedOptions.imageReader && (options.baselineRunDir || options.currentRunDir)) {
|
|
36
|
+
enrichedOptions.imageReader = defaultImageReader;
|
|
179
37
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const metaParts = [];
|
|
183
|
-
if (summary.baselineArtifactName) metaParts.push(`Baseline: <code>${escapeHtml(summary.baselineArtifactName)}</code>`);
|
|
184
|
-
if (summary.baselineSourceSha) metaParts.push(`SHA: <code>${escapeHtml(summary.baselineSourceSha)}</code>`);
|
|
185
|
-
metaParts.push(`Diff mode: <code>${escapeHtml(summary.diffMode)}</code>`);
|
|
186
|
-
metaParts.push(`Threshold: <code>${summary.threshold}</code>`);
|
|
187
|
-
|
|
188
|
-
const generatedAt = escapeHtml(summary.finishedAt || summary.startedAt);
|
|
189
|
-
|
|
190
|
-
return `<!DOCTYPE html>
|
|
191
|
-
<html lang="en">
|
|
192
|
-
<head>
|
|
193
|
-
<meta charset="UTF-8" />
|
|
194
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
195
|
-
<title>SnapDrift Report</title>
|
|
196
|
-
<style>
|
|
197
|
-
*, *::before, *::after { box-sizing: border-box; }
|
|
198
|
-
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 24px; background: #f6f8fa; color: #24292f; line-height: 1.5; }
|
|
199
|
-
a { color: #0969da; }
|
|
200
|
-
h1 { margin: 0 0 8px; font-size: 1.4em; }
|
|
201
|
-
h2 { font-size: 1.1em; border-bottom: 1px solid #d0d7de; padding-bottom: 6px; margin: 0 0 12px; }
|
|
202
|
-
.header { background: #fff; border: 1px solid #d0d7de; border-radius: 8px; padding: 20px 24px; margin-bottom: 20px; }
|
|
203
|
-
.status { display: inline-block; font-size: 1.1em; font-weight: 600; padding: 3px 10px; border-radius: 12px; }
|
|
204
|
-
.status-clean { background: #dafbe1; color: #116329; }
|
|
205
|
-
.status-changes { background: #fff8c5; color: #7d4e00; }
|
|
206
|
-
.status-incomplete { background: #fff3cd; color: #664d03; }
|
|
207
|
-
.status-skipped { background: #f6f8fa; color: #57606a; }
|
|
208
|
-
.stats { display: flex; gap: 10px; margin-top: 14px; flex-wrap: wrap; }
|
|
209
|
-
.stat { background: #f6f8fa; border: 1px solid #d0d7de; border-radius: 6px; padding: 8px 14px; min-width: 100px; }
|
|
210
|
-
.stat strong { display: block; font-size: 1.5em; line-height: 1.2; }
|
|
211
|
-
.stat span { font-size: 0.8em; color: #57606a; }
|
|
212
|
-
.meta { margin-top: 12px; font-size: 0.85em; color: #57606a; }
|
|
213
|
-
.meta em { margin-right: 14px; font-style: normal; }
|
|
214
|
-
section { background: #fff; border: 1px solid #d0d7de; border-radius: 8px; padding: 20px 24px; margin-bottom: 20px; }
|
|
215
|
-
table { border-collapse: collapse; width: 100%; font-size: 0.9em; }
|
|
216
|
-
th { background: #f6f8fa; text-align: left; padding: 7px 10px; border-bottom: 1px solid #d0d7de; white-space: nowrap; }
|
|
217
|
-
td { padding: 7px 10px; border-bottom: 1px solid #eaecef; vertical-align: top; word-break: break-word; }
|
|
218
|
-
tr:last-child td { border-bottom: none; }
|
|
219
|
-
.images-row td { padding: 0 10px 14px; background: #fafafa; }
|
|
220
|
-
.image-compare { display: flex; gap: 16px; flex-wrap: wrap; }
|
|
221
|
-
.image-col { flex: 1; min-width: 260px; }
|
|
222
|
-
.image-label { font-size: 0.78em; font-weight: 600; color: #57606a; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; }
|
|
223
|
-
.image-col img { max-width: 100%; border: 1px solid #d0d7de; border-radius: 4px; display: block; }
|
|
224
|
-
.no-img { color: #57606a; font-size: 0.85em; font-style: italic; }
|
|
225
|
-
.none { color: #57606a; font-style: italic; margin: 0; }
|
|
226
|
-
code { background: #eaeef2; padding: 1px 5px; border-radius: 4px; font-size: 0.88em; }
|
|
227
|
-
footer { font-size: 0.8em; color: #57606a; text-align: right; margin-top: 8px; }
|
|
228
|
-
</style>
|
|
229
|
-
</head>
|
|
230
|
-
<body>
|
|
231
|
-
<div class="header">
|
|
232
|
-
<h1>SnapDrift Visual Diff Report</h1>
|
|
233
|
-
<span class="status ${statusClass}">${statusLabel}</span>
|
|
234
|
-
<div class="stats">
|
|
235
|
-
<div class="stat"><strong>${summary.changedScreenshots}</strong><span>Drift signals</span></div>
|
|
236
|
-
<div class="stat"><strong>${summary.matchedScreenshots}</strong><span>Stable captures</span></div>
|
|
237
|
-
<div class="stat"><strong>${summary.missingInBaseline + summary.missingInCurrent}</strong><span>Capture gaps</span></div>
|
|
238
|
-
<div class="stat"><strong>${dimensionChanges.length}</strong><span>Dimension shifts</span></div>
|
|
239
|
-
<div class="stat"><strong>${summary.errors.length}</strong><span>Errors</span></div>
|
|
240
|
-
</div>
|
|
241
|
-
<div class="meta">${metaParts.map((p) => `<em>${p}</em>`).join('')}</div>
|
|
242
|
-
</div>
|
|
243
|
-
|
|
244
|
-
<section>
|
|
245
|
-
<h2>Drift signals</h2>
|
|
246
|
-
${changedHtml}
|
|
247
|
-
</section>
|
|
248
|
-
|
|
249
|
-
<section>
|
|
250
|
-
<h2>Capture gaps</h2>
|
|
251
|
-
${missingHtml}
|
|
252
|
-
</section>
|
|
253
|
-
|
|
254
|
-
<section>
|
|
255
|
-
<h2>Dimension shifts</h2>
|
|
256
|
-
${dimensionHtml}
|
|
257
|
-
</section>
|
|
258
|
-
|
|
259
|
-
<section>
|
|
260
|
-
<h2>Comparison errors</h2>
|
|
261
|
-
${errorsHtml}
|
|
262
|
-
</section>
|
|
263
|
-
|
|
264
|
-
<footer>Generated by <a href="https://github.com/ranacseruet/snapdrift">SnapDrift</a> · ${generatedAt}</footer>
|
|
265
|
-
</body>
|
|
266
|
-
</html>
|
|
267
|
-
`;
|
|
268
|
-
}
|
|
38
|
+
return _generateHtmlReport(summary, enrichedOptions);
|
|
39
|
+
}
|