@vizzly-testing/cli 0.5.0 → 0.7.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 (33) hide show
  1. package/README.md +55 -9
  2. package/dist/cli.js +15 -2
  3. package/dist/commands/finalize.js +72 -0
  4. package/dist/commands/run.js +59 -19
  5. package/dist/commands/tdd.js +6 -13
  6. package/dist/commands/upload.js +1 -0
  7. package/dist/server/handlers/tdd-handler.js +82 -8
  8. package/dist/services/api-service.js +14 -0
  9. package/dist/services/html-report-generator.js +377 -0
  10. package/dist/services/report-generator/report.css +355 -0
  11. package/dist/services/report-generator/viewer.js +100 -0
  12. package/dist/services/server-manager.js +3 -2
  13. package/dist/services/tdd-service.js +436 -66
  14. package/dist/services/test-runner.js +56 -28
  15. package/dist/services/uploader.js +3 -2
  16. package/dist/types/commands/finalize.d.ts +13 -0
  17. package/dist/types/server/handlers/tdd-handler.d.ts +18 -1
  18. package/dist/types/services/api-service.d.ts +6 -0
  19. package/dist/types/services/html-report-generator.d.ts +52 -0
  20. package/dist/types/services/report-generator/viewer.d.ts +0 -0
  21. package/dist/types/services/server-manager.d.ts +19 -1
  22. package/dist/types/services/tdd-service.d.ts +24 -3
  23. package/dist/types/services/uploader.d.ts +2 -1
  24. package/dist/types/utils/config-loader.d.ts +3 -0
  25. package/dist/types/utils/environment-config.d.ts +5 -0
  26. package/dist/types/utils/security.d.ts +29 -0
  27. package/dist/utils/config-loader.js +11 -1
  28. package/dist/utils/environment-config.js +9 -0
  29. package/dist/utils/security.js +154 -0
  30. package/docs/api-reference.md +27 -0
  31. package/docs/tdd-mode.md +58 -12
  32. package/docs/test-integration.md +69 -0
  33. package/package.json +3 -2
@@ -6,37 +6,59 @@ import { colors } from '../utils/colors.js';
6
6
  import { getDefaultBranch } from '../utils/git.js';
7
7
  import { fetchWithTimeout } from '../utils/fetch-utils.js';
8
8
  import { NetworkError } from '../errors/vizzly-error.js';
9
+ import { HtmlReportGenerator } from './html-report-generator.js';
10
+ import { sanitizeScreenshotName, validatePathSecurity, safePath, validateScreenshotProperties } from '../utils/security.js';
9
11
  const logger = createServiceLogger('TDD');
10
12
 
11
13
  /**
12
14
  * Create a new TDD service instance
13
15
  */
14
16
  export function createTDDService(config, options = {}) {
15
- return new TddService(config, options.workingDir);
17
+ return new TddService(config, options.workingDir, options.setBaseline);
16
18
  }
17
19
  export class TddService {
18
- constructor(config, workingDir = process.cwd()) {
20
+ constructor(config, workingDir = process.cwd(), setBaseline = false) {
19
21
  this.config = config;
22
+ this.setBaseline = setBaseline;
20
23
  this.api = new ApiService({
21
24
  baseUrl: config.apiUrl,
22
25
  token: config.apiKey,
23
26
  command: 'tdd',
24
27
  allowNoToken: true // TDD can run without a token to create new screenshots
25
28
  });
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');
29
+
30
+ // Validate and secure the working directory
31
+ try {
32
+ this.workingDir = validatePathSecurity(workingDir, workingDir);
33
+ } catch (error) {
34
+ logger.error(`Invalid working directory: ${error.message}`);
35
+ throw new Error(`Working directory validation failed: ${error.message}`);
36
+ }
37
+
38
+ // Use safe path construction for subdirectories
39
+ this.baselinePath = safePath(this.workingDir, '.vizzly', 'baselines');
40
+ this.currentPath = safePath(this.workingDir, '.vizzly', 'current');
41
+ this.diffPath = safePath(this.workingDir, '.vizzly', 'diffs');
30
42
  this.baselineData = null;
31
43
  this.comparisons = [];
32
- this.threshold = config.comparison?.threshold || 0.01;
44
+ this.threshold = config.comparison?.threshold || 0.1;
45
+
46
+ // Check if we're in baseline update mode
47
+ if (this.setBaseline) {
48
+ logger.info('🐻 Baseline update mode - will overwrite existing baselines with new ones');
49
+ }
33
50
 
34
51
  // Ensure directories exist
35
52
  [this.baselinePath, this.currentPath, this.diffPath].forEach(dir => {
36
53
  if (!existsSync(dir)) {
37
- mkdirSync(dir, {
38
- recursive: true
39
- });
54
+ try {
55
+ mkdirSync(dir, {
56
+ recursive: true
57
+ });
58
+ } catch (error) {
59
+ logger.error(`Failed to create directory ${dir}: ${error.message}`);
60
+ throw new Error(`Directory creation failed: ${error.message}`);
61
+ }
40
62
  }
41
63
  });
42
64
  }
@@ -57,9 +79,34 @@ export class TddService {
57
79
  try {
58
80
  let baselineBuild;
59
81
  if (buildId) {
60
- // Use specific build ID
82
+ // Use specific build ID - get it with screenshots in one call
61
83
  logger.info(`šŸ“Œ Using specified build: ${buildId}`);
62
- baselineBuild = await this.api.getBuild(buildId);
84
+ const apiResponse = await this.api.getBuild(buildId, 'screenshots');
85
+
86
+ // Debug the full API response (only in debug mode)
87
+ logger.debug(`šŸ“Š Raw API response:`, {
88
+ apiResponse
89
+ });
90
+ if (!apiResponse) {
91
+ throw new Error(`Build ${buildId} not found or API returned null`);
92
+ }
93
+
94
+ // Handle wrapped response format
95
+ baselineBuild = apiResponse.build || apiResponse;
96
+ if (!baselineBuild.id) {
97
+ logger.warn(`āš ļø Build response structure: ${JSON.stringify(Object.keys(apiResponse))}`);
98
+ logger.warn(`āš ļø Extracted build keys: ${JSON.stringify(Object.keys(baselineBuild))}`);
99
+ }
100
+
101
+ // Check build status and warn if it's not successful
102
+ if (baselineBuild.status === 'failed') {
103
+ logger.warn(`āš ļø Build ${buildId} is marked as FAILED - falling back to local baselines`);
104
+ logger.info(`šŸ’” To use remote baselines, specify a successful build ID instead`);
105
+ // Fall back to local baseline logic
106
+ return await this.handleLocalBaselines();
107
+ } else if (baselineBuild.status !== 'completed') {
108
+ logger.warn(`āš ļø Build ${buildId} has status: ${baselineBuild.status} (expected: completed)`);
109
+ }
63
110
  } else if (comparisonId) {
64
111
  // Use specific comparison ID
65
112
  logger.info(`šŸ“Œ Using comparison: ${comparisonId}`);
@@ -80,53 +127,244 @@ export class TddService {
80
127
  }
81
128
  baselineBuild = builds.data[0];
82
129
  }
83
- logger.info(`šŸ“„ Found baseline build: ${colors.cyan(baselineBuild.name)} (${baselineBuild.id})`);
130
+ logger.info(`šŸ“„ Found baseline build: ${colors.cyan(baselineBuild.name || 'Unknown')} (${baselineBuild.id || 'Unknown ID'})`);
84
131
 
85
- // Get build details with screenshots
86
- const buildDetails = await this.api.getBuild(baselineBuild.id, 'screenshots');
132
+ // For specific buildId, we already have screenshots, otherwise get build details
133
+ let buildDetails = baselineBuild;
134
+ if (!buildId) {
135
+ // Get build details with screenshots for non-buildId cases
136
+ const actualBuildId = baselineBuild.id;
137
+ buildDetails = await this.api.getBuild(actualBuildId, 'screenshots');
138
+ }
87
139
  if (!buildDetails.screenshots || buildDetails.screenshots.length === 0) {
88
140
  logger.warn('āš ļø No screenshots found in baseline build');
89
141
  return null;
90
142
  }
91
143
  logger.info(`šŸ“ø Downloading ${colors.cyan(buildDetails.screenshots.length)} baseline screenshots...`);
92
144
 
93
- // Download each screenshot
145
+ // Debug screenshots structure (only in debug mode)
146
+ logger.debug(`šŸ“Š Screenshots array structure:`, {
147
+ screenshotSample: buildDetails.screenshots.slice(0, 2),
148
+ totalCount: buildDetails.screenshots.length
149
+ });
150
+
151
+ // Check existing baseline metadata for efficient SHA comparison
152
+ const existingBaseline = await this.loadBaseline();
153
+ const existingShaMap = new Map();
154
+ if (existingBaseline) {
155
+ existingBaseline.screenshots.forEach(s => {
156
+ if (s.sha256) {
157
+ existingShaMap.set(s.name, s.sha256);
158
+ }
159
+ });
160
+ }
161
+
162
+ // Download screenshots in batches with progress indication
163
+ let downloadedCount = 0;
164
+ let skippedCount = 0;
165
+ let errorCount = 0;
166
+ const totalScreenshots = buildDetails.screenshots.length;
167
+ const batchSize = 5; // Download up to 5 screenshots concurrently
168
+
169
+ // Filter screenshots that need to be downloaded
170
+ const screenshotsToProcess = [];
94
171
  for (const screenshot of buildDetails.screenshots) {
95
- const imagePath = join(this.baselinePath, `${screenshot.name}.png`);
172
+ // Sanitize screenshot name for security
173
+ let sanitizedName;
174
+ try {
175
+ sanitizedName = sanitizeScreenshotName(screenshot.name);
176
+ } catch (error) {
177
+ logger.warn(`Skipping screenshot with invalid name '${screenshot.name}': ${error.message}`);
178
+ errorCount++;
179
+ continue;
180
+ }
181
+ const imagePath = safePath(this.baselinePath, `${sanitizedName}.png`);
182
+
183
+ // Check if we already have this file with the same SHA (using metadata)
184
+ if (existsSync(imagePath) && screenshot.sha256) {
185
+ const storedSha = existingShaMap.get(sanitizedName);
186
+ if (storedSha === screenshot.sha256) {
187
+ logger.debug(`⚔ Skipping ${sanitizedName} - SHA match from metadata`);
188
+ downloadedCount++; // Count as "downloaded" since we have it
189
+ skippedCount++;
190
+ continue;
191
+ } else if (storedSha) {
192
+ logger.debug(`šŸ”„ SHA mismatch for ${sanitizedName} - will re-download (stored: ${storedSha?.slice(0, 8)}..., remote: ${screenshot.sha256?.slice(0, 8)}...)`);
193
+ }
194
+ }
195
+
196
+ // Use original_url as the download URL
197
+ const downloadUrl = screenshot.original_url || screenshot.url;
198
+ if (!downloadUrl) {
199
+ logger.warn(`āš ļø Screenshot ${sanitizedName} has no download URL - skipping`);
200
+ errorCount++;
201
+ continue;
202
+ }
203
+ screenshotsToProcess.push({
204
+ screenshot,
205
+ sanitizedName,
206
+ imagePath,
207
+ downloadUrl
208
+ });
209
+ }
210
+
211
+ // Process downloads in batches
212
+ const actualDownloadsNeeded = screenshotsToProcess.length;
213
+ if (actualDownloadsNeeded > 0) {
214
+ logger.info(`šŸ“„ Downloading ${actualDownloadsNeeded} new/updated screenshots in batches of ${batchSize}...`);
215
+ for (let i = 0; i < screenshotsToProcess.length; i += batchSize) {
216
+ const batch = screenshotsToProcess.slice(i, i + batchSize);
217
+ const batchNum = Math.floor(i / batchSize) + 1;
218
+ const totalBatches = Math.ceil(screenshotsToProcess.length / batchSize);
219
+ logger.info(`šŸ“¦ Processing batch ${batchNum}/${totalBatches} (${batch.length} screenshots)`);
220
+
221
+ // Download batch concurrently
222
+ const downloadPromises = batch.map(async ({
223
+ sanitizedName,
224
+ imagePath,
225
+ downloadUrl
226
+ }) => {
227
+ try {
228
+ logger.debug(`šŸ“„ Downloading: ${sanitizedName}`);
229
+ const response = await fetchWithTimeout(downloadUrl);
230
+ if (!response.ok) {
231
+ throw new NetworkError(`Failed to download ${sanitizedName}: ${response.statusText}`);
232
+ }
233
+ const arrayBuffer = await response.arrayBuffer();
234
+ const imageBuffer = Buffer.from(arrayBuffer);
235
+ writeFileSync(imagePath, imageBuffer);
236
+ logger.debug(`āœ“ Downloaded ${sanitizedName}.png`);
237
+ return {
238
+ success: true,
239
+ name: sanitizedName
240
+ };
241
+ } catch (error) {
242
+ logger.warn(`āš ļø Failed to download ${sanitizedName}: ${error.message}`);
243
+ return {
244
+ success: false,
245
+ name: sanitizedName,
246
+ error: error.message
247
+ };
248
+ }
249
+ });
250
+ const batchResults = await Promise.all(downloadPromises);
251
+ const batchSuccesses = batchResults.filter(r => r.success).length;
252
+ const batchFailures = batchResults.filter(r => !r.success).length;
253
+ downloadedCount += batchSuccesses;
254
+ errorCount += batchFailures;
255
+
256
+ // Show progress
257
+ const totalProcessed = downloadedCount + skippedCount + errorCount;
258
+ const progressPercent = Math.round(totalProcessed / totalScreenshots * 100);
259
+ logger.info(`šŸ“Š Progress: ${totalProcessed}/${totalScreenshots} (${progressPercent}%) - ${batchSuccesses} downloaded, ${batchFailures} failed in this batch`);
260
+ }
261
+ }
96
262
 
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}`);
263
+ // Check if we actually downloaded any screenshots
264
+ if (downloadedCount === 0 && skippedCount === 0) {
265
+ logger.error('āŒ No screenshots were successfully downloaded from the baseline build');
266
+ if (errorCount > 0) {
267
+ logger.info(`šŸ’” ${errorCount} screenshots had errors - check download URLs and network connection`);
101
268
  }
102
- const imageBuffer = await response.buffer();
103
- writeFileSync(imagePath, imageBuffer);
104
- logger.debug(`āœ“ Downloaded ${screenshot.name}.png`);
269
+ logger.info('šŸ’” This usually means the build failed or screenshots have no download URLs');
270
+ logger.info('šŸ’” Try using a successful build ID, or run without --baseline-build to create local baselines');
271
+ return null;
105
272
  }
106
273
 
107
- // Store baseline metadata
274
+ // Store enhanced baseline metadata with SHA hashes and build info
108
275
  this.baselineData = {
109
276
  buildId: baselineBuild.id,
110
277
  buildName: baselineBuild.name,
111
278
  environment,
112
279
  branch,
113
280
  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
- }))
281
+ createdAt: new Date().toISOString(),
282
+ buildInfo: {
283
+ commitSha: baselineBuild.commit_sha,
284
+ commitMessage: baselineBuild.commit_message,
285
+ approvalStatus: baselineBuild.approval_status,
286
+ completedAt: baselineBuild.completed_at
287
+ },
288
+ screenshots: buildDetails.screenshots.map(s => {
289
+ let sanitizedName;
290
+ try {
291
+ sanitizedName = sanitizeScreenshotName(s.name);
292
+ } catch (error) {
293
+ logger.warn(`Screenshot name sanitization failed for '${s.name}': ${error.message}`);
294
+ return null; // Skip invalid screenshots
295
+ }
296
+ return {
297
+ name: sanitizedName,
298
+ originalName: s.name,
299
+ sha256: s.sha256,
300
+ // Store remote SHA for quick comparison
301
+ id: s.id,
302
+ properties: validateScreenshotProperties(s.metadata || s.properties || {}),
303
+ path: safePath(this.baselinePath, `${sanitizedName}.png`),
304
+ originalUrl: s.original_url,
305
+ fileSize: s.file_size_bytes,
306
+ dimensions: {
307
+ width: s.width,
308
+ height: s.height
309
+ }
310
+ };
311
+ }).filter(Boolean) // Remove null entries from invalid screenshots
119
312
  };
120
313
  const metadataPath = join(this.baselinePath, 'metadata.json');
121
314
  writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
122
- logger.info(`āœ… Baseline downloaded successfully`);
315
+
316
+ // Final summary
317
+ const actualDownloads = downloadedCount - skippedCount;
318
+ const totalAttempted = downloadedCount + errorCount;
319
+ if (skippedCount > 0 || errorCount > 0) {
320
+ let summaryParts = [];
321
+ if (actualDownloads > 0) summaryParts.push(`${actualDownloads} downloaded`);
322
+ if (skippedCount > 0) summaryParts.push(`${skippedCount} skipped (matching SHA)`);
323
+ if (errorCount > 0) summaryParts.push(`${errorCount} failed`);
324
+ logger.info(`āœ… Baseline ready - ${summaryParts.join(', ')} - ${totalAttempted}/${buildDetails.screenshots.length} total`);
325
+ } else {
326
+ logger.info(`āœ… Baseline downloaded successfully - ${downloadedCount}/${buildDetails.screenshots.length} screenshots`);
327
+ }
123
328
  return this.baselineData;
124
329
  } catch (error) {
125
330
  logger.error(`āŒ Failed to download baseline: ${error.message}`);
126
331
  throw error;
127
332
  }
128
333
  }
334
+
335
+ /**
336
+ * Handle local baseline logic (either load existing or prepare for new baselines)
337
+ * @returns {Promise<Object|null>} Baseline data or null if no local baselines exist
338
+ */
339
+ async handleLocalBaselines() {
340
+ // Check if we're in baseline update mode - skip loading existing baselines
341
+ if (this.setBaseline) {
342
+ logger.info('šŸ“ Ready for new baseline creation - all screenshots will be treated as new baselines');
343
+
344
+ // Reset baseline data since we're creating new ones
345
+ this.baselineData = null;
346
+ return null;
347
+ }
348
+ const baseline = await this.loadBaseline();
349
+ if (!baseline) {
350
+ if (this.config.apiKey) {
351
+ logger.info('šŸ“„ No local baseline found, but API key available for future remote fetching');
352
+ logger.info('šŸ†• Current run will create new local baselines');
353
+ } else {
354
+ logger.info('šŸ“ No local baseline found and no API token - all screenshots will be marked as new');
355
+ }
356
+ return null;
357
+ } else {
358
+ logger.info(`āœ… Using existing baseline: ${colors.cyan(baseline.buildName)}`);
359
+ return baseline;
360
+ }
361
+ }
129
362
  async loadBaseline() {
363
+ // In baseline update mode, never load existing baselines
364
+ if (this.setBaseline) {
365
+ logger.debug('🐻 Baseline update mode - skipping baseline loading');
366
+ return null;
367
+ }
130
368
  const metadataPath = join(this.baselinePath, 'metadata.json');
131
369
  if (!existsSync(metadataPath)) {
132
370
  return null;
@@ -142,22 +380,36 @@ export class TddService {
142
380
  }
143
381
  }
144
382
  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`);
383
+ // Sanitize screenshot name and validate properties
384
+ let sanitizedName;
385
+ try {
386
+ sanitizedName = sanitizeScreenshotName(name);
387
+ } catch (error) {
388
+ logger.error(`Invalid screenshot name '${name}': ${error.message}`);
389
+ throw new Error(`Screenshot name validation failed: ${error.message}`);
390
+ }
391
+ let validatedProperties;
392
+ try {
393
+ validatedProperties = validateScreenshotProperties(properties);
394
+ } catch (error) {
395
+ logger.warn(`Property validation failed for '${sanitizedName}': ${error.message}`);
396
+ validatedProperties = {};
397
+ }
398
+ const currentImagePath = safePath(this.currentPath, `${sanitizedName}.png`);
399
+ const baselineImagePath = safePath(this.baselinePath, `${sanitizedName}.png`);
400
+ const diffImagePath = safePath(this.diffPath, `${sanitizedName}.png`);
148
401
 
149
402
  // Save current screenshot
150
403
  writeFileSync(currentImagePath, imageBuffer);
151
404
 
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);
405
+ // Check if we're in baseline update mode - treat as first run, no comparisons
406
+ if (this.setBaseline) {
407
+ return this.createNewBaseline(sanitizedName, imageBuffer, validatedProperties, currentImagePath, baselineImagePath);
156
408
  }
157
409
 
158
410
  // Check if baseline exists
159
411
  if (!existsSync(baselineImagePath)) {
160
- logger.warn(`āš ļø No baseline found for ${name} - creating baseline`);
412
+ logger.warn(`āš ļø No baseline found for ${sanitizedName} - creating baseline`);
161
413
 
162
414
  // Copy current screenshot to baseline directory for future comparisons
163
415
  writeFileSync(baselineImagePath, imageBuffer);
@@ -176,11 +428,11 @@ export class TddService {
176
428
 
177
429
  // Add screenshot to baseline metadata
178
430
  const screenshotEntry = {
179
- name,
180
- properties: properties || {},
431
+ name: sanitizedName,
432
+ properties: validatedProperties,
181
433
  path: baselineImagePath
182
434
  };
183
- const existingIndex = this.baselineData.screenshots.findIndex(s => s.name === name);
435
+ const existingIndex = this.baselineData.screenshots.findIndex(s => s.name === sanitizedName);
184
436
  if (existingIndex >= 0) {
185
437
  this.baselineData.screenshots[existingIndex] = screenshotEntry;
186
438
  } else {
@@ -190,14 +442,14 @@ export class TddService {
190
442
  // Save updated metadata
191
443
  const metadataPath = join(this.baselinePath, 'metadata.json');
192
444
  writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
193
- logger.info(`āœ… Created baseline for ${name}`);
445
+ logger.info(`āœ… Created baseline for ${sanitizedName}`);
194
446
  const result = {
195
- name,
447
+ name: sanitizedName,
196
448
  status: 'new',
197
449
  baseline: baselineImagePath,
198
450
  current: currentImagePath,
199
451
  diff: null,
200
- properties
452
+ properties: validatedProperties
201
453
  };
202
454
  this.comparisons.push(result);
203
455
  return result;
@@ -215,15 +467,15 @@ export class TddService {
215
467
  if (result.match) {
216
468
  // Images match
217
469
  const comparison = {
218
- name,
470
+ name: sanitizedName,
219
471
  status: 'passed',
220
472
  baseline: baselineImagePath,
221
473
  current: currentImagePath,
222
474
  diff: null,
223
- properties,
475
+ properties: validatedProperties,
224
476
  threshold: this.threshold
225
477
  };
226
- logger.info(`āœ… ${colors.green('PASSED')} ${name}`);
478
+ logger.info(`āœ… ${colors.green('PASSED')} ${sanitizedName}`);
227
479
  this.comparisons.push(comparison);
228
480
  return comparison;
229
481
  } else {
@@ -235,32 +487,32 @@ export class TddService {
235
487
  diffInfo = ' (layout difference)';
236
488
  }
237
489
  const comparison = {
238
- name,
490
+ name: sanitizedName,
239
491
  status: 'failed',
240
492
  baseline: baselineImagePath,
241
493
  current: currentImagePath,
242
494
  diff: diffImagePath,
243
- properties,
495
+ properties: validatedProperties,
244
496
  threshold: this.threshold,
245
497
  diffPercentage: result.reason === 'pixel-diff' ? result.diffPercentage : null,
246
498
  diffCount: result.reason === 'pixel-diff' ? result.diffCount : null,
247
499
  reason: result.reason
248
500
  };
249
- logger.warn(`āŒ ${colors.red('FAILED')} ${name} - differences detected${diffInfo}`);
501
+ logger.warn(`āŒ ${colors.red('FAILED')} ${sanitizedName} - differences detected${diffInfo}`);
250
502
  logger.info(` Diff saved to: ${diffImagePath}`);
251
503
  this.comparisons.push(comparison);
252
504
  return comparison;
253
505
  }
254
506
  } catch (error) {
255
507
  // Handle file errors or other issues
256
- logger.error(`āŒ Error comparing ${name}: ${error.message}`);
508
+ logger.error(`āŒ Error comparing ${sanitizedName}: ${error.message}`);
257
509
  const comparison = {
258
- name,
510
+ name: sanitizedName,
259
511
  status: 'error',
260
512
  baseline: baselineImagePath,
261
513
  current: currentImagePath,
262
514
  diff: null,
263
- properties,
515
+ properties: validatedProperties,
264
516
  error: error.message
265
517
  };
266
518
  this.comparisons.push(comparison);
@@ -282,7 +534,7 @@ export class TddService {
282
534
  baseline: this.baselineData
283
535
  };
284
536
  }
285
- printResults() {
537
+ async printResults() {
286
538
  const results = this.getResults();
287
539
  logger.info('\nšŸ“Š TDD Results:');
288
540
  logger.info(`Total: ${colors.cyan(results.total)}`);
@@ -303,9 +555,6 @@ export class TddService {
303
555
  logger.info('\nāŒ Failed comparisons:');
304
556
  failedComparisons.forEach(comp => {
305
557
  logger.info(` • ${comp.name}`);
306
- logger.info(` Baseline: ${comp.baseline}`);
307
- logger.info(` Current: ${comp.current}`);
308
- logger.info(` Diff: ${comp.diff}`);
309
558
  });
310
559
  }
311
560
 
@@ -315,13 +564,74 @@ export class TddService {
315
564
  logger.info('\nšŸ“ø New screenshots:');
316
565
  newComparisons.forEach(comp => {
317
566
  logger.info(` • ${comp.name}`);
318
- logger.info(` Current: ${comp.current}`);
319
567
  });
320
568
  }
321
- logger.info(`\nšŸ“ Results saved to: ${colors.dim('.vizzly/')}`);
569
+
570
+ // Generate HTML report
571
+ await this.generateHtmlReport(results);
322
572
  return results;
323
573
  }
324
574
 
575
+ /**
576
+ * Generate HTML report for TDD results
577
+ * @param {Object} results - TDD comparison results
578
+ */
579
+ async generateHtmlReport(results) {
580
+ try {
581
+ const reportGenerator = new HtmlReportGenerator(this.workingDir, this.config);
582
+ const reportPath = await reportGenerator.generateReport(results, {
583
+ baseline: this.baselineData,
584
+ threshold: this.threshold
585
+ });
586
+
587
+ // Show report path (always clickable)
588
+ logger.info(`\n🐻 View detailed report: ${colors.cyan('file://' + reportPath)}`);
589
+
590
+ // Auto-open if configured
591
+ if (this.config.tdd?.openReport) {
592
+ await this.openReport(reportPath);
593
+ }
594
+ return reportPath;
595
+ } catch (error) {
596
+ logger.warn(`Failed to generate HTML report: ${error.message}`);
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Open HTML report in default browser
602
+ * @param {string} reportPath - Path to HTML report
603
+ */
604
+ async openReport(reportPath) {
605
+ try {
606
+ const {
607
+ exec
608
+ } = await import('child_process');
609
+ const {
610
+ promisify
611
+ } = await import('util');
612
+ const execAsync = promisify(exec);
613
+ let command;
614
+ switch (process.platform) {
615
+ case 'darwin':
616
+ // macOS
617
+ command = `open "${reportPath}"`;
618
+ break;
619
+ case 'win32':
620
+ // Windows
621
+ command = `start "" "${reportPath}"`;
622
+ break;
623
+ default:
624
+ // Linux and others
625
+ command = `xdg-open "${reportPath}"`;
626
+ break;
627
+ }
628
+ await execAsync(command);
629
+ logger.info('šŸ“– Report opened in browser');
630
+ } catch (error) {
631
+ logger.debug(`Failed to open report: ${error.message}`);
632
+ }
633
+ }
634
+
325
635
  /**
326
636
  * Update baselines with current screenshots (accept changes)
327
637
  * @returns {number} Number of baselines updated
@@ -353,7 +663,16 @@ export class TddService {
353
663
  logger.warn(`Current screenshot not found for ${name}, skipping`);
354
664
  continue;
355
665
  }
356
- const baselineImagePath = join(this.baselinePath, `${name}.png`);
666
+
667
+ // Sanitize screenshot name for security
668
+ let sanitizedName;
669
+ try {
670
+ sanitizedName = sanitizeScreenshotName(name);
671
+ } catch (error) {
672
+ logger.warn(`Skipping baseline update for invalid name '${name}': ${error.message}`);
673
+ continue;
674
+ }
675
+ const baselineImagePath = safePath(this.baselinePath, `${sanitizedName}.png`);
357
676
  try {
358
677
  // Copy current screenshot to baseline
359
678
  const currentBuffer = readFileSync(current);
@@ -361,20 +680,20 @@ export class TddService {
361
680
 
362
681
  // Update baseline metadata
363
682
  const screenshotEntry = {
364
- name,
365
- properties: comparison.properties || {},
683
+ name: sanitizedName,
684
+ properties: validateScreenshotProperties(comparison.properties || {}),
366
685
  path: baselineImagePath
367
686
  };
368
- const existingIndex = this.baselineData.screenshots.findIndex(s => s.name === name);
687
+ const existingIndex = this.baselineData.screenshots.findIndex(s => s.name === sanitizedName);
369
688
  if (existingIndex >= 0) {
370
689
  this.baselineData.screenshots[existingIndex] = screenshotEntry;
371
690
  } else {
372
691
  this.baselineData.screenshots.push(screenshotEntry);
373
692
  }
374
693
  updatedCount++;
375
- logger.info(`āœ… Updated baseline for ${name}`);
694
+ logger.info(`āœ… Updated baseline for ${sanitizedName}`);
376
695
  } catch (error) {
377
- logger.error(`āŒ Failed to update baseline for ${name}: ${error.message}`);
696
+ logger.error(`āŒ Failed to update baseline for ${sanitizedName}: ${error.message}`);
378
697
  }
379
698
  }
380
699
 
@@ -391,6 +710,57 @@ export class TddService {
391
710
  return updatedCount;
392
711
  }
393
712
 
713
+ /**
714
+ * Create a new baseline (used during --set-baseline mode)
715
+ * @private
716
+ */
717
+ createNewBaseline(name, imageBuffer, properties, currentImagePath, baselineImagePath) {
718
+ logger.info(`🐻 Creating baseline for ${name}`);
719
+
720
+ // Copy current screenshot to baseline directory
721
+ writeFileSync(baselineImagePath, imageBuffer);
722
+
723
+ // Update or create baseline metadata
724
+ if (!this.baselineData) {
725
+ this.baselineData = {
726
+ buildId: 'local-baseline',
727
+ buildName: 'Local TDD Baseline',
728
+ environment: 'test',
729
+ branch: 'local',
730
+ threshold: this.threshold,
731
+ screenshots: []
732
+ };
733
+ }
734
+
735
+ // Add screenshot to baseline metadata
736
+ const screenshotEntry = {
737
+ name,
738
+ properties: properties || {},
739
+ path: baselineImagePath
740
+ };
741
+ const existingIndex = this.baselineData.screenshots.findIndex(s => s.name === name);
742
+ if (existingIndex >= 0) {
743
+ this.baselineData.screenshots[existingIndex] = screenshotEntry;
744
+ } else {
745
+ this.baselineData.screenshots.push(screenshotEntry);
746
+ }
747
+
748
+ // Save updated metadata
749
+ const metadataPath = join(this.baselinePath, 'metadata.json');
750
+ writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
751
+ const result = {
752
+ name,
753
+ status: 'new',
754
+ baseline: baselineImagePath,
755
+ current: currentImagePath,
756
+ diff: null,
757
+ properties
758
+ };
759
+ this.comparisons.push(result);
760
+ logger.info(`āœ… Baseline created for ${name}`);
761
+ return result;
762
+ }
763
+
394
764
  /**
395
765
  * Update a single baseline with current screenshot
396
766
  * @private