@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
@@ -1,1437 +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 = config.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 || this.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
- // Per-screenshot threshold/minClusterSize override support
861
- // Priority: screenshot-level > config > defaults
862
- // Validate overrides before using them
863
- const effectiveThreshold = typeof validatedProperties.threshold === 'number' && validatedProperties.threshold >= 0 ? validatedProperties.threshold : this.threshold;
864
- const effectiveMinClusterSize = Number.isInteger(validatedProperties.minClusterSize) && validatedProperties.minClusterSize >= 1 ? validatedProperties.minClusterSize : this.minClusterSize;
865
-
866
- // Try to compare - honeydiff will throw if dimensions don't match
867
- const result = await compare(baselineImagePath, currentImagePath, {
868
- threshold: effectiveThreshold,
869
- // CIEDE2000 Delta E (2.0 = recommended default)
870
- antialiasing: true,
871
- diffPath: diffImagePath,
872
- overwrite: true,
873
- includeClusters: true,
874
- // Enable spatial clustering analysis
875
- minClusterSize: effectiveMinClusterSize // Filter single-pixel noise (default: 2)
876
- });
877
- if (!result.isDifferent) {
878
- // Images match
879
- const comparison = {
880
- id: generateComparisonId(signature),
881
- name: sanitizedName,
882
- status: 'passed',
883
- baseline: baselineImagePath,
884
- current: currentImagePath,
885
- diff: null,
886
- properties: validatedProperties,
887
- signature,
888
- threshold: effectiveThreshold,
889
- minClusterSize: effectiveMinClusterSize,
890
- // Include honeydiff metrics even for passing comparisons
891
- totalPixels: result.totalPixels,
892
- aaPixelsIgnored: result.aaPixelsIgnored,
893
- aaPercentage: result.aaPercentage
894
- };
895
-
896
- // Result tracked by event handler
897
- this.comparisons.push(comparison);
898
- return comparison;
899
- } else {
900
- // Images differ - check if differences are in known hotspot regions
901
- const hotspotAnalysis = this.getHotspotForScreenshot(name);
902
- let hotspotCoverage = null;
903
- let isHotspotFiltered = false;
904
- if (hotspotAnalysis && result.diffClusters && result.diffClusters.length > 0) {
905
- hotspotCoverage = this.calculateHotspotCoverage(result.diffClusters, hotspotAnalysis);
906
-
907
- // Consider it filtered if:
908
- // 1. High confidence hotspot data (score >= 70)
909
- // 2. 80%+ of the diff is within hotspot regions
910
- const isHighConfidence = hotspotAnalysis.confidence === 'high' || hotspotAnalysis.confidence_score && hotspotAnalysis.confidence_score >= 70;
911
- if (isHighConfidence && hotspotCoverage.coverage >= 0.8) {
912
- isHotspotFiltered = true;
913
- }
914
- }
915
- let diffInfo = ` (${result.diffPercentage.toFixed(2)}% different, ${result.diffPixels} pixels)`;
916
-
917
- // Add cluster info to log if available
918
- if (result.diffClusters && result.diffClusters.length > 0) {
919
- diffInfo += `, ${result.diffClusters.length} region${result.diffClusters.length > 1 ? 's' : ''}`;
920
- }
921
-
922
- // Add hotspot info if applicable
923
- if (hotspotCoverage && hotspotCoverage.coverage > 0) {
924
- diffInfo += `, ${Math.round(hotspotCoverage.coverage * 100)}% in hotspots`;
925
- }
926
- const comparison = {
927
- id: generateComparisonId(signature),
928
- name: sanitizedName,
929
- status: isHotspotFiltered ? 'passed' : 'failed',
930
- baseline: baselineImagePath,
931
- current: currentImagePath,
932
- diff: diffImagePath,
933
- properties: validatedProperties,
934
- signature,
935
- threshold: effectiveThreshold,
936
- minClusterSize: effectiveMinClusterSize,
937
- diffPercentage: result.diffPercentage,
938
- diffCount: result.diffPixels,
939
- reason: isHotspotFiltered ? 'hotspot-filtered' : 'pixel-diff',
940
- // Honeydiff metrics
941
- totalPixels: result.totalPixels,
942
- aaPixelsIgnored: result.aaPixelsIgnored,
943
- aaPercentage: result.aaPercentage,
944
- boundingBox: result.boundingBox,
945
- heightDiff: result.heightDiff,
946
- intensityStats: result.intensityStats,
947
- diffClusters: result.diffClusters,
948
- // Hotspot analysis data
949
- hotspotAnalysis: hotspotCoverage ? {
950
- coverage: hotspotCoverage.coverage,
951
- linesInHotspots: hotspotCoverage.linesInHotspots,
952
- totalLines: hotspotCoverage.totalLines,
953
- confidence: hotspotAnalysis?.confidence,
954
- confidenceScore: hotspotAnalysis?.confidence_score,
955
- regionCount: hotspotAnalysis?.regions?.length || 0,
956
- isFiltered: isHotspotFiltered
957
- } : null
958
- };
959
- if (isHotspotFiltered) {
960
- output.info(`✅ ${colors.green('PASSED')} ${sanitizedName} - differences in known hotspots${diffInfo}`);
961
- output.debug('tdd', `Hotspot filtered: ${Math.round(hotspotCoverage.coverage * 100)}% coverage, confidence: ${hotspotAnalysis.confidence}`);
962
- } else {
963
- output.warn(`❌ ${colors.red('FAILED')} ${sanitizedName} - differences detected${diffInfo}`);
964
- output.info(` Diff saved to: ${diffImagePath}`);
965
- }
966
- this.comparisons.push(comparison);
967
- return comparison;
968
- }
969
- } catch (error) {
970
- // Check if error is due to dimension mismatch
971
- const isDimensionMismatch = error.message?.includes("Image dimensions don't match");
972
- if (isDimensionMismatch) {
973
- // Different dimensions = different screenshot signature
974
- // This shouldn't happen if signatures are working correctly, but handle gracefully
975
- output.warn(`⚠️ Dimension mismatch for ${sanitizedName} - baseline file exists but has different dimensions`);
976
- output.warn(` This indicates a signature collision. Creating new baseline with correct signature.`);
977
- output.debug('tdd', 'dimension mismatch', {
978
- error: error.message
979
- });
980
-
981
- // Create a new baseline for this screenshot (overwriting the incorrect one)
982
- writeFileSync(baselineImagePath, imageBuffer);
983
-
984
- // Update baseline metadata
985
- if (!this.baselineData) {
986
- this.baselineData = {
987
- buildId: 'local-baseline',
988
- buildName: 'Local TDD Baseline',
989
- environment: 'test',
990
- branch: 'local',
991
- threshold: this.threshold,
992
- screenshots: []
993
- };
994
- }
995
- const screenshotEntry = {
996
- name: sanitizedName,
997
- properties: validatedProperties,
998
- path: baselineImagePath,
999
- signature: signature
1000
- };
1001
- const existingIndex = this.baselineData.screenshots.findIndex(s => s.signature === signature);
1002
- if (existingIndex >= 0) {
1003
- this.baselineData.screenshots[existingIndex] = screenshotEntry;
1004
- } else {
1005
- this.baselineData.screenshots.push(screenshotEntry);
1006
- }
1007
- const metadataPath = join(this.baselinePath, 'metadata.json');
1008
- writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
1009
- output.info(`✅ Created new baseline for ${sanitizedName} (different dimensions)`);
1010
- const comparison = {
1011
- id: generateComparisonId(signature),
1012
- name: sanitizedName,
1013
- status: 'new',
1014
- baseline: baselineImagePath,
1015
- current: currentImagePath,
1016
- diff: null,
1017
- properties: validatedProperties,
1018
- signature
1019
- };
1020
- this.comparisons.push(comparison);
1021
- return comparison;
1022
- }
1023
-
1024
- // Handle other file errors or issues
1025
- output.error(`❌ Error comparing ${sanitizedName}: ${error.message}`);
1026
- const comparison = {
1027
- id: generateComparisonId(signature),
1028
- name: sanitizedName,
1029
- status: 'error',
1030
- baseline: baselineImagePath,
1031
- current: currentImagePath,
1032
- diff: null,
1033
- properties: validatedProperties,
1034
- signature,
1035
- error: error.message
1036
- };
1037
- this.comparisons.push(comparison);
1038
- return comparison;
1039
- }
1040
- }
1041
- getResults() {
1042
- const passed = this.comparisons.filter(c => c.status === 'passed').length;
1043
- const failed = this.comparisons.filter(c => c.status === 'failed').length;
1044
- const newScreenshots = this.comparisons.filter(c => c.status === 'new').length;
1045
- const errors = this.comparisons.filter(c => c.status === 'error').length;
1046
- return {
1047
- total: this.comparisons.length,
1048
- passed,
1049
- failed,
1050
- new: newScreenshots,
1051
- errors,
1052
- comparisons: this.comparisons,
1053
- baseline: this.baselineData
1054
- };
1055
- }
1056
- async printResults() {
1057
- const results = this.getResults();
1058
- output.info('\n📊 TDD Results:');
1059
- output.info(`Total: ${colors.cyan(results.total)}`);
1060
- output.info(`Passed: ${colors.green(results.passed)}`);
1061
- if (results.failed > 0) {
1062
- output.info(`Failed: ${colors.red(results.failed)}`);
1063
- }
1064
- if (results.new > 0) {
1065
- output.info(`New: ${colors.yellow(results.new)}`);
1066
- }
1067
- if (results.errors > 0) {
1068
- output.info(`Errors: ${colors.red(results.errors)}`);
1069
- }
1070
-
1071
- // Show failed comparisons
1072
- const failedComparisons = results.comparisons.filter(c => c.status === 'failed');
1073
- if (failedComparisons.length > 0) {
1074
- output.info('\n❌ Failed comparisons:');
1075
- failedComparisons.forEach(comp => {
1076
- output.info(` • ${comp.name}`);
1077
- });
1078
- }
1079
-
1080
- // Show new screenshots
1081
- const newComparisons = results.comparisons.filter(c => c.status === 'new');
1082
- if (newComparisons.length > 0) {
1083
- output.info('\n📸 New screenshots:');
1084
- newComparisons.forEach(comp => {
1085
- output.info(` • ${comp.name}`);
1086
- });
1087
- }
1088
-
1089
- // Generate HTML report
1090
- await this.generateHtmlReport(results);
1091
- return results;
1092
- }
1093
-
1094
- /**
1095
- * Generate HTML report for TDD results
1096
- * @param {Object} results - TDD comparison results
1097
- */
1098
- async generateHtmlReport(results) {
1099
- try {
1100
- const reportGenerator = new HtmlReportGenerator(this.workingDir, this.config);
1101
- const reportPath = await reportGenerator.generateReport(results, {
1102
- baseline: this.baselineData,
1103
- threshold: this.threshold
1104
- });
1105
-
1106
- // Show report path (always clickable)
1107
- output.info(`\n🐻 View detailed report: ${colors.cyan(`file://${reportPath}`)}`);
1108
-
1109
- // Auto-open if configured
1110
- if (this.config.tdd?.openReport) {
1111
- await this.openReport(reportPath);
1112
- }
1113
- return reportPath;
1114
- } catch (error) {
1115
- output.warn(`Failed to generate HTML report: ${error.message}`);
1116
- }
1117
- }
1118
-
1119
- /**
1120
- * Open HTML report in default browser
1121
- * @param {string} reportPath - Path to HTML report
1122
- */
1123
- async openReport(reportPath) {
1124
- try {
1125
- const {
1126
- exec
1127
- } = await import('node:child_process');
1128
- const {
1129
- promisify
1130
- } = await import('node:util');
1131
- const execAsync = promisify(exec);
1132
- let command;
1133
- switch (process.platform) {
1134
- case 'darwin':
1135
- // macOS
1136
- command = `open "${reportPath}"`;
1137
- break;
1138
- case 'win32':
1139
- // Windows
1140
- command = `start "" "${reportPath}"`;
1141
- break;
1142
- default:
1143
- // Linux and others
1144
- command = `xdg-open "${reportPath}"`;
1145
- break;
1146
- }
1147
- await execAsync(command);
1148
- output.info('📖 Report opened in browser');
1149
- } catch {
1150
- // Browser open may fail silently
1151
- }
1152
- }
1153
-
1154
- /**
1155
- * Update baselines with current screenshots (accept changes)
1156
- * @returns {number} Number of baselines updated
1157
- */
1158
- updateBaselines() {
1159
- if (this.comparisons.length === 0) {
1160
- output.warn('No comparisons found - nothing to update');
1161
- return 0;
1162
- }
1163
- let updatedCount = 0;
1164
-
1165
- // Initialize baseline data if it doesn't exist
1166
- if (!this.baselineData) {
1167
- this.baselineData = {
1168
- buildId: 'local-baseline',
1169
- buildName: 'Local TDD Baseline',
1170
- environment: 'test',
1171
- branch: 'local',
1172
- threshold: this.threshold,
1173
- screenshots: []
1174
- };
1175
- }
1176
- for (const comparison of this.comparisons) {
1177
- const {
1178
- name,
1179
- current
1180
- } = comparison;
1181
- if (!current || !existsSync(current)) {
1182
- output.warn(`Current screenshot not found for ${name}, skipping`);
1183
- continue;
1184
- }
1185
-
1186
- // Sanitize screenshot name for security
1187
- let sanitizedName;
1188
- try {
1189
- sanitizedName = sanitizeScreenshotName(name);
1190
- } catch (error) {
1191
- output.warn(`Skipping baseline update for invalid name '${name}': ${error.message}`);
1192
- continue;
1193
- }
1194
- const validatedProperties = validateScreenshotProperties(comparison.properties || {});
1195
- const signature = generateScreenshotSignature(sanitizedName, validatedProperties, this.signatureProperties);
1196
- const filename = generateBaselineFilename(sanitizedName, signature);
1197
- const baselineImagePath = safePath(this.baselinePath, filename);
1198
- try {
1199
- // Copy current screenshot to baseline
1200
- const currentBuffer = readFileSync(current);
1201
- writeFileSync(baselineImagePath, currentBuffer);
1202
-
1203
- // Update baseline metadata
1204
- const screenshotEntry = {
1205
- name: sanitizedName,
1206
- properties: validatedProperties,
1207
- path: baselineImagePath,
1208
- signature: signature
1209
- };
1210
- const existingIndex = this.baselineData.screenshots.findIndex(s => s.signature === signature);
1211
- if (existingIndex >= 0) {
1212
- this.baselineData.screenshots[existingIndex] = screenshotEntry;
1213
- } else {
1214
- this.baselineData.screenshots.push(screenshotEntry);
1215
- }
1216
- updatedCount++;
1217
- output.info(`✅ Updated baseline for ${sanitizedName}`);
1218
- } catch (error) {
1219
- output.error(`❌ Failed to update baseline for ${sanitizedName}: ${error.message}`);
1220
- }
1221
- }
1222
-
1223
- // Save updated metadata
1224
- if (updatedCount > 0) {
1225
- try {
1226
- const metadataPath = join(this.baselinePath, 'metadata.json');
1227
- writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
1228
- output.info(`✅ Updated ${updatedCount} baseline(s)`);
1229
- } catch (error) {
1230
- output.error(`❌ Failed to save baseline metadata: ${error.message}`);
1231
- }
1232
- }
1233
- return updatedCount;
1234
- }
1235
-
1236
- /**
1237
- * Create a new baseline (used during --set-baseline mode)
1238
- * @private
1239
- */
1240
- createNewBaseline(name, imageBuffer, properties, currentImagePath, baselineImagePath) {
1241
- output.info(`🐻 Creating baseline for ${name}`);
1242
-
1243
- // Copy current screenshot to baseline directory
1244
- writeFileSync(baselineImagePath, imageBuffer);
1245
-
1246
- // Update or create baseline metadata
1247
- if (!this.baselineData) {
1248
- this.baselineData = {
1249
- buildId: 'local-baseline',
1250
- buildName: 'Local TDD Baseline',
1251
- environment: 'test',
1252
- branch: 'local',
1253
- threshold: this.threshold,
1254
- screenshots: []
1255
- };
1256
- }
1257
-
1258
- // Generate signature for this screenshot
1259
- const signature = generateScreenshotSignature(name, properties || {}, this.signatureProperties);
1260
-
1261
- // Add screenshot to baseline metadata
1262
- const screenshotEntry = {
1263
- name,
1264
- properties: properties || {},
1265
- path: baselineImagePath,
1266
- signature: signature
1267
- };
1268
- const existingIndex = this.baselineData.screenshots.findIndex(s => s.signature === signature);
1269
- if (existingIndex >= 0) {
1270
- this.baselineData.screenshots[existingIndex] = screenshotEntry;
1271
- } else {
1272
- this.baselineData.screenshots.push(screenshotEntry);
1273
- }
1274
-
1275
- // Save updated metadata
1276
- const metadataPath = join(this.baselinePath, 'metadata.json');
1277
- writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
1278
- const result = {
1279
- id: generateComparisonId(signature),
1280
- name,
1281
- status: 'new',
1282
- baseline: baselineImagePath,
1283
- current: currentImagePath,
1284
- diff: null,
1285
- properties,
1286
- signature
1287
- };
1288
- this.comparisons.push(result);
1289
- output.info(`✅ Baseline created for ${name}`);
1290
- return result;
1291
- }
1292
-
1293
- /**
1294
- * Update a single baseline with current screenshot
1295
- * @private
1296
- */
1297
- updateSingleBaseline(name, imageBuffer, properties, currentImagePath, baselineImagePath) {
1298
- output.info(`🐻 Setting baseline for ${name}`);
1299
-
1300
- // Copy current screenshot to baseline directory
1301
- writeFileSync(baselineImagePath, imageBuffer);
1302
-
1303
- // Update or create baseline metadata
1304
- if (!this.baselineData) {
1305
- this.baselineData = {
1306
- buildId: 'local-baseline',
1307
- buildName: 'Local TDD Baseline',
1308
- environment: 'test',
1309
- branch: 'local',
1310
- threshold: this.threshold,
1311
- screenshots: []
1312
- };
1313
- }
1314
-
1315
- // Generate signature for this screenshot
1316
- const signature = generateScreenshotSignature(name, properties || {}, this.signatureProperties);
1317
-
1318
- // Add screenshot to baseline metadata
1319
- const screenshotEntry = {
1320
- name,
1321
- properties: properties || {},
1322
- path: baselineImagePath,
1323
- signature: signature
1324
- };
1325
- const existingIndex = this.baselineData.screenshots.findIndex(s => s.signature === signature);
1326
- if (existingIndex >= 0) {
1327
- this.baselineData.screenshots[existingIndex] = screenshotEntry;
1328
- } else {
1329
- this.baselineData.screenshots.push(screenshotEntry);
1330
- }
1331
-
1332
- // Save updated metadata
1333
- const metadataPath = join(this.baselinePath, 'metadata.json');
1334
- writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
1335
- const result = {
1336
- id: generateComparisonId(signature),
1337
- name,
1338
- status: 'baseline-updated',
1339
- baseline: baselineImagePath,
1340
- current: currentImagePath,
1341
- diff: null,
1342
- properties,
1343
- signature
1344
- };
1345
- this.comparisons.push(result);
1346
- output.info(`🐻 Baseline set for ${name}`);
1347
- return result;
1348
- }
1349
-
1350
- /**
1351
- * Accept a current screenshot as the new baseline
1352
- * @param {string|Object} idOrComparison - Comparison ID or comparison object
1353
- * @returns {Object} Result object
1354
- */
1355
- async acceptBaseline(idOrComparison) {
1356
- let comparison;
1357
-
1358
- // Support both ID lookup and direct comparison object
1359
- if (typeof idOrComparison === 'string') {
1360
- // Find the comparison by ID in memory
1361
- comparison = this.comparisons.find(c => c.id === idOrComparison);
1362
- if (!comparison) {
1363
- throw new Error(`No comparison found with ID: ${idOrComparison}`);
1364
- }
1365
- } else {
1366
- // Use the provided comparison object directly
1367
- comparison = idOrComparison;
1368
- }
1369
- const sanitizedName = comparison.name;
1370
- const properties = comparison.properties || {};
1371
- const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
1372
- const filename = generateBaselineFilename(sanitizedName, signature);
1373
-
1374
- // Find the current screenshot file
1375
- const currentImagePath = safePath(this.currentPath, filename);
1376
- if (!existsSync(currentImagePath)) {
1377
- output.error(`Current screenshot not found at: ${currentImagePath}`);
1378
- throw new Error(`Current screenshot not found: ${sanitizedName} (looked at ${currentImagePath})`);
1379
- }
1380
-
1381
- // Read the current image
1382
- const imageBuffer = readFileSync(currentImagePath);
1383
-
1384
- // Create baseline directory if it doesn't exist
1385
- if (!existsSync(this.baselinePath)) {
1386
- mkdirSync(this.baselinePath, {
1387
- recursive: true
1388
- });
1389
- }
1390
-
1391
- // Update the baseline
1392
- const baselineImagePath = safePath(this.baselinePath, `${filename}.png`);
1393
-
1394
- // Write the baseline image directly
1395
- writeFileSync(baselineImagePath, imageBuffer);
1396
-
1397
- // Verify the write
1398
- if (!existsSync(baselineImagePath)) {
1399
- output.error(`Baseline file does not exist after write!`);
1400
- }
1401
-
1402
- // Update baseline metadata
1403
- if (!this.baselineData) {
1404
- this.baselineData = {
1405
- buildId: 'local-baseline',
1406
- buildName: 'Local TDD Baseline',
1407
- environment: 'test',
1408
- branch: 'local',
1409
- threshold: this.threshold,
1410
- screenshots: []
1411
- };
1412
- }
1413
-
1414
- // Add or update screenshot in baseline metadata
1415
- const screenshotEntry = {
1416
- name: sanitizedName,
1417
- properties: properties,
1418
- path: baselineImagePath,
1419
- signature: signature
1420
- };
1421
- const existingIndex = this.baselineData.screenshots.findIndex(s => s.signature === signature);
1422
- if (existingIndex >= 0) {
1423
- this.baselineData.screenshots[existingIndex] = screenshotEntry;
1424
- } else {
1425
- this.baselineData.screenshots.push(screenshotEntry);
1426
- }
1427
-
1428
- // Save updated metadata
1429
- const metadataPath = join(this.baselinePath, 'metadata.json');
1430
- writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
1431
- return {
1432
- name: sanitizedName,
1433
- status: 'accepted',
1434
- message: 'Screenshot accepted as new baseline'
1435
- };
1436
- }
1437
- }