snapdrift 0.3.0 → 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 +18 -0
- package/actions/comment/action.yml +22 -3
- package/actions/pr-diff/action.yml +16 -3
- package/docs/contracts.md +122 -0
- package/lib/cli.mjs +160 -26
- package/lib/init-from-action.mjs +244 -0
- package/lib/migrate-baselines.mjs +199 -0
- package/lib/provider.mjs +106 -0
- package/lib/snap-provider.mjs +734 -0
- package/lib/snapdrift-config.mjs +3 -1
- package/package.json +9 -5
- package/types/visual-diff-types.d.ts +65 -0
|
@@ -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
|
+
}
|
package/lib/provider.mjs
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { runBaselineCapture, generateDriftReport, stageArtifacts } from '@snapdrift/adapter-fs';
|
|
4
|
+
import { buildReportCommentBody } from '@snapdrift/adapter-report-md';
|
|
5
|
+
import { SnapProvider, SnapApiError, SnapUnavailableError, SnapFallbackError, SnapSkipError } from './snap-provider.mjs';
|
|
6
|
+
|
|
7
|
+
/** @typedef {import('../types/visual-diff-types').VisualProvider} VisualProvider */
|
|
8
|
+
/** @typedef {import('../types/visual-diff-types').ProviderCaptureOptions} CaptureOptions */
|
|
9
|
+
/** @typedef {import('../types/visual-diff-types').ProviderCaptureResult} CaptureResult */
|
|
10
|
+
/** @typedef {import('../types/visual-diff-types').ProviderDiffOptions} DiffOptions */
|
|
11
|
+
/** @typedef {import('../types/visual-diff-types').ProviderDiffResult} DiffResult */
|
|
12
|
+
/** @typedef {import('../types/visual-diff-types').ProviderPublishBaselineOptions} PublishBaselineOptions */
|
|
13
|
+
/** @typedef {import('../types/visual-diff-types').ProviderPublishBaselineResult} PublishBaselineResult */
|
|
14
|
+
/** @typedef {import('../types/visual-diff-types').ProviderFetchBaselineOptions} FetchBaselineOptions */
|
|
15
|
+
/** @typedef {import('../types/visual-diff-types').ProviderBaselineData} BaselineData */
|
|
16
|
+
/** @typedef {import('../types/visual-diff-types').SnapConfig} SnapConfig */
|
|
17
|
+
/** @typedef {import('../types/visual-diff-types').VisualRegressionConfig} VisualRegressionConfig */
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Local filesystem provider — wraps the current snapdrift behavior
|
|
21
|
+
* (Playwright capture, pixel diff, GH artifact baselines).
|
|
22
|
+
* @implements {VisualProvider}
|
|
23
|
+
*/
|
|
24
|
+
export class LocalProvider {
|
|
25
|
+
/**
|
|
26
|
+
* @param {CaptureOptions} options
|
|
27
|
+
* @returns {Promise<CaptureResult>}
|
|
28
|
+
*/
|
|
29
|
+
async capture(options) {
|
|
30
|
+
return runBaselineCapture(options);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {DiffOptions} options
|
|
35
|
+
* @returns {Promise<DiffResult>}
|
|
36
|
+
*/
|
|
37
|
+
async diff(options) {
|
|
38
|
+
return generateDriftReport(options);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {PublishBaselineOptions} options
|
|
43
|
+
* @returns {Promise<PublishBaselineResult>}
|
|
44
|
+
*/
|
|
45
|
+
async publishBaseline(options) {
|
|
46
|
+
const { configPath: _unused, ...stageOpts } = options;
|
|
47
|
+
return stageArtifacts({ artifactType: 'baseline', ...stageOpts });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {FetchBaselineOptions} _options
|
|
52
|
+
* @returns {Promise<never>}
|
|
53
|
+
* @throws {Error} Not yet implemented for LocalProvider.
|
|
54
|
+
* Use the pr-diff action for baseline resolution, or wait for a future release.
|
|
55
|
+
*/
|
|
56
|
+
async fetchLatestBaseline(_options) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
'LocalProvider.fetchLatestBaseline is not yet implemented. ' +
|
|
59
|
+
'Use the pr-diff action for baseline resolution, or wait for a future release.'
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build a PR comment body from a diff summary.
|
|
65
|
+
* LocalProvider omits the dashboard link (no dashboard exists).
|
|
66
|
+
*
|
|
67
|
+
* @param {Record<string, unknown>} summary
|
|
68
|
+
* @param {import('../types/visual-diff-types').ProviderCommentMeta} [meta]
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
buildCommentBody(summary, meta = {}) {
|
|
72
|
+
return buildReportCommentBody(summary, meta);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** @type {Record<string, (config?: VisualRegressionConfig) => VisualProvider>} */
|
|
77
|
+
const PROVIDER_FACTORIES = {
|
|
78
|
+
local: () => new LocalProvider(),
|
|
79
|
+
snap: (config) => {
|
|
80
|
+
if (!config?.snap) {
|
|
81
|
+
throw new Error('Snap provider requires snap configuration.');
|
|
82
|
+
}
|
|
83
|
+
return new SnapProvider(config.snap);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Create a VisualProvider instance by name.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} providerName - Provider identifier ("local" or "snap")
|
|
91
|
+
* @param {VisualRegressionConfig} [config] - Full config (required for "snap" provider)
|
|
92
|
+
* @returns {VisualProvider}
|
|
93
|
+
* @throws {Error} If the provider name is not recognized
|
|
94
|
+
*/
|
|
95
|
+
export function createProvider(providerName, config) {
|
|
96
|
+
const factory = PROVIDER_FACTORIES[providerName];
|
|
97
|
+
if (!factory) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Unknown SnapDrift provider: "${providerName}". ` +
|
|
100
|
+
`Available providers: ${Object.keys(PROVIDER_FACTORIES).join(', ')}.`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return factory(config);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export { SnapProvider, SnapApiError, SnapUnavailableError, SnapFallbackError, SnapSkipError };
|