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.
@@ -1,156 +1,5 @@
1
1
  // @ts-check
2
+ // Re-exports from packages — this module is a thin shim.
2
3
 
3
- import fs from 'node:fs/promises';
4
- import path from 'node:path';
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
+ }