@vizzly-testing/cli 0.1.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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +363 -0
  3. package/bin/vizzly.js +3 -0
  4. package/dist/cli.js +104 -0
  5. package/dist/client/index.js +237 -0
  6. package/dist/commands/doctor.js +158 -0
  7. package/dist/commands/init.js +102 -0
  8. package/dist/commands/run.js +224 -0
  9. package/dist/commands/status.js +164 -0
  10. package/dist/commands/tdd.js +212 -0
  11. package/dist/commands/upload.js +181 -0
  12. package/dist/container/index.js +184 -0
  13. package/dist/errors/vizzly-error.js +149 -0
  14. package/dist/index.js +31 -0
  15. package/dist/screenshot-wrapper.js +68 -0
  16. package/dist/sdk/index.js +364 -0
  17. package/dist/server/index.js +522 -0
  18. package/dist/services/api-service.js +215 -0
  19. package/dist/services/base-service.js +154 -0
  20. package/dist/services/build-manager.js +214 -0
  21. package/dist/services/screenshot-server.js +96 -0
  22. package/dist/services/server-manager.js +61 -0
  23. package/dist/services/service-utils.js +171 -0
  24. package/dist/services/tdd-service.js +444 -0
  25. package/dist/services/test-runner.js +210 -0
  26. package/dist/services/uploader.js +413 -0
  27. package/dist/types/cli.d.ts +2 -0
  28. package/dist/types/client/index.d.ts +76 -0
  29. package/dist/types/commands/doctor.d.ts +11 -0
  30. package/dist/types/commands/init.d.ts +14 -0
  31. package/dist/types/commands/run.d.ts +13 -0
  32. package/dist/types/commands/status.d.ts +13 -0
  33. package/dist/types/commands/tdd.d.ts +13 -0
  34. package/dist/types/commands/upload.d.ts +13 -0
  35. package/dist/types/container/index.d.ts +61 -0
  36. package/dist/types/errors/vizzly-error.d.ts +75 -0
  37. package/dist/types/index.d.ts +10 -0
  38. package/dist/types/index.js +153 -0
  39. package/dist/types/screenshot-wrapper.d.ts +27 -0
  40. package/dist/types/sdk/index.d.ts +108 -0
  41. package/dist/types/server/index.d.ts +38 -0
  42. package/dist/types/services/api-service.d.ts +77 -0
  43. package/dist/types/services/base-service.d.ts +72 -0
  44. package/dist/types/services/build-manager.d.ts +68 -0
  45. package/dist/types/services/screenshot-server.d.ts +10 -0
  46. package/dist/types/services/server-manager.d.ts +8 -0
  47. package/dist/types/services/service-utils.d.ts +45 -0
  48. package/dist/types/services/tdd-service.d.ts +55 -0
  49. package/dist/types/services/test-runner.d.ts +25 -0
  50. package/dist/types/services/uploader.d.ts +34 -0
  51. package/dist/types/types/index.d.ts +373 -0
  52. package/dist/types/utils/colors.d.ts +12 -0
  53. package/dist/types/utils/config-helpers.d.ts +6 -0
  54. package/dist/types/utils/config-loader.d.ts +22 -0
  55. package/dist/types/utils/console-ui.d.ts +61 -0
  56. package/dist/types/utils/diagnostics.d.ts +69 -0
  57. package/dist/types/utils/environment-config.d.ts +54 -0
  58. package/dist/types/utils/environment.d.ts +36 -0
  59. package/dist/types/utils/error-messages.d.ts +42 -0
  60. package/dist/types/utils/fetch-utils.d.ts +1 -0
  61. package/dist/types/utils/framework-detector.d.ts +5 -0
  62. package/dist/types/utils/git.d.ts +44 -0
  63. package/dist/types/utils/help.d.ts +11 -0
  64. package/dist/types/utils/image-comparison.d.ts +42 -0
  65. package/dist/types/utils/logger-factory.d.ts +26 -0
  66. package/dist/types/utils/logger.d.ts +79 -0
  67. package/dist/types/utils/package-info.d.ts +15 -0
  68. package/dist/types/utils/package.d.ts +1 -0
  69. package/dist/types/utils/project-detection.d.ts +19 -0
  70. package/dist/types/utils/ui-helpers.d.ts +23 -0
  71. package/dist/utils/colors.js +66 -0
  72. package/dist/utils/config-helpers.js +8 -0
  73. package/dist/utils/config-loader.js +120 -0
  74. package/dist/utils/console-ui.js +226 -0
  75. package/dist/utils/diagnostics.js +184 -0
  76. package/dist/utils/environment-config.js +93 -0
  77. package/dist/utils/environment.js +109 -0
  78. package/dist/utils/error-messages.js +34 -0
  79. package/dist/utils/fetch-utils.js +9 -0
  80. package/dist/utils/framework-detector.js +40 -0
  81. package/dist/utils/git.js +226 -0
  82. package/dist/utils/help.js +66 -0
  83. package/dist/utils/image-comparison.js +172 -0
  84. package/dist/utils/logger-factory.js +76 -0
  85. package/dist/utils/logger.js +231 -0
  86. package/dist/utils/package-info.js +38 -0
  87. package/dist/utils/package.js +9 -0
  88. package/dist/utils/project-detection.js +145 -0
  89. package/dist/utils/ui-helpers.js +86 -0
  90. package/package.json +103 -0
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Server Manager Service
3
+ * Manages the Vizzly HTTP server
4
+ */
5
+
6
+ import { BaseService } from './base-service.js';
7
+ import { VizzlyServer } from '../server/index.js';
8
+ import { EventEmitter } from 'events';
9
+ export class ServerManager extends BaseService {
10
+ constructor(config, logger) {
11
+ super(config, {
12
+ logger
13
+ });
14
+ this.server = null;
15
+ }
16
+ async start(buildId = null, buildInfo = null, mode = 'lazy') {
17
+ if (this.started) {
18
+ this.logger.warn(`${this.constructor.name} already started`);
19
+ return;
20
+ }
21
+
22
+ // Create event emitter for server events
23
+ const emitter = new EventEmitter();
24
+ this.server = new VizzlyServer({
25
+ port: this.config?.server?.port || 47392,
26
+ config: this.config,
27
+ buildId,
28
+ buildInfo,
29
+ vizzlyApi: buildInfo || mode === 'eager' ? await this.createApiService() : null,
30
+ tddMode: mode === 'tdd',
31
+ // TDD mode only when explicitly set
32
+ baselineBuild: this.config?.baselineBuildId,
33
+ baselineComparison: this.config?.baselineComparisonId,
34
+ workingDir: process.cwd(),
35
+ emitter // Pass the emitter to the server
36
+ });
37
+ await super.start();
38
+ }
39
+ async onStart() {
40
+ if (this.server) {
41
+ await this.server.start();
42
+ }
43
+ }
44
+ async createApiService() {
45
+ if (!this.config.apiKey) return null;
46
+ const {
47
+ ApiService
48
+ } = await import('./api-service.js');
49
+ return new ApiService({
50
+ ...this.config,
51
+ command: 'run'
52
+ }, {
53
+ logger: this.logger
54
+ });
55
+ }
56
+ async onStop() {
57
+ if (this.server) {
58
+ await this.server.stop();
59
+ }
60
+ }
61
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Service Utilities
3
+ *
4
+ * Provides utilities for service composition using higher-order functions
5
+ * and event-based architecture patterns.
6
+ */
7
+
8
+ import { EventEmitter } from 'events';
9
+
10
+ /**
11
+ * Create an event emitter with enhanced functionality
12
+ * @returns {EventEmitter} Enhanced event emitter
13
+ */
14
+ export function createEventEmitter() {
15
+ const emitter = new EventEmitter();
16
+
17
+ // Add helper methods
18
+ emitter.emitProgress = (stage, message, data = {}) => {
19
+ const progressData = {
20
+ stage,
21
+ message,
22
+ timestamp: new Date().toISOString(),
23
+ ...data
24
+ };
25
+ emitter.emit('progress', progressData);
26
+ };
27
+ emitter.emitError = (error, context = {}) => {
28
+ emitter.emit('error', {
29
+ error: error.message,
30
+ stack: error.stack,
31
+ context,
32
+ timestamp: new Date().toISOString()
33
+ });
34
+ };
35
+ return emitter;
36
+ }
37
+
38
+ /**
39
+ * Create a cleanup manager
40
+ * @returns {Object} Cleanup manager with add/execute methods
41
+ */
42
+ export function createCleanupManager() {
43
+ const cleanupFunctions = [];
44
+ return {
45
+ add: fn => cleanupFunctions.push(fn),
46
+ execute: async () => {
47
+ for (const fn of cleanupFunctions) {
48
+ try {
49
+ await fn();
50
+ } catch (error) {
51
+ console.error('Cleanup error:', error);
52
+ }
53
+ }
54
+ cleanupFunctions.length = 0;
55
+ }
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Create signal handlers for graceful shutdown
61
+ * @param {Function} onSignal - Function to call on signal
62
+ * @returns {Function} Cleanup function to remove handlers
63
+ */
64
+ export function createSignalHandlers(onSignal) {
65
+ const handleSignal = async signal => {
66
+ await onSignal(signal);
67
+ process.exit(signal === 'SIGINT' ? 130 : 1);
68
+ };
69
+ process.once('SIGINT', () => handleSignal('SIGINT'));
70
+ process.once('SIGTERM', () => handleSignal('SIGTERM'));
71
+ return () => {
72
+ process.removeAllListeners('SIGINT');
73
+ process.removeAllListeners('SIGTERM');
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Higher-order function to add error handling to any function
79
+ * @param {Function} fn - Function to wrap
80
+ * @param {Object} options - Error handling options
81
+ * @returns {Function} Wrapped function with error handling
82
+ */
83
+ export function withErrorHandling(fn, options = {}) {
84
+ return async (...args) => {
85
+ try {
86
+ return await fn(...args);
87
+ } catch (error) {
88
+ if (options.onError) {
89
+ options.onError(error);
90
+ }
91
+ if (options.rethrow !== false) {
92
+ throw error;
93
+ }
94
+ return options.defaultReturn;
95
+ }
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Higher-order function to add logging to any function
101
+ * @param {Function} fn - Function to wrap
102
+ * @param {Object} logger - Logger instance
103
+ * @param {string} operation - Operation name for logging
104
+ * @returns {Function} Wrapped function with logging
105
+ */
106
+ export function withLogging(fn, logger, operation) {
107
+ return async (...args) => {
108
+ logger.debug(`Starting ${operation}`);
109
+ const start = Date.now();
110
+ try {
111
+ const result = await fn(...args);
112
+ const duration = Date.now() - start;
113
+ logger.debug(`Completed ${operation} in ${duration}ms`);
114
+ return result;
115
+ } catch (error) {
116
+ const duration = Date.now() - start;
117
+ logger.error(`Failed ${operation} after ${duration}ms:`, error.message);
118
+ throw error;
119
+ }
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Compose multiple functions together
125
+ * @param {...Function} fns - Functions to compose
126
+ * @returns {Function} Composed function
127
+ */
128
+ export function compose(...fns) {
129
+ return value => fns.reduceRight((acc, fn) => fn(acc), value);
130
+ }
131
+
132
+ /**
133
+ * Create a service context with shared functionality
134
+ * @param {Object} config - Service configuration
135
+ * @param {Object} options - Service options
136
+ * @returns {Object} Service context
137
+ */
138
+ export function createServiceContext(config, options = {}) {
139
+ const emitter = createEventEmitter(options);
140
+ const cleanup = createCleanupManager();
141
+ let isRunning = false;
142
+ const context = {
143
+ config,
144
+ emitter,
145
+ cleanup,
146
+ get isRunning() {
147
+ return isRunning;
148
+ },
149
+ set isRunning(value) {
150
+ isRunning = value;
151
+ },
152
+ // Convenience methods
153
+ emitProgress: emitter.emitProgress,
154
+ emitError: emitter.emitError,
155
+ emit: emitter.emit.bind(emitter),
156
+ on: emitter.on.bind(emitter),
157
+ off: emitter.off.bind(emitter),
158
+ once: emitter.once.bind(emitter),
159
+ // Signal handling
160
+ setupSignalHandlers: () => {
161
+ const removeHandlers = createSignalHandlers(async signal => {
162
+ if (isRunning) {
163
+ emitter.emitProgress('cleanup', `Received ${signal}, cleaning up...`);
164
+ await cleanup.execute();
165
+ }
166
+ });
167
+ cleanup.add(removeHandlers);
168
+ }
169
+ };
170
+ return context;
171
+ }
@@ -0,0 +1,444 @@
1
+ import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { ApiService } from '../services/api-service.js';
4
+ import { createServiceLogger } from '../utils/logger-factory.js';
5
+ import { colors } from '../utils/colors.js';
6
+ import { getDefaultBranch } from '../utils/git.js';
7
+ import { fetchWithTimeout } from '../utils/fetch-utils.js';
8
+ import { NetworkError } from '../errors/vizzly-error.js';
9
+ const logger = createServiceLogger('TDD');
10
+
11
+ /**
12
+ * Create a new TDD service instance
13
+ */
14
+ export function createTDDService(config, options = {}) {
15
+ return new TddService(config, options.workingDir);
16
+ }
17
+ export class TddService {
18
+ constructor(config, workingDir = process.cwd()) {
19
+ this.config = config;
20
+ this.api = new ApiService({
21
+ baseUrl: config.apiUrl,
22
+ token: config.apiKey,
23
+ command: 'tdd',
24
+ allowNoToken: true // TDD can run without a token to create new screenshots
25
+ });
26
+ this.workingDir = workingDir;
27
+ this.baselinePath = join(workingDir, '.vizzly', 'baselines');
28
+ this.currentPath = join(workingDir, '.vizzly', 'current');
29
+ this.diffPath = join(workingDir, '.vizzly', 'diffs');
30
+ this.baselineData = null;
31
+ this.comparisons = [];
32
+ this.threshold = config.comparison?.threshold || 0.01;
33
+
34
+ // Ensure directories exist
35
+ [this.baselinePath, this.currentPath, this.diffPath].forEach(dir => {
36
+ if (!existsSync(dir)) {
37
+ mkdirSync(dir, {
38
+ recursive: true
39
+ });
40
+ }
41
+ });
42
+ }
43
+ async downloadBaselines(environment = 'test', branch = null, buildId = null, comparisonId = null) {
44
+ logger.info('šŸ” Looking for baseline build...');
45
+
46
+ // If no branch specified, try to detect the default branch
47
+ if (!branch) {
48
+ branch = await getDefaultBranch();
49
+ if (!branch) {
50
+ // If we can't detect a default branch, use 'main' as fallback
51
+ branch = 'main';
52
+ logger.warn(`āš ļø Could not detect default branch, using 'main' as fallback`);
53
+ } else {
54
+ logger.debug(`Using detected default branch: ${branch}`);
55
+ }
56
+ }
57
+ try {
58
+ let baselineBuild;
59
+ if (buildId) {
60
+ // Use specific build ID
61
+ logger.info(`šŸ“Œ Using specified build: ${buildId}`);
62
+ baselineBuild = await this.api.getBuild(buildId);
63
+ } else if (comparisonId) {
64
+ // Use specific comparison ID
65
+ logger.info(`šŸ“Œ Using comparison: ${comparisonId}`);
66
+ const comparison = await this.api.getComparison(comparisonId);
67
+ baselineBuild = comparison.baselineBuild;
68
+ } else {
69
+ // Get the latest passed build for this environment and branch
70
+ const builds = await this.api.getBuilds({
71
+ environment,
72
+ branch,
73
+ status: 'passed',
74
+ limit: 1
75
+ });
76
+ if (!builds.data || builds.data.length === 0) {
77
+ logger.warn(`āš ļø No baseline builds found for ${environment}/${branch}`);
78
+ logger.info('šŸ’” Run a build in normal mode first to create baselines');
79
+ return null;
80
+ }
81
+ baselineBuild = builds.data[0];
82
+ }
83
+ logger.info(`šŸ“„ Found baseline build: ${colors.cyan(baselineBuild.name)} (${baselineBuild.id})`);
84
+
85
+ // Get build details with screenshots
86
+ const buildDetails = await this.api.getBuild(baselineBuild.id, 'screenshots');
87
+ if (!buildDetails.screenshots || buildDetails.screenshots.length === 0) {
88
+ logger.warn('āš ļø No screenshots found in baseline build');
89
+ return null;
90
+ }
91
+ logger.info(`šŸ“ø Downloading ${colors.cyan(buildDetails.screenshots.length)} baseline screenshots...`);
92
+
93
+ // Download each screenshot
94
+ for (const screenshot of buildDetails.screenshots) {
95
+ const imagePath = join(this.baselinePath, `${screenshot.name}.png`);
96
+
97
+ // Download the image
98
+ const response = await fetchWithTimeout(screenshot.url);
99
+ if (!response.ok) {
100
+ throw new NetworkError(`Failed to download ${screenshot.name}: ${response.statusText}`);
101
+ }
102
+ const imageBuffer = await response.buffer();
103
+ writeFileSync(imagePath, imageBuffer);
104
+ logger.debug(`āœ“ Downloaded ${screenshot.name}.png`);
105
+ }
106
+
107
+ // Store baseline metadata
108
+ this.baselineData = {
109
+ buildId: baselineBuild.id,
110
+ buildName: baselineBuild.name,
111
+ environment,
112
+ branch,
113
+ threshold: this.threshold,
114
+ screenshots: buildDetails.screenshots.map(s => ({
115
+ name: s.name,
116
+ properties: s.properties || {},
117
+ path: join(this.baselinePath, `${s.name}.png`)
118
+ }))
119
+ };
120
+ const metadataPath = join(this.baselinePath, 'metadata.json');
121
+ writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
122
+ logger.info(`āœ… Baseline downloaded successfully`);
123
+ return this.baselineData;
124
+ } catch (error) {
125
+ logger.error(`āŒ Failed to download baseline: ${error.message}`);
126
+ throw error;
127
+ }
128
+ }
129
+ async loadBaseline() {
130
+ const metadataPath = join(this.baselinePath, 'metadata.json');
131
+ if (!existsSync(metadataPath)) {
132
+ return null;
133
+ }
134
+ try {
135
+ const metadata = JSON.parse(readFileSync(metadataPath, 'utf8'));
136
+ this.baselineData = metadata;
137
+ this.threshold = metadata.threshold || this.threshold;
138
+ return metadata;
139
+ } catch (error) {
140
+ logger.error(`āŒ Failed to load baseline metadata: ${error.message}`);
141
+ return null;
142
+ }
143
+ }
144
+ async compareScreenshot(name, imageBuffer, properties = {}) {
145
+ const currentImagePath = join(this.currentPath, `${name}.png`);
146
+ const baselineImagePath = join(this.baselinePath, `${name}.png`);
147
+ const diffImagePath = join(this.diffPath, `${name}.png`);
148
+
149
+ // Save current screenshot
150
+ writeFileSync(currentImagePath, imageBuffer);
151
+
152
+ // Check if we're in baseline update mode - skip all comparisons
153
+ const setBaseline = process.env.VIZZLY_SET_BASELINE === 'true';
154
+ if (setBaseline) {
155
+ return this.updateSingleBaseline(name, imageBuffer, properties, currentImagePath, baselineImagePath);
156
+ }
157
+
158
+ // Check if baseline exists
159
+ if (!existsSync(baselineImagePath)) {
160
+ logger.warn(`āš ļø No baseline found for ${name} - creating baseline`);
161
+
162
+ // Copy current screenshot to baseline directory for future comparisons
163
+ writeFileSync(baselineImagePath, imageBuffer);
164
+
165
+ // Update or create baseline metadata
166
+ if (!this.baselineData) {
167
+ this.baselineData = {
168
+ buildId: 'local-baseline',
169
+ buildName: 'Local TDD Baseline',
170
+ environment: 'test',
171
+ branch: 'local',
172
+ threshold: this.threshold,
173
+ screenshots: []
174
+ };
175
+ }
176
+
177
+ // Add screenshot to baseline metadata
178
+ const screenshotEntry = {
179
+ name,
180
+ properties: properties || {},
181
+ path: baselineImagePath
182
+ };
183
+ const existingIndex = this.baselineData.screenshots.findIndex(s => s.name === name);
184
+ if (existingIndex >= 0) {
185
+ this.baselineData.screenshots[existingIndex] = screenshotEntry;
186
+ } else {
187
+ this.baselineData.screenshots.push(screenshotEntry);
188
+ }
189
+
190
+ // Save updated metadata
191
+ const metadataPath = join(this.baselinePath, 'metadata.json');
192
+ writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
193
+ logger.info(`āœ… Created baseline for ${name}`);
194
+ const result = {
195
+ name,
196
+ status: 'new',
197
+ baseline: baselineImagePath,
198
+ current: currentImagePath,
199
+ diff: null,
200
+ properties
201
+ };
202
+ this.comparisons.push(result);
203
+ return result;
204
+ }
205
+ try {
206
+ // Use odiff Node.js API to compare images
207
+ const {
208
+ compare
209
+ } = await import('odiff-bin');
210
+ logger.debug(`Comparing ${baselineImagePath} vs ${currentImagePath}`);
211
+ const result = await compare(baselineImagePath, currentImagePath, diffImagePath, {
212
+ threshold: this.threshold,
213
+ outputDiffMask: true
214
+ });
215
+ if (result.match) {
216
+ // Images match
217
+ const comparison = {
218
+ name,
219
+ status: 'passed',
220
+ baseline: baselineImagePath,
221
+ current: currentImagePath,
222
+ diff: null,
223
+ properties,
224
+ threshold: this.threshold
225
+ };
226
+ logger.info(`āœ… ${colors.green('PASSED')} ${name}`);
227
+ this.comparisons.push(comparison);
228
+ return comparison;
229
+ } else {
230
+ // Images differ
231
+ let diffInfo = '';
232
+ if (result.reason === 'pixel-diff') {
233
+ diffInfo = ` (${result.diffPercentage.toFixed(2)}% different, ${result.diffCount} pixels)`;
234
+ } else if (result.reason === 'layout-diff') {
235
+ diffInfo = ' (layout difference)';
236
+ }
237
+ const comparison = {
238
+ name,
239
+ status: 'failed',
240
+ baseline: baselineImagePath,
241
+ current: currentImagePath,
242
+ diff: diffImagePath,
243
+ properties,
244
+ threshold: this.threshold,
245
+ diffPercentage: result.reason === 'pixel-diff' ? result.diffPercentage : null,
246
+ diffCount: result.reason === 'pixel-diff' ? result.diffCount : null,
247
+ reason: result.reason
248
+ };
249
+ logger.warn(`āŒ ${colors.red('FAILED')} ${name} - differences detected${diffInfo}`);
250
+ logger.info(` Diff saved to: ${diffImagePath}`);
251
+ this.comparisons.push(comparison);
252
+ return comparison;
253
+ }
254
+ } catch (error) {
255
+ // Handle file errors or other issues
256
+ logger.error(`āŒ Error comparing ${name}: ${error.message}`);
257
+ const comparison = {
258
+ name,
259
+ status: 'error',
260
+ baseline: baselineImagePath,
261
+ current: currentImagePath,
262
+ diff: null,
263
+ properties,
264
+ error: error.message
265
+ };
266
+ this.comparisons.push(comparison);
267
+ return comparison;
268
+ }
269
+ }
270
+ getResults() {
271
+ const passed = this.comparisons.filter(c => c.status === 'passed').length;
272
+ const failed = this.comparisons.filter(c => c.status === 'failed').length;
273
+ const newScreenshots = this.comparisons.filter(c => c.status === 'new').length;
274
+ const errors = this.comparisons.filter(c => c.status === 'error').length;
275
+ return {
276
+ total: this.comparisons.length,
277
+ passed,
278
+ failed,
279
+ new: newScreenshots,
280
+ errors,
281
+ comparisons: this.comparisons,
282
+ baseline: this.baselineData
283
+ };
284
+ }
285
+ printResults() {
286
+ const results = this.getResults();
287
+ logger.info('\nšŸ“Š TDD Results:');
288
+ logger.info(`Total: ${colors.cyan(results.total)}`);
289
+ logger.info(`Passed: ${colors.green(results.passed)}`);
290
+ if (results.failed > 0) {
291
+ logger.info(`Failed: ${colors.red(results.failed)}`);
292
+ }
293
+ if (results.new > 0) {
294
+ logger.info(`New: ${colors.yellow(results.new)}`);
295
+ }
296
+ if (results.errors > 0) {
297
+ logger.info(`Errors: ${colors.red(results.errors)}`);
298
+ }
299
+
300
+ // Show failed comparisons
301
+ const failedComparisons = results.comparisons.filter(c => c.status === 'failed');
302
+ if (failedComparisons.length > 0) {
303
+ logger.info('\nāŒ Failed comparisons:');
304
+ failedComparisons.forEach(comp => {
305
+ logger.info(` • ${comp.name}`);
306
+ logger.info(` Baseline: ${comp.baseline}`);
307
+ logger.info(` Current: ${comp.current}`);
308
+ logger.info(` Diff: ${comp.diff}`);
309
+ });
310
+ }
311
+
312
+ // Show new screenshots
313
+ const newComparisons = results.comparisons.filter(c => c.status === 'new');
314
+ if (newComparisons.length > 0) {
315
+ logger.info('\nšŸ“ø New screenshots:');
316
+ newComparisons.forEach(comp => {
317
+ logger.info(` • ${comp.name}`);
318
+ logger.info(` Current: ${comp.current}`);
319
+ });
320
+ }
321
+ logger.info(`\nšŸ“ Results saved to: ${colors.dim('.vizzly/')}`);
322
+ return results;
323
+ }
324
+
325
+ /**
326
+ * Update baselines with current screenshots (accept changes)
327
+ * @returns {number} Number of baselines updated
328
+ */
329
+ updateBaselines() {
330
+ if (this.comparisons.length === 0) {
331
+ logger.warn('No comparisons found - nothing to update');
332
+ return 0;
333
+ }
334
+ let updatedCount = 0;
335
+
336
+ // Initialize baseline data if it doesn't exist
337
+ if (!this.baselineData) {
338
+ this.baselineData = {
339
+ buildId: 'local-baseline',
340
+ buildName: 'Local TDD Baseline',
341
+ environment: 'test',
342
+ branch: 'local',
343
+ threshold: this.threshold,
344
+ screenshots: []
345
+ };
346
+ }
347
+ for (const comparison of this.comparisons) {
348
+ const {
349
+ name,
350
+ current
351
+ } = comparison;
352
+ if (!current || !existsSync(current)) {
353
+ logger.warn(`Current screenshot not found for ${name}, skipping`);
354
+ continue;
355
+ }
356
+ const baselineImagePath = join(this.baselinePath, `${name}.png`);
357
+ try {
358
+ // Copy current screenshot to baseline
359
+ const currentBuffer = readFileSync(current);
360
+ writeFileSync(baselineImagePath, currentBuffer);
361
+
362
+ // Update baseline metadata
363
+ const screenshotEntry = {
364
+ name,
365
+ properties: comparison.properties || {},
366
+ path: baselineImagePath
367
+ };
368
+ const existingIndex = this.baselineData.screenshots.findIndex(s => s.name === name);
369
+ if (existingIndex >= 0) {
370
+ this.baselineData.screenshots[existingIndex] = screenshotEntry;
371
+ } else {
372
+ this.baselineData.screenshots.push(screenshotEntry);
373
+ }
374
+ updatedCount++;
375
+ logger.info(`āœ… Updated baseline for ${name}`);
376
+ } catch (error) {
377
+ logger.error(`āŒ Failed to update baseline for ${name}: ${error.message}`);
378
+ }
379
+ }
380
+
381
+ // Save updated metadata
382
+ if (updatedCount > 0) {
383
+ try {
384
+ const metadataPath = join(this.baselinePath, 'metadata.json');
385
+ writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
386
+ logger.info(`āœ… Updated ${updatedCount} baseline(s)`);
387
+ } catch (error) {
388
+ logger.error(`āŒ Failed to save baseline metadata: ${error.message}`);
389
+ }
390
+ }
391
+ return updatedCount;
392
+ }
393
+
394
+ /**
395
+ * Update a single baseline with current screenshot
396
+ * @private
397
+ */
398
+ updateSingleBaseline(name, imageBuffer, properties, currentImagePath, baselineImagePath) {
399
+ logger.info(`🐻 Setting baseline for ${name}`);
400
+
401
+ // Copy current screenshot to baseline directory
402
+ writeFileSync(baselineImagePath, imageBuffer);
403
+
404
+ // Update or create baseline metadata
405
+ if (!this.baselineData) {
406
+ this.baselineData = {
407
+ buildId: 'local-baseline',
408
+ buildName: 'Local TDD Baseline',
409
+ environment: 'test',
410
+ branch: 'local',
411
+ threshold: this.threshold,
412
+ screenshots: []
413
+ };
414
+ }
415
+
416
+ // Add screenshot to baseline metadata
417
+ const screenshotEntry = {
418
+ name,
419
+ properties: properties || {},
420
+ path: baselineImagePath
421
+ };
422
+ const existingIndex = this.baselineData.screenshots.findIndex(s => s.name === name);
423
+ if (existingIndex >= 0) {
424
+ this.baselineData.screenshots[existingIndex] = screenshotEntry;
425
+ } else {
426
+ this.baselineData.screenshots.push(screenshotEntry);
427
+ }
428
+
429
+ // Save updated metadata
430
+ const metadataPath = join(this.baselinePath, 'metadata.json');
431
+ writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
432
+ const result = {
433
+ name,
434
+ status: 'baseline-updated',
435
+ baseline: baselineImagePath,
436
+ current: currentImagePath,
437
+ diff: null,
438
+ properties
439
+ };
440
+ this.comparisons.push(result);
441
+ logger.info(`🐻 Baseline set for ${name}`);
442
+ return result;
443
+ }
444
+ }