snapdrift 0.3.0 → 0.5.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.
@@ -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
+ }
@@ -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, isLocalBaseUrl } 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, isLocalBaseUrl };