@vizzly-testing/cli 0.20.0 → 0.20.1-beta.1

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 (84) 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 +178 -3
  10. package/dist/client/index.js +144 -77
  11. package/dist/commands/doctor.js +121 -36
  12. package/dist/commands/finalize.js +49 -18
  13. package/dist/commands/init.js +13 -18
  14. package/dist/commands/login.js +49 -55
  15. package/dist/commands/logout.js +17 -9
  16. package/dist/commands/project.js +100 -71
  17. package/dist/commands/run.js +189 -95
  18. package/dist/commands/status.js +101 -66
  19. package/dist/commands/tdd-daemon.js +61 -32
  20. package/dist/commands/tdd.js +104 -98
  21. package/dist/commands/upload.js +78 -34
  22. package/dist/commands/whoami.js +44 -42
  23. package/dist/config/core.js +438 -0
  24. package/dist/config/index.js +13 -0
  25. package/dist/config/operations.js +327 -0
  26. package/dist/index.js +1 -1
  27. package/dist/project/core.js +295 -0
  28. package/dist/project/index.js +13 -0
  29. package/dist/project/operations.js +393 -0
  30. package/dist/reporter/reporter-bundle.css +1 -1
  31. package/dist/reporter/reporter-bundle.iife.js +16 -16
  32. package/dist/screenshot-server/core.js +157 -0
  33. package/dist/screenshot-server/index.js +11 -0
  34. package/dist/screenshot-server/operations.js +183 -0
  35. package/dist/sdk/index.js +3 -2
  36. package/dist/server/handlers/api-handler.js +14 -5
  37. package/dist/server/handlers/tdd-handler.js +191 -53
  38. package/dist/server/http-server.js +9 -3
  39. package/dist/server/routers/baseline.js +58 -0
  40. package/dist/server/routers/dashboard.js +10 -6
  41. package/dist/server/routers/screenshot.js +32 -0
  42. package/dist/server-manager/core.js +186 -0
  43. package/dist/server-manager/index.js +81 -0
  44. package/dist/server-manager/operations.js +209 -0
  45. package/dist/services/build-manager.js +2 -69
  46. package/dist/services/index.js +21 -48
  47. package/dist/services/screenshot-server.js +40 -74
  48. package/dist/services/server-manager.js +45 -80
  49. package/dist/services/test-runner.js +90 -250
  50. package/dist/services/uploader.js +56 -358
  51. package/dist/tdd/core/hotspot-coverage.js +112 -0
  52. package/dist/tdd/core/signature.js +101 -0
  53. package/dist/tdd/index.js +19 -0
  54. package/dist/tdd/metadata/baseline-metadata.js +103 -0
  55. package/dist/tdd/metadata/hotspot-metadata.js +93 -0
  56. package/dist/tdd/services/baseline-downloader.js +151 -0
  57. package/dist/tdd/services/baseline-manager.js +166 -0
  58. package/dist/tdd/services/comparison-service.js +230 -0
  59. package/dist/tdd/services/hotspot-service.js +71 -0
  60. package/dist/tdd/services/result-service.js +123 -0
  61. package/dist/tdd/tdd-service.js +1145 -0
  62. package/dist/test-runner/core.js +255 -0
  63. package/dist/test-runner/index.js +13 -0
  64. package/dist/test-runner/operations.js +483 -0
  65. package/dist/types/client.d.ts +25 -2
  66. package/dist/uploader/core.js +396 -0
  67. package/dist/uploader/index.js +11 -0
  68. package/dist/uploader/operations.js +412 -0
  69. package/dist/utils/colors.js +187 -39
  70. package/dist/utils/config-loader.js +3 -6
  71. package/dist/utils/context.js +228 -0
  72. package/dist/utils/output.js +449 -14
  73. package/docs/api-reference.md +173 -8
  74. package/docs/tui-elements.md +560 -0
  75. package/package.json +13 -13
  76. package/dist/services/api-service.js +0 -412
  77. package/dist/services/auth-service.js +0 -226
  78. package/dist/services/config-service.js +0 -369
  79. package/dist/services/html-report-generator.js +0 -455
  80. package/dist/services/project-service.js +0 -326
  81. package/dist/services/report-generator/report.css +0 -411
  82. package/dist/services/report-generator/viewer.js +0 -102
  83. package/dist/services/static-report-generator.js +0 -207
  84. package/dist/services/tdd-service.js +0 -1437
@@ -0,0 +1,1145 @@
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 { colors as defaultColors } from '../utils/colors.js';
15
+ import { fetchWithTimeout as defaultFetchWithTimeout } from '../utils/fetch-utils.js';
16
+ import { getDefaultBranch as defaultGetDefaultBranch } from '../utils/git.js';
17
+ import * as defaultOutput from '../utils/output.js';
18
+ import { safePath as defaultSafePath, sanitizeScreenshotName as defaultSanitizeScreenshotName, validatePathSecurity as defaultValidatePathSecurity, validateScreenshotProperties as defaultValidateScreenshotProperties } from '../utils/security.js';
19
+ import { calculateHotspotCoverage as defaultCalculateHotspotCoverage } from './core/hotspot-coverage.js';
20
+ // Import from extracted modules
21
+ import { generateBaselineFilename as defaultGenerateBaselineFilename, generateComparisonId as defaultGenerateComparisonId, generateScreenshotSignature as defaultGenerateScreenshotSignature } from './core/signature.js';
22
+ import { createEmptyBaselineMetadata as defaultCreateEmptyBaselineMetadata, loadBaselineMetadata as defaultLoadBaselineMetadata, saveBaselineMetadata as defaultSaveBaselineMetadata, upsertScreenshotInMetadata as defaultUpsertScreenshotInMetadata } from './metadata/baseline-metadata.js';
23
+ import { loadHotspotMetadata as defaultLoadHotspotMetadata, saveHotspotMetadata as defaultSaveHotspotMetadata } from './metadata/hotspot-metadata.js';
24
+ 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';
25
+ import { buildErrorComparison as defaultBuildErrorComparison, buildFailedComparison as defaultBuildFailedComparison, buildNewComparison as defaultBuildNewComparison, buildPassedComparison as defaultBuildPassedComparison, compareImages as defaultCompareImages, isDimensionMismatchError as defaultIsDimensionMismatchError } from './services/comparison-service.js';
26
+ import { buildResults as defaultBuildResults, getFailedComparisons as defaultGetFailedComparisons, getNewComparisons as defaultGetNewComparisons } from './services/result-service.js';
27
+
28
+ /**
29
+ * Create a new TDD service instance
30
+ * @param {Object} config - Configuration object
31
+ * @param {Object} options - Options
32
+ * @param {string} options.workingDir - Working directory
33
+ * @param {boolean} options.setBaseline - Whether to set baselines
34
+ * @param {Object} options.authService - Authentication service
35
+ * @param {Object} deps - Injectable dependencies for testing
36
+ */
37
+ export function createTDDService(config, options = {}, deps = {}) {
38
+ return new TddService(config, options.workingDir, options.setBaseline, options.authService, deps);
39
+ }
40
+ export class TddService {
41
+ constructor(config, workingDir = process.cwd(), setBaseline = false, authService = null, deps = {}) {
42
+ // Grouped dependencies with defaults
43
+ let {
44
+ // Core utilities
45
+ output = defaultOutput,
46
+ colors = defaultColors,
47
+ validatePathSecurity = defaultValidatePathSecurity,
48
+ initializeDirectories = defaultInitializeDirectories,
49
+ // File system operations
50
+ fs = {},
51
+ // API operations
52
+ api = {},
53
+ // Baseline metadata operations
54
+ metadata = {},
55
+ // Baseline file management
56
+ baseline = {},
57
+ // Screenshot comparison
58
+ comparison = {},
59
+ // Signature generation and security
60
+ signature = {},
61
+ // Result building
62
+ results = {},
63
+ // Other
64
+ calculateHotspotCoverage = defaultCalculateHotspotCoverage
65
+ } = deps;
66
+
67
+ // Merge grouped deps with defaults
68
+ let fsOps = {
69
+ existsSync: defaultExistsSync,
70
+ mkdirSync: defaultMkdirSync,
71
+ readFileSync: defaultReadFileSync,
72
+ writeFileSync: defaultWriteFileSync,
73
+ ...fs
74
+ };
75
+ let apiOps = {
76
+ createApiClient: defaultCreateApiClient,
77
+ getTddBaselines: defaultGetTddBaselines,
78
+ getBuilds: defaultGetBuilds,
79
+ getComparison: defaultGetComparison,
80
+ getBatchHotspots: defaultGetBatchHotspots,
81
+ fetchWithTimeout: defaultFetchWithTimeout,
82
+ getDefaultBranch: defaultGetDefaultBranch,
83
+ ...api
84
+ };
85
+ let metadataOps = {
86
+ loadBaselineMetadata: defaultLoadBaselineMetadata,
87
+ saveBaselineMetadata: defaultSaveBaselineMetadata,
88
+ createEmptyBaselineMetadata: defaultCreateEmptyBaselineMetadata,
89
+ upsertScreenshotInMetadata: defaultUpsertScreenshotInMetadata,
90
+ loadHotspotMetadata: defaultLoadHotspotMetadata,
91
+ saveHotspotMetadata: defaultSaveHotspotMetadata,
92
+ ...metadata
93
+ };
94
+ let baselineOps = {
95
+ baselineExists: defaultBaselineExists,
96
+ clearBaselineData: defaultClearBaselineData,
97
+ getBaselinePath: defaultGetBaselinePath,
98
+ getCurrentPath: defaultGetCurrentPath,
99
+ getDiffPath: defaultGetDiffPath,
100
+ saveBaseline: defaultSaveBaseline,
101
+ saveCurrent: defaultSaveCurrent,
102
+ ...baseline
103
+ };
104
+ let comparisonOps = {
105
+ compareImages: defaultCompareImages,
106
+ buildPassedComparison: defaultBuildPassedComparison,
107
+ buildNewComparison: defaultBuildNewComparison,
108
+ buildFailedComparison: defaultBuildFailedComparison,
109
+ buildErrorComparison: defaultBuildErrorComparison,
110
+ isDimensionMismatchError: defaultIsDimensionMismatchError,
111
+ ...comparison
112
+ };
113
+ let signatureOps = {
114
+ generateScreenshotSignature: defaultGenerateScreenshotSignature,
115
+ generateBaselineFilename: defaultGenerateBaselineFilename,
116
+ generateComparisonId: defaultGenerateComparisonId,
117
+ sanitizeScreenshotName: defaultSanitizeScreenshotName,
118
+ validateScreenshotProperties: defaultValidateScreenshotProperties,
119
+ safePath: defaultSafePath,
120
+ ...signature
121
+ };
122
+ let resultsOps = {
123
+ buildResults: defaultBuildResults,
124
+ getFailedComparisons: defaultGetFailedComparisons,
125
+ getNewComparisons: defaultGetNewComparisons,
126
+ ...results
127
+ };
128
+
129
+ // Store flattened dependencies for use in methods
130
+ this._deps = {
131
+ output,
132
+ colors,
133
+ validatePathSecurity,
134
+ initializeDirectories,
135
+ calculateHotspotCoverage,
136
+ ...fsOps,
137
+ ...apiOps,
138
+ ...metadataOps,
139
+ ...baselineOps,
140
+ ...comparisonOps,
141
+ ...signatureOps,
142
+ ...resultsOps
143
+ };
144
+ this.config = config;
145
+ this.setBaseline = setBaseline;
146
+ this.authService = authService;
147
+ this.client = apiOps.createApiClient({
148
+ baseUrl: config.apiUrl,
149
+ token: config.apiKey,
150
+ command: 'tdd',
151
+ allowNoToken: true
152
+ });
153
+
154
+ // Validate and secure the working directory
155
+ try {
156
+ this.workingDir = validatePathSecurity(workingDir, workingDir);
157
+ } catch (error) {
158
+ output.error(`Invalid working directory: ${error.message}`);
159
+ throw new Error(`Working directory validation failed: ${error.message}`);
160
+ }
161
+
162
+ // Initialize directories using extracted module
163
+ let paths = initializeDirectories(this.workingDir);
164
+ this.baselinePath = paths.baselinePath;
165
+ this.currentPath = paths.currentPath;
166
+ this.diffPath = paths.diffPath;
167
+
168
+ // State
169
+ this.baselineData = null;
170
+ this.comparisons = [];
171
+ this.threshold = config.comparison?.threshold || 2.0;
172
+ this.minClusterSize = config.comparison?.minClusterSize ?? 2;
173
+ this.signatureProperties = config.signatureProperties ?? [];
174
+
175
+ // Hotspot data (loaded lazily from disk or downloaded from cloud)
176
+ this.hotspotData = null;
177
+
178
+ // Track whether results have been printed (to avoid duplicate output)
179
+ this._resultsPrinted = false;
180
+ if (this.setBaseline) {
181
+ output.info('[vizzly] 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('Tip: 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
+ } = this._deps;
642
+
643
+ // Sanitize and validate
644
+ let sanitizedName;
645
+ try {
646
+ sanitizedName = sanitizeScreenshotName(name);
647
+ } catch (error) {
648
+ output.error(`Invalid screenshot name '${name}': ${error.message}`);
649
+ throw new Error(`Screenshot name validation failed: ${error.message}`);
650
+ }
651
+ let validatedProperties;
652
+ try {
653
+ validatedProperties = validateScreenshotProperties(properties);
654
+ } catch (error) {
655
+ output.warn(`Property validation failed for '${sanitizedName}': ${error.message}`);
656
+ validatedProperties = {};
657
+ }
658
+
659
+ // Preserve metadata
660
+ if (properties.metadata && typeof properties.metadata === 'object') {
661
+ validatedProperties.metadata = properties.metadata;
662
+ }
663
+
664
+ // Normalize viewport_width
665
+ if (validatedProperties.viewport?.width && !validatedProperties.viewport_width) {
666
+ validatedProperties.viewport_width = validatedProperties.viewport.width;
667
+ }
668
+
669
+ // Generate signature and filename
670
+ let signature = generateScreenshotSignature(sanitizedName, validatedProperties, this.signatureProperties);
671
+ let filename = generateBaselineFilename(sanitizedName, signature);
672
+ let currentImagePath = getCurrentPath(this.currentPath, filename);
673
+ let baselineImagePath = getBaselinePath(this.baselinePath, filename);
674
+ let diffImagePath = getDiffPath(this.diffPath, filename);
675
+
676
+ // Save current screenshot
677
+ saveCurrent(this.currentPath, filename, imageBuffer);
678
+
679
+ // Handle baseline update mode
680
+ if (this.setBaseline) {
681
+ return this.createNewBaseline(sanitizedName, imageBuffer, validatedProperties, currentImagePath, baselineImagePath);
682
+ }
683
+
684
+ // Check if baseline exists
685
+ if (!baselineExists(this.baselinePath, filename)) {
686
+ // Create new baseline
687
+ saveBaseline(this.baselinePath, filename, imageBuffer);
688
+
689
+ // Update metadata
690
+ if (!this.baselineData) {
691
+ this.baselineData = createEmptyBaselineMetadata({
692
+ threshold: this.threshold,
693
+ signatureProperties: this.signatureProperties
694
+ });
695
+ }
696
+ let screenshotEntry = {
697
+ name: sanitizedName,
698
+ properties: validatedProperties,
699
+ path: baselineImagePath,
700
+ signature
701
+ };
702
+ upsertScreenshotInMetadata(this.baselineData, screenshotEntry, signature);
703
+ saveBaselineMetadata(this.baselinePath, this.baselineData);
704
+ let result = buildNewComparison({
705
+ name: sanitizedName,
706
+ signature,
707
+ baselinePath: baselineImagePath,
708
+ currentPath: currentImagePath,
709
+ properties: validatedProperties
710
+ });
711
+ this.comparisons.push(result);
712
+ return result;
713
+ }
714
+
715
+ // Baseline exists - compare
716
+ try {
717
+ let effectiveThreshold = typeof validatedProperties.threshold === 'number' && validatedProperties.threshold >= 0 ? validatedProperties.threshold : this.threshold;
718
+ let effectiveMinClusterSize = Number.isInteger(validatedProperties.minClusterSize) && validatedProperties.minClusterSize >= 1 ? validatedProperties.minClusterSize : this.minClusterSize;
719
+ let honeydiffResult = await compareImages(baselineImagePath, currentImagePath, diffImagePath, {
720
+ threshold: effectiveThreshold,
721
+ minClusterSize: effectiveMinClusterSize
722
+ });
723
+ if (!honeydiffResult.isDifferent) {
724
+ let result = buildPassedComparison({
725
+ name: sanitizedName,
726
+ signature,
727
+ baselinePath: baselineImagePath,
728
+ currentPath: currentImagePath,
729
+ properties: validatedProperties,
730
+ threshold: effectiveThreshold,
731
+ minClusterSize: effectiveMinClusterSize,
732
+ honeydiffResult
733
+ });
734
+ this.comparisons.push(result);
735
+ return result;
736
+ } else {
737
+ let hotspotAnalysis = this.getHotspotForScreenshot(name);
738
+ let result = buildFailedComparison({
739
+ name: sanitizedName,
740
+ signature,
741
+ baselinePath: baselineImagePath,
742
+ currentPath: currentImagePath,
743
+ diffPath: diffImagePath,
744
+ properties: validatedProperties,
745
+ threshold: effectiveThreshold,
746
+ minClusterSize: effectiveMinClusterSize,
747
+ honeydiffResult,
748
+ hotspotAnalysis
749
+ });
750
+
751
+ // Log at debug level only (shown with --verbose)
752
+ let diffInfo = `${honeydiffResult.diffPercentage.toFixed(2)}% diff, ${honeydiffResult.diffPixels} pixels`;
753
+ if (honeydiffResult.diffClusters?.length > 0) {
754
+ diffInfo += `, ${honeydiffResult.diffClusters.length} regions`;
755
+ }
756
+ output.debug('comparison', `${sanitizedName}: ${result.status}`, {
757
+ diff: diffInfo
758
+ });
759
+ this.comparisons.push(result);
760
+ return result;
761
+ }
762
+ } catch (error) {
763
+ if (isDimensionMismatchError(error)) {
764
+ output.debug('comparison', `${sanitizedName}: dimension mismatch, creating new baseline`);
765
+ saveBaseline(this.baselinePath, filename, imageBuffer);
766
+ if (!this.baselineData) {
767
+ this.baselineData = createEmptyBaselineMetadata({
768
+ threshold: this.threshold,
769
+ signatureProperties: this.signatureProperties
770
+ });
771
+ }
772
+ let screenshotEntry = {
773
+ name: sanitizedName,
774
+ properties: validatedProperties,
775
+ path: baselineImagePath,
776
+ signature
777
+ };
778
+ upsertScreenshotInMetadata(this.baselineData, screenshotEntry, signature);
779
+ saveBaselineMetadata(this.baselinePath, this.baselineData);
780
+ let result = buildNewComparison({
781
+ name: sanitizedName,
782
+ signature,
783
+ baselinePath: baselineImagePath,
784
+ currentPath: currentImagePath,
785
+ properties: validatedProperties
786
+ });
787
+ this.comparisons.push(result);
788
+ return result;
789
+ }
790
+ output.debug('comparison', `${sanitizedName}: error - ${error.message}`);
791
+ let result = buildErrorComparison({
792
+ name: sanitizedName,
793
+ signature,
794
+ baselinePath: baselineImagePath,
795
+ currentPath: currentImagePath,
796
+ properties: validatedProperties,
797
+ errorMessage: error.message
798
+ });
799
+ this.comparisons.push(result);
800
+ return result;
801
+ }
802
+ }
803
+
804
+ /**
805
+ * Get results summary
806
+ */
807
+ getResults() {
808
+ let {
809
+ buildResults
810
+ } = this._deps;
811
+ return buildResults(this.comparisons, this.baselineData);
812
+ }
813
+
814
+ /**
815
+ * Print results to console
816
+ * Only prints once per test run to avoid duplicate output
817
+ */
818
+ async printResults() {
819
+ // Skip if already printed (prevents duplicate output from vizzlyFlush)
820
+ if (this._resultsPrinted) {
821
+ return this.getResults();
822
+ }
823
+ this._resultsPrinted = true;
824
+ let {
825
+ output,
826
+ colors,
827
+ getFailedComparisons,
828
+ getNewComparisons,
829
+ existsSync,
830
+ readFileSync
831
+ } = this._deps;
832
+ let results = this.getResults();
833
+ let failedComparisons = getFailedComparisons(this.comparisons);
834
+ let newComparisons = getNewComparisons(this.comparisons);
835
+ let passedComparisons = this.comparisons.filter(c => c.status === 'passed' || c.status === 'baseline-created' || c.status === 'baseline-updated');
836
+ let hasChanges = failedComparisons.length > 0 || newComparisons.length > 0;
837
+
838
+ // Header with summary - use bear emoji as Vizzly mascot
839
+ output.blank();
840
+ output.print(`🐻 ${colors.bold(results.total)} screenshot${results.total !== 1 ? 's' : ''} compared`);
841
+ output.blank();
842
+
843
+ // Passed section - use Observatory success color
844
+ if (results.passed > 0) {
845
+ let successColor = colors.brand?.success || colors.green;
846
+ if (output.isVerbose()) {
847
+ // Verbose mode: show each screenshot
848
+ for (let comp of passedComparisons) {
849
+ output.print(` ${successColor('✓')} ${comp.name}`);
850
+ }
851
+ } else {
852
+ // Default mode: just show count with green checkmark and number
853
+ output.print(` ${successColor('✓')} ${successColor(results.passed)} passed`);
854
+ }
855
+ output.blank();
856
+ }
857
+
858
+ // Failed comparisons with diff bars - use Observatory warning/danger colors
859
+ if (failedComparisons.length > 0) {
860
+ let warningColor = colors.brand?.warning || colors.yellow;
861
+ let dangerColor = colors.brand?.danger || colors.red;
862
+ output.print(` ${warningColor('◐')} ${warningColor(failedComparisons.length)} visual change${failedComparisons.length !== 1 ? 's' : ''} detected`);
863
+
864
+ // Find longest name for alignment
865
+ let maxNameLen = Math.max(...failedComparisons.map(c => c.name.length));
866
+ let textMuted = colors.brand?.textMuted || colors.dim;
867
+ for (let comp of failedComparisons) {
868
+ let diffDisplay = '';
869
+ if (comp.diffPercentage !== undefined) {
870
+ // Use the new diffBar helper for visual representation
871
+ let bar = output.diffBar(comp.diffPercentage, 10);
872
+ let paddedName = comp.name.padEnd(maxNameLen);
873
+ diffDisplay = ` ${bar} ${textMuted(`${comp.diffPercentage.toFixed(1)}%`)}`;
874
+ output.print(` ${dangerColor('✗')} ${paddedName}${diffDisplay}`);
875
+ } else {
876
+ output.print(` ${dangerColor('✗')} ${comp.name}`);
877
+ }
878
+ }
879
+ output.blank();
880
+ }
881
+
882
+ // New screenshots - use Observatory info color
883
+ if (newComparisons.length > 0) {
884
+ let infoColor = colors.brand?.info || colors.cyan;
885
+ let textMuted = colors.brand?.textMuted || colors.dim;
886
+ output.print(` ${infoColor('+')} ${infoColor(newComparisons.length)} new screenshot${newComparisons.length !== 1 ? 's' : ''}`);
887
+ for (let comp of newComparisons) {
888
+ output.print(` ${textMuted('○')} ${comp.name}`);
889
+ }
890
+ output.blank();
891
+ }
892
+
893
+ // Errors - use Observatory danger color
894
+ if (results.errors > 0) {
895
+ let dangerColor = colors.brand?.danger || colors.red;
896
+ let errorComparisons = this.comparisons.filter(c => c.status === 'error');
897
+ output.print(` ${dangerColor('!')} ${dangerColor(results.errors)} error${results.errors !== 1 ? 's' : ''}`);
898
+ for (let comp of errorComparisons) {
899
+ output.print(` ${dangerColor('✗')} ${comp.name}`);
900
+ }
901
+ output.blank();
902
+ }
903
+
904
+ // Dashboard link with prominent styling - detect if server is running
905
+ if (hasChanges) {
906
+ let infoColor = colors.brand?.info || colors.cyan;
907
+ let textTertiary = colors.brand?.textTertiary || colors.dim;
908
+
909
+ // Check if TDD server is already running
910
+ let serverFile = `${this.workingDir}/.vizzly/server.json`;
911
+ let serverRunning = false;
912
+ let serverPort = 47392;
913
+ try {
914
+ if (existsSync(serverFile)) {
915
+ let serverInfo = JSON.parse(readFileSync(serverFile, 'utf8'));
916
+ if (serverInfo.port) {
917
+ serverPort = serverInfo.port;
918
+ serverRunning = true;
919
+ }
920
+ }
921
+ } catch {
922
+ // Ignore errors reading server file
923
+ }
924
+ if (serverRunning) {
925
+ // Server is running - show the dashboard URL
926
+ output.print(` ${textTertiary('→')} Review changes: ${infoColor(colors.underline(`http://localhost:${serverPort}`))}`);
927
+ } else {
928
+ // Server not running - suggest starting it
929
+ output.print(` ${textTertiary('→')} Review changes: ${infoColor(colors.underline('vizzly tdd start --open'))}`);
930
+ }
931
+ output.blank();
932
+ }
933
+ return results;
934
+ }
935
+
936
+ /**
937
+ * Update all baselines with current screenshots
938
+ */
939
+ updateBaselines() {
940
+ // Destructure dependencies
941
+ let {
942
+ output,
943
+ generateScreenshotSignature,
944
+ generateBaselineFilename,
945
+ sanitizeScreenshotName,
946
+ validateScreenshotProperties,
947
+ getBaselinePath,
948
+ existsSync,
949
+ readFileSync,
950
+ writeFileSync,
951
+ createEmptyBaselineMetadata,
952
+ upsertScreenshotInMetadata,
953
+ saveBaselineMetadata
954
+ } = this._deps;
955
+ if (this.comparisons.length === 0) {
956
+ output.warn('No comparisons found - nothing to update');
957
+ return 0;
958
+ }
959
+ let updatedCount = 0;
960
+ if (!this.baselineData) {
961
+ this.baselineData = createEmptyBaselineMetadata({
962
+ threshold: this.threshold,
963
+ signatureProperties: this.signatureProperties
964
+ });
965
+ }
966
+ for (let comparison of this.comparisons) {
967
+ let {
968
+ name,
969
+ current
970
+ } = comparison;
971
+ if (!current || !existsSync(current)) {
972
+ output.warn(`Current screenshot not found for ${name}, skipping`);
973
+ continue;
974
+ }
975
+ let sanitizedName;
976
+ try {
977
+ sanitizedName = sanitizeScreenshotName(name);
978
+ } catch (error) {
979
+ output.warn(`Skipping baseline update for invalid name '${name}': ${error.message}`);
980
+ continue;
981
+ }
982
+ let validatedProperties = validateScreenshotProperties(comparison.properties || {});
983
+ let signature = generateScreenshotSignature(sanitizedName, validatedProperties, this.signatureProperties);
984
+ let filename = generateBaselineFilename(sanitizedName, signature);
985
+ let baselineImagePath = getBaselinePath(this.baselinePath, filename);
986
+ try {
987
+ let currentBuffer = readFileSync(current);
988
+ writeFileSync(baselineImagePath, currentBuffer);
989
+ let screenshotEntry = {
990
+ name: sanitizedName,
991
+ properties: validatedProperties,
992
+ path: baselineImagePath,
993
+ signature
994
+ };
995
+ upsertScreenshotInMetadata(this.baselineData, screenshotEntry, signature);
996
+ updatedCount++;
997
+ output.info(`Updated baseline for ${sanitizedName}`);
998
+ } catch (error) {
999
+ output.error(`Failed to update baseline for ${sanitizedName}: ${error.message}`);
1000
+ }
1001
+ }
1002
+ if (updatedCount > 0) {
1003
+ try {
1004
+ saveBaselineMetadata(this.baselinePath, this.baselineData);
1005
+ output.info(`Updated ${updatedCount} baseline(s)`);
1006
+ } catch (error) {
1007
+ output.error(`Failed to save baseline metadata: ${error.message}`);
1008
+ }
1009
+ }
1010
+ return updatedCount;
1011
+ }
1012
+
1013
+ /**
1014
+ * Accept a single baseline
1015
+ */
1016
+ async acceptBaseline(idOrComparison) {
1017
+ // Destructure dependencies
1018
+ let {
1019
+ output,
1020
+ generateScreenshotSignature,
1021
+ generateBaselineFilename,
1022
+ sanitizeScreenshotName,
1023
+ safePath,
1024
+ existsSync,
1025
+ readFileSync,
1026
+ mkdirSync,
1027
+ writeFileSync,
1028
+ createEmptyBaselineMetadata,
1029
+ upsertScreenshotInMetadata,
1030
+ saveBaselineMetadata
1031
+ } = this._deps;
1032
+ let comparison;
1033
+ if (typeof idOrComparison === 'string') {
1034
+ comparison = this.comparisons.find(c => c.id === idOrComparison);
1035
+ if (!comparison) {
1036
+ throw new Error(`No comparison found with ID: ${idOrComparison}`);
1037
+ }
1038
+ } else {
1039
+ comparison = idOrComparison;
1040
+ }
1041
+
1042
+ // Sanitize name for consistency, even though comparison.name is typically pre-sanitized
1043
+ let sanitizedName;
1044
+ try {
1045
+ sanitizedName = sanitizeScreenshotName(comparison.name);
1046
+ } catch (error) {
1047
+ output.error(`Invalid screenshot name '${comparison.name}': ${error.message}`);
1048
+ throw new Error(`Screenshot name validation failed: ${error.message}`);
1049
+ }
1050
+ let properties = comparison.properties || {};
1051
+
1052
+ // Generate signature from properties (don't rely on comparison.signature)
1053
+ let signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
1054
+ let filename = generateBaselineFilename(sanitizedName, signature);
1055
+
1056
+ // Find the current screenshot file
1057
+ let currentImagePath = safePath(this.currentPath, filename);
1058
+ if (!existsSync(currentImagePath)) {
1059
+ output.error(`Current screenshot not found at: ${currentImagePath}`);
1060
+ throw new Error(`Current screenshot not found: ${sanitizedName} (looked at ${currentImagePath})`);
1061
+ }
1062
+
1063
+ // Read the current image
1064
+ let imageBuffer = readFileSync(currentImagePath);
1065
+
1066
+ // Create baseline directory if it doesn't exist
1067
+ if (!existsSync(this.baselinePath)) {
1068
+ mkdirSync(this.baselinePath, {
1069
+ recursive: true
1070
+ });
1071
+ }
1072
+
1073
+ // Update the baseline (filename already includes .png extension)
1074
+ let baselineImagePath = safePath(this.baselinePath, filename);
1075
+ writeFileSync(baselineImagePath, imageBuffer);
1076
+
1077
+ // Update baseline metadata
1078
+ if (!this.baselineData) {
1079
+ this.baselineData = createEmptyBaselineMetadata({
1080
+ threshold: this.threshold,
1081
+ signatureProperties: this.signatureProperties
1082
+ });
1083
+ }
1084
+ let screenshotEntry = {
1085
+ name: sanitizedName,
1086
+ properties,
1087
+ path: baselineImagePath,
1088
+ signature
1089
+ };
1090
+ upsertScreenshotInMetadata(this.baselineData, screenshotEntry, signature);
1091
+ saveBaselineMetadata(this.baselinePath, this.baselineData);
1092
+ return {
1093
+ name: sanitizedName,
1094
+ status: 'accepted',
1095
+ message: 'Screenshot accepted as new baseline'
1096
+ };
1097
+ }
1098
+
1099
+ /**
1100
+ * Create new baseline (used during --set-baseline mode)
1101
+ * @private
1102
+ */
1103
+ createNewBaseline(name, imageBuffer, properties, currentImagePath, baselineImagePath) {
1104
+ // Destructure dependencies
1105
+ let {
1106
+ output,
1107
+ generateScreenshotSignature,
1108
+ generateComparisonId,
1109
+ writeFileSync,
1110
+ createEmptyBaselineMetadata,
1111
+ upsertScreenshotInMetadata,
1112
+ saveBaselineMetadata
1113
+ } = this._deps;
1114
+ output.info(`Creating baseline for ${name}`);
1115
+ writeFileSync(baselineImagePath, imageBuffer);
1116
+ if (!this.baselineData) {
1117
+ this.baselineData = createEmptyBaselineMetadata({
1118
+ threshold: this.threshold,
1119
+ signatureProperties: this.signatureProperties
1120
+ });
1121
+ }
1122
+ let signature = generateScreenshotSignature(name, properties || {}, this.signatureProperties);
1123
+ let screenshotEntry = {
1124
+ name,
1125
+ properties: properties || {},
1126
+ path: baselineImagePath,
1127
+ signature
1128
+ };
1129
+ upsertScreenshotInMetadata(this.baselineData, screenshotEntry, signature);
1130
+ saveBaselineMetadata(this.baselinePath, this.baselineData);
1131
+ let result = {
1132
+ id: generateComparisonId(signature),
1133
+ name,
1134
+ status: 'new',
1135
+ baseline: baselineImagePath,
1136
+ current: currentImagePath,
1137
+ diff: null,
1138
+ properties,
1139
+ signature
1140
+ };
1141
+ this.comparisons.push(result);
1142
+ output.info(`Baseline created for ${name}`);
1143
+ return result;
1144
+ }
1145
+ }