@vizzly-testing/cli 0.4.0 → 0.6.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.
@@ -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,183 @@ 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 each screenshot (with efficient SHA checking)
163
+ let downloadedCount = 0;
164
+ let skippedCount = 0;
94
165
  for (const screenshot of buildDetails.screenshots) {
95
- const imagePath = join(this.baselinePath, `${screenshot.name}.png`);
166
+ // Sanitize screenshot name for security
167
+ let sanitizedName;
168
+ try {
169
+ sanitizedName = sanitizeScreenshotName(screenshot.name);
170
+ } catch (error) {
171
+ logger.warn(`Skipping screenshot with invalid name '${screenshot.name}': ${error.message}`);
172
+ continue;
173
+ }
174
+ const imagePath = safePath(this.baselinePath, `${sanitizedName}.png`);
175
+
176
+ // Check if we already have this file with the same SHA (using metadata)
177
+ if (existsSync(imagePath) && screenshot.sha256) {
178
+ const storedSha = existingShaMap.get(sanitizedName);
179
+ if (storedSha === screenshot.sha256) {
180
+ logger.debug(`⚔ Skipping ${sanitizedName} - SHA match from metadata`);
181
+ downloadedCount++; // Count as "downloaded" since we have it
182
+ skippedCount++;
183
+ continue;
184
+ } else if (storedSha) {
185
+ logger.debug(`šŸ”„ SHA mismatch for ${sanitizedName} - will re-download (stored: ${storedSha?.slice(0, 8)}..., remote: ${screenshot.sha256?.slice(0, 8)}...)`);
186
+ }
187
+ }
96
188
 
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}`);
189
+ // Use original_url as the download URL
190
+ const downloadUrl = screenshot.original_url || screenshot.url;
191
+ if (!downloadUrl) {
192
+ logger.warn(`āš ļø Screenshot ${sanitizedName} has no download URL - skipping`);
193
+ continue; // Skip screenshots without URLs
194
+ }
195
+ logger.debug(`šŸ“„ Downloading screenshot: ${sanitizedName} from ${downloadUrl}`);
196
+ try {
197
+ // Download the image
198
+ const response = await fetchWithTimeout(downloadUrl);
199
+ if (!response.ok) {
200
+ throw new NetworkError(`Failed to download ${sanitizedName}: ${response.statusText}`);
201
+ }
202
+ const arrayBuffer = await response.arrayBuffer();
203
+ const imageBuffer = Buffer.from(arrayBuffer);
204
+ writeFileSync(imagePath, imageBuffer);
205
+ downloadedCount++;
206
+ logger.debug(`āœ“ Downloaded ${sanitizedName}.png`);
207
+ } catch (error) {
208
+ logger.warn(`āš ļø Failed to download ${sanitizedName}: ${error.message}`);
101
209
  }
102
- const imageBuffer = await response.buffer();
103
- writeFileSync(imagePath, imageBuffer);
104
- logger.debug(`āœ“ Downloaded ${screenshot.name}.png`);
105
210
  }
106
211
 
107
- // Store baseline metadata
212
+ // Check if we actually downloaded any screenshots
213
+ if (downloadedCount === 0) {
214
+ logger.error('āŒ No screenshots were successfully downloaded from the baseline build');
215
+ logger.info('šŸ’” This usually means the build failed or screenshots have no download URLs');
216
+ logger.info('šŸ’” Try using a successful build ID, or run without --baseline-build to create local baselines');
217
+ return null;
218
+ }
219
+
220
+ // Store enhanced baseline metadata with SHA hashes and build info
108
221
  this.baselineData = {
109
222
  buildId: baselineBuild.id,
110
223
  buildName: baselineBuild.name,
111
224
  environment,
112
225
  branch,
113
226
  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
- }))
227
+ createdAt: new Date().toISOString(),
228
+ buildInfo: {
229
+ commitSha: baselineBuild.commit_sha,
230
+ commitMessage: baselineBuild.commit_message,
231
+ approvalStatus: baselineBuild.approval_status,
232
+ completedAt: baselineBuild.completed_at
233
+ },
234
+ screenshots: buildDetails.screenshots.map(s => {
235
+ let sanitizedName;
236
+ try {
237
+ sanitizedName = sanitizeScreenshotName(s.name);
238
+ } catch (error) {
239
+ logger.warn(`Screenshot name sanitization failed for '${s.name}': ${error.message}`);
240
+ return null; // Skip invalid screenshots
241
+ }
242
+ return {
243
+ name: sanitizedName,
244
+ originalName: s.name,
245
+ sha256: s.sha256,
246
+ // Store remote SHA for quick comparison
247
+ id: s.id,
248
+ properties: validateScreenshotProperties(s.metadata || s.properties || {}),
249
+ path: safePath(this.baselinePath, `${sanitizedName}.png`),
250
+ originalUrl: s.original_url,
251
+ fileSize: s.file_size_bytes,
252
+ dimensions: {
253
+ width: s.width,
254
+ height: s.height
255
+ }
256
+ };
257
+ }).filter(Boolean) // Remove null entries from invalid screenshots
119
258
  };
120
259
  const metadataPath = join(this.baselinePath, 'metadata.json');
121
260
  writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
122
- logger.info(`āœ… Baseline downloaded successfully`);
261
+ if (skippedCount > 0) {
262
+ const actualDownloads = downloadedCount - skippedCount;
263
+ logger.info(`āœ… Baseline ready - ${actualDownloads} downloaded, ${skippedCount} skipped (matching SHA) - ${downloadedCount}/${buildDetails.screenshots.length} total`);
264
+ } else {
265
+ logger.info(`āœ… Baseline downloaded successfully - ${downloadedCount}/${buildDetails.screenshots.length} screenshots`);
266
+ }
123
267
  return this.baselineData;
124
268
  } catch (error) {
125
269
  logger.error(`āŒ Failed to download baseline: ${error.message}`);
126
270
  throw error;
127
271
  }
128
272
  }
273
+
274
+ /**
275
+ * Handle local baseline logic (either load existing or prepare for new baselines)
276
+ * @returns {Promise<Object|null>} Baseline data or null if no local baselines exist
277
+ */
278
+ async handleLocalBaselines() {
279
+ // Check if we're in baseline update mode - skip loading existing baselines
280
+ if (this.setBaseline) {
281
+ logger.info('šŸ“ Ready for new baseline creation - all screenshots will be treated as new baselines');
282
+
283
+ // Reset baseline data since we're creating new ones
284
+ this.baselineData = null;
285
+ return null;
286
+ }
287
+ const baseline = await this.loadBaseline();
288
+ if (!baseline) {
289
+ if (this.config.apiKey) {
290
+ logger.info('šŸ“„ No local baseline found, but API key available for future remote fetching');
291
+ logger.info('šŸ†• Current run will create new local baselines');
292
+ } else {
293
+ logger.info('šŸ“ No local baseline found and no API token - all screenshots will be marked as new');
294
+ }
295
+ return null;
296
+ } else {
297
+ logger.info(`āœ… Using existing baseline: ${colors.cyan(baseline.buildName)}`);
298
+ return baseline;
299
+ }
300
+ }
129
301
  async loadBaseline() {
302
+ // In baseline update mode, never load existing baselines
303
+ if (this.setBaseline) {
304
+ logger.debug('🐻 Baseline update mode - skipping baseline loading');
305
+ return null;
306
+ }
130
307
  const metadataPath = join(this.baselinePath, 'metadata.json');
131
308
  if (!existsSync(metadataPath)) {
132
309
  return null;
@@ -142,22 +319,36 @@ export class TddService {
142
319
  }
143
320
  }
144
321
  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`);
322
+ // Sanitize screenshot name and validate properties
323
+ let sanitizedName;
324
+ try {
325
+ sanitizedName = sanitizeScreenshotName(name);
326
+ } catch (error) {
327
+ logger.error(`Invalid screenshot name '${name}': ${error.message}`);
328
+ throw new Error(`Screenshot name validation failed: ${error.message}`);
329
+ }
330
+ let validatedProperties;
331
+ try {
332
+ validatedProperties = validateScreenshotProperties(properties);
333
+ } catch (error) {
334
+ logger.warn(`Property validation failed for '${sanitizedName}': ${error.message}`);
335
+ validatedProperties = {};
336
+ }
337
+ const currentImagePath = safePath(this.currentPath, `${sanitizedName}.png`);
338
+ const baselineImagePath = safePath(this.baselinePath, `${sanitizedName}.png`);
339
+ const diffImagePath = safePath(this.diffPath, `${sanitizedName}.png`);
148
340
 
149
341
  // Save current screenshot
150
342
  writeFileSync(currentImagePath, imageBuffer);
151
343
 
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);
344
+ // Check if we're in baseline update mode - treat as first run, no comparisons
345
+ if (this.setBaseline) {
346
+ return this.createNewBaseline(sanitizedName, imageBuffer, validatedProperties, currentImagePath, baselineImagePath);
156
347
  }
157
348
 
158
349
  // Check if baseline exists
159
350
  if (!existsSync(baselineImagePath)) {
160
- logger.warn(`āš ļø No baseline found for ${name} - creating baseline`);
351
+ logger.warn(`āš ļø No baseline found for ${sanitizedName} - creating baseline`);
161
352
 
162
353
  // Copy current screenshot to baseline directory for future comparisons
163
354
  writeFileSync(baselineImagePath, imageBuffer);
@@ -176,11 +367,11 @@ export class TddService {
176
367
 
177
368
  // Add screenshot to baseline metadata
178
369
  const screenshotEntry = {
179
- name,
180
- properties: properties || {},
370
+ name: sanitizedName,
371
+ properties: validatedProperties,
181
372
  path: baselineImagePath
182
373
  };
183
- const existingIndex = this.baselineData.screenshots.findIndex(s => s.name === name);
374
+ const existingIndex = this.baselineData.screenshots.findIndex(s => s.name === sanitizedName);
184
375
  if (existingIndex >= 0) {
185
376
  this.baselineData.screenshots[existingIndex] = screenshotEntry;
186
377
  } else {
@@ -190,14 +381,14 @@ export class TddService {
190
381
  // Save updated metadata
191
382
  const metadataPath = join(this.baselinePath, 'metadata.json');
192
383
  writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
193
- logger.info(`āœ… Created baseline for ${name}`);
384
+ logger.info(`āœ… Created baseline for ${sanitizedName}`);
194
385
  const result = {
195
- name,
386
+ name: sanitizedName,
196
387
  status: 'new',
197
388
  baseline: baselineImagePath,
198
389
  current: currentImagePath,
199
390
  diff: null,
200
- properties
391
+ properties: validatedProperties
201
392
  };
202
393
  this.comparisons.push(result);
203
394
  return result;
@@ -215,15 +406,15 @@ export class TddService {
215
406
  if (result.match) {
216
407
  // Images match
217
408
  const comparison = {
218
- name,
409
+ name: sanitizedName,
219
410
  status: 'passed',
220
411
  baseline: baselineImagePath,
221
412
  current: currentImagePath,
222
413
  diff: null,
223
- properties,
414
+ properties: validatedProperties,
224
415
  threshold: this.threshold
225
416
  };
226
- logger.info(`āœ… ${colors.green('PASSED')} ${name}`);
417
+ logger.info(`āœ… ${colors.green('PASSED')} ${sanitizedName}`);
227
418
  this.comparisons.push(comparison);
228
419
  return comparison;
229
420
  } else {
@@ -235,32 +426,32 @@ export class TddService {
235
426
  diffInfo = ' (layout difference)';
236
427
  }
237
428
  const comparison = {
238
- name,
429
+ name: sanitizedName,
239
430
  status: 'failed',
240
431
  baseline: baselineImagePath,
241
432
  current: currentImagePath,
242
433
  diff: diffImagePath,
243
- properties,
434
+ properties: validatedProperties,
244
435
  threshold: this.threshold,
245
436
  diffPercentage: result.reason === 'pixel-diff' ? result.diffPercentage : null,
246
437
  diffCount: result.reason === 'pixel-diff' ? result.diffCount : null,
247
438
  reason: result.reason
248
439
  };
249
- logger.warn(`āŒ ${colors.red('FAILED')} ${name} - differences detected${diffInfo}`);
440
+ logger.warn(`āŒ ${colors.red('FAILED')} ${sanitizedName} - differences detected${diffInfo}`);
250
441
  logger.info(` Diff saved to: ${diffImagePath}`);
251
442
  this.comparisons.push(comparison);
252
443
  return comparison;
253
444
  }
254
445
  } catch (error) {
255
446
  // Handle file errors or other issues
256
- logger.error(`āŒ Error comparing ${name}: ${error.message}`);
447
+ logger.error(`āŒ Error comparing ${sanitizedName}: ${error.message}`);
257
448
  const comparison = {
258
- name,
449
+ name: sanitizedName,
259
450
  status: 'error',
260
451
  baseline: baselineImagePath,
261
452
  current: currentImagePath,
262
453
  diff: null,
263
- properties,
454
+ properties: validatedProperties,
264
455
  error: error.message
265
456
  };
266
457
  this.comparisons.push(comparison);
@@ -282,7 +473,7 @@ export class TddService {
282
473
  baseline: this.baselineData
283
474
  };
284
475
  }
285
- printResults() {
476
+ async printResults() {
286
477
  const results = this.getResults();
287
478
  logger.info('\nšŸ“Š TDD Results:');
288
479
  logger.info(`Total: ${colors.cyan(results.total)}`);
@@ -303,9 +494,6 @@ export class TddService {
303
494
  logger.info('\nāŒ Failed comparisons:');
304
495
  failedComparisons.forEach(comp => {
305
496
  logger.info(` • ${comp.name}`);
306
- logger.info(` Baseline: ${comp.baseline}`);
307
- logger.info(` Current: ${comp.current}`);
308
- logger.info(` Diff: ${comp.diff}`);
309
497
  });
310
498
  }
311
499
 
@@ -315,13 +503,74 @@ export class TddService {
315
503
  logger.info('\nšŸ“ø New screenshots:');
316
504
  newComparisons.forEach(comp => {
317
505
  logger.info(` • ${comp.name}`);
318
- logger.info(` Current: ${comp.current}`);
319
506
  });
320
507
  }
321
- logger.info(`\nšŸ“ Results saved to: ${colors.dim('.vizzly/')}`);
508
+
509
+ // Generate HTML report
510
+ await this.generateHtmlReport(results);
322
511
  return results;
323
512
  }
324
513
 
514
+ /**
515
+ * Generate HTML report for TDD results
516
+ * @param {Object} results - TDD comparison results
517
+ */
518
+ async generateHtmlReport(results) {
519
+ try {
520
+ const reportGenerator = new HtmlReportGenerator(this.workingDir, this.config);
521
+ const reportPath = await reportGenerator.generateReport(results, {
522
+ baseline: this.baselineData,
523
+ threshold: this.threshold
524
+ });
525
+
526
+ // Show report path (always clickable)
527
+ logger.info(`\n🐻 View detailed report: ${colors.cyan('file://' + reportPath)}`);
528
+
529
+ // Auto-open if configured
530
+ if (this.config.tdd?.openReport) {
531
+ await this.openReport(reportPath);
532
+ }
533
+ return reportPath;
534
+ } catch (error) {
535
+ logger.warn(`Failed to generate HTML report: ${error.message}`);
536
+ }
537
+ }
538
+
539
+ /**
540
+ * Open HTML report in default browser
541
+ * @param {string} reportPath - Path to HTML report
542
+ */
543
+ async openReport(reportPath) {
544
+ try {
545
+ const {
546
+ exec
547
+ } = await import('child_process');
548
+ const {
549
+ promisify
550
+ } = await import('util');
551
+ const execAsync = promisify(exec);
552
+ let command;
553
+ switch (process.platform) {
554
+ case 'darwin':
555
+ // macOS
556
+ command = `open "${reportPath}"`;
557
+ break;
558
+ case 'win32':
559
+ // Windows
560
+ command = `start "" "${reportPath}"`;
561
+ break;
562
+ default:
563
+ // Linux and others
564
+ command = `xdg-open "${reportPath}"`;
565
+ break;
566
+ }
567
+ await execAsync(command);
568
+ logger.info('šŸ“– Report opened in browser');
569
+ } catch (error) {
570
+ logger.debug(`Failed to open report: ${error.message}`);
571
+ }
572
+ }
573
+
325
574
  /**
326
575
  * Update baselines with current screenshots (accept changes)
327
576
  * @returns {number} Number of baselines updated
@@ -353,7 +602,16 @@ export class TddService {
353
602
  logger.warn(`Current screenshot not found for ${name}, skipping`);
354
603
  continue;
355
604
  }
356
- const baselineImagePath = join(this.baselinePath, `${name}.png`);
605
+
606
+ // Sanitize screenshot name for security
607
+ let sanitizedName;
608
+ try {
609
+ sanitizedName = sanitizeScreenshotName(name);
610
+ } catch (error) {
611
+ logger.warn(`Skipping baseline update for invalid name '${name}': ${error.message}`);
612
+ continue;
613
+ }
614
+ const baselineImagePath = safePath(this.baselinePath, `${sanitizedName}.png`);
357
615
  try {
358
616
  // Copy current screenshot to baseline
359
617
  const currentBuffer = readFileSync(current);
@@ -361,20 +619,20 @@ export class TddService {
361
619
 
362
620
  // Update baseline metadata
363
621
  const screenshotEntry = {
364
- name,
365
- properties: comparison.properties || {},
622
+ name: sanitizedName,
623
+ properties: validateScreenshotProperties(comparison.properties || {}),
366
624
  path: baselineImagePath
367
625
  };
368
- const existingIndex = this.baselineData.screenshots.findIndex(s => s.name === name);
626
+ const existingIndex = this.baselineData.screenshots.findIndex(s => s.name === sanitizedName);
369
627
  if (existingIndex >= 0) {
370
628
  this.baselineData.screenshots[existingIndex] = screenshotEntry;
371
629
  } else {
372
630
  this.baselineData.screenshots.push(screenshotEntry);
373
631
  }
374
632
  updatedCount++;
375
- logger.info(`āœ… Updated baseline for ${name}`);
633
+ logger.info(`āœ… Updated baseline for ${sanitizedName}`);
376
634
  } catch (error) {
377
- logger.error(`āŒ Failed to update baseline for ${name}: ${error.message}`);
635
+ logger.error(`āŒ Failed to update baseline for ${sanitizedName}: ${error.message}`);
378
636
  }
379
637
  }
380
638
 
@@ -391,6 +649,57 @@ export class TddService {
391
649
  return updatedCount;
392
650
  }
393
651
 
652
+ /**
653
+ * Create a new baseline (used during --set-baseline mode)
654
+ * @private
655
+ */
656
+ createNewBaseline(name, imageBuffer, properties, currentImagePath, baselineImagePath) {
657
+ logger.info(`🐻 Creating baseline for ${name}`);
658
+
659
+ // Copy current screenshot to baseline directory
660
+ writeFileSync(baselineImagePath, imageBuffer);
661
+
662
+ // Update or create baseline metadata
663
+ if (!this.baselineData) {
664
+ this.baselineData = {
665
+ buildId: 'local-baseline',
666
+ buildName: 'Local TDD Baseline',
667
+ environment: 'test',
668
+ branch: 'local',
669
+ threshold: this.threshold,
670
+ screenshots: []
671
+ };
672
+ }
673
+
674
+ // Add screenshot to baseline metadata
675
+ const screenshotEntry = {
676
+ name,
677
+ properties: properties || {},
678
+ path: baselineImagePath
679
+ };
680
+ const existingIndex = this.baselineData.screenshots.findIndex(s => s.name === name);
681
+ if (existingIndex >= 0) {
682
+ this.baselineData.screenshots[existingIndex] = screenshotEntry;
683
+ } else {
684
+ this.baselineData.screenshots.push(screenshotEntry);
685
+ }
686
+
687
+ // Save updated metadata
688
+ const metadataPath = join(this.baselinePath, 'metadata.json');
689
+ writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
690
+ const result = {
691
+ name,
692
+ status: 'new',
693
+ baseline: baselineImagePath,
694
+ current: currentImagePath,
695
+ diff: null,
696
+ properties
697
+ };
698
+ this.comparisons.push(result);
699
+ logger.info(`āœ… Baseline created for ${name}`);
700
+ return result;
701
+ }
702
+
394
703
  /**
395
704
  * Update a single baseline with current screenshot
396
705
  * @private