@vizzly-testing/cli 0.19.0 → 0.19.2

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.
@@ -12,13 +12,11 @@ import { sendError, sendServiceUnavailable, sendSuccess } from '../middleware/re
12
12
  * @param {Object} context - Router context
13
13
  * @param {Object} context.screenshotHandler - Screenshot handler
14
14
  * @param {Object} context.tddService - TDD service for baseline downloads
15
- * @param {Object} context.authService - Auth service for OAuth requests
16
15
  * @returns {Function} Route handler
17
16
  */
18
17
  export function createBaselineRouter({
19
18
  screenshotHandler,
20
- tddService,
21
- authService
19
+ tddService
22
20
  }) {
23
21
  return async function handleBaselineRoute(req, res, pathname) {
24
22
  // Accept a single screenshot as baseline
@@ -96,49 +94,16 @@ export function createBaselineRouter({
96
94
  return true;
97
95
  }
98
96
  try {
99
- const body = await parseJsonBody(req);
100
- const {
101
- buildId,
102
- organizationSlug,
103
- projectSlug
97
+ let body = await parseJsonBody(req);
98
+ let {
99
+ buildId
104
100
  } = body;
105
101
  if (!buildId) {
106
102
  sendError(res, 400, 'buildId is required');
107
103
  return true;
108
104
  }
109
105
  output.info(`Downloading baselines from build ${buildId}...`);
110
-
111
- // If organizationSlug and projectSlug are provided, use OAuth-based download
112
- if (organizationSlug && projectSlug && authService) {
113
- try {
114
- const result = await tddService.downloadBaselinesWithAuth(buildId, organizationSlug, projectSlug, authService);
115
- sendSuccess(res, {
116
- success: true,
117
- message: `Baselines downloaded from build ${buildId}`,
118
- ...result
119
- });
120
- return true;
121
- } catch (authError) {
122
- // Log the OAuth error with details
123
- output.warn(`OAuth download failed (org=${organizationSlug}, project=${projectSlug}): ${authError.message}`);
124
-
125
- // If the error is a 404, it's likely the build doesn't belong to the project
126
- // or the project/org is incorrect - provide a helpful error
127
- if (authError.message?.includes('404')) {
128
- sendError(res, 404, `Build not found or does not belong to project "${projectSlug}" in organization "${organizationSlug}". ` + `Please verify the build exists and you have access to it.`);
129
- return true;
130
- }
131
-
132
- // For auth errors, try API token fallback
133
- if (!authError.message?.includes('401')) {
134
- // For other errors, don't fall through - report them directly
135
- throw authError;
136
- }
137
- }
138
- }
139
-
140
- // Fall back to API token-based download (when no OAuth info or OAuth auth failed)
141
- const result = await tddService.downloadBaselines('test',
106
+ let result = await tddService.downloadBaselines('test',
142
107
  // environment
143
108
  null,
144
109
  // branch (not needed when buildId is specified)
@@ -122,6 +122,16 @@ export class ApiService {
122
122
  return this.request(endpoint);
123
123
  }
124
124
 
125
+ /**
126
+ * Get TDD baselines for a build
127
+ * Returns screenshots with pre-computed filenames for baseline download
128
+ * @param {string} buildId - Build ID
129
+ * @returns {Promise<Object>} { build, screenshots, signatureProperties }
130
+ */
131
+ async getTddBaselines(buildId) {
132
+ return this.request(`/api/sdk/builds/${buildId}/tdd-baselines`);
133
+ }
134
+
125
135
  /**
126
136
  * Get comparison information
127
137
  * @param {string} comparisonId - Comparison ID
@@ -21,7 +21,7 @@
21
21
  */
22
22
 
23
23
  import crypto from 'node:crypto';
24
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
24
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
25
25
  import { join } from 'node:path';
26
26
  import { compare } from '@vizzly-testing/honeydiff';
27
27
  import { NetworkError } from '../errors/vizzly-error.js';
@@ -178,41 +178,71 @@ export class TddService {
178
178
  try {
179
179
  let baselineBuild;
180
180
  if (buildId) {
181
- // Use specific build ID - get it with screenshots in one call
182
- const apiResponse = await this.api.getBuild(buildId, 'screenshots');
183
-
184
- // API response available in verbose mode
185
- output.debug('tdd', 'fetched baseline build', {
186
- id: apiResponse?.build?.id || apiResponse?.id
187
- });
181
+ // Use the tdd-baselines endpoint which returns pre-computed filenames
182
+ let apiResponse = await this.api.getTddBaselines(buildId);
188
183
  if (!apiResponse) {
189
184
  throw new Error(`Build ${buildId} not found or API returned null`);
190
185
  }
191
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
+
192
226
  // Extract signature properties from API response (for variant support)
193
227
  if (apiResponse.signatureProperties && Array.isArray(apiResponse.signatureProperties)) {
194
228
  this.signatureProperties = apiResponse.signatureProperties;
195
229
  if (this.signatureProperties.length > 0) {
196
- output.info(`Using custom signature properties: ${this.signatureProperties.join(', ')}`);
230
+ output.info(`Using signature properties: ${this.signatureProperties.join(', ')}`);
197
231
  }
198
232
  }
199
-
200
- // Handle wrapped response format
201
- baselineBuild = apiResponse.build || apiResponse;
202
- if (!baselineBuild.id) {
203
- output.warn(`⚠️ Build response structure: ${JSON.stringify(Object.keys(apiResponse))}`);
204
- output.warn(`⚠️ Extracted build keys: ${JSON.stringify(Object.keys(baselineBuild))}`);
205
- }
233
+ baselineBuild = apiResponse.build;
206
234
 
207
235
  // Check build status and warn if it's not successful
208
236
  if (baselineBuild.status === 'failed') {
209
237
  output.warn(`⚠️ Build ${buildId} is marked as FAILED - falling back to local baselines`);
210
238
  output.info(`💡 To use remote baselines, specify a successful build ID instead`);
211
- // Fall back to local baseline logic
212
239
  return await this.handleLocalBaselines();
213
240
  } else if (baselineBuild.status !== 'completed') {
214
241
  output.warn(`⚠️ Build ${buildId} has status: ${baselineBuild.status} (expected: completed)`);
215
242
  }
243
+
244
+ // Attach screenshots to build for unified processing below
245
+ baselineBuild.screenshots = apiResponse.screenshots;
216
246
  } else if (comparisonId) {
217
247
  // Use specific comparison ID - download only this comparison's baseline screenshot
218
248
  output.info(`Using comparison: ${comparisonId}`);
@@ -262,6 +292,11 @@ export class TddService {
262
292
  }
263
293
  output.info(`📊 Extracted properties for signature: ${JSON.stringify(screenshotProperties)}`);
264
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
+
265
300
  // For a specific comparison, we only download that one baseline screenshot
266
301
  // Create a mock build structure with just this one screenshot
267
302
  baselineBuild = {
@@ -269,10 +304,11 @@ export class TddService {
269
304
  name: `Comparison ${comparisonId.substring(0, 8)}`,
270
305
  screenshots: [{
271
306
  id: comparison.baseline_screenshot.id,
272
- name: comparison.baseline_name || comparison.current_name,
307
+ name: screenshotName,
273
308
  original_url: baselineUrl,
274
309
  metadata: screenshotProperties,
275
- properties: screenshotProperties
310
+ properties: screenshotProperties,
311
+ filename: filename // Generated locally for comparison path
276
312
  }]
277
313
  };
278
314
  } else {
@@ -288,18 +324,27 @@ export class TddService {
288
324
  output.info('💡 Run a build in normal mode first to create baselines');
289
325
  return null;
290
326
  }
291
- baselineBuild = builds.data[0];
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;
292
343
  }
293
344
 
294
- // For specific buildId, we already have screenshots
345
+ // For both buildId and getBuilds paths, we now have screenshots with filenames
295
346
  // For comparisonId, we created a mock build with just the one screenshot
296
- // Otherwise, get build details with screenshots
297
347
  let buildDetails = baselineBuild;
298
- if (!buildId && !comparisonId) {
299
- // Get build details with screenshots for non-buildId/non-comparisonId cases
300
- const actualBuildId = baselineBuild.id;
301
- buildDetails = await this.api.getBuild(actualBuildId, 'screenshots');
302
- }
303
348
  if (!buildDetails.screenshots || buildDetails.screenshots.length === 0) {
304
349
  output.warn('⚠️ No screenshots found in baseline build');
305
350
  return null;
@@ -308,12 +353,12 @@ export class TddService {
308
353
  output.info(`Checking ${colors.cyan(buildDetails.screenshots.length)} baseline screenshots...`);
309
354
 
310
355
  // Check existing baseline metadata for efficient SHA comparison
311
- const existingBaseline = await this.loadBaseline();
312
- const existingShaMap = new Map();
356
+ let existingBaseline = await this.loadBaseline();
357
+ let existingShaMap = new Map();
313
358
  if (existingBaseline) {
314
359
  existingBaseline.screenshots.forEach(s => {
315
- if (s.sha256 && s.signature) {
316
- existingShaMap.set(s.signature, s.sha256);
360
+ if (s.sha256 && s.filename) {
361
+ existingShaMap.set(s.filename, s.sha256);
317
362
  }
318
363
  });
319
364
  }
@@ -338,26 +383,21 @@ export class TddService {
338
383
  continue;
339
384
  }
340
385
 
341
- // Generate signature for baseline matching (same as compareScreenshot)
342
- // Build properties object with top-level viewport_width and browser
343
- // These are returned as top-level fields from the API, not inside metadata
344
- const properties = validateScreenshotProperties({
345
- viewport_width: screenshot.viewport_width,
346
- browser: screenshot.browser,
347
- ...(screenshot.metadata || screenshot.properties || {})
348
- });
349
- const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
350
-
351
- // Use API-provided filename if available, otherwise generate hash-based filename
352
- // Both return the full filename with .png extension
353
- const filename = screenshot.filename || generateBaselineFilename(sanitizedName, signature);
354
- const imagePath = safePath(this.baselinePath, filename);
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);
355
395
 
356
- // Check if we already have this file with the same SHA (using metadata)
396
+ // Check if we already have this file with the same SHA
357
397
  if (existsSync(imagePath) && screenshot.sha256) {
358
- const storedSha = existingShaMap.get(signature);
398
+ let storedSha = existingShaMap.get(filename);
359
399
  if (storedSha === screenshot.sha256) {
360
- downloadedCount++; // Count as "downloaded" since we have it
400
+ downloadedCount++;
361
401
  skippedCount++;
362
402
  continue;
363
403
  }
@@ -375,9 +415,7 @@ export class TddService {
375
415
  sanitizedName,
376
416
  imagePath,
377
417
  downloadUrl,
378
- signature,
379
- filename,
380
- properties
418
+ filename
381
419
  });
382
420
  }
383
421
 
@@ -458,41 +496,23 @@ export class TddService {
458
496
  approvalStatus: baselineBuild.approval_status,
459
497
  completedAt: baselineBuild.completed_at
460
498
  },
461
- screenshots: buildDetails.screenshots.map(s => {
462
- let sanitizedName;
463
- try {
464
- sanitizedName = sanitizeScreenshotName(s.name);
465
- } catch (error) {
466
- output.warn(`Screenshot name sanitization failed for '${s.name}': ${error.message}`);
467
- return null; // Skip invalid screenshots
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
468
514
  }
469
-
470
- // Build properties object with top-level viewport_width and browser
471
- // These are returned as top-level fields from the API, not inside metadata
472
- const properties = validateScreenshotProperties({
473
- viewport_width: s.viewport_width,
474
- browser: s.browser,
475
- ...(s.metadata || s.properties || {})
476
- });
477
- const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
478
- const filename = generateBaselineFilename(sanitizedName, signature);
479
- return {
480
- name: sanitizedName,
481
- originalName: s.name,
482
- sha256: s.sha256,
483
- // Store remote SHA for quick comparison
484
- id: s.id,
485
- properties: properties,
486
- path: safePath(this.baselinePath, filename),
487
- signature: signature,
488
- originalUrl: s.original_url,
489
- fileSize: s.file_size_bytes,
490
- dimensions: {
491
- width: s.width,
492
- height: s.height
493
- }
494
- };
495
- }).filter(Boolean) // Remove null entries from invalid screenshots
515
+ }))
496
516
  };
497
517
  const metadataPath = join(this.baselinePath, 'metadata.json');
498
518
  writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
@@ -685,180 +705,6 @@ export class TddService {
685
705
  };
686
706
  }
687
707
 
688
- /**
689
- * Download baselines using OAuth authentication
690
- * Used when user is logged in via device flow but no API token is configured
691
- * @param {string} buildId - Build ID to download from
692
- * @param {string} organizationSlug - Organization slug
693
- * @param {string} projectSlug - Project slug
694
- * @param {Object} authService - Auth service for OAuth requests
695
- * @returns {Promise<Object>} Download result
696
- */
697
- async downloadBaselinesWithAuth(buildId, organizationSlug, projectSlug, authService) {
698
- output.info(`Downloading baselines using OAuth from build ${buildId}...`);
699
- try {
700
- // Fetch build with screenshots via OAuth endpoint
701
- const endpoint = `/api/build/${projectSlug}/${buildId}/tdd-baselines`;
702
- const response = await authService.authenticatedRequest(endpoint, {
703
- method: 'GET',
704
- headers: {
705
- 'X-Organization': organizationSlug
706
- }
707
- });
708
- const {
709
- build,
710
- screenshots,
711
- signatureProperties
712
- } = response;
713
-
714
- // Extract signature properties from API response (for variant support)
715
- if (signatureProperties && Array.isArray(signatureProperties)) {
716
- this.signatureProperties = signatureProperties;
717
- if (this.signatureProperties.length > 0) {
718
- output.info(`Using custom signature properties: ${this.signatureProperties.join(', ')}`);
719
- }
720
- }
721
- if (!screenshots || screenshots.length === 0) {
722
- output.warn('⚠️ No screenshots found in build');
723
- return {
724
- downloadedCount: 0,
725
- skippedCount: 0,
726
- errorCount: 0
727
- };
728
- }
729
- output.info(`Using baseline from build: ${colors.cyan(build.name || 'Unknown')} (${build.id})`);
730
- output.info(`Checking ${colors.cyan(screenshots.length)} baseline screenshots...`);
731
-
732
- // Load existing baseline metadata for SHA comparison
733
- const existingBaseline = await this.loadBaseline();
734
- const existingShaMap = new Map();
735
- if (existingBaseline) {
736
- existingBaseline.screenshots.forEach(s => {
737
- if (s.sha256 && s.signature) {
738
- existingShaMap.set(s.signature, s.sha256);
739
- }
740
- });
741
- }
742
-
743
- // Process and download screenshots
744
- let downloadedCount = 0;
745
- let skippedCount = 0;
746
- let errorCount = 0;
747
- const downloadedScreenshots = [];
748
- for (const screenshot of screenshots) {
749
- let sanitizedName;
750
- try {
751
- sanitizedName = sanitizeScreenshotName(screenshot.name);
752
- } catch (error) {
753
- output.warn(`Screenshot name sanitization failed for '${screenshot.name}': ${error.message}`);
754
- errorCount++;
755
- continue;
756
- }
757
-
758
- // Build properties object with top-level viewport_width and browser
759
- // These are returned as top-level fields from the API, not inside metadata
760
- const properties = validateScreenshotProperties({
761
- viewport_width: screenshot.viewport_width,
762
- browser: screenshot.browser,
763
- ...screenshot.metadata
764
- });
765
- const signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
766
- // Use API-provided filename if available, otherwise generate hash-based filename
767
- const filename = screenshot.filename || generateBaselineFilename(sanitizedName, signature);
768
- const filePath = safePath(this.baselinePath, filename);
769
-
770
- // Check if we can skip via SHA comparison
771
- if (screenshot.sha256 && existingShaMap.get(signature) === screenshot.sha256) {
772
- skippedCount++;
773
- downloadedScreenshots.push({
774
- name: sanitizedName,
775
- sha256: screenshot.sha256,
776
- signature,
777
- path: filePath,
778
- properties
779
- });
780
- continue;
781
- }
782
-
783
- // Download the screenshot
784
- const downloadUrl = screenshot.original_url;
785
- if (!downloadUrl) {
786
- output.warn(`⚠️ No download URL for screenshot: ${sanitizedName}`);
787
- errorCount++;
788
- continue;
789
- }
790
- try {
791
- const imageResponse = await fetchWithTimeout(downloadUrl, {}, 30000);
792
- if (!imageResponse.ok) {
793
- throw new Error(`HTTP ${imageResponse.status}`);
794
- }
795
- const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
796
-
797
- // Calculate SHA256 of downloaded content
798
- const sha256 = crypto.createHash('sha256').update(imageBuffer).digest('hex');
799
- writeFileSync(filePath, imageBuffer);
800
- downloadedCount++;
801
- downloadedScreenshots.push({
802
- name: sanitizedName,
803
- sha256,
804
- signature,
805
- path: filePath,
806
- properties,
807
- originalUrl: downloadUrl
808
- });
809
- } catch (error) {
810
- output.warn(`⚠️ Failed to download ${sanitizedName}: ${error.message}`);
811
- errorCount++;
812
- }
813
- }
814
-
815
- // Store baseline metadata
816
- this.baselineData = {
817
- buildId: build.id,
818
- buildName: build.name,
819
- branch: build.branch,
820
- threshold: this.threshold,
821
- signatureProperties: this.signatureProperties,
822
- // Store for TDD comparison
823
- screenshots: downloadedScreenshots
824
- };
825
- const metadataPath = join(this.baselinePath, 'metadata.json');
826
- writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2));
827
-
828
- // Save baseline build metadata
829
- const baselineMetadataPath = safePath(this.workingDir, '.vizzly', 'baseline-metadata.json');
830
- writeFileSync(baselineMetadataPath, JSON.stringify({
831
- buildId: build.id,
832
- buildName: build.name,
833
- branch: build.branch,
834
- commitSha: build.commit_sha,
835
- downloadedAt: new Date().toISOString()
836
- }, null, 2));
837
-
838
- // Summary
839
- if (skippedCount > 0 && downloadedCount === 0) {
840
- output.info(`✅ All ${skippedCount} baselines up-to-date (matching local SHA)`);
841
- } else if (skippedCount > 0) {
842
- output.info(`✅ Downloaded ${downloadedCount} new screenshots, ${skippedCount} already up-to-date`);
843
- } else {
844
- output.info(`✅ Downloaded ${downloadedCount}/${screenshots.length} screenshots successfully`);
845
- }
846
- if (errorCount > 0) {
847
- output.warn(`⚠️ ${errorCount} screenshots failed to download`);
848
- }
849
- return {
850
- downloadedCount,
851
- skippedCount,
852
- errorCount,
853
- buildId: build.id,
854
- buildName: build.name
855
- };
856
- } catch (error) {
857
- output.error(`❌ OAuth download failed: ${error.message} (org=${organizationSlug}, project=${projectSlug}, build=${buildId})`);
858
- throw error;
859
- }
860
- }
861
-
862
708
  /**
863
709
  * Handle local baseline logic (either load existing or prepare for new baselines)
864
710
  * @returns {Promise<Object|null>} Baseline data or null if no local baselines exist
@@ -13,6 +13,62 @@ import * as output from './output.js';
13
13
  * @param {boolean} allowSlashes - Whether to allow forward slashes (for browser version strings)
14
14
  * @returns {string} Sanitized screenshot name
15
15
  */
16
+ /**
17
+ * Validate screenshot name for security (no transformations, just validation)
18
+ * Throws if name contains path traversal or other dangerous patterns
19
+ *
20
+ * @param {string} name - Screenshot name to validate
21
+ * @param {number} maxLength - Maximum allowed length
22
+ * @returns {string} The original name (unchanged) if valid
23
+ * @throws {Error} If name contains dangerous patterns
24
+ */
25
+ export function validateScreenshotName(name, maxLength = 255) {
26
+ if (typeof name !== 'string' || name.length === 0) {
27
+ throw new Error('Screenshot name must be a non-empty string');
28
+ }
29
+ if (name.length > maxLength) {
30
+ throw new Error(`Screenshot name exceeds maximum length of ${maxLength} characters`);
31
+ }
32
+
33
+ // Block directory traversal patterns
34
+ if (name.includes('..') || name.includes('\\')) {
35
+ throw new Error('Screenshot name contains invalid path characters');
36
+ }
37
+
38
+ // Block forward slashes (path separators)
39
+ if (name.includes('/')) {
40
+ throw new Error('Screenshot name cannot contain forward slashes');
41
+ }
42
+
43
+ // Block absolute paths
44
+ if (isAbsolute(name)) {
45
+ throw new Error('Screenshot name cannot be an absolute path');
46
+ }
47
+
48
+ // Return the original name unchanged - validation only!
49
+ return name;
50
+ }
51
+
52
+ /**
53
+ * Validate screenshot name for security (allows spaces, preserves original name)
54
+ *
55
+ * This function only validates for security - it does NOT transform spaces.
56
+ * Spaces are preserved so that:
57
+ * 1. generateScreenshotSignature() uses the original name with spaces (matches cloud)
58
+ * 2. generateBaselineFilename() handles space→hyphen conversion (matches cloud)
59
+ *
60
+ * Flow: "VBtn dark" → sanitize → "VBtn dark" → signature: "VBtn dark|1265||" → filename: "VBtn-dark_hash.png"
61
+ *
62
+ * @param {string} name - Screenshot name to validate
63
+ * @param {number} maxLength - Maximum allowed length (default: 255)
64
+ * @param {boolean} allowSlashes - Whether to allow forward slashes (for browser version strings)
65
+ * @returns {string} The validated name (unchanged if valid, spaces preserved)
66
+ * @throws {Error} If name contains dangerous patterns
67
+ *
68
+ * @example
69
+ * sanitizeScreenshotName("VBtn dark") // Returns "VBtn dark" (spaces preserved)
70
+ * sanitizeScreenshotName("My/Component") // Throws error (contains /)
71
+ */
16
72
  export function sanitizeScreenshotName(name, maxLength = 255, allowSlashes = false) {
17
73
  if (typeof name !== 'string' || name.length === 0) {
18
74
  throw new Error('Screenshot name must be a non-empty string');
@@ -36,9 +92,10 @@ export function sanitizeScreenshotName(name, maxLength = 255, allowSlashes = fal
36
92
  throw new Error('Screenshot name cannot be an absolute path');
37
93
  }
38
94
 
39
- // Allow only safe characters: alphanumeric, hyphens, underscores, dots, and optionally slashes
95
+ // Allow only safe characters: alphanumeric, hyphens, underscores, dots, spaces, and optionally slashes
96
+ // Spaces are allowed here and will be converted to hyphens in generateBaselineFilename() to match cloud behavior
40
97
  // Replace other characters with underscores
41
- const allowedChars = allowSlashes ? /[^a-zA-Z0-9._/-]/g : /[^a-zA-Z0-9._-]/g;
98
+ const allowedChars = allowSlashes ? /[^a-zA-Z0-9._ /-]/g : /[^a-zA-Z0-9._ -]/g;
42
99
  let sanitized = name.replace(allowedChars, '_');
43
100
 
44
101
  // Prevent names that start with dots (hidden files)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizzly-testing/cli",
3
- "version": "0.19.0",
3
+ "version": "0.19.2",
4
4
  "description": "Visual review platform for UI developers and designers",
5
5
  "keywords": [
6
6
  "visual-testing",