@vizzly-testing/cli 0.20.0 → 0.20.1-beta.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.
Files changed (72) hide show
  1. package/dist/api/client.js +134 -0
  2. package/dist/api/core.js +341 -0
  3. package/dist/api/endpoints.js +314 -0
  4. package/dist/api/index.js +19 -0
  5. package/dist/auth/client.js +91 -0
  6. package/dist/auth/core.js +176 -0
  7. package/dist/auth/index.js +30 -0
  8. package/dist/auth/operations.js +148 -0
  9. package/dist/cli.js +1 -1
  10. package/dist/commands/doctor.js +3 -3
  11. package/dist/commands/finalize.js +41 -15
  12. package/dist/commands/login.js +7 -6
  13. package/dist/commands/logout.js +4 -4
  14. package/dist/commands/project.js +5 -4
  15. package/dist/commands/run.js +158 -90
  16. package/dist/commands/status.js +22 -18
  17. package/dist/commands/tdd.js +105 -78
  18. package/dist/commands/upload.js +61 -26
  19. package/dist/commands/whoami.js +4 -4
  20. package/dist/config/core.js +438 -0
  21. package/dist/config/index.js +13 -0
  22. package/dist/config/operations.js +327 -0
  23. package/dist/index.js +1 -1
  24. package/dist/project/core.js +295 -0
  25. package/dist/project/index.js +13 -0
  26. package/dist/project/operations.js +393 -0
  27. package/dist/report-generator/core.js +315 -0
  28. package/dist/report-generator/index.js +8 -0
  29. package/dist/report-generator/operations.js +196 -0
  30. package/dist/reporter/reporter-bundle.iife.js +16 -16
  31. package/dist/screenshot-server/core.js +157 -0
  32. package/dist/screenshot-server/index.js +11 -0
  33. package/dist/screenshot-server/operations.js +183 -0
  34. package/dist/sdk/index.js +3 -2
  35. package/dist/server/handlers/api-handler.js +14 -5
  36. package/dist/server/handlers/tdd-handler.js +80 -48
  37. package/dist/server-manager/core.js +183 -0
  38. package/dist/server-manager/index.js +81 -0
  39. package/dist/server-manager/operations.js +208 -0
  40. package/dist/services/build-manager.js +2 -69
  41. package/dist/services/index.js +21 -48
  42. package/dist/services/screenshot-server.js +40 -74
  43. package/dist/services/server-manager.js +45 -80
  44. package/dist/services/static-report-generator.js +21 -163
  45. package/dist/services/test-runner.js +90 -250
  46. package/dist/services/uploader.js +56 -358
  47. package/dist/tdd/core/hotspot-coverage.js +112 -0
  48. package/dist/tdd/core/signature.js +101 -0
  49. package/dist/tdd/index.js +19 -0
  50. package/dist/tdd/metadata/baseline-metadata.js +103 -0
  51. package/dist/tdd/metadata/hotspot-metadata.js +93 -0
  52. package/dist/tdd/services/baseline-downloader.js +151 -0
  53. package/dist/tdd/services/baseline-manager.js +166 -0
  54. package/dist/tdd/services/comparison-service.js +230 -0
  55. package/dist/tdd/services/hotspot-service.js +71 -0
  56. package/dist/tdd/services/result-service.js +123 -0
  57. package/dist/tdd/tdd-service.js +1081 -0
  58. package/dist/test-runner/core.js +255 -0
  59. package/dist/test-runner/index.js +13 -0
  60. package/dist/test-runner/operations.js +483 -0
  61. package/dist/uploader/core.js +396 -0
  62. package/dist/uploader/index.js +11 -0
  63. package/dist/uploader/operations.js +412 -0
  64. package/package.json +7 -12
  65. package/dist/services/api-service.js +0 -412
  66. package/dist/services/auth-service.js +0 -226
  67. package/dist/services/config-service.js +0 -369
  68. package/dist/services/html-report-generator.js +0 -455
  69. package/dist/services/project-service.js +0 -326
  70. package/dist/services/report-generator/report.css +0 -411
  71. package/dist/services/report-generator/viewer.js +0 -102
  72. package/dist/services/tdd-service.js +0 -1437
@@ -0,0 +1,1081 @@
1
+ /**
2
+ * TDD Service - Local Visual Testing
3
+ *
4
+ * Orchestrates visual testing by composing the extracted modules.
5
+ * This is a thin orchestration layer - most logic lives in the modules.
6
+ *
7
+ * CRITICAL: Signature/filename generation MUST stay in sync with the cloud!
8
+ * See src/tdd/core/signature.js for details.
9
+ */
10
+
11
+ import { existsSync as defaultExistsSync, mkdirSync as defaultMkdirSync, readFileSync as defaultReadFileSync, writeFileSync as defaultWriteFileSync } from 'node:fs';
12
+ import { createApiClient as defaultCreateApiClient, getBatchHotspots as defaultGetBatchHotspots, getBuilds as defaultGetBuilds, getComparison as defaultGetComparison, getTddBaselines as defaultGetTddBaselines } from '../api/index.js';
13
+ import { NetworkError } from '../errors/vizzly-error.js';
14
+ import { StaticReportGenerator as DefaultStaticReportGenerator } from '../services/static-report-generator.js';
15
+ import { colors as defaultColors } from '../utils/colors.js';
16
+ import { fetchWithTimeout as defaultFetchWithTimeout } from '../utils/fetch-utils.js';
17
+ import { getDefaultBranch as defaultGetDefaultBranch } from '../utils/git.js';
18
+ import * as defaultOutput from '../utils/output.js';
19
+ import { safePath as defaultSafePath, sanitizeScreenshotName as defaultSanitizeScreenshotName, validatePathSecurity as defaultValidatePathSecurity, validateScreenshotProperties as defaultValidateScreenshotProperties } from '../utils/security.js';
20
+ import { calculateHotspotCoverage as defaultCalculateHotspotCoverage } from './core/hotspot-coverage.js';
21
+ // Import from extracted modules
22
+ import { generateBaselineFilename as defaultGenerateBaselineFilename, generateComparisonId as defaultGenerateComparisonId, generateScreenshotSignature as defaultGenerateScreenshotSignature } from './core/signature.js';
23
+ import { createEmptyBaselineMetadata as defaultCreateEmptyBaselineMetadata, loadBaselineMetadata as defaultLoadBaselineMetadata, saveBaselineMetadata as defaultSaveBaselineMetadata, upsertScreenshotInMetadata as defaultUpsertScreenshotInMetadata } from './metadata/baseline-metadata.js';
24
+ import { loadHotspotMetadata as defaultLoadHotspotMetadata, saveHotspotMetadata as defaultSaveHotspotMetadata } from './metadata/hotspot-metadata.js';
25
+ import { baselineExists as defaultBaselineExists, clearBaselineData as defaultClearBaselineData, getBaselinePath as defaultGetBaselinePath, getCurrentPath as defaultGetCurrentPath, getDiffPath as defaultGetDiffPath, initializeDirectories as defaultInitializeDirectories, saveBaseline as defaultSaveBaseline, saveCurrent as defaultSaveCurrent } from './services/baseline-manager.js';
26
+ import { buildErrorComparison as defaultBuildErrorComparison, buildFailedComparison as defaultBuildFailedComparison, buildNewComparison as defaultBuildNewComparison, buildPassedComparison as defaultBuildPassedComparison, compareImages as defaultCompareImages, isDimensionMismatchError as defaultIsDimensionMismatchError } from './services/comparison-service.js';
27
+ import { buildResults as defaultBuildResults, getFailedComparisons as defaultGetFailedComparisons, getNewComparisons as defaultGetNewComparisons } from './services/result-service.js';
28
+
29
+ /**
30
+ * Create a new TDD service instance
31
+ * @param {Object} config - Configuration object
32
+ * @param {Object} options - Options
33
+ * @param {string} options.workingDir - Working directory
34
+ * @param {boolean} options.setBaseline - Whether to set baselines
35
+ * @param {Object} options.authService - Authentication service
36
+ * @param {Object} deps - Injectable dependencies for testing
37
+ */
38
+ export function createTDDService(config, options = {}, deps = {}) {
39
+ return new TddService(config, options.workingDir, options.setBaseline, options.authService, deps);
40
+ }
41
+ export class TddService {
42
+ constructor(config, workingDir = process.cwd(), setBaseline = false, authService = null, deps = {}) {
43
+ // Grouped dependencies with defaults
44
+ let {
45
+ // Core utilities
46
+ output = defaultOutput,
47
+ colors = defaultColors,
48
+ validatePathSecurity = defaultValidatePathSecurity,
49
+ initializeDirectories = defaultInitializeDirectories,
50
+ // File system operations
51
+ fs = {},
52
+ // API operations
53
+ api = {},
54
+ // Baseline metadata operations
55
+ metadata = {},
56
+ // Baseline file management
57
+ baseline = {},
58
+ // Screenshot comparison
59
+ comparison = {},
60
+ // Signature generation and security
61
+ signature = {},
62
+ // Result building
63
+ results = {},
64
+ // Other
65
+ calculateHotspotCoverage = defaultCalculateHotspotCoverage,
66
+ StaticReportGenerator = DefaultStaticReportGenerator
67
+ } = deps;
68
+
69
+ // Merge grouped deps with defaults
70
+ let fsOps = {
71
+ existsSync: defaultExistsSync,
72
+ mkdirSync: defaultMkdirSync,
73
+ readFileSync: defaultReadFileSync,
74
+ writeFileSync: defaultWriteFileSync,
75
+ ...fs
76
+ };
77
+ let apiOps = {
78
+ createApiClient: defaultCreateApiClient,
79
+ getTddBaselines: defaultGetTddBaselines,
80
+ getBuilds: defaultGetBuilds,
81
+ getComparison: defaultGetComparison,
82
+ getBatchHotspots: defaultGetBatchHotspots,
83
+ fetchWithTimeout: defaultFetchWithTimeout,
84
+ getDefaultBranch: defaultGetDefaultBranch,
85
+ ...api
86
+ };
87
+ let metadataOps = {
88
+ loadBaselineMetadata: defaultLoadBaselineMetadata,
89
+ saveBaselineMetadata: defaultSaveBaselineMetadata,
90
+ createEmptyBaselineMetadata: defaultCreateEmptyBaselineMetadata,
91
+ upsertScreenshotInMetadata: defaultUpsertScreenshotInMetadata,
92
+ loadHotspotMetadata: defaultLoadHotspotMetadata,
93
+ saveHotspotMetadata: defaultSaveHotspotMetadata,
94
+ ...metadata
95
+ };
96
+ let baselineOps = {
97
+ baselineExists: defaultBaselineExists,
98
+ clearBaselineData: defaultClearBaselineData,
99
+ getBaselinePath: defaultGetBaselinePath,
100
+ getCurrentPath: defaultGetCurrentPath,
101
+ getDiffPath: defaultGetDiffPath,
102
+ saveBaseline: defaultSaveBaseline,
103
+ saveCurrent: defaultSaveCurrent,
104
+ ...baseline
105
+ };
106
+ let comparisonOps = {
107
+ compareImages: defaultCompareImages,
108
+ buildPassedComparison: defaultBuildPassedComparison,
109
+ buildNewComparison: defaultBuildNewComparison,
110
+ buildFailedComparison: defaultBuildFailedComparison,
111
+ buildErrorComparison: defaultBuildErrorComparison,
112
+ isDimensionMismatchError: defaultIsDimensionMismatchError,
113
+ ...comparison
114
+ };
115
+ let signatureOps = {
116
+ generateScreenshotSignature: defaultGenerateScreenshotSignature,
117
+ generateBaselineFilename: defaultGenerateBaselineFilename,
118
+ generateComparisonId: defaultGenerateComparisonId,
119
+ sanitizeScreenshotName: defaultSanitizeScreenshotName,
120
+ validateScreenshotProperties: defaultValidateScreenshotProperties,
121
+ safePath: defaultSafePath,
122
+ ...signature
123
+ };
124
+ let resultsOps = {
125
+ buildResults: defaultBuildResults,
126
+ getFailedComparisons: defaultGetFailedComparisons,
127
+ getNewComparisons: defaultGetNewComparisons,
128
+ ...results
129
+ };
130
+
131
+ // Store flattened dependencies for use in methods
132
+ this._deps = {
133
+ output,
134
+ colors,
135
+ validatePathSecurity,
136
+ initializeDirectories,
137
+ calculateHotspotCoverage,
138
+ StaticReportGenerator,
139
+ ...fsOps,
140
+ ...apiOps,
141
+ ...metadataOps,
142
+ ...baselineOps,
143
+ ...comparisonOps,
144
+ ...signatureOps,
145
+ ...resultsOps
146
+ };
147
+ this.config = config;
148
+ this.setBaseline = setBaseline;
149
+ this.authService = authService;
150
+ this.client = apiOps.createApiClient({
151
+ baseUrl: config.apiUrl,
152
+ token: config.apiKey,
153
+ command: 'tdd',
154
+ allowNoToken: true
155
+ });
156
+
157
+ // Validate and secure the working directory
158
+ try {
159
+ this.workingDir = validatePathSecurity(workingDir, workingDir);
160
+ } catch (error) {
161
+ output.error(`Invalid working directory: ${error.message}`);
162
+ throw new Error(`Working directory validation failed: ${error.message}`);
163
+ }
164
+
165
+ // Initialize directories using extracted module
166
+ let paths = initializeDirectories(this.workingDir);
167
+ this.baselinePath = paths.baselinePath;
168
+ this.currentPath = paths.currentPath;
169
+ this.diffPath = paths.diffPath;
170
+
171
+ // State
172
+ this.baselineData = null;
173
+ this.comparisons = [];
174
+ this.threshold = config.comparison?.threshold || 2.0;
175
+ this.minClusterSize = config.comparison?.minClusterSize ?? 2;
176
+ this.signatureProperties = config.signatureProperties ?? [];
177
+
178
+ // Hotspot data (loaded lazily from disk or downloaded from cloud)
179
+ this.hotspotData = null;
180
+ if (this.setBaseline) {
181
+ output.info('🐻 Baseline update mode - will overwrite existing baselines with new ones');
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Download baselines from cloud
187
+ */
188
+ async downloadBaselines(environment = 'test', branch = null, buildId = null, comparisonId = null) {
189
+ // If no branch specified, detect default branch
190
+ if (!branch) {
191
+ branch = await getDefaultBranch();
192
+ if (!branch) {
193
+ branch = 'main';
194
+ output.warn(`⚠️ Could not detect default branch, using 'main' as fallback`);
195
+ } else {
196
+ output.debug('tdd', `detected default branch: ${branch}`);
197
+ }
198
+ }
199
+ try {
200
+ let baselineBuild;
201
+ if (buildId) {
202
+ let apiResponse = await getTddBaselines(this.client, buildId);
203
+ if (!apiResponse) {
204
+ throw new Error(`Build ${buildId} not found or API returned null`);
205
+ }
206
+
207
+ // Clear local state before downloading
208
+ output.info('Clearing local state before downloading baselines...');
209
+ clearBaselineData({
210
+ baselinePath: this.baselinePath,
211
+ currentPath: this.currentPath,
212
+ diffPath: this.diffPath
213
+ });
214
+
215
+ // Extract signature properties
216
+ if (apiResponse.signatureProperties && Array.isArray(apiResponse.signatureProperties)) {
217
+ this.signatureProperties = apiResponse.signatureProperties;
218
+ if (this.signatureProperties.length > 0) {
219
+ output.info(`Using signature properties: ${this.signatureProperties.join(', ')}`);
220
+ }
221
+ }
222
+ baselineBuild = apiResponse.build;
223
+ if (baselineBuild.status === 'failed') {
224
+ output.warn(`⚠️ Build ${buildId} is marked as FAILED - falling back to local baselines`);
225
+ return await this.handleLocalBaselines();
226
+ } else if (baselineBuild.status !== 'completed') {
227
+ output.warn(`⚠️ Build ${buildId} has status: ${baselineBuild.status} (expected: completed)`);
228
+ }
229
+ baselineBuild.screenshots = apiResponse.screenshots;
230
+ } else if (comparisonId) {
231
+ // Handle specific comparison download
232
+ output.info(`Using comparison: ${comparisonId}`);
233
+ let comparison = await getComparison(this.client, comparisonId);
234
+ if (!comparison.baseline_screenshot) {
235
+ throw new Error(`Comparison ${comparisonId} has no baseline screenshot. This comparison may be a "new" screenshot.`);
236
+ }
237
+ let baselineUrl = comparison.baseline_screenshot.original_url || comparison.baseline_screenshot_url;
238
+ if (!baselineUrl) {
239
+ throw new Error(`Baseline screenshot for comparison ${comparisonId} has no download URL`);
240
+ }
241
+ let screenshotProperties = {};
242
+ if (comparison.current_viewport_width || comparison.current_browser) {
243
+ if (comparison.current_viewport_width) {
244
+ screenshotProperties.viewport = {
245
+ width: comparison.current_viewport_width,
246
+ height: comparison.current_viewport_height
247
+ };
248
+ }
249
+ if (comparison.current_browser) {
250
+ screenshotProperties.browser = comparison.current_browser;
251
+ }
252
+ } else if (comparison.baseline_viewport_width || comparison.baseline_browser) {
253
+ if (comparison.baseline_viewport_width) {
254
+ screenshotProperties.viewport = {
255
+ width: comparison.baseline_viewport_width,
256
+ height: comparison.baseline_viewport_height
257
+ };
258
+ }
259
+ if (comparison.baseline_browser) {
260
+ screenshotProperties.browser = comparison.baseline_browser;
261
+ }
262
+ }
263
+ let screenshotName = comparison.baseline_name || comparison.current_name;
264
+ let signature = generateScreenshotSignature(screenshotName, screenshotProperties, this.signatureProperties);
265
+ let filename = generateBaselineFilename(screenshotName, signature);
266
+ baselineBuild = {
267
+ id: comparison.baseline_screenshot.build_id || 'comparison-baseline',
268
+ name: `Comparison ${comparisonId.substring(0, 8)}`,
269
+ screenshots: [{
270
+ id: comparison.baseline_screenshot.id,
271
+ name: screenshotName,
272
+ original_url: baselineUrl,
273
+ metadata: screenshotProperties,
274
+ properties: screenshotProperties,
275
+ filename: filename
276
+ }]
277
+ };
278
+ } else {
279
+ // Get latest passed build
280
+ let builds = await getBuilds(this.client, {
281
+ environment,
282
+ branch,
283
+ status: 'passed',
284
+ limit: 1
285
+ });
286
+ if (!builds.data || builds.data.length === 0) {
287
+ output.warn(`⚠️ No baseline builds found for ${environment}/${branch}`);
288
+ output.info('💡 Run a build in normal mode first to create baselines');
289
+ return null;
290
+ }
291
+ let apiResponse = await getTddBaselines(this.client, builds.data[0].id);
292
+ if (!apiResponse) {
293
+ throw new Error(`Build ${builds.data[0].id} not found or API returned null`);
294
+ }
295
+ if (apiResponse.signatureProperties && Array.isArray(apiResponse.signatureProperties)) {
296
+ this.signatureProperties = apiResponse.signatureProperties;
297
+ if (this.signatureProperties.length > 0) {
298
+ output.info(`Using custom signature properties: ${this.signatureProperties.join(', ')}`);
299
+ }
300
+ }
301
+ baselineBuild = apiResponse.build;
302
+ baselineBuild.screenshots = apiResponse.screenshots;
303
+ }
304
+ let buildDetails = baselineBuild;
305
+ if (!buildDetails.screenshots || buildDetails.screenshots.length === 0) {
306
+ output.warn('⚠️ No screenshots found in baseline build');
307
+ return null;
308
+ }
309
+ output.info(`Using baseline from build: ${colors.cyan(baselineBuild.name || 'Unknown')} (${baselineBuild.id || 'Unknown ID'})`);
310
+ output.info(`Checking ${colors.cyan(buildDetails.screenshots.length)} baseline screenshots...`);
311
+
312
+ // Check existing baseline metadata for SHA comparison
313
+ let existingBaseline = await this.loadBaseline();
314
+ let existingShaMap = new Map();
315
+ if (existingBaseline) {
316
+ existingBaseline.screenshots.forEach(s => {
317
+ if (s.sha256 && s.filename) {
318
+ existingShaMap.set(s.filename, s.sha256);
319
+ }
320
+ });
321
+ }
322
+
323
+ // Download screenshots
324
+ let downloadedCount = 0;
325
+ let skippedCount = 0;
326
+ let errorCount = 0;
327
+ let batchSize = 5;
328
+ let screenshotsToProcess = [];
329
+ for (let screenshot of buildDetails.screenshots) {
330
+ let sanitizedName;
331
+ try {
332
+ sanitizedName = sanitizeScreenshotName(screenshot.name);
333
+ } catch (error) {
334
+ output.warn(`Skipping screenshot with invalid name '${screenshot.name}': ${error.message}`);
335
+ errorCount++;
336
+ continue;
337
+ }
338
+ let filename = screenshot.filename;
339
+ if (!filename) {
340
+ output.warn(`⚠️ Screenshot ${sanitizedName} has no filename from API - skipping`);
341
+ errorCount++;
342
+ continue;
343
+ }
344
+ let imagePath = safePath(this.baselinePath, filename);
345
+
346
+ // Check SHA
347
+ if (existsSync(imagePath) && screenshot.sha256) {
348
+ let storedSha = existingShaMap.get(filename);
349
+ if (storedSha === screenshot.sha256) {
350
+ downloadedCount++;
351
+ skippedCount++;
352
+ continue;
353
+ }
354
+ }
355
+ let downloadUrl = screenshot.original_url || screenshot.url;
356
+ if (!downloadUrl) {
357
+ output.warn(`⚠️ Screenshot ${sanitizedName} has no download URL - skipping`);
358
+ errorCount++;
359
+ continue;
360
+ }
361
+ screenshotsToProcess.push({
362
+ screenshot,
363
+ sanitizedName,
364
+ imagePath,
365
+ downloadUrl,
366
+ filename
367
+ });
368
+ }
369
+
370
+ // Process downloads in batches
371
+ if (screenshotsToProcess.length > 0) {
372
+ output.info(`📥 Downloading ${screenshotsToProcess.length} new/updated screenshots...`);
373
+ for (let i = 0; i < screenshotsToProcess.length; i += batchSize) {
374
+ let batch = screenshotsToProcess.slice(i, i + batchSize);
375
+ let batchNum = Math.floor(i / batchSize) + 1;
376
+ let totalBatches = Math.ceil(screenshotsToProcess.length / batchSize);
377
+ output.info(`📦 Processing batch ${batchNum}/${totalBatches}`);
378
+ let downloadPromises = batch.map(async ({
379
+ sanitizedName,
380
+ imagePath,
381
+ downloadUrl
382
+ }) => {
383
+ try {
384
+ let response = await fetchWithTimeout(downloadUrl);
385
+ if (!response.ok) {
386
+ throw new NetworkError(`Failed to download ${sanitizedName}: ${response.statusText}`);
387
+ }
388
+ let arrayBuffer = await response.arrayBuffer();
389
+ let imageBuffer = Buffer.from(arrayBuffer);
390
+ writeFileSync(imagePath, imageBuffer);
391
+ return {
392
+ success: true,
393
+ name: sanitizedName
394
+ };
395
+ } catch (error) {
396
+ output.warn(`⚠️ Failed to download ${sanitizedName}: ${error.message}`);
397
+ return {
398
+ success: false,
399
+ name: sanitizedName,
400
+ error: error.message
401
+ };
402
+ }
403
+ });
404
+ let batchResults = await Promise.all(downloadPromises);
405
+ let batchSuccesses = batchResults.filter(r => r.success).length;
406
+ let batchFailures = batchResults.filter(r => !r.success).length;
407
+ downloadedCount += batchSuccesses;
408
+ errorCount += batchFailures;
409
+ }
410
+ }
411
+ if (downloadedCount === 0 && skippedCount === 0) {
412
+ output.error('❌ No screenshots were successfully downloaded');
413
+ return null;
414
+ }
415
+
416
+ // Store baseline metadata
417
+ this.baselineData = {
418
+ buildId: baselineBuild.id,
419
+ buildName: baselineBuild.name,
420
+ environment,
421
+ branch,
422
+ threshold: this.threshold,
423
+ signatureProperties: this.signatureProperties,
424
+ createdAt: new Date().toISOString(),
425
+ buildInfo: {
426
+ commitSha: baselineBuild.commit_sha,
427
+ commitMessage: baselineBuild.commit_message,
428
+ approvalStatus: baselineBuild.approval_status,
429
+ completedAt: baselineBuild.completed_at
430
+ },
431
+ screenshots: buildDetails.screenshots.filter(s => s.filename).map(s => ({
432
+ name: sanitizeScreenshotName(s.name),
433
+ originalName: s.name,
434
+ sha256: s.sha256,
435
+ id: s.id,
436
+ filename: s.filename,
437
+ path: safePath(this.baselinePath, s.filename),
438
+ browser: s.browser,
439
+ viewport_width: s.viewport_width,
440
+ originalUrl: s.original_url,
441
+ fileSize: s.file_size_bytes,
442
+ dimensions: {
443
+ width: s.width,
444
+ height: s.height
445
+ }
446
+ }))
447
+ };
448
+ saveBaselineMetadata(this.baselinePath, this.baselineData);
449
+
450
+ // Download hotspots
451
+ await this.downloadHotspots(buildDetails.screenshots);
452
+
453
+ // Save baseline build metadata for MCP plugin
454
+ let baselineMetadataPath = safePath(this.workingDir, '.vizzly', 'baseline-metadata.json');
455
+ writeFileSync(baselineMetadataPath, JSON.stringify({
456
+ buildId: baselineBuild.id,
457
+ buildName: baselineBuild.name,
458
+ branch,
459
+ environment,
460
+ commitSha: baselineBuild.commit_sha,
461
+ commitMessage: baselineBuild.commit_message,
462
+ approvalStatus: baselineBuild.approval_status,
463
+ completedAt: baselineBuild.completed_at,
464
+ downloadedAt: new Date().toISOString()
465
+ }, null, 2));
466
+
467
+ // Summary
468
+ let actualDownloads = downloadedCount - skippedCount;
469
+ if (skippedCount > 0) {
470
+ if (actualDownloads === 0) {
471
+ output.info(`✅ All ${skippedCount} baselines up-to-date`);
472
+ } else {
473
+ output.info(`✅ Downloaded ${actualDownloads} new screenshots, ${skippedCount} already up-to-date`);
474
+ }
475
+ } else {
476
+ output.info(`✅ Downloaded ${downloadedCount}/${buildDetails.screenshots.length} screenshots successfully`);
477
+ }
478
+ if (errorCount > 0) {
479
+ output.warn(`⚠️ ${errorCount} screenshots failed to download`);
480
+ }
481
+ return this.baselineData;
482
+ } catch (error) {
483
+ output.error(`❌ Failed to download baseline: ${error.message}`);
484
+ throw error;
485
+ }
486
+ }
487
+
488
+ /**
489
+ * Download hotspot data for screenshots
490
+ */
491
+ async downloadHotspots(screenshots) {
492
+ if (!this.config.apiKey) {
493
+ output.debug('tdd', 'Skipping hotspot download - no API token configured');
494
+ return;
495
+ }
496
+ try {
497
+ let screenshotNames = [...new Set(screenshots.map(s => s.name))];
498
+ if (screenshotNames.length === 0) {
499
+ return;
500
+ }
501
+ output.info(`🔥 Fetching hotspot data for ${screenshotNames.length} screenshots...`);
502
+ let response = await getBatchHotspots(this.client, screenshotNames);
503
+ if (!response.hotspots || Object.keys(response.hotspots).length === 0) {
504
+ output.debug('tdd', 'No hotspot data available from cloud');
505
+ return;
506
+ }
507
+
508
+ // Update memory cache
509
+ this.hotspotData = response.hotspots;
510
+
511
+ // Save to disk using extracted module
512
+ saveHotspotMetadata(this.workingDir, response.hotspots, response.summary);
513
+ let hotspotCount = Object.keys(response.hotspots).length;
514
+ let totalRegions = Object.values(response.hotspots).reduce((sum, h) => sum + (h.regions?.length || 0), 0);
515
+ output.info(`✅ Downloaded hotspot data for ${hotspotCount} screenshots (${totalRegions} regions total)`);
516
+ } catch (error) {
517
+ output.debug('tdd', `Hotspot download failed: ${error.message}`);
518
+ output.warn('⚠️ Could not fetch hotspot data - comparisons will run without noise filtering');
519
+ }
520
+ }
521
+
522
+ /**
523
+ * Load hotspot data from disk
524
+ */
525
+ loadHotspots() {
526
+ let {
527
+ loadHotspotMetadata
528
+ } = this._deps;
529
+ return loadHotspotMetadata(this.workingDir);
530
+ }
531
+
532
+ /**
533
+ * Get hotspot for a specific screenshot
534
+ *
535
+ * Note: Once hotspotData is loaded (from disk or cloud), we don't reload.
536
+ * This is intentional - hotspots are downloaded once per session and cached.
537
+ * If a screenshot isn't in the cache, it means no hotspot data exists for it.
538
+ */
539
+ getHotspotForScreenshot(screenshotName) {
540
+ // Check memory cache first
541
+ if (this.hotspotData?.[screenshotName]) {
542
+ return this.hotspotData[screenshotName];
543
+ }
544
+
545
+ // Try loading from disk (only if we haven't loaded yet)
546
+ if (!this.hotspotData) {
547
+ this.hotspotData = this.loadHotspots();
548
+ }
549
+ return this.hotspotData?.[screenshotName] || null;
550
+ }
551
+
552
+ /**
553
+ * Calculate hotspot coverage (delegating to pure function)
554
+ */
555
+ calculateHotspotCoverage(diffClusters, hotspotAnalysis) {
556
+ let {
557
+ calculateHotspotCoverage
558
+ } = this._deps;
559
+ return calculateHotspotCoverage(diffClusters, hotspotAnalysis);
560
+ }
561
+
562
+ /**
563
+ * Handle local baselines logic
564
+ */
565
+ async handleLocalBaselines() {
566
+ let {
567
+ output,
568
+ colors
569
+ } = this._deps;
570
+ if (this.setBaseline) {
571
+ output.info('📁 Ready for new baseline creation');
572
+ this.baselineData = null;
573
+ return null;
574
+ }
575
+ let baseline = await this.loadBaseline();
576
+ if (!baseline) {
577
+ if (this.config.apiKey) {
578
+ output.info('📥 No local baseline found, but API key available');
579
+ output.info('🆕 Current run will create new local baselines');
580
+ } else {
581
+ output.info('📝 No local baseline found - all screenshots will be marked as new');
582
+ }
583
+ return null;
584
+ } else {
585
+ output.info(`✅ Using existing baseline: ${colors.cyan(baseline.buildName)}`);
586
+ return baseline;
587
+ }
588
+ }
589
+
590
+ /**
591
+ * Load baseline metadata
592
+ */
593
+ async loadBaseline() {
594
+ let {
595
+ output,
596
+ loadBaselineMetadata
597
+ } = this._deps;
598
+ if (this.setBaseline) {
599
+ output.debug('tdd', 'baseline update mode - skipping loading');
600
+ return null;
601
+ }
602
+ let metadata = loadBaselineMetadata(this.baselinePath);
603
+ if (!metadata) {
604
+ return null;
605
+ }
606
+ this.baselineData = metadata;
607
+ this.threshold = metadata.threshold || this.threshold;
608
+ this.signatureProperties = metadata.signatureProperties || this.signatureProperties;
609
+ if (this.signatureProperties.length > 0) {
610
+ output.debug('tdd', `loaded signature properties: ${this.signatureProperties.join(', ')}`);
611
+ }
612
+ return metadata;
613
+ }
614
+
615
+ /**
616
+ * Compare a screenshot against baseline
617
+ */
618
+ async compareScreenshot(name, imageBuffer, properties = {}) {
619
+ // Destructure dependencies
620
+ let {
621
+ output,
622
+ sanitizeScreenshotName,
623
+ validateScreenshotProperties,
624
+ generateScreenshotSignature,
625
+ generateBaselineFilename,
626
+ getCurrentPath,
627
+ getBaselinePath,
628
+ getDiffPath,
629
+ saveCurrent,
630
+ baselineExists,
631
+ saveBaseline,
632
+ createEmptyBaselineMetadata,
633
+ upsertScreenshotInMetadata,
634
+ saveBaselineMetadata,
635
+ buildNewComparison,
636
+ compareImages,
637
+ buildPassedComparison,
638
+ buildFailedComparison,
639
+ buildErrorComparison,
640
+ isDimensionMismatchError,
641
+ colors
642
+ } = this._deps;
643
+
644
+ // Sanitize and validate
645
+ let sanitizedName;
646
+ try {
647
+ sanitizedName = sanitizeScreenshotName(name);
648
+ } catch (error) {
649
+ output.error(`Invalid screenshot name '${name}': ${error.message}`);
650
+ throw new Error(`Screenshot name validation failed: ${error.message}`);
651
+ }
652
+ let validatedProperties;
653
+ try {
654
+ validatedProperties = validateScreenshotProperties(properties);
655
+ } catch (error) {
656
+ output.warn(`Property validation failed for '${sanitizedName}': ${error.message}`);
657
+ validatedProperties = {};
658
+ }
659
+
660
+ // Preserve metadata
661
+ if (properties.metadata && typeof properties.metadata === 'object') {
662
+ validatedProperties.metadata = properties.metadata;
663
+ }
664
+
665
+ // Normalize viewport_width
666
+ if (validatedProperties.viewport?.width && !validatedProperties.viewport_width) {
667
+ validatedProperties.viewport_width = validatedProperties.viewport.width;
668
+ }
669
+
670
+ // Generate signature and filename
671
+ let signature = generateScreenshotSignature(sanitizedName, validatedProperties, this.signatureProperties);
672
+ let filename = generateBaselineFilename(sanitizedName, signature);
673
+ let currentImagePath = getCurrentPath(this.currentPath, filename);
674
+ let baselineImagePath = getBaselinePath(this.baselinePath, filename);
675
+ let diffImagePath = getDiffPath(this.diffPath, filename);
676
+
677
+ // Save current screenshot
678
+ saveCurrent(this.currentPath, filename, imageBuffer);
679
+
680
+ // Handle baseline update mode
681
+ if (this.setBaseline) {
682
+ return this.createNewBaseline(sanitizedName, imageBuffer, validatedProperties, currentImagePath, baselineImagePath);
683
+ }
684
+
685
+ // Check if baseline exists
686
+ if (!baselineExists(this.baselinePath, filename)) {
687
+ // Create new baseline
688
+ saveBaseline(this.baselinePath, filename, imageBuffer);
689
+
690
+ // Update metadata
691
+ if (!this.baselineData) {
692
+ this.baselineData = createEmptyBaselineMetadata({
693
+ threshold: this.threshold,
694
+ signatureProperties: this.signatureProperties
695
+ });
696
+ }
697
+ let screenshotEntry = {
698
+ name: sanitizedName,
699
+ properties: validatedProperties,
700
+ path: baselineImagePath,
701
+ signature
702
+ };
703
+ upsertScreenshotInMetadata(this.baselineData, screenshotEntry, signature);
704
+ saveBaselineMetadata(this.baselinePath, this.baselineData);
705
+ let result = buildNewComparison({
706
+ name: sanitizedName,
707
+ signature,
708
+ baselinePath: baselineImagePath,
709
+ currentPath: currentImagePath,
710
+ properties: validatedProperties
711
+ });
712
+ this.comparisons.push(result);
713
+ return result;
714
+ }
715
+
716
+ // Baseline exists - compare
717
+ try {
718
+ let effectiveThreshold = typeof validatedProperties.threshold === 'number' && validatedProperties.threshold >= 0 ? validatedProperties.threshold : this.threshold;
719
+ let effectiveMinClusterSize = Number.isInteger(validatedProperties.minClusterSize) && validatedProperties.minClusterSize >= 1 ? validatedProperties.minClusterSize : this.minClusterSize;
720
+ let honeydiffResult = await compareImages(baselineImagePath, currentImagePath, diffImagePath, {
721
+ threshold: effectiveThreshold,
722
+ minClusterSize: effectiveMinClusterSize
723
+ });
724
+ if (!honeydiffResult.isDifferent) {
725
+ let result = buildPassedComparison({
726
+ name: sanitizedName,
727
+ signature,
728
+ baselinePath: baselineImagePath,
729
+ currentPath: currentImagePath,
730
+ properties: validatedProperties,
731
+ threshold: effectiveThreshold,
732
+ minClusterSize: effectiveMinClusterSize,
733
+ honeydiffResult
734
+ });
735
+ this.comparisons.push(result);
736
+ return result;
737
+ } else {
738
+ let hotspotAnalysis = this.getHotspotForScreenshot(name);
739
+ let result = buildFailedComparison({
740
+ name: sanitizedName,
741
+ signature,
742
+ baselinePath: baselineImagePath,
743
+ currentPath: currentImagePath,
744
+ diffPath: diffImagePath,
745
+ properties: validatedProperties,
746
+ threshold: effectiveThreshold,
747
+ minClusterSize: effectiveMinClusterSize,
748
+ honeydiffResult,
749
+ hotspotAnalysis
750
+ });
751
+
752
+ // Log result
753
+ let diffInfo = ` (${honeydiffResult.diffPercentage.toFixed(2)}% different, ${honeydiffResult.diffPixels} pixels)`;
754
+ if (honeydiffResult.diffClusters?.length > 0) {
755
+ diffInfo += `, ${honeydiffResult.diffClusters.length} region${honeydiffResult.diffClusters.length > 1 ? 's' : ''}`;
756
+ }
757
+ if (result.hotspotAnalysis?.coverage > 0) {
758
+ diffInfo += `, ${Math.round(result.hotspotAnalysis.coverage * 100)}% in hotspots`;
759
+ }
760
+ if (result.status === 'passed') {
761
+ output.info(`✅ ${colors.green('PASSED')} ${sanitizedName} - differences in known hotspots${diffInfo}`);
762
+ } else {
763
+ output.warn(`❌ ${colors.red('FAILED')} ${sanitizedName} - differences detected${diffInfo}`);
764
+ output.info(` Diff saved to: ${diffImagePath}`);
765
+ }
766
+ this.comparisons.push(result);
767
+ return result;
768
+ }
769
+ } catch (error) {
770
+ if (isDimensionMismatchError(error)) {
771
+ output.warn(`⚠️ Dimension mismatch for ${sanitizedName} - creating new baseline`);
772
+ saveBaseline(this.baselinePath, filename, imageBuffer);
773
+ if (!this.baselineData) {
774
+ this.baselineData = createEmptyBaselineMetadata({
775
+ threshold: this.threshold,
776
+ signatureProperties: this.signatureProperties
777
+ });
778
+ }
779
+ let screenshotEntry = {
780
+ name: sanitizedName,
781
+ properties: validatedProperties,
782
+ path: baselineImagePath,
783
+ signature
784
+ };
785
+ upsertScreenshotInMetadata(this.baselineData, screenshotEntry, signature);
786
+ saveBaselineMetadata(this.baselinePath, this.baselineData);
787
+ output.info(`✅ Created new baseline for ${sanitizedName} (different dimensions)`);
788
+ let result = buildNewComparison({
789
+ name: sanitizedName,
790
+ signature,
791
+ baselinePath: baselineImagePath,
792
+ currentPath: currentImagePath,
793
+ properties: validatedProperties
794
+ });
795
+ this.comparisons.push(result);
796
+ return result;
797
+ }
798
+ output.error(`❌ Error comparing ${sanitizedName}: ${error.message}`);
799
+ let result = buildErrorComparison({
800
+ name: sanitizedName,
801
+ signature,
802
+ baselinePath: baselineImagePath,
803
+ currentPath: currentImagePath,
804
+ properties: validatedProperties,
805
+ errorMessage: error.message
806
+ });
807
+ this.comparisons.push(result);
808
+ return result;
809
+ }
810
+ }
811
+
812
+ /**
813
+ * Get results summary
814
+ */
815
+ getResults() {
816
+ let {
817
+ buildResults
818
+ } = this._deps;
819
+ return buildResults(this.comparisons, this.baselineData);
820
+ }
821
+
822
+ /**
823
+ * Print results to console
824
+ */
825
+ async printResults() {
826
+ let results = this.getResults();
827
+ output.info('\n📊 TDD Results:');
828
+ output.info(`Total: ${colors.cyan(results.total)}`);
829
+ output.info(`Passed: ${colors.green(results.passed)}`);
830
+ if (results.failed > 0) {
831
+ output.info(`Failed: ${colors.red(results.failed)}`);
832
+ }
833
+ if (results.new > 0) {
834
+ output.info(`New: ${colors.yellow(results.new)}`);
835
+ }
836
+ if (results.errors > 0) {
837
+ output.info(`Errors: ${colors.red(results.errors)}`);
838
+ }
839
+ let failedComparisons = getFailedComparisons(this.comparisons);
840
+ if (failedComparisons.length > 0) {
841
+ output.info('\n❌ Failed comparisons:');
842
+ for (let comp of failedComparisons) {
843
+ output.info(` • ${comp.name}`);
844
+ }
845
+ }
846
+ let newComparisons = getNewComparisons(this.comparisons);
847
+ if (newComparisons.length > 0) {
848
+ output.info('\n📸 New screenshots:');
849
+ for (let comp of newComparisons) {
850
+ output.info(` • ${comp.name}`);
851
+ }
852
+ }
853
+ await this.generateHtmlReport(results);
854
+ return results;
855
+ }
856
+
857
+ /**
858
+ * Generate HTML report using React reporter
859
+ */
860
+ async generateHtmlReport(results) {
861
+ try {
862
+ let reportGenerator = new StaticReportGenerator(this.workingDir, this.config);
863
+
864
+ // Transform results to React reporter format
865
+ let reportData = {
866
+ buildId: this.baselineData?.buildId || 'local-tdd',
867
+ summary: {
868
+ passed: results.passed,
869
+ failed: results.failed,
870
+ total: results.total,
871
+ new: results.new,
872
+ errors: results.errors
873
+ },
874
+ comparisons: results.comparisons,
875
+ baseline: this.baselineData,
876
+ threshold: this.threshold
877
+ };
878
+ let reportPath = await reportGenerator.generateReport(reportData);
879
+ output.info(`\n🐻 View detailed report: ${colors.cyan(`file://${reportPath}`)}`);
880
+ if (this.config.tdd?.openReport) {
881
+ await this.openReport(reportPath);
882
+ }
883
+ return reportPath;
884
+ } catch (error) {
885
+ output.warn(`Failed to generate HTML report: ${error.message}`);
886
+ }
887
+ }
888
+
889
+ /**
890
+ * Open report in browser
891
+ */
892
+ async openReport(reportPath) {
893
+ try {
894
+ let {
895
+ exec
896
+ } = await import('node:child_process');
897
+ let {
898
+ promisify
899
+ } = await import('node:util');
900
+ let execAsync = promisify(exec);
901
+ let command;
902
+ switch (process.platform) {
903
+ case 'darwin':
904
+ command = `open "${reportPath}"`;
905
+ break;
906
+ case 'win32':
907
+ command = `start "" "${reportPath}"`;
908
+ break;
909
+ default:
910
+ command = `xdg-open "${reportPath}"`;
911
+ break;
912
+ }
913
+ await execAsync(command);
914
+ output.info('📖 Report opened in browser');
915
+ } catch {
916
+ // Browser open may fail silently
917
+ }
918
+ }
919
+
920
+ /**
921
+ * Update all baselines with current screenshots
922
+ */
923
+ updateBaselines() {
924
+ if (this.comparisons.length === 0) {
925
+ output.warn('No comparisons found - nothing to update');
926
+ return 0;
927
+ }
928
+ let updatedCount = 0;
929
+ if (!this.baselineData) {
930
+ this.baselineData = createEmptyBaselineMetadata({
931
+ threshold: this.threshold,
932
+ signatureProperties: this.signatureProperties
933
+ });
934
+ }
935
+ for (let comparison of this.comparisons) {
936
+ let {
937
+ name,
938
+ current
939
+ } = comparison;
940
+ if (!current || !existsSync(current)) {
941
+ output.warn(`Current screenshot not found for ${name}, skipping`);
942
+ continue;
943
+ }
944
+ let sanitizedName;
945
+ try {
946
+ sanitizedName = sanitizeScreenshotName(name);
947
+ } catch (error) {
948
+ output.warn(`Skipping baseline update for invalid name '${name}': ${error.message}`);
949
+ continue;
950
+ }
951
+ let validatedProperties = validateScreenshotProperties(comparison.properties || {});
952
+ let signature = generateScreenshotSignature(sanitizedName, validatedProperties, this.signatureProperties);
953
+ let filename = generateBaselineFilename(sanitizedName, signature);
954
+ let baselineImagePath = getBaselinePath(this.baselinePath, filename);
955
+ try {
956
+ let currentBuffer = readFileSync(current);
957
+ writeFileSync(baselineImagePath, currentBuffer);
958
+ let screenshotEntry = {
959
+ name: sanitizedName,
960
+ properties: validatedProperties,
961
+ path: baselineImagePath,
962
+ signature
963
+ };
964
+ upsertScreenshotInMetadata(this.baselineData, screenshotEntry, signature);
965
+ updatedCount++;
966
+ output.info(`✅ Updated baseline for ${sanitizedName}`);
967
+ } catch (error) {
968
+ output.error(`❌ Failed to update baseline for ${sanitizedName}: ${error.message}`);
969
+ }
970
+ }
971
+ if (updatedCount > 0) {
972
+ try {
973
+ saveBaselineMetadata(this.baselinePath, this.baselineData);
974
+ output.info(`✅ Updated ${updatedCount} baseline(s)`);
975
+ } catch (error) {
976
+ output.error(`❌ Failed to save baseline metadata: ${error.message}`);
977
+ }
978
+ }
979
+ return updatedCount;
980
+ }
981
+
982
+ /**
983
+ * Accept a single baseline
984
+ */
985
+ async acceptBaseline(idOrComparison) {
986
+ let comparison;
987
+ if (typeof idOrComparison === 'string') {
988
+ comparison = this.comparisons.find(c => c.id === idOrComparison);
989
+ if (!comparison) {
990
+ throw new Error(`No comparison found with ID: ${idOrComparison}`);
991
+ }
992
+ } else {
993
+ comparison = idOrComparison;
994
+ }
995
+ let sanitizedName = comparison.name;
996
+ let properties = comparison.properties || {};
997
+
998
+ // Generate signature from properties (don't rely on comparison.signature)
999
+ let signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
1000
+ let filename = generateBaselineFilename(sanitizedName, signature);
1001
+
1002
+ // Find the current screenshot file
1003
+ let currentImagePath = safePath(this.currentPath, filename);
1004
+ if (!existsSync(currentImagePath)) {
1005
+ output.error(`Current screenshot not found at: ${currentImagePath}`);
1006
+ throw new Error(`Current screenshot not found: ${sanitizedName} (looked at ${currentImagePath})`);
1007
+ }
1008
+
1009
+ // Read the current image
1010
+ let imageBuffer = readFileSync(currentImagePath);
1011
+
1012
+ // Create baseline directory if it doesn't exist
1013
+ if (!existsSync(this.baselinePath)) {
1014
+ mkdirSync(this.baselinePath, {
1015
+ recursive: true
1016
+ });
1017
+ }
1018
+
1019
+ // Update the baseline
1020
+ let baselineImagePath = safePath(this.baselinePath, `${filename}.png`);
1021
+ writeFileSync(baselineImagePath, imageBuffer);
1022
+
1023
+ // Update baseline metadata
1024
+ if (!this.baselineData) {
1025
+ this.baselineData = createEmptyBaselineMetadata({
1026
+ threshold: this.threshold,
1027
+ signatureProperties: this.signatureProperties
1028
+ });
1029
+ }
1030
+ let screenshotEntry = {
1031
+ name: sanitizedName,
1032
+ properties,
1033
+ path: baselineImagePath,
1034
+ signature
1035
+ };
1036
+ upsertScreenshotInMetadata(this.baselineData, screenshotEntry, signature);
1037
+ saveBaselineMetadata(this.baselinePath, this.baselineData);
1038
+ return {
1039
+ name: sanitizedName,
1040
+ status: 'accepted',
1041
+ message: 'Screenshot accepted as new baseline'
1042
+ };
1043
+ }
1044
+
1045
+ /**
1046
+ * Create new baseline (used during --set-baseline mode)
1047
+ * @private
1048
+ */
1049
+ createNewBaseline(name, imageBuffer, properties, currentImagePath, baselineImagePath) {
1050
+ output.info(`🐻 Creating baseline for ${name}`);
1051
+ writeFileSync(baselineImagePath, imageBuffer);
1052
+ if (!this.baselineData) {
1053
+ this.baselineData = createEmptyBaselineMetadata({
1054
+ threshold: this.threshold,
1055
+ signatureProperties: this.signatureProperties
1056
+ });
1057
+ }
1058
+ let signature = generateScreenshotSignature(name, properties || {}, this.signatureProperties);
1059
+ let screenshotEntry = {
1060
+ name,
1061
+ properties: properties || {},
1062
+ path: baselineImagePath,
1063
+ signature
1064
+ };
1065
+ upsertScreenshotInMetadata(this.baselineData, screenshotEntry, signature);
1066
+ saveBaselineMetadata(this.baselinePath, this.baselineData);
1067
+ let result = {
1068
+ id: generateComparisonId(signature),
1069
+ name,
1070
+ status: 'new',
1071
+ baseline: baselineImagePath,
1072
+ current: currentImagePath,
1073
+ diff: null,
1074
+ properties,
1075
+ signature
1076
+ };
1077
+ this.comparisons.push(result);
1078
+ output.info(`✅ Baseline created for ${name}`);
1079
+ return result;
1080
+ }
1081
+ }