@vizzly-testing/cli 0.19.2 → 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 (76) 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/client/index.js +0 -1
  11. package/dist/commands/doctor.js +3 -3
  12. package/dist/commands/finalize.js +41 -15
  13. package/dist/commands/login.js +7 -6
  14. package/dist/commands/logout.js +4 -4
  15. package/dist/commands/project.js +5 -4
  16. package/dist/commands/run.js +158 -90
  17. package/dist/commands/status.js +22 -18
  18. package/dist/commands/tdd.js +105 -78
  19. package/dist/commands/upload.js +61 -26
  20. package/dist/commands/whoami.js +4 -4
  21. package/dist/config/core.js +438 -0
  22. package/dist/config/index.js +13 -0
  23. package/dist/config/operations.js +327 -0
  24. package/dist/index.js +1 -1
  25. package/dist/project/core.js +295 -0
  26. package/dist/project/index.js +13 -0
  27. package/dist/project/operations.js +393 -0
  28. package/dist/report-generator/core.js +315 -0
  29. package/dist/report-generator/index.js +8 -0
  30. package/dist/report-generator/operations.js +196 -0
  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 +80 -48
  38. package/dist/server-manager/core.js +183 -0
  39. package/dist/server-manager/index.js +81 -0
  40. package/dist/server-manager/operations.js +208 -0
  41. package/dist/services/build-manager.js +2 -69
  42. package/dist/services/index.js +21 -48
  43. package/dist/services/screenshot-server.js +40 -74
  44. package/dist/services/server-manager.js +45 -80
  45. package/dist/services/static-report-generator.js +21 -163
  46. package/dist/services/test-runner.js +90 -249
  47. package/dist/services/uploader.js +56 -358
  48. package/dist/tdd/core/hotspot-coverage.js +112 -0
  49. package/dist/tdd/core/signature.js +101 -0
  50. package/dist/tdd/index.js +19 -0
  51. package/dist/tdd/metadata/baseline-metadata.js +103 -0
  52. package/dist/tdd/metadata/hotspot-metadata.js +93 -0
  53. package/dist/tdd/services/baseline-downloader.js +151 -0
  54. package/dist/tdd/services/baseline-manager.js +166 -0
  55. package/dist/tdd/services/comparison-service.js +230 -0
  56. package/dist/tdd/services/hotspot-service.js +71 -0
  57. package/dist/tdd/services/result-service.js +123 -0
  58. package/dist/tdd/tdd-service.js +1081 -0
  59. package/dist/test-runner/core.js +255 -0
  60. package/dist/test-runner/index.js +13 -0
  61. package/dist/test-runner/operations.js +483 -0
  62. package/dist/types/client.d.ts +4 -2
  63. package/dist/types/index.d.ts +5 -0
  64. package/dist/uploader/core.js +396 -0
  65. package/dist/uploader/index.js +11 -0
  66. package/dist/uploader/operations.js +412 -0
  67. package/dist/utils/config-schema.js +8 -3
  68. package/package.json +7 -12
  69. package/dist/services/api-service.js +0 -412
  70. package/dist/services/auth-service.js +0 -226
  71. package/dist/services/config-service.js +0 -369
  72. package/dist/services/html-report-generator.js +0 -455
  73. package/dist/services/project-service.js +0 -326
  74. package/dist/services/report-generator/report.css +0 -411
  75. package/dist/services/report-generator/viewer.js +0 -102
  76. package/dist/services/tdd-service.js +0 -1429
@@ -1,1429 +0,0 @@
1
- /**
2
- * TDD Service - Local Visual Testing
3
- *
4
- * ⚠️ CRITICAL: Signature/filename generation MUST stay in sync with the cloud!
5
- *
6
- * Cloud counterpart: vizzly/src/utils/screenshot-identity.js
7
- * - generateScreenshotSignature()
8
- * - generateBaselineFilename()
9
- *
10
- * Contract tests: Both repos have golden tests that must produce identical values:
11
- * - Cloud: tests/contracts/signature-parity.test.js
12
- * - CLI: tests/contracts/signature-parity.spec.js
13
- *
14
- * If you modify signature or filename generation here, you MUST:
15
- * 1. Make the same change in the cloud repo
16
- * 2. Update golden test values in BOTH repos
17
- * 3. Run contract tests in both repos to verify parity
18
- *
19
- * The signature format is: name|viewport_width|browser|custom1|custom2|...
20
- * The filename format is: {sanitized-name}_{12-char-sha256-hash}.png
21
- */
22
-
23
- import crypto from 'node:crypto';
24
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
25
- import { join } from 'node:path';
26
- import { compare } from '@vizzly-testing/honeydiff';
27
- import { NetworkError } from '../errors/vizzly-error.js';
28
- import { ApiService } from '../services/api-service.js';
29
- import { colors } from '../utils/colors.js';
30
- import { fetchWithTimeout } from '../utils/fetch-utils.js';
31
- import { getDefaultBranch } from '../utils/git.js';
32
- import * as output from '../utils/output.js';
33
- import { safePath, sanitizeScreenshotName, validatePathSecurity, validateScreenshotProperties } from '../utils/security.js';
34
- import { HtmlReportGenerator } from './html-report-generator.js';
35
-
36
- /**
37
- * Generate a screenshot signature for baseline matching
38
- *
39
- * ⚠️ SYNC WITH: vizzly/src/utils/screenshot-identity.js - generateScreenshotSignature()
40
- *
41
- * Uses same logic as cloud: name + viewport_width + browser + custom properties
42
- *
43
- * @param {string} name - Screenshot name
44
- * @param {Object} properties - Screenshot properties (viewport, browser, metadata, etc.)
45
- * @param {Array<string>} customProperties - Custom property names from project settings
46
- * @returns {string} Signature like "Login|1920|chrome|iPhone 15 Pro"
47
- */
48
- function generateScreenshotSignature(name, properties = {}, customProperties = []) {
49
- // Match cloud screenshot-identity.js behavior exactly:
50
- // Always include all default properties (name, viewport_width, browser)
51
- // even if null/undefined, using empty string as placeholder
52
- const defaultProperties = ['name', 'viewport_width', 'browser'];
53
- const allProperties = [...defaultProperties, ...customProperties];
54
- const parts = allProperties.map(propName => {
55
- let value;
56
- if (propName === 'name') {
57
- value = name;
58
- } else if (propName === 'viewport_width') {
59
- // Check for viewport_width as top-level property first (backend format)
60
- value = properties.viewport_width;
61
- // Fallback to nested viewport.width (SDK format)
62
- if (value === null || value === undefined) {
63
- value = properties.viewport?.width;
64
- }
65
- } else if (propName === 'browser') {
66
- value = properties.browser;
67
- } else {
68
- // Custom property - check multiple locations
69
- value = properties[propName] ?? properties.metadata?.[propName] ?? properties.metadata?.properties?.[propName];
70
- }
71
-
72
- // Handle null/undefined values consistently (match cloud behavior)
73
- if (value === null || value === undefined) {
74
- return '';
75
- }
76
-
77
- // Convert to string and normalize
78
- return String(value).trim();
79
- });
80
- return parts.join('|');
81
- }
82
-
83
- /**
84
- * Generate a stable, filesystem-safe filename for a screenshot baseline
85
- * Uses a hash of the signature to avoid character encoding issues
86
- * Matches the cloud's generateBaselineFilename implementation exactly
87
- *
88
- * @param {string} name - Screenshot name
89
- * @param {string} signature - Full signature string
90
- * @returns {string} Filename like "homepage_a1b2c3d4e5f6.png"
91
- */
92
- function generateBaselineFilename(name, signature) {
93
- const hash = crypto.createHash('sha256').update(signature).digest('hex').slice(0, 12);
94
-
95
- // Sanitize the name for filesystem safety
96
- const safeName = name.replace(/[/\\:*?"<>|]/g, '') // Remove unsafe chars
97
- .replace(/\s+/g, '-') // Spaces to hyphens
98
- .slice(0, 50); // Limit length
99
-
100
- return `${safeName}_${hash}.png`;
101
- }
102
-
103
- /**
104
- * Generate a stable unique ID from signature for TDD comparisons
105
- * This allows UI to reference specific variants without database IDs
106
- */
107
- function generateComparisonId(signature) {
108
- return crypto.createHash('sha256').update(signature).digest('hex').slice(0, 16);
109
- }
110
-
111
- /**
112
- * Create a new TDD service instance
113
- */
114
- export function createTDDService(config, options = {}) {
115
- return new TddService(config, options.workingDir, options.setBaseline, options.authService);
116
- }
117
- export class TddService {
118
- constructor(config, workingDir = process.cwd(), setBaseline = false, authService = null) {
119
- this.config = config;
120
- this.setBaseline = setBaseline;
121
- this.authService = authService;
122
- this.api = new ApiService({
123
- baseUrl: config.apiUrl,
124
- token: config.apiKey,
125
- command: 'tdd',
126
- allowNoToken: true // TDD can run without a token to create new screenshots
127
- });
128
-
129
- // Validate and secure the working directory
130
- try {
131
- this.workingDir = validatePathSecurity(workingDir, workingDir);
132
- } catch (error) {
133
- output.error(`Invalid working directory: ${error.message}`);
134
- throw new Error(`Working directory validation failed: ${error.message}`);
135
- }
136
-
137
- // Use safe path construction for subdirectories
138
- this.baselinePath = safePath(this.workingDir, '.vizzly', 'baselines');
139
- this.currentPath = safePath(this.workingDir, '.vizzly', 'current');
140
- this.diffPath = safePath(this.workingDir, '.vizzly', 'diffs');
141
- this.baselineData = null;
142
- this.comparisons = [];
143
- this.threshold = config.comparison?.threshold || 2.0;
144
- this.minClusterSize = config.comparison?.minClusterSize ?? 2; // Filter single-pixel noise by default
145
- this.signatureProperties = []; // Custom properties from project's baseline_signature_properties
146
-
147
- // Check if we're in baseline update mode
148
- if (this.setBaseline) {
149
- output.info('🐻 Baseline update mode - will overwrite existing baselines with new ones');
150
- }
151
-
152
- // Ensure directories exist
153
- [this.baselinePath, this.currentPath, this.diffPath].forEach(dir => {
154
- if (!existsSync(dir)) {
155
- try {
156
- mkdirSync(dir, {
157
- recursive: true
158
- });
159
- } catch (error) {
160
- output.error(`Failed to create directory ${dir}: ${error.message}`);
161
- throw new Error(`Directory creation failed: ${error.message}`);
162
- }
163
- }
164
- });
165
- }
166
- async downloadBaselines(environment = 'test', branch = null, buildId = null, comparisonId = null) {
167
- // If no branch specified, try to detect the default branch
168
- if (!branch) {
169
- branch = await getDefaultBranch();
170
- if (!branch) {
171
- // If we can't detect a default branch, use 'main' as fallback
172
- branch = 'main';
173
- output.warn(`⚠️ Could not detect default branch, using 'main' as fallback`);
174
- } else {
175
- output.debug('tdd', `detected default branch: ${branch}`);
176
- }
177
- }
178
- try {
179
- let baselineBuild;
180
- if (buildId) {
181
- // Use the tdd-baselines endpoint which returns pre-computed filenames
182
- let apiResponse = await this.api.getTddBaselines(buildId);
183
- if (!apiResponse) {
184
- throw new Error(`Build ${buildId} not found or API returned null`);
185
- }
186
-
187
- // When downloading baselines, always start with a clean slate
188
- // This handles signature property changes, build switches, and any stale state
189
- output.info('Clearing local state before downloading baselines...');
190
- try {
191
- // Clear everything - baselines, current screenshots, diffs, and metadata
192
- // This ensures we start fresh with the new baseline build
193
- rmSync(this.baselinePath, {
194
- recursive: true,
195
- force: true
196
- });
197
- rmSync(this.currentPath, {
198
- recursive: true,
199
- force: true
200
- });
201
- rmSync(this.diffPath, {
202
- recursive: true,
203
- force: true
204
- });
205
- mkdirSync(this.baselinePath, {
206
- recursive: true
207
- });
208
- mkdirSync(this.currentPath, {
209
- recursive: true
210
- });
211
- mkdirSync(this.diffPath, {
212
- recursive: true
213
- });
214
-
215
- // Clear baseline metadata file (will be regenerated with new baseline)
216
- const baselineMetadataPath = safePath(this.workingDir, '.vizzly', 'baseline-metadata.json');
217
- if (existsSync(baselineMetadataPath)) {
218
- rmSync(baselineMetadataPath, {
219
- force: true
220
- });
221
- }
222
- } catch (error) {
223
- output.error(`Failed to clear local state: ${error.message}`);
224
- }
225
-
226
- // Extract signature properties from API response (for variant support)
227
- if (apiResponse.signatureProperties && Array.isArray(apiResponse.signatureProperties)) {
228
- this.signatureProperties = apiResponse.signatureProperties;
229
- if (this.signatureProperties.length > 0) {
230
- output.info(`Using signature properties: ${this.signatureProperties.join(', ')}`);
231
- }
232
- }
233
- baselineBuild = apiResponse.build;
234
-
235
- // Check build status and warn if it's not successful
236
- if (baselineBuild.status === 'failed') {
237
- output.warn(`⚠️ Build ${buildId} is marked as FAILED - falling back to local baselines`);
238
- output.info(`💡 To use remote baselines, specify a successful build ID instead`);
239
- return await this.handleLocalBaselines();
240
- } else if (baselineBuild.status !== 'completed') {
241
- output.warn(`⚠️ Build ${buildId} has status: ${baselineBuild.status} (expected: completed)`);
242
- }
243
-
244
- // Attach screenshots to build for unified processing below
245
- baselineBuild.screenshots = apiResponse.screenshots;
246
- } else if (comparisonId) {
247
- // Use specific comparison ID - download only this comparison's baseline screenshot
248
- output.info(`Using comparison: ${comparisonId}`);
249
- const comparison = await this.api.getComparison(comparisonId);
250
-
251
- // A comparison doesn't have baselineBuild directly - we need to get it
252
- // The comparison has baseline_screenshot which contains the build_id
253
- if (!comparison.baseline_screenshot) {
254
- throw new Error(`Comparison ${comparisonId} has no baseline screenshot. This comparison may be a "new" screenshot with no baseline to compare against.`);
255
- }
256
-
257
- // The original_url might be in baseline_screenshot.original_url or comparison.baseline_screenshot_url
258
- const baselineUrl = comparison.baseline_screenshot.original_url || comparison.baseline_screenshot_url;
259
- if (!baselineUrl) {
260
- throw new Error(`Baseline screenshot for comparison ${comparisonId} has no download URL`);
261
- }
262
-
263
- // Extract properties from the current screenshot to ensure signature matching
264
- // The baseline should use the same properties (viewport/browser) as the current screenshot
265
- // so that generateScreenshotSignature produces the correct filename
266
- // Use current screenshot properties since we're downloading baseline to compare against current
267
- const screenshotProperties = {};
268
-
269
- // Build properties from comparison API fields (added in backend update)
270
- // Use current_* fields since we're matching against the current screenshot being tested
271
- if (comparison.current_viewport_width || comparison.current_browser) {
272
- if (comparison.current_viewport_width) {
273
- screenshotProperties.viewport = {
274
- width: comparison.current_viewport_width,
275
- height: comparison.current_viewport_height
276
- };
277
- }
278
- if (comparison.current_browser) {
279
- screenshotProperties.browser = comparison.current_browser;
280
- }
281
- } else if (comparison.baseline_viewport_width || comparison.baseline_browser) {
282
- // Fallback to baseline properties if current not available
283
- if (comparison.baseline_viewport_width) {
284
- screenshotProperties.viewport = {
285
- width: comparison.baseline_viewport_width,
286
- height: comparison.baseline_viewport_height
287
- };
288
- }
289
- if (comparison.baseline_browser) {
290
- screenshotProperties.browser = comparison.baseline_browser;
291
- }
292
- }
293
- output.info(`📊 Extracted properties for signature: ${JSON.stringify(screenshotProperties)}`);
294
-
295
- // Generate filename locally for comparison path (we don't have API-provided filename)
296
- const screenshotName = comparison.baseline_name || comparison.current_name;
297
- const signature = generateScreenshotSignature(screenshotName, screenshotProperties, this.signatureProperties);
298
- const filename = generateBaselineFilename(screenshotName, signature);
299
-
300
- // For a specific comparison, we only download that one baseline screenshot
301
- // Create a mock build structure with just this one screenshot
302
- baselineBuild = {
303
- id: comparison.baseline_screenshot.build_id || 'comparison-baseline',
304
- name: `Comparison ${comparisonId.substring(0, 8)}`,
305
- screenshots: [{
306
- id: comparison.baseline_screenshot.id,
307
- name: screenshotName,
308
- original_url: baselineUrl,
309
- metadata: screenshotProperties,
310
- properties: screenshotProperties,
311
- filename: filename // Generated locally for comparison path
312
- }]
313
- };
314
- } else {
315
- // Get the latest passed build for this environment and branch
316
- const builds = await this.api.getBuilds({
317
- environment,
318
- branch,
319
- status: 'passed',
320
- limit: 1
321
- });
322
- if (!builds.data || builds.data.length === 0) {
323
- output.warn(`⚠️ No baseline builds found for ${environment}/${branch}`);
324
- output.info('💡 Run a build in normal mode first to create baselines');
325
- return null;
326
- }
327
-
328
- // Use getTddBaselines to get screenshots with pre-computed filenames
329
- const apiResponse = await this.api.getTddBaselines(builds.data[0].id);
330
- if (!apiResponse) {
331
- throw new Error(`Build ${builds.data[0].id} not found or API returned null`);
332
- }
333
-
334
- // Extract signature properties from API response (for variant support)
335
- if (apiResponse.signatureProperties && Array.isArray(apiResponse.signatureProperties)) {
336
- this.signatureProperties = apiResponse.signatureProperties;
337
- if (this.signatureProperties.length > 0) {
338
- output.info(`Using custom signature properties: ${this.signatureProperties.join(', ')}`);
339
- }
340
- }
341
- baselineBuild = apiResponse.build;
342
- baselineBuild.screenshots = apiResponse.screenshots;
343
- }
344
-
345
- // For both buildId and getBuilds paths, we now have screenshots with filenames
346
- // For comparisonId, we created a mock build with just the one screenshot
347
- let buildDetails = baselineBuild;
348
- if (!buildDetails.screenshots || buildDetails.screenshots.length === 0) {
349
- output.warn('⚠️ No screenshots found in baseline build');
350
- return null;
351
- }
352
- output.info(`Using baseline from build: ${colors.cyan(baselineBuild.name || 'Unknown')} (${baselineBuild.id || 'Unknown ID'})`);
353
- output.info(`Checking ${colors.cyan(buildDetails.screenshots.length)} baseline screenshots...`);
354
-
355
- // Check existing baseline metadata for efficient SHA comparison
356
- let existingBaseline = await this.loadBaseline();
357
- let existingShaMap = new Map();
358
- if (existingBaseline) {
359
- existingBaseline.screenshots.forEach(s => {
360
- if (s.sha256 && s.filename) {
361
- existingShaMap.set(s.filename, s.sha256);
362
- }
363
- });
364
- }
365
-
366
- // Download screenshots in batches with progress indication
367
- let downloadedCount = 0;
368
- let skippedCount = 0;
369
- let errorCount = 0;
370
- const totalScreenshots = buildDetails.screenshots.length;
371
- const batchSize = 5; // Download up to 5 screenshots concurrently
372
-
373
- // Filter screenshots that need to be downloaded
374
- const screenshotsToProcess = [];
375
- for (const screenshot of buildDetails.screenshots) {
376
- // Sanitize screenshot name for security
377
- let sanitizedName;
378
- try {
379
- sanitizedName = sanitizeScreenshotName(screenshot.name);
380
- } catch (error) {
381
- output.warn(`Skipping screenshot with invalid name '${screenshot.name}': ${error.message}`);
382
- errorCount++;
383
- continue;
384
- }
385
-
386
- // Use API-provided filename (required from tdd-baselines endpoint)
387
- // This ensures filenames match between cloud and local TDD
388
- let filename = screenshot.filename;
389
- if (!filename) {
390
- output.warn(`⚠️ Screenshot ${sanitizedName} has no filename from API - skipping`);
391
- errorCount++;
392
- continue;
393
- }
394
- let imagePath = safePath(this.baselinePath, filename);
395
-
396
- // Check if we already have this file with the same SHA
397
- if (existsSync(imagePath) && screenshot.sha256) {
398
- let storedSha = existingShaMap.get(filename);
399
- if (storedSha === screenshot.sha256) {
400
- downloadedCount++;
401
- skippedCount++;
402
- continue;
403
- }
404
- }
405
-
406
- // Use original_url as the download URL
407
- const downloadUrl = screenshot.original_url || screenshot.url;
408
- if (!downloadUrl) {
409
- output.warn(`⚠️ Screenshot ${sanitizedName} has no download URL - skipping`);
410
- errorCount++;
411
- continue;
412
- }
413
- screenshotsToProcess.push({
414
- screenshot,
415
- sanitizedName,
416
- imagePath,
417
- downloadUrl,
418
- filename
419
- });
420
- }
421
-
422
- // Process downloads in batches
423
- const actualDownloadsNeeded = screenshotsToProcess.length;
424
- if (actualDownloadsNeeded > 0) {
425
- output.info(`📥 Downloading ${actualDownloadsNeeded} new/updated screenshots in batches of ${batchSize}...`);
426
- for (let i = 0; i < screenshotsToProcess.length; i += batchSize) {
427
- const batch = screenshotsToProcess.slice(i, i + batchSize);
428
- const batchNum = Math.floor(i / batchSize) + 1;
429
- const totalBatches = Math.ceil(screenshotsToProcess.length / batchSize);
430
- output.info(`📦 Processing batch ${batchNum}/${totalBatches} (${batch.length} screenshots)`);
431
-
432
- // Download batch concurrently
433
- const downloadPromises = batch.map(async ({
434
- sanitizedName,
435
- imagePath,
436
- downloadUrl
437
- }) => {
438
- try {
439
- const response = await fetchWithTimeout(downloadUrl);
440
- if (!response.ok) {
441
- throw new NetworkError(`Failed to download ${sanitizedName}: ${response.statusText}`);
442
- }
443
- const arrayBuffer = await response.arrayBuffer();
444
- const imageBuffer = Buffer.from(arrayBuffer);
445
- writeFileSync(imagePath, imageBuffer);
446
- return {
447
- success: true,
448
- name: sanitizedName
449
- };
450
- } catch (error) {
451
- output.warn(`⚠️ Failed to download ${sanitizedName}: ${error.message}`);
452
- return {
453
- success: false,
454
- name: sanitizedName,
455
- error: error.message
456
- };
457
- }
458
- });
459
- const batchResults = await Promise.all(downloadPromises);
460
- const batchSuccesses = batchResults.filter(r => r.success).length;
461
- const batchFailures = batchResults.filter(r => !r.success).length;
462
- downloadedCount += batchSuccesses;
463
- errorCount += batchFailures;
464
-
465
- // Show progress
466
- const totalProcessed = downloadedCount + skippedCount + errorCount;
467
- const progressPercent = Math.round(totalProcessed / totalScreenshots * 100);
468
- output.info(`📊 Progress: ${totalProcessed}/${totalScreenshots} (${progressPercent}%) - ${batchSuccesses} downloaded, ${batchFailures} failed in this batch`);
469
- }
470
- }
471
-
472
- // Check if we actually downloaded any screenshots
473
- if (downloadedCount === 0 && skippedCount === 0) {
474
- output.error('❌ No screenshots were successfully downloaded from the baseline build');
475
- if (errorCount > 0) {
476
- output.info(`💡 ${errorCount} screenshots had errors - check download URLs and network connection`);
477
- }
478
- output.info('💡 This usually means the build failed or screenshots have no download URLs');
479
- output.info('💡 Try using a successful build ID, or run without --baseline-build to create local baselines');
480
- return null;
481
- }
482
-
483
- // Store enhanced baseline metadata with SHA hashes and build info
484
- this.baselineData = {
485
- buildId: baselineBuild.id,
486
- buildName: baselineBuild.name,
487
- environment,
488
- branch,
489
- threshold: this.threshold,
490
- signatureProperties: this.signatureProperties,
491
- // Store for TDD comparison
492
- createdAt: new Date().toISOString(),
493
- buildInfo: {
494
- commitSha: baselineBuild.commit_sha,
495
- commitMessage: baselineBuild.commit_message,
496
- approvalStatus: baselineBuild.approval_status,
497
- completedAt: baselineBuild.completed_at
498
- },
499
- screenshots: buildDetails.screenshots.filter(s => s.filename) // Only include screenshots with filenames
500
- .map(s => ({
501
- name: sanitizeScreenshotName(s.name),
502
- originalName: s.name,
503
- sha256: s.sha256,
504
- id: s.id,
505
- filename: s.filename,
506
- path: safePath(this.baselinePath, s.filename),
507
- browser: s.browser,
508
- viewport_width: s.viewport_width,
509
- originalUrl: s.original_url,
510
- fileSize: s.file_size_bytes,
511
- dimensions: {
512
- width: s.width,
513
- height: s.height
514
- }
515
- }))
516
- };
517
- const metadataPath = join(this.baselinePath, 'metadata.json');
518
- writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
519
-
520
- // Download hotspot data for noise filtering
521
- await this.downloadHotspots(buildDetails.screenshots);
522
-
523
- // Save baseline build metadata for MCP plugin
524
- const baselineMetadataPath = safePath(this.workingDir, '.vizzly', 'baseline-metadata.json');
525
- const buildMetadata = {
526
- buildId: baselineBuild.id,
527
- buildName: baselineBuild.name,
528
- branch: branch,
529
- environment: environment,
530
- commitSha: baselineBuild.commit_sha,
531
- commitMessage: baselineBuild.commit_message,
532
- approvalStatus: baselineBuild.approval_status,
533
- completedAt: baselineBuild.completed_at,
534
- downloadedAt: new Date().toISOString()
535
- };
536
- writeFileSync(baselineMetadataPath, JSON.stringify(buildMetadata, null, 2));
537
-
538
- // Final summary
539
- const actualDownloads = downloadedCount - skippedCount;
540
- if (skippedCount > 0) {
541
- // All skipped (up-to-date)
542
- if (actualDownloads === 0) {
543
- output.info(`✅ All ${skippedCount} baselines up-to-date (matching local SHA)`);
544
- } else {
545
- // Mixed: some downloaded, some skipped
546
- output.info(`✅ Downloaded ${actualDownloads} new screenshots, ${skippedCount} already up-to-date`);
547
- }
548
- } else {
549
- // Fresh download
550
- output.info(`✅ Downloaded ${downloadedCount}/${buildDetails.screenshots.length} screenshots successfully`);
551
- }
552
- if (errorCount > 0) {
553
- output.warn(`⚠️ ${errorCount} screenshots failed to download`);
554
- }
555
- return this.baselineData;
556
- } catch (error) {
557
- output.error(`❌ Failed to download baseline: ${error.message}`);
558
- throw error;
559
- }
560
- }
561
-
562
- /**
563
- * Download hotspot data for screenshots from the cloud
564
- * Hotspots identify regions that frequently change (timestamps, IDs, etc.)
565
- * Used to filter out known dynamic content during comparisons
566
- * @param {Array} screenshots - Array of screenshot objects with name property
567
- */
568
- async downloadHotspots(screenshots) {
569
- // Only attempt if we have an API token
570
- if (!this.config.apiKey) {
571
- output.debug('tdd', 'Skipping hotspot download - no API token configured');
572
- return;
573
- }
574
- try {
575
- // Get unique screenshot names
576
- const screenshotNames = [...new Set(screenshots.map(s => s.name))];
577
- if (screenshotNames.length === 0) {
578
- return;
579
- }
580
- output.info(`🔥 Fetching hotspot data for ${screenshotNames.length} screenshots...`);
581
-
582
- // Use batch endpoint for efficiency
583
- const response = await this.api.getBatchHotspots(screenshotNames);
584
- if (!response.hotspots || Object.keys(response.hotspots).length === 0) {
585
- output.debug('tdd', 'No hotspot data available from cloud');
586
- return;
587
- }
588
-
589
- // Store hotspots in a separate file for easy access during comparisons
590
- this.hotspotData = response.hotspots;
591
- const hotspotsPath = safePath(this.workingDir, '.vizzly', 'hotspots.json');
592
- writeFileSync(hotspotsPath, JSON.stringify({
593
- downloadedAt: new Date().toISOString(),
594
- summary: response.summary,
595
- hotspots: response.hotspots
596
- }, null, 2));
597
- const hotspotCount = Object.keys(response.hotspots).length;
598
- const totalRegions = Object.values(response.hotspots).reduce((sum, h) => sum + (h.regions?.length || 0), 0);
599
- output.info(`✅ Downloaded hotspot data for ${hotspotCount} screenshots (${totalRegions} regions total)`);
600
- } catch (error) {
601
- // Don't fail baseline download if hotspot fetch fails
602
- output.debug('tdd', `Hotspot download failed: ${error.message}`);
603
- output.warn('⚠️ Could not fetch hotspot data - comparisons will run without noise filtering');
604
- }
605
- }
606
-
607
- /**
608
- * Load hotspot data from disk
609
- * @returns {Object|null} Hotspot data keyed by screenshot name, or null if not available
610
- */
611
- loadHotspots() {
612
- try {
613
- const hotspotsPath = safePath(this.workingDir, '.vizzly', 'hotspots.json');
614
- if (!existsSync(hotspotsPath)) {
615
- return null;
616
- }
617
- const data = JSON.parse(readFileSync(hotspotsPath, 'utf8'));
618
- return data.hotspots || null;
619
- } catch (error) {
620
- output.debug('tdd', `Failed to load hotspots: ${error.message}`);
621
- return null;
622
- }
623
- }
624
-
625
- /**
626
- * Get hotspot analysis for a specific screenshot
627
- * @param {string} screenshotName - Name of the screenshot
628
- * @returns {Object|null} Hotspot analysis or null if not available
629
- */
630
- getHotspotForScreenshot(screenshotName) {
631
- // Check memory cache first
632
- if (this.hotspotData?.[screenshotName]) {
633
- return this.hotspotData[screenshotName];
634
- }
635
-
636
- // Try loading from disk
637
- if (!this.hotspotData) {
638
- this.hotspotData = this.loadHotspots();
639
- }
640
- return this.hotspotData?.[screenshotName] || null;
641
- }
642
-
643
- /**
644
- * Calculate what percentage of diff falls within hotspot regions
645
- * Uses 1D Y-coordinate matching (same algorithm as cloud)
646
- * @param {Array} diffClusters - Array of diff clusters from honeydiff
647
- * @param {Object} hotspotAnalysis - Hotspot data with regions array
648
- * @returns {Object} Coverage info { coverage, linesInHotspots, totalLines }
649
- */
650
- calculateHotspotCoverage(diffClusters, hotspotAnalysis) {
651
- if (!diffClusters || diffClusters.length === 0) {
652
- return {
653
- coverage: 0,
654
- linesInHotspots: 0,
655
- totalLines: 0
656
- };
657
- }
658
- if (!hotspotAnalysis || !hotspotAnalysis.regions || hotspotAnalysis.regions.length === 0) {
659
- return {
660
- coverage: 0,
661
- linesInHotspots: 0,
662
- totalLines: 0
663
- };
664
- }
665
-
666
- // Extract Y-coordinates (diff lines) from clusters
667
- // Each cluster has a boundingBox with y and height
668
- let diffLines = [];
669
- for (const cluster of diffClusters) {
670
- if (cluster.boundingBox) {
671
- const {
672
- y,
673
- height
674
- } = cluster.boundingBox;
675
- // Add all Y lines covered by this cluster
676
- for (let line = y; line < y + height; line++) {
677
- diffLines.push(line);
678
- }
679
- }
680
- }
681
- if (diffLines.length === 0) {
682
- return {
683
- coverage: 0,
684
- linesInHotspots: 0,
685
- totalLines: 0
686
- };
687
- }
688
-
689
- // Remove duplicates and sort
690
- diffLines = [...new Set(diffLines)].sort((a, b) => a - b);
691
-
692
- // Check how many diff lines fall within hotspot regions
693
- let linesInHotspots = 0;
694
- for (const line of diffLines) {
695
- const inHotspot = hotspotAnalysis.regions.some(region => line >= region.y1 && line <= region.y2);
696
- if (inHotspot) {
697
- linesInHotspots++;
698
- }
699
- }
700
- const coverage = linesInHotspots / diffLines.length;
701
- return {
702
- coverage,
703
- linesInHotspots,
704
- totalLines: diffLines.length
705
- };
706
- }
707
-
708
- /**
709
- * Handle local baseline logic (either load existing or prepare for new baselines)
710
- * @returns {Promise<Object|null>} Baseline data or null if no local baselines exist
711
- */
712
- async handleLocalBaselines() {
713
- // Check if we're in baseline update mode - skip loading existing baselines
714
- if (this.setBaseline) {
715
- output.info('📁 Ready for new baseline creation - all screenshots will be treated as new baselines');
716
-
717
- // Reset baseline data since we're creating new ones
718
- this.baselineData = null;
719
- return null;
720
- }
721
- const baseline = await this.loadBaseline();
722
- if (!baseline) {
723
- if (this.config.apiKey) {
724
- output.info('📥 No local baseline found, but API key available for future remote fetching');
725
- output.info('🆕 Current run will create new local baselines');
726
- } else {
727
- output.info('📝 No local baseline found and no API token - all screenshots will be marked as new');
728
- }
729
- return null;
730
- } else {
731
- output.info(`✅ Using existing baseline: ${colors.cyan(baseline.buildName)}`);
732
- return baseline;
733
- }
734
- }
735
- async loadBaseline() {
736
- // In baseline update mode, never load existing baselines
737
- if (this.setBaseline) {
738
- output.debug('tdd', 'baseline update mode - skipping loading');
739
- return null;
740
- }
741
- const metadataPath = join(this.baselinePath, 'metadata.json');
742
- if (!existsSync(metadataPath)) {
743
- return null;
744
- }
745
- try {
746
- const metadata = JSON.parse(readFileSync(metadataPath, 'utf8'));
747
- this.baselineData = metadata;
748
- this.threshold = metadata.threshold || this.threshold;
749
-
750
- // Restore signature properties from saved metadata (for variant support)
751
- this.signatureProperties = metadata.signatureProperties || [];
752
- if (this.signatureProperties.length > 0) {
753
- output.debug('tdd', `loaded signature properties: ${this.signatureProperties.join(', ')}`);
754
- }
755
- return metadata;
756
- } catch (error) {
757
- output.error(`❌ Failed to load baseline metadata: ${error.message}`);
758
- return null;
759
- }
760
- }
761
- async compareScreenshot(name, imageBuffer, properties = {}) {
762
- // Sanitize screenshot name and validate properties
763
- let sanitizedName;
764
- try {
765
- sanitizedName = sanitizeScreenshotName(name);
766
- } catch (error) {
767
- output.error(`Invalid screenshot name '${name}': ${error.message}`);
768
- throw new Error(`Screenshot name validation failed: ${error.message}`);
769
- }
770
- let validatedProperties;
771
- try {
772
- validatedProperties = validateScreenshotProperties(properties);
773
- } catch (error) {
774
- output.warn(`Property validation failed for '${sanitizedName}': ${error.message}`);
775
- validatedProperties = {};
776
- }
777
-
778
- // Preserve metadata object through validation (validateScreenshotProperties strips non-primitives)
779
- // This is needed because signature generation checks properties.metadata.* for custom properties
780
- if (properties.metadata && typeof properties.metadata === 'object') {
781
- validatedProperties.metadata = properties.metadata;
782
- }
783
-
784
- // Normalize properties to match backend format (viewport_width at top level)
785
- // This ensures signature generation matches backend's screenshot-identity.js
786
- if (validatedProperties.viewport?.width && !validatedProperties.viewport_width) {
787
- validatedProperties.viewport_width = validatedProperties.viewport.width;
788
- }
789
-
790
- // Generate signature for baseline matching (name + viewport_width + browser + custom props)
791
- const signature = generateScreenshotSignature(sanitizedName, validatedProperties, this.signatureProperties);
792
- // Use hash-based filename for reliable matching (matches cloud format)
793
- const filename = generateBaselineFilename(sanitizedName, signature);
794
- const currentImagePath = safePath(this.currentPath, filename);
795
- const baselineImagePath = safePath(this.baselinePath, filename);
796
- const diffImagePath = safePath(this.diffPath, filename);
797
-
798
- // Save current screenshot
799
- writeFileSync(currentImagePath, imageBuffer);
800
-
801
- // Check if we're in baseline update mode - treat as first run, no comparisons
802
- if (this.setBaseline) {
803
- return this.createNewBaseline(sanitizedName, imageBuffer, validatedProperties, currentImagePath, baselineImagePath);
804
- }
805
-
806
- // Check if baseline exists
807
- const baselineExists = existsSync(baselineImagePath);
808
- if (!baselineExists) {
809
- // Copy current screenshot to baseline directory for future comparisons
810
- writeFileSync(baselineImagePath, imageBuffer);
811
-
812
- // Update or create baseline metadata
813
- if (!this.baselineData) {
814
- this.baselineData = {
815
- buildId: 'local-baseline',
816
- buildName: 'Local TDD Baseline',
817
- environment: 'test',
818
- branch: 'local',
819
- threshold: this.threshold,
820
- screenshots: []
821
- };
822
- }
823
-
824
- // Add screenshot to baseline metadata
825
- const screenshotEntry = {
826
- name: sanitizedName,
827
- properties: validatedProperties,
828
- path: baselineImagePath,
829
- signature: signature
830
- };
831
- const existingIndex = this.baselineData.screenshots.findIndex(s => s.signature === signature);
832
- if (existingIndex >= 0) {
833
- this.baselineData.screenshots[existingIndex] = screenshotEntry;
834
- } else {
835
- this.baselineData.screenshots.push(screenshotEntry);
836
- }
837
-
838
- // Save updated metadata
839
- const metadataPath = join(this.baselinePath, 'metadata.json');
840
- writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
841
-
842
- // Baseline creation tracked by event handler
843
-
844
- const result = {
845
- id: generateComparisonId(signature),
846
- name: sanitizedName,
847
- status: 'new',
848
- baseline: baselineImagePath,
849
- current: currentImagePath,
850
- diff: null,
851
- properties: validatedProperties,
852
- signature
853
- };
854
- this.comparisons.push(result);
855
- return result;
856
- }
857
-
858
- // Baseline exists - compare with it
859
- try {
860
- // Try to compare - honeydiff will throw if dimensions don't match
861
- const result = await compare(baselineImagePath, currentImagePath, {
862
- threshold: this.threshold,
863
- // CIEDE2000 Delta E (2.0 = recommended default)
864
- antialiasing: true,
865
- diffPath: diffImagePath,
866
- overwrite: true,
867
- includeClusters: true,
868
- // Enable spatial clustering analysis
869
- minClusterSize: this.minClusterSize // Filter single-pixel noise (default: 2)
870
- });
871
- if (!result.isDifferent) {
872
- // Images match
873
- const comparison = {
874
- id: generateComparisonId(signature),
875
- name: sanitizedName,
876
- status: 'passed',
877
- baseline: baselineImagePath,
878
- current: currentImagePath,
879
- diff: null,
880
- properties: validatedProperties,
881
- signature,
882
- threshold: this.threshold,
883
- // Include honeydiff metrics even for passing comparisons
884
- totalPixels: result.totalPixels,
885
- aaPixelsIgnored: result.aaPixelsIgnored,
886
- aaPercentage: result.aaPercentage
887
- };
888
-
889
- // Result tracked by event handler
890
- this.comparisons.push(comparison);
891
- return comparison;
892
- } else {
893
- // Images differ - check if differences are in known hotspot regions
894
- const hotspotAnalysis = this.getHotspotForScreenshot(name);
895
- let hotspotCoverage = null;
896
- let isHotspotFiltered = false;
897
- if (hotspotAnalysis && result.diffClusters && result.diffClusters.length > 0) {
898
- hotspotCoverage = this.calculateHotspotCoverage(result.diffClusters, hotspotAnalysis);
899
-
900
- // Consider it filtered if:
901
- // 1. High confidence hotspot data (score >= 70)
902
- // 2. 80%+ of the diff is within hotspot regions
903
- const isHighConfidence = hotspotAnalysis.confidence === 'high' || hotspotAnalysis.confidence_score && hotspotAnalysis.confidence_score >= 70;
904
- if (isHighConfidence && hotspotCoverage.coverage >= 0.8) {
905
- isHotspotFiltered = true;
906
- }
907
- }
908
- let diffInfo = ` (${result.diffPercentage.toFixed(2)}% different, ${result.diffPixels} pixels)`;
909
-
910
- // Add cluster info to log if available
911
- if (result.diffClusters && result.diffClusters.length > 0) {
912
- diffInfo += `, ${result.diffClusters.length} region${result.diffClusters.length > 1 ? 's' : ''}`;
913
- }
914
-
915
- // Add hotspot info if applicable
916
- if (hotspotCoverage && hotspotCoverage.coverage > 0) {
917
- diffInfo += `, ${Math.round(hotspotCoverage.coverage * 100)}% in hotspots`;
918
- }
919
- const comparison = {
920
- id: generateComparisonId(signature),
921
- name: sanitizedName,
922
- status: isHotspotFiltered ? 'passed' : 'failed',
923
- baseline: baselineImagePath,
924
- current: currentImagePath,
925
- diff: diffImagePath,
926
- properties: validatedProperties,
927
- signature,
928
- threshold: this.threshold,
929
- diffPercentage: result.diffPercentage,
930
- diffCount: result.diffPixels,
931
- reason: isHotspotFiltered ? 'hotspot-filtered' : 'pixel-diff',
932
- // Honeydiff metrics
933
- totalPixels: result.totalPixels,
934
- aaPixelsIgnored: result.aaPixelsIgnored,
935
- aaPercentage: result.aaPercentage,
936
- boundingBox: result.boundingBox,
937
- heightDiff: result.heightDiff,
938
- intensityStats: result.intensityStats,
939
- diffClusters: result.diffClusters,
940
- // Hotspot analysis data
941
- hotspotAnalysis: hotspotCoverage ? {
942
- coverage: hotspotCoverage.coverage,
943
- linesInHotspots: hotspotCoverage.linesInHotspots,
944
- totalLines: hotspotCoverage.totalLines,
945
- confidence: hotspotAnalysis?.confidence,
946
- confidenceScore: hotspotAnalysis?.confidence_score,
947
- regionCount: hotspotAnalysis?.regions?.length || 0,
948
- isFiltered: isHotspotFiltered
949
- } : null
950
- };
951
- if (isHotspotFiltered) {
952
- output.info(`✅ ${colors.green('PASSED')} ${sanitizedName} - differences in known hotspots${diffInfo}`);
953
- output.debug('tdd', `Hotspot filtered: ${Math.round(hotspotCoverage.coverage * 100)}% coverage, confidence: ${hotspotAnalysis.confidence}`);
954
- } else {
955
- output.warn(`❌ ${colors.red('FAILED')} ${sanitizedName} - differences detected${diffInfo}`);
956
- output.info(` Diff saved to: ${diffImagePath}`);
957
- }
958
- this.comparisons.push(comparison);
959
- return comparison;
960
- }
961
- } catch (error) {
962
- // Check if error is due to dimension mismatch
963
- const isDimensionMismatch = error.message?.includes("Image dimensions don't match");
964
- if (isDimensionMismatch) {
965
- // Different dimensions = different screenshot signature
966
- // This shouldn't happen if signatures are working correctly, but handle gracefully
967
- output.warn(`⚠️ Dimension mismatch for ${sanitizedName} - baseline file exists but has different dimensions`);
968
- output.warn(` This indicates a signature collision. Creating new baseline with correct signature.`);
969
- output.debug('tdd', 'dimension mismatch', {
970
- error: error.message
971
- });
972
-
973
- // Create a new baseline for this screenshot (overwriting the incorrect one)
974
- writeFileSync(baselineImagePath, imageBuffer);
975
-
976
- // Update baseline metadata
977
- if (!this.baselineData) {
978
- this.baselineData = {
979
- buildId: 'local-baseline',
980
- buildName: 'Local TDD Baseline',
981
- environment: 'test',
982
- branch: 'local',
983
- threshold: this.threshold,
984
- screenshots: []
985
- };
986
- }
987
- const screenshotEntry = {
988
- name: sanitizedName,
989
- properties: validatedProperties,
990
- path: baselineImagePath,
991
- signature: signature
992
- };
993
- const existingIndex = this.baselineData.screenshots.findIndex(s => s.signature === signature);
994
- if (existingIndex >= 0) {
995
- this.baselineData.screenshots[existingIndex] = screenshotEntry;
996
- } else {
997
- this.baselineData.screenshots.push(screenshotEntry);
998
- }
999
- const metadataPath = join(this.baselinePath, 'metadata.json');
1000
- writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
1001
- output.info(`✅ Created new baseline for ${sanitizedName} (different dimensions)`);
1002
- const comparison = {
1003
- id: generateComparisonId(signature),
1004
- name: sanitizedName,
1005
- status: 'new',
1006
- baseline: baselineImagePath,
1007
- current: currentImagePath,
1008
- diff: null,
1009
- properties: validatedProperties,
1010
- signature
1011
- };
1012
- this.comparisons.push(comparison);
1013
- return comparison;
1014
- }
1015
-
1016
- // Handle other file errors or issues
1017
- output.error(`❌ Error comparing ${sanitizedName}: ${error.message}`);
1018
- const comparison = {
1019
- id: generateComparisonId(signature),
1020
- name: sanitizedName,
1021
- status: 'error',
1022
- baseline: baselineImagePath,
1023
- current: currentImagePath,
1024
- diff: null,
1025
- properties: validatedProperties,
1026
- signature,
1027
- error: error.message
1028
- };
1029
- this.comparisons.push(comparison);
1030
- return comparison;
1031
- }
1032
- }
1033
- getResults() {
1034
- const passed = this.comparisons.filter(c => c.status === 'passed').length;
1035
- const failed = this.comparisons.filter(c => c.status === 'failed').length;
1036
- const newScreenshots = this.comparisons.filter(c => c.status === 'new').length;
1037
- const errors = this.comparisons.filter(c => c.status === 'error').length;
1038
- return {
1039
- total: this.comparisons.length,
1040
- passed,
1041
- failed,
1042
- new: newScreenshots,
1043
- errors,
1044
- comparisons: this.comparisons,
1045
- baseline: this.baselineData
1046
- };
1047
- }
1048
- async printResults() {
1049
- const results = this.getResults();
1050
- output.info('\n📊 TDD Results:');
1051
- output.info(`Total: ${colors.cyan(results.total)}`);
1052
- output.info(`Passed: ${colors.green(results.passed)}`);
1053
- if (results.failed > 0) {
1054
- output.info(`Failed: ${colors.red(results.failed)}`);
1055
- }
1056
- if (results.new > 0) {
1057
- output.info(`New: ${colors.yellow(results.new)}`);
1058
- }
1059
- if (results.errors > 0) {
1060
- output.info(`Errors: ${colors.red(results.errors)}`);
1061
- }
1062
-
1063
- // Show failed comparisons
1064
- const failedComparisons = results.comparisons.filter(c => c.status === 'failed');
1065
- if (failedComparisons.length > 0) {
1066
- output.info('\n❌ Failed comparisons:');
1067
- failedComparisons.forEach(comp => {
1068
- output.info(` • ${comp.name}`);
1069
- });
1070
- }
1071
-
1072
- // Show new screenshots
1073
- const newComparisons = results.comparisons.filter(c => c.status === 'new');
1074
- if (newComparisons.length > 0) {
1075
- output.info('\n📸 New screenshots:');
1076
- newComparisons.forEach(comp => {
1077
- output.info(` • ${comp.name}`);
1078
- });
1079
- }
1080
-
1081
- // Generate HTML report
1082
- await this.generateHtmlReport(results);
1083
- return results;
1084
- }
1085
-
1086
- /**
1087
- * Generate HTML report for TDD results
1088
- * @param {Object} results - TDD comparison results
1089
- */
1090
- async generateHtmlReport(results) {
1091
- try {
1092
- const reportGenerator = new HtmlReportGenerator(this.workingDir, this.config);
1093
- const reportPath = await reportGenerator.generateReport(results, {
1094
- baseline: this.baselineData,
1095
- threshold: this.threshold
1096
- });
1097
-
1098
- // Show report path (always clickable)
1099
- output.info(`\n🐻 View detailed report: ${colors.cyan(`file://${reportPath}`)}`);
1100
-
1101
- // Auto-open if configured
1102
- if (this.config.tdd?.openReport) {
1103
- await this.openReport(reportPath);
1104
- }
1105
- return reportPath;
1106
- } catch (error) {
1107
- output.warn(`Failed to generate HTML report: ${error.message}`);
1108
- }
1109
- }
1110
-
1111
- /**
1112
- * Open HTML report in default browser
1113
- * @param {string} reportPath - Path to HTML report
1114
- */
1115
- async openReport(reportPath) {
1116
- try {
1117
- const {
1118
- exec
1119
- } = await import('node:child_process');
1120
- const {
1121
- promisify
1122
- } = await import('node:util');
1123
- const execAsync = promisify(exec);
1124
- let command;
1125
- switch (process.platform) {
1126
- case 'darwin':
1127
- // macOS
1128
- command = `open "${reportPath}"`;
1129
- break;
1130
- case 'win32':
1131
- // Windows
1132
- command = `start "" "${reportPath}"`;
1133
- break;
1134
- default:
1135
- // Linux and others
1136
- command = `xdg-open "${reportPath}"`;
1137
- break;
1138
- }
1139
- await execAsync(command);
1140
- output.info('📖 Report opened in browser');
1141
- } catch {
1142
- // Browser open may fail silently
1143
- }
1144
- }
1145
-
1146
- /**
1147
- * Update baselines with current screenshots (accept changes)
1148
- * @returns {number} Number of baselines updated
1149
- */
1150
- updateBaselines() {
1151
- if (this.comparisons.length === 0) {
1152
- output.warn('No comparisons found - nothing to update');
1153
- return 0;
1154
- }
1155
- let updatedCount = 0;
1156
-
1157
- // Initialize baseline data if it doesn't exist
1158
- if (!this.baselineData) {
1159
- this.baselineData = {
1160
- buildId: 'local-baseline',
1161
- buildName: 'Local TDD Baseline',
1162
- environment: 'test',
1163
- branch: 'local',
1164
- threshold: this.threshold,
1165
- screenshots: []
1166
- };
1167
- }
1168
- for (const comparison of this.comparisons) {
1169
- const {
1170
- name,
1171
- current
1172
- } = comparison;
1173
- if (!current || !existsSync(current)) {
1174
- output.warn(`Current screenshot not found for ${name}, skipping`);
1175
- continue;
1176
- }
1177
-
1178
- // Sanitize screenshot name for security
1179
- let sanitizedName;
1180
- try {
1181
- sanitizedName = sanitizeScreenshotName(name);
1182
- } catch (error) {
1183
- output.warn(`Skipping baseline update for invalid name '${name}': ${error.message}`);
1184
- continue;
1185
- }
1186
- const validatedProperties = validateScreenshotProperties(comparison.properties || {});
1187
- const signature = generateScreenshotSignature(sanitizedName, validatedProperties, this.signatureProperties);
1188
- const filename = generateBaselineFilename(sanitizedName, signature);
1189
- const baselineImagePath = safePath(this.baselinePath, filename);
1190
- try {
1191
- // Copy current screenshot to baseline
1192
- const currentBuffer = readFileSync(current);
1193
- writeFileSync(baselineImagePath, currentBuffer);
1194
-
1195
- // Update baseline metadata
1196
- const screenshotEntry = {
1197
- name: sanitizedName,
1198
- properties: validatedProperties,
1199
- path: baselineImagePath,
1200
- signature: signature
1201
- };
1202
- const existingIndex = this.baselineData.screenshots.findIndex(s => s.signature === signature);
1203
- if (existingIndex >= 0) {
1204
- this.baselineData.screenshots[existingIndex] = screenshotEntry;
1205
- } else {
1206
- this.baselineData.screenshots.push(screenshotEntry);
1207
- }
1208
- updatedCount++;
1209
- output.info(`✅ Updated baseline for ${sanitizedName}`);
1210
- } catch (error) {
1211
- output.error(`❌ Failed to update baseline for ${sanitizedName}: ${error.message}`);
1212
- }
1213
- }
1214
-
1215
- // Save updated metadata
1216
- if (updatedCount > 0) {
1217
- try {
1218
- const metadataPath = join(this.baselinePath, 'metadata.json');
1219
- writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
1220
- output.info(`✅ Updated ${updatedCount} baseline(s)`);
1221
- } catch (error) {
1222
- output.error(`❌ Failed to save baseline metadata: ${error.message}`);
1223
- }
1224
- }
1225
- return updatedCount;
1226
- }
1227
-
1228
- /**
1229
- * Create a new baseline (used during --set-baseline mode)
1230
- * @private
1231
- */
1232
- createNewBaseline(name, imageBuffer, properties, currentImagePath, baselineImagePath) {
1233
- output.info(`🐻 Creating baseline for ${name}`);
1234
-
1235
- // Copy current screenshot to baseline directory
1236
- writeFileSync(baselineImagePath, imageBuffer);
1237
-
1238
- // Update or create baseline metadata
1239
- if (!this.baselineData) {
1240
- this.baselineData = {
1241
- buildId: 'local-baseline',
1242
- buildName: 'Local TDD Baseline',
1243
- environment: 'test',
1244
- branch: 'local',
1245
- threshold: this.threshold,
1246
- screenshots: []
1247
- };
1248
- }
1249
-
1250
- // Generate signature for this screenshot
1251
- const signature = generateScreenshotSignature(name, properties || {}, this.signatureProperties);
1252
-
1253
- // Add screenshot to baseline metadata
1254
- const screenshotEntry = {
1255
- name,
1256
- properties: properties || {},
1257
- path: baselineImagePath,
1258
- signature: signature
1259
- };
1260
- const existingIndex = this.baselineData.screenshots.findIndex(s => s.signature === signature);
1261
- if (existingIndex >= 0) {
1262
- this.baselineData.screenshots[existingIndex] = screenshotEntry;
1263
- } else {
1264
- this.baselineData.screenshots.push(screenshotEntry);
1265
- }
1266
-
1267
- // Save updated metadata
1268
- const metadataPath = join(this.baselinePath, 'metadata.json');
1269
- writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
1270
- const result = {
1271
- id: generateComparisonId(signature),
1272
- name,
1273
- status: 'new',
1274
- baseline: baselineImagePath,
1275
- current: currentImagePath,
1276
- diff: null,
1277
- properties,
1278
- signature
1279
- };
1280
- this.comparisons.push(result);
1281
- output.info(`✅ Baseline created for ${name}`);
1282
- return result;
1283
- }
1284
-
1285
- /**
1286
- * Update a single baseline with current screenshot
1287
- * @private
1288
- */
1289
- updateSingleBaseline(name, imageBuffer, properties, currentImagePath, baselineImagePath) {
1290
- output.info(`🐻 Setting baseline for ${name}`);
1291
-
1292
- // Copy current screenshot to baseline directory
1293
- writeFileSync(baselineImagePath, imageBuffer);
1294
-
1295
- // Update or create baseline metadata
1296
- if (!this.baselineData) {
1297
- this.baselineData = {
1298
- buildId: 'local-baseline',
1299
- buildName: 'Local TDD Baseline',
1300
- environment: 'test',
1301
- branch: 'local',
1302
- threshold: this.threshold,
1303
- screenshots: []
1304
- };
1305
- }
1306
-
1307
- // Generate signature for this screenshot
1308
- const signature = generateScreenshotSignature(name, properties || {}, this.signatureProperties);
1309
-
1310
- // Add screenshot to baseline metadata
1311
- const screenshotEntry = {
1312
- name,
1313
- properties: properties || {},
1314
- path: baselineImagePath,
1315
- signature: signature
1316
- };
1317
- const existingIndex = this.baselineData.screenshots.findIndex(s => s.signature === signature);
1318
- if (existingIndex >= 0) {
1319
- this.baselineData.screenshots[existingIndex] = screenshotEntry;
1320
- } else {
1321
- this.baselineData.screenshots.push(screenshotEntry);
1322
- }
1323
-
1324
- // Save updated metadata
1325
- const metadataPath = join(this.baselinePath, 'metadata.json');
1326
- writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
1327
- const result = {
1328
- id: generateComparisonId(signature),
1329
- name,
1330
- status: 'baseline-updated',
1331
- baseline: baselineImagePath,
1332
- current: currentImagePath,
1333
- diff: null,
1334
- properties,
1335
- signature
1336
- };
1337
- this.comparisons.push(result);
1338
- output.info(`🐻 Baseline set for ${name}`);
1339
- return result;
1340
- }
1341
-
1342
- /**
1343
- * Accept a current screenshot as the new baseline
1344
- * @param {string|Object} idOrComparison - Comparison ID or comparison object
1345
- * @returns {Object} Result object
1346
- */
1347
- async acceptBaseline(idOrComparison) {
1348
- let comparison;
1349
-
1350
- // Support both ID lookup and direct comparison object
1351
- if (typeof idOrComparison === 'string') {
1352
- // Find the comparison by ID in memory
1353
- comparison = this.comparisons.find(c => c.id === idOrComparison);
1354
- if (!comparison) {
1355
- throw new Error(`No comparison found with ID: ${idOrComparison}`);
1356
- }
1357
- } else {
1358
- // Use the provided comparison object directly
1359
- comparison = idOrComparison;
1360
- }
1361
- const sanitizedName = comparison.name;
1362
- const properties = comparison.properties || {};
1363
- const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
1364
- const filename = generateBaselineFilename(sanitizedName, signature);
1365
-
1366
- // Find the current screenshot file
1367
- const currentImagePath = safePath(this.currentPath, filename);
1368
- if (!existsSync(currentImagePath)) {
1369
- output.error(`Current screenshot not found at: ${currentImagePath}`);
1370
- throw new Error(`Current screenshot not found: ${sanitizedName} (looked at ${currentImagePath})`);
1371
- }
1372
-
1373
- // Read the current image
1374
- const imageBuffer = readFileSync(currentImagePath);
1375
-
1376
- // Create baseline directory if it doesn't exist
1377
- if (!existsSync(this.baselinePath)) {
1378
- mkdirSync(this.baselinePath, {
1379
- recursive: true
1380
- });
1381
- }
1382
-
1383
- // Update the baseline
1384
- const baselineImagePath = safePath(this.baselinePath, `${filename}.png`);
1385
-
1386
- // Write the baseline image directly
1387
- writeFileSync(baselineImagePath, imageBuffer);
1388
-
1389
- // Verify the write
1390
- if (!existsSync(baselineImagePath)) {
1391
- output.error(`Baseline file does not exist after write!`);
1392
- }
1393
-
1394
- // Update baseline metadata
1395
- if (!this.baselineData) {
1396
- this.baselineData = {
1397
- buildId: 'local-baseline',
1398
- buildName: 'Local TDD Baseline',
1399
- environment: 'test',
1400
- branch: 'local',
1401
- threshold: this.threshold,
1402
- screenshots: []
1403
- };
1404
- }
1405
-
1406
- // Add or update screenshot in baseline metadata
1407
- const screenshotEntry = {
1408
- name: sanitizedName,
1409
- properties: properties,
1410
- path: baselineImagePath,
1411
- signature: signature
1412
- };
1413
- const existingIndex = this.baselineData.screenshots.findIndex(s => s.signature === signature);
1414
- if (existingIndex >= 0) {
1415
- this.baselineData.screenshots[existingIndex] = screenshotEntry;
1416
- } else {
1417
- this.baselineData.screenshots.push(screenshotEntry);
1418
- }
1419
-
1420
- // Save updated metadata
1421
- const metadataPath = join(this.baselinePath, 'metadata.json');
1422
- writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
1423
- return {
1424
- name: sanitizedName,
1425
- status: 'accepted',
1426
- message: 'Screenshot accepted as new baseline'
1427
- };
1428
- }
1429
- }