snapdrift 0.2.1 → 0.4.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/actions/comment/action.yml +22 -3
- package/actions/pr-diff/action.yml +16 -3
- package/docs/contracts.md +126 -1
- package/lib/capture-routes.mjs +2 -368
- package/lib/cli.mjs +167 -34
- package/lib/compare-results.mjs +21 -642
- package/lib/drift-summary.mjs +3 -154
- package/lib/init-from-action.mjs +244 -0
- package/lib/migrate-baselines.mjs +199 -0
- package/lib/pr-comment.mjs +2 -147
- package/lib/provider.mjs +106 -0
- package/lib/report.mjs +17 -246
- package/lib/snap-provider.mjs +734 -0
- package/lib/snapdrift-config.mjs +22 -284
- package/lib/stage-artifacts.mjs +2 -123
- package/package.json +16 -4
- package/types/visual-diff-types.d.ts +65 -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';
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import yaml from 'js-yaml';
|
|
6
|
+
|
|
7
|
+
/** @typedef {import('../types/visual-diff-types').InitWarning} InitWarning */
|
|
8
|
+
|
|
9
|
+
const DEFAULT_OUTPUT_DIR = '.github';
|
|
10
|
+
const DEFAULT_CONFIG_FILENAME = 'snapdrift.json';
|
|
11
|
+
const MIGRATION_NOTES_FILENAME = 'MIGRATION_NOTES.md';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Find the step that uses the Snap visual testing action.
|
|
15
|
+
*
|
|
16
|
+
* @param {object} workflow - Parsed YAML workflow
|
|
17
|
+
* @returns {{ step: object, jobName: string, stepIndex: number } | null}
|
|
18
|
+
*/
|
|
19
|
+
function findSnapActionStep(workflow) {
|
|
20
|
+
const jobs = workflow.jobs || {};
|
|
21
|
+
for (const [jobName, job] of Object.entries(jobs)) {
|
|
22
|
+
const steps = job.steps || [];
|
|
23
|
+
for (let i = 0; i < steps.length; i++) {
|
|
24
|
+
const step = steps[i];
|
|
25
|
+
const uses = step.uses || '';
|
|
26
|
+
if ((uses.includes('snap/github-action') || uses.includes('i2dev-com/snap')) && uses.includes('action')) {
|
|
27
|
+
return { step, jobName, stepIndex: i };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Map Snap action enforcement mode to snapdrift diff.mode.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} inputs
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
function mapDiffMode(inputs) {
|
|
41
|
+
const failOnChanges = inputs['fail-on-changes'] || inputs.fail_on_changes;
|
|
42
|
+
if (failOnChanges === 'true' || failOnChanges === true) {
|
|
43
|
+
return 'fail-on-changes';
|
|
44
|
+
}
|
|
45
|
+
const failOnIncomplete = inputs['fail-on-incomplete'] || inputs.fail_on_incomplete;
|
|
46
|
+
if (failOnIncomplete === 'true' || failOnIncomplete === true) {
|
|
47
|
+
return 'fail-on-incomplete';
|
|
48
|
+
}
|
|
49
|
+
return 'report-only';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Extract Snap action inputs and translate to snapdrift config fields.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} step - The workflow step using the Snap action
|
|
56
|
+
* @returns {{ config: object, warnings: InitWarning[] }}
|
|
57
|
+
*/
|
|
58
|
+
function translateStepToConfig(step) {
|
|
59
|
+
/** @type {InitWarning[]} */
|
|
60
|
+
const warnings = [];
|
|
61
|
+
const inputs = step.with || {};
|
|
62
|
+
|
|
63
|
+
// Diff settings
|
|
64
|
+
const threshold = parseFloat(inputs.threshold ?? inputs['diff-threshold'] ?? '0.01');
|
|
65
|
+
const diffMode = mapDiffMode(inputs);
|
|
66
|
+
|
|
67
|
+
// Format warning
|
|
68
|
+
const format = inputs.format;
|
|
69
|
+
if (format && format !== 'png') {
|
|
70
|
+
warnings.push({
|
|
71
|
+
field: 'format',
|
|
72
|
+
originalValue: format,
|
|
73
|
+
message: `SnapDrift only supports PNG screenshots. The Snap action's "format: ${format}" setting has no snapdrift equivalent and will be ignored.`,
|
|
74
|
+
severity: 'warning'
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Baseline tag warning
|
|
79
|
+
const baselineTag = inputs.baseline_tag || inputs['baseline-tag'];
|
|
80
|
+
if (baselineTag) {
|
|
81
|
+
warnings.push({
|
|
82
|
+
field: 'baseline_tag',
|
|
83
|
+
originalValue: baselineTag,
|
|
84
|
+
message: `SnapDrift uses commit-based baselines. The Snap action's "baseline_tag: ${baselineTag}" is a semantic gap that cannot be automatically translated. SnapDrift always uses the latest baseline from the target branch.`,
|
|
85
|
+
severity: 'warning'
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Snap provider config (if present in inputs)
|
|
90
|
+
/** @type {object | undefined} */
|
|
91
|
+
let snap;
|
|
92
|
+
const snapApiUrl = inputs['snap-api-url'] || inputs.snap_api_url;
|
|
93
|
+
const snapApiKeyEnv = inputs['snap-api-key-env'] || inputs.snap_api_key_env;
|
|
94
|
+
const snapProjectId = inputs['snap-project-id'] || inputs.snap_project_id;
|
|
95
|
+
|
|
96
|
+
if (snapApiKeyEnv || snapProjectId) {
|
|
97
|
+
snap = {};
|
|
98
|
+
if (snapApiUrl) snap.apiUrl = snapApiUrl;
|
|
99
|
+
if (snapApiKeyEnv) snap.apiKeyEnv = snapApiKeyEnv;
|
|
100
|
+
if (snapProjectId) snap.projectId = snapProjectId;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const config = {
|
|
104
|
+
baselineArtifactName: 'snapdrift-baseline',
|
|
105
|
+
workingDirectory: '.',
|
|
106
|
+
baseUrl: 'http://localhost:3000',
|
|
107
|
+
resultsFile: 'qa-artifacts/snapdrift/baseline/current/results.json',
|
|
108
|
+
manifestFile: 'qa-artifacts/snapdrift/baseline/current/manifest.json',
|
|
109
|
+
screenshotsRoot: 'qa-artifacts/snapdrift/baseline/current',
|
|
110
|
+
routes: [],
|
|
111
|
+
diff: {
|
|
112
|
+
threshold: isNaN(threshold) ? 0.01 : threshold,
|
|
113
|
+
mode: diffMode
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
if (snap) {
|
|
118
|
+
config.provider = 'snap';
|
|
119
|
+
config.snap = snap;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Note about routes — cannot be auto-translated from action inputs
|
|
123
|
+
warnings.push({
|
|
124
|
+
field: 'routes',
|
|
125
|
+
originalValue: '',
|
|
126
|
+
message: 'Routes cannot be automatically translated from the Snap action. You must fill in the routes array in snapdrift.json manually, matching the pages you want to test.',
|
|
127
|
+
severity: 'note'
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Note about baseUrl — cannot be auto-translated
|
|
131
|
+
warnings.push({
|
|
132
|
+
field: 'baseUrl',
|
|
133
|
+
originalValue: '',
|
|
134
|
+
message: 'The baseUrl was set to a placeholder (http://localhost:3000). Update it to match your app URL before running snapdrift.',
|
|
135
|
+
severity: 'note'
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
return { config, warnings };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Generate MIGRATION_NOTES.md content.
|
|
143
|
+
*
|
|
144
|
+
* @param {InitWarning[]} warnings
|
|
145
|
+
* @param {string} sourceYamlPath
|
|
146
|
+
* @returns {string}
|
|
147
|
+
*/
|
|
148
|
+
function generateMigrationNotes(warnings, sourceYamlPath) {
|
|
149
|
+
const lines = [
|
|
150
|
+
'# Migration Notes',
|
|
151
|
+
'',
|
|
152
|
+
`Generated from: \`${sourceYamlPath}\``,
|
|
153
|
+
'',
|
|
154
|
+
'## Warnings',
|
|
155
|
+
''
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
const warningsList = warnings.filter((w) => w.severity === 'warning');
|
|
159
|
+
if (warningsList.length === 0) {
|
|
160
|
+
lines.push('No warnings.');
|
|
161
|
+
} else {
|
|
162
|
+
for (const w of warningsList) {
|
|
163
|
+
lines.push(`- **${w.field}**: ${w.message}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
lines.push('');
|
|
168
|
+
lines.push('## Notes');
|
|
169
|
+
lines.push('');
|
|
170
|
+
|
|
171
|
+
const notesList = warnings.filter((w) => w.severity === 'note');
|
|
172
|
+
for (const n of notesList) {
|
|
173
|
+
lines.push(`- **${n.field}**: ${n.message}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
lines.push('');
|
|
177
|
+
return lines.join('\n');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Run the init --from-snap-action codemod.
|
|
182
|
+
*
|
|
183
|
+
* Reads a Snap action workflow YAML, translates it into snapdrift.json,
|
|
184
|
+
* and emits MIGRATION_NOTES.md with warnings.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} actionYamlPath - Path to the Snap action workflow YAML
|
|
187
|
+
* @returns {Promise<{ configPath: string, warningsCount: number }>}
|
|
188
|
+
*/
|
|
189
|
+
export async function runInitFromAction(actionYamlPath) {
|
|
190
|
+
// Read and parse YAML
|
|
191
|
+
let workflowContent;
|
|
192
|
+
try {
|
|
193
|
+
workflowContent = await fs.readFile(actionYamlPath, 'utf-8');
|
|
194
|
+
} catch {
|
|
195
|
+
throw new Error(`Cannot read workflow file: ${actionYamlPath}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const workflow = yaml.load(workflowContent);
|
|
199
|
+
if (!workflow || typeof workflow !== 'object') {
|
|
200
|
+
throw new Error(`Invalid workflow YAML: ${actionYamlPath}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Find the Snap action step
|
|
204
|
+
const snapStep = findSnapActionStep(workflow);
|
|
205
|
+
if (!snapStep) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`No Snap action step found in ${actionYamlPath}. ` +
|
|
208
|
+
`Looked for steps with "uses" containing "snap" and "action".`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Translate
|
|
213
|
+
const { config, warnings } = translateStepToConfig(snapStep.step);
|
|
214
|
+
|
|
215
|
+
// Ensure output directory exists
|
|
216
|
+
const outputDir = DEFAULT_OUTPUT_DIR;
|
|
217
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
218
|
+
|
|
219
|
+
const configPath = path.join(outputDir, DEFAULT_CONFIG_FILENAME);
|
|
220
|
+
const notesPath = path.join(outputDir, MIGRATION_NOTES_FILENAME);
|
|
221
|
+
|
|
222
|
+
// Idempotency: refuse to overwrite existing snapdrift.json
|
|
223
|
+
const configExists = await fs.access(configPath).then(() => true, () => false);
|
|
224
|
+
if (configExists) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
`${configPath} already exists. Remove it manually and re-run to regenerate.`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Write snapdrift.json
|
|
231
|
+
await fs.writeFile(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
232
|
+
|
|
233
|
+
// Write MIGRATION_NOTES.md
|
|
234
|
+
const notesContent = generateMigrationNotes(warnings, actionYamlPath);
|
|
235
|
+
await fs.writeFile(notesPath, notesContent);
|
|
236
|
+
|
|
237
|
+
process.stdout.write(`Created ${configPath}\n`);
|
|
238
|
+
process.stdout.write(`Created ${notesPath}\n`);
|
|
239
|
+
if (warnings.length > 0) {
|
|
240
|
+
process.stdout.write(`${warnings.length} warning(s)/note(s) — review ${notesPath}\n`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { configPath, warningsCount: warnings.length };
|
|
244
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { execSync } from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { createProvider } from './provider.mjs';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the current commit HEAD SHA.
|
|
11
|
+
* Tries GITHUB_SHA first, then git rev-parse HEAD.
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
function resolveHeadSha() {
|
|
15
|
+
if (process.env.GITHUB_SHA) {
|
|
16
|
+
return process.env.GITHUB_SHA;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
return execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim();
|
|
20
|
+
} catch {
|
|
21
|
+
throw new Error(
|
|
22
|
+
'Cannot resolve commit SHA. Set GITHUB_SHA or run inside a git repository.'
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read local baseline files from a directory.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} baselineDir
|
|
31
|
+
* @returns {Promise<{ results: object, manifest: object, screenshots: Array<{ filename: string, data: string }>, headSha: string }>}
|
|
32
|
+
*/
|
|
33
|
+
export async function readLocalBaselines(baselineDir) {
|
|
34
|
+
const resultsPath = path.join(baselineDir, 'results.json');
|
|
35
|
+
const manifestPath = path.join(baselineDir, 'manifest.json');
|
|
36
|
+
const screenshotsDir = path.join(baselineDir, 'screenshots');
|
|
37
|
+
|
|
38
|
+
let results;
|
|
39
|
+
try {
|
|
40
|
+
results = JSON.parse(await fs.readFile(resultsPath, 'utf-8'));
|
|
41
|
+
} catch {
|
|
42
|
+
throw new Error(`Cannot read baseline results: ${resultsPath}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let manifest;
|
|
46
|
+
try {
|
|
47
|
+
manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
|
|
48
|
+
} catch {
|
|
49
|
+
throw new Error(`Cannot read baseline manifest: ${manifestPath}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const screenshots = [];
|
|
53
|
+
try {
|
|
54
|
+
const entries = await fs.readdir(screenshotsDir);
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
if (!entry.endsWith('.png')) continue;
|
|
57
|
+
const filePath = path.join(screenshotsDir, entry);
|
|
58
|
+
const buffer = await fs.readFile(filePath);
|
|
59
|
+
screenshots.push({
|
|
60
|
+
filename: entry,
|
|
61
|
+
data: buffer.toString('base64')
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (err.code !== 'ENOENT') throw err;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const headSha = results.headSha || resolveHeadSha();
|
|
69
|
+
|
|
70
|
+
return { results, manifest, screenshots, headSha };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Validate that config has snap provider configuration.
|
|
75
|
+
*
|
|
76
|
+
* @param {import('../types/visual-diff-types').VisualRegressionConfig} config
|
|
77
|
+
* @throws {Error}
|
|
78
|
+
*/
|
|
79
|
+
function requireSnapConfig(config) {
|
|
80
|
+
if (!config.snap) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
'Migration requires snap config in snapdrift.json. ' +
|
|
83
|
+
'Add a "snap" section with apiKeyEnv or apiKey.'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Run the migrate-to-snap direction.
|
|
90
|
+
* Reads local baselines and uploads them to Snap.
|
|
91
|
+
*
|
|
92
|
+
* @param {import('../types/visual-diff-types').VisualRegressionConfig} config
|
|
93
|
+
* @param {import('../types/visual-diff-types').CliOptions} opts
|
|
94
|
+
* @returns {Promise<void>}
|
|
95
|
+
*/
|
|
96
|
+
export async function runMigrateToSnap(config, opts) {
|
|
97
|
+
process.stdout.write(`Migrating baselines to Snap ...\n`);
|
|
98
|
+
|
|
99
|
+
requireSnapConfig(config);
|
|
100
|
+
const provider = createProvider('snap', config);
|
|
101
|
+
const baselineDir = opts.baselineDir;
|
|
102
|
+
|
|
103
|
+
const { results, manifest, screenshots, headSha } = await readLocalBaselines(baselineDir);
|
|
104
|
+
|
|
105
|
+
process.stdout.write(`Found ${screenshots.length} screenshot(s) for commit ${headSha.substring(0, 8)}\n`);
|
|
106
|
+
|
|
107
|
+
// Check idempotency — skip if baseline already exists
|
|
108
|
+
const existing = await provider.checkBaselineExists(headSha);
|
|
109
|
+
if (existing) {
|
|
110
|
+
process.stdout.write(`Baseline already exists for commit ${headSha.substring(0, 8)}. Skipping upload.\n`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const { uploaded, baselineId } = await provider.migrateBaselineFromLocal({
|
|
115
|
+
results,
|
|
116
|
+
manifest,
|
|
117
|
+
screenshots,
|
|
118
|
+
headSha
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
process.stdout.write(`Uploaded ${uploaded} screenshot(s) as baseline ${baselineId}\n`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Run the migrate-to-local direction.
|
|
126
|
+
* Downloads baselines from Snap and writes them to the local directory.
|
|
127
|
+
*
|
|
128
|
+
* @param {import('../types/visual-diff-types').VisualRegressionConfig} config
|
|
129
|
+
* @param {import('../types/visual-diff-types').CliOptions} opts
|
|
130
|
+
* @returns {Promise<void>}
|
|
131
|
+
*/
|
|
132
|
+
export async function runMigrateToLocal(config, opts) {
|
|
133
|
+
process.stdout.write(`Migrating baselines from Snap to local ...\n`);
|
|
134
|
+
|
|
135
|
+
requireSnapConfig(config);
|
|
136
|
+
const provider = createProvider('snap', config);
|
|
137
|
+
const baselineDir = opts.baselineDir;
|
|
138
|
+
|
|
139
|
+
/** @type {{ results: object, manifest: object, screenshots: Array<{ filename: string, data: Buffer }>, engine: { name: string, version: string } }} */
|
|
140
|
+
let exported;
|
|
141
|
+
try {
|
|
142
|
+
exported = await provider.exportBaselines();
|
|
143
|
+
} catch (error) {
|
|
144
|
+
// Provide a clearer message for the stub case
|
|
145
|
+
if (error instanceof Error && error.message.includes('not yet available')) {
|
|
146
|
+
process.stderr.write(`Error: ${error.message}\n`);
|
|
147
|
+
process.exitCode = 1;
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Engine compatibility check
|
|
154
|
+
const engineName = exported.engine?.name || 'unknown';
|
|
155
|
+
if (engineName !== 'snapdrift-local' && !opts.acceptCrossEngine) {
|
|
156
|
+
process.stderr.write(
|
|
157
|
+
`Error: Cannot import baselines captured by a different engine ("${engineName}").\n` +
|
|
158
|
+
`Baselines from a different capture engine may produce different screenshots, making comparison results unreliable.\n\n` +
|
|
159
|
+
`If you want to proceed anyway, re-run with --accept-cross-engine.\n` +
|
|
160
|
+
`This will override the engine name to "snapdrift-local" in the imported manifest, but visual differences may occur.\n`
|
|
161
|
+
);
|
|
162
|
+
process.exitCode = 1;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (engineName !== 'snapdrift-local' && opts.acceptCrossEngine) {
|
|
167
|
+
process.stderr.write(
|
|
168
|
+
`Warning: Overriding engine name from "${engineName}" to "snapdrift-local". Visual differences may occur.\n`
|
|
169
|
+
);
|
|
170
|
+
if (exported.manifest && typeof exported.manifest === 'object') {
|
|
171
|
+
exported.manifest.captureProfile = exported.manifest.captureProfile || {};
|
|
172
|
+
exported.manifest.captureProfile.engine = exported.manifest.captureProfile.engine || {};
|
|
173
|
+
exported.manifest.captureProfile.engine.name = 'snapdrift-local';
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Write files
|
|
178
|
+
await fs.mkdir(baselineDir, { recursive: true });
|
|
179
|
+
await fs.mkdir(path.join(baselineDir, 'screenshots'), { recursive: true });
|
|
180
|
+
|
|
181
|
+
await fs.writeFile(path.join(baselineDir, 'results.json'), JSON.stringify(exported.results, null, 2));
|
|
182
|
+
await fs.writeFile(path.join(baselineDir, 'manifest.json'), JSON.stringify(exported.manifest, null, 2));
|
|
183
|
+
|
|
184
|
+
for (const screenshot of exported.screenshots) {
|
|
185
|
+
await fs.writeFile(path.join(baselineDir, 'screenshots', screenshot.filename), screenshot.data);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Write migration metadata for idempotency tracking
|
|
189
|
+
await fs.writeFile(
|
|
190
|
+
path.join(baselineDir, '.migration-metadata.json'),
|
|
191
|
+
JSON.stringify({
|
|
192
|
+
source: 'snap',
|
|
193
|
+
migratedAt: new Date().toISOString(),
|
|
194
|
+
engine: exported.engine
|
|
195
|
+
}, null, 2)
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
process.stdout.write(`Downloaded ${exported.screenshots.length} screenshot(s) to ${baselineDir}\n`);
|
|
199
|
+
}
|