@vizzly-testing/cli 0.16.2 → 0.16.3

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.
@@ -27,7 +27,7 @@ export class ApiService {
27
27
  const sdkUserAgent = options.userAgent || getUserAgent();
28
28
  this.userAgent = sdkUserAgent ? `${baseUserAgent} ${sdkUserAgent}` : baseUserAgent;
29
29
  if (!this.token && !options.allowNoToken) {
30
- throw new VizzlyError('No API token provided. Set VIZZLY_TOKEN environment variable or run "vizzly login".');
30
+ throw new VizzlyError('No API token provided. Set VIZZLY_TOKEN environment variable or link a project in the TDD dashboard.');
31
31
  }
32
32
  }
33
33
 
@@ -101,10 +101,10 @@ export class ApiService {
101
101
  // Token refresh failed, fall through to auth error
102
102
  }
103
103
  }
104
- throw new AuthError('Invalid or expired API token. Please run "vizzly login" to authenticate.');
104
+ throw new AuthError('Invalid or expired API token. Link a project via "vizzly project:select" or set VIZZLY_TOKEN.');
105
105
  }
106
106
  if (response.status === 401) {
107
- throw new AuthError('Invalid or expired API token. Please run "vizzly login" to authenticate.');
107
+ throw new AuthError('Invalid or expired API token. Link a project via "vizzly project:select" or set VIZZLY_TOKEN.');
108
108
  }
109
109
  throw new VizzlyError(`API request failed: ${response.status}${errorText ? ` - ${errorText}` : ''} (URL: ${url})`);
110
110
  }
@@ -13,14 +13,20 @@ import { sanitizeScreenshotName, validatePathSecurity, safePath, validateScreens
13
13
 
14
14
  /**
15
15
  * Generate a screenshot signature for baseline matching
16
- * Uses same logic as screenshot-identity.js: name + viewport_width + browser
16
+ * Uses same logic as screenshot-identity.js: name + viewport_width + browser + custom properties
17
17
  *
18
18
  * Matches backend signature generation which uses:
19
19
  * - screenshot.name
20
20
  * - screenshot.viewport_width (top-level property)
21
21
  * - screenshot.browser (top-level property)
22
+ * - custom properties from project's baseline_signature_properties setting
23
+ *
24
+ * @param {string} name - Screenshot name
25
+ * @param {Object} properties - Screenshot properties (viewport, browser, metadata, etc.)
26
+ * @param {Array<string>} customProperties - Custom property names from project settings
27
+ * @returns {string} Signature like "Login|1920|chrome|iPhone 15 Pro"
22
28
  */
23
- function generateScreenshotSignature(name, properties = {}) {
29
+ function generateScreenshotSignature(name, properties = {}, customProperties = []) {
24
30
  let parts = [name];
25
31
 
26
32
  // Check for viewport_width as top-level property first (backend format)
@@ -40,15 +46,30 @@ function generateScreenshotSignature(name, properties = {}) {
40
46
  if (properties.browser) {
41
47
  parts.push(properties.browser);
42
48
  }
49
+
50
+ // Add custom properties in order (matches cloud screenshot-identity.js behavior)
51
+ for (let propName of customProperties) {
52
+ // Check multiple locations where property might exist:
53
+ // 1. Top-level property (e.g., properties.device)
54
+ // 2. In metadata object (e.g., properties.metadata.device)
55
+ // 3. In nested metadata.properties (e.g., properties.metadata.properties.device)
56
+ let value = properties[propName] ?? properties.metadata?.[propName] ?? properties.metadata?.properties?.[propName] ?? '';
57
+
58
+ // Normalize: convert to string, trim whitespace
59
+ parts.push(String(value).trim());
60
+ }
43
61
  return parts.join('|');
44
62
  }
45
63
 
46
64
  /**
47
65
  * Create a safe filename from signature
66
+ * Handles custom property values that may contain spaces or special characters
48
67
  */
49
68
  function signatureToFilename(signature) {
50
- // Replace pipe separators with underscores for filesystem safety
51
- return signature.replace(/\|/g, '_');
69
+ return signature.replace(/\|/g, '_') // pipes to underscores
70
+ .replace(/\s+/g, '_') // spaces to underscores
71
+ .replace(/[/\\:*?"<>]/g, '') // remove unsafe filesystem chars
72
+ .replace(/_+/g, '_'); // collapse multiple underscores
52
73
  }
53
74
 
54
75
  /**
@@ -92,6 +113,7 @@ export class TddService {
92
113
  this.baselineData = null;
93
114
  this.comparisons = [];
94
115
  this.threshold = config.comparison?.threshold || 0.1;
116
+ this.signatureProperties = []; // Custom properties from project's baseline_signature_properties
95
117
 
96
118
  // Check if we're in baseline update mode
97
119
  if (this.setBaseline) {
@@ -138,6 +160,14 @@ export class TddService {
138
160
  throw new Error(`Build ${buildId} not found or API returned null`);
139
161
  }
140
162
 
163
+ // Extract signature properties from API response (for variant support)
164
+ if (apiResponse.signatureProperties && Array.isArray(apiResponse.signatureProperties)) {
165
+ this.signatureProperties = apiResponse.signatureProperties;
166
+ if (this.signatureProperties.length > 0) {
167
+ output.info(`Using custom signature properties: ${this.signatureProperties.join(', ')}`);
168
+ }
169
+ }
170
+
141
171
  // Handle wrapped response format
142
172
  baselineBuild = apiResponse.build || apiResponse;
143
173
  if (!baselineBuild.id) {
@@ -280,8 +310,14 @@ export class TddService {
280
310
  }
281
311
 
282
312
  // Generate signature for baseline matching (same as compareScreenshot)
283
- let properties = validateScreenshotProperties(screenshot.metadata || screenshot.properties || {});
284
- let signature = generateScreenshotSignature(sanitizedName, properties);
313
+ // Build properties object with top-level viewport_width and browser
314
+ // These are returned as top-level fields from the API, not inside metadata
315
+ let properties = validateScreenshotProperties({
316
+ viewport_width: screenshot.viewport_width,
317
+ browser: screenshot.browser,
318
+ ...(screenshot.metadata || screenshot.properties || {})
319
+ });
320
+ let signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
285
321
  let filename = signatureToFilename(signature);
286
322
  const imagePath = safePath(this.baselinePath, `${filename}.png`);
287
323
 
@@ -381,6 +417,8 @@ export class TddService {
381
417
  environment,
382
418
  branch,
383
419
  threshold: this.threshold,
420
+ signatureProperties: this.signatureProperties,
421
+ // Store for TDD comparison
384
422
  createdAt: new Date().toISOString(),
385
423
  buildInfo: {
386
424
  commitSha: baselineBuild.commit_sha,
@@ -396,8 +434,15 @@ export class TddService {
396
434
  output.warn(`Screenshot name sanitization failed for '${s.name}': ${error.message}`);
397
435
  return null; // Skip invalid screenshots
398
436
  }
399
- let properties = validateScreenshotProperties(s.metadata || s.properties || {});
400
- let signature = generateScreenshotSignature(sanitizedName, properties);
437
+
438
+ // Build properties object with top-level viewport_width and browser
439
+ // These are returned as top-level fields from the API, not inside metadata
440
+ let properties = validateScreenshotProperties({
441
+ viewport_width: s.viewport_width,
442
+ browser: s.browser,
443
+ ...(s.metadata || s.properties || {})
444
+ });
445
+ let signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
401
446
  let filename = signatureToFilename(signature);
402
447
  return {
403
448
  name: sanitizedName,
@@ -630,8 +675,17 @@ export class TddService {
630
675
  });
631
676
  let {
632
677
  build,
633
- screenshots
678
+ screenshots,
679
+ signatureProperties
634
680
  } = response;
681
+
682
+ // Extract signature properties from API response (for variant support)
683
+ if (signatureProperties && Array.isArray(signatureProperties)) {
684
+ this.signatureProperties = signatureProperties;
685
+ if (this.signatureProperties.length > 0) {
686
+ output.info(`Using custom signature properties: ${this.signatureProperties.join(', ')}`);
687
+ }
688
+ }
635
689
  if (!screenshots || screenshots.length === 0) {
636
690
  output.warn('⚠️ No screenshots found in build');
637
691
  return {
@@ -668,8 +722,15 @@ export class TddService {
668
722
  errorCount++;
669
723
  continue;
670
724
  }
671
- let properties = validateScreenshotProperties(screenshot.metadata || {});
672
- let signature = generateScreenshotSignature(sanitizedName, properties);
725
+
726
+ // Build properties object with top-level viewport_width and browser
727
+ // These are returned as top-level fields from the API, not inside metadata
728
+ let properties = validateScreenshotProperties({
729
+ viewport_width: screenshot.viewport_width,
730
+ browser: screenshot.browser,
731
+ ...screenshot.metadata
732
+ });
733
+ let signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
673
734
  let filename = signatureToFilename(signature);
674
735
  let filePath = safePath(this.baselinePath, `${filename}.png`);
675
736
 
@@ -724,6 +785,8 @@ export class TddService {
724
785
  buildName: build.name,
725
786
  branch: build.branch,
726
787
  threshold: this.threshold,
788
+ signatureProperties: this.signatureProperties,
789
+ // Store for TDD comparison
727
790
  screenshots: downloadedScreenshots
728
791
  };
729
792
  let metadataPath = join(this.baselinePath, 'metadata.json');
@@ -804,6 +867,12 @@ export class TddService {
804
867
  const metadata = JSON.parse(readFileSync(metadataPath, 'utf8'));
805
868
  this.baselineData = metadata;
806
869
  this.threshold = metadata.threshold || this.threshold;
870
+
871
+ // Restore signature properties from saved metadata (for variant support)
872
+ this.signatureProperties = metadata.signatureProperties || [];
873
+ if (this.signatureProperties.length > 0) {
874
+ output.debug('tdd', `loaded signature properties: ${this.signatureProperties.join(', ')}`);
875
+ }
807
876
  return metadata;
808
877
  } catch (error) {
809
878
  output.error(`❌ Failed to load baseline metadata: ${error.message}`);
@@ -833,8 +902,8 @@ export class TddService {
833
902
  validatedProperties.viewport_width = validatedProperties.viewport.width;
834
903
  }
835
904
 
836
- // Generate signature for baseline matching (name + viewport_width + browser)
837
- const signature = generateScreenshotSignature(sanitizedName, validatedProperties);
905
+ // Generate signature for baseline matching (name + viewport_width + browser + custom props)
906
+ const signature = generateScreenshotSignature(sanitizedName, validatedProperties, this.signatureProperties);
838
907
  const filename = signatureToFilename(signature);
839
908
  const currentImagePath = safePath(this.currentPath, `${filename}.png`);
840
909
  const baselineImagePath = safePath(this.baselinePath, `${filename}.png`);
@@ -1227,7 +1296,7 @@ export class TddService {
1227
1296
  continue;
1228
1297
  }
1229
1298
  let validatedProperties = validateScreenshotProperties(comparison.properties || {});
1230
- let signature = generateScreenshotSignature(sanitizedName, validatedProperties);
1299
+ let signature = generateScreenshotSignature(sanitizedName, validatedProperties, this.signatureProperties);
1231
1300
  let filename = signatureToFilename(signature);
1232
1301
  const baselineImagePath = safePath(this.baselinePath, `${filename}.png`);
1233
1302
  try {
@@ -1291,7 +1360,7 @@ export class TddService {
1291
1360
  }
1292
1361
 
1293
1362
  // Generate signature for this screenshot
1294
- let signature = generateScreenshotSignature(name, properties || {});
1363
+ let signature = generateScreenshotSignature(name, properties || {}, this.signatureProperties);
1295
1364
 
1296
1365
  // Add screenshot to baseline metadata
1297
1366
  const screenshotEntry = {
@@ -1348,7 +1417,7 @@ export class TddService {
1348
1417
  }
1349
1418
 
1350
1419
  // Generate signature for this screenshot
1351
- let signature = generateScreenshotSignature(name, properties || {});
1420
+ let signature = generateScreenshotSignature(name, properties || {}, this.signatureProperties);
1352
1421
 
1353
1422
  // Add screenshot to baseline metadata
1354
1423
  const screenshotEntry = {
@@ -1403,7 +1472,7 @@ export class TddService {
1403
1472
  }
1404
1473
  const sanitizedName = comparison.name;
1405
1474
  let properties = comparison.properties || {};
1406
- let signature = generateScreenshotSignature(sanitizedName, properties);
1475
+ let signature = generateScreenshotSignature(sanitizedName, properties, this.signatureProperties);
1407
1476
  let filename = signatureToFilename(signature);
1408
1477
 
1409
1478
  // Find the current screenshot file
@@ -2,7 +2,7 @@ import { cosmiconfigSync } from 'cosmiconfig';
2
2
  import { resolve } from 'path';
3
3
  import { getApiToken, getApiUrl, getParallelId } from './environment-config.js';
4
4
  import { validateVizzlyConfigWithDefaults } from './config-schema.js';
5
- import { getAccessToken, getProjectMapping } from './global-config.js';
5
+ import { getProjectMapping } from './global-config.js';
6
6
  import * as output from './output.js';
7
7
  let DEFAULT_CONFIG = {
8
8
  // API Configuration
@@ -98,14 +98,6 @@ export async function loadConfig(configPath = null, cliOverrides = {}) {
98
98
  }
99
99
  }
100
100
 
101
- // 3.5. Check global config for user access token (if no CLI flag)
102
- if (!config.apiKey && !cliOverrides.token) {
103
- let globalToken = await getAccessToken();
104
- if (globalToken) {
105
- config.apiKey = globalToken;
106
- }
107
- }
108
-
109
101
  // 4. Override with environment variables (higher priority than fallbacks)
110
102
  let envApiKey = getApiToken();
111
103
  let envApiUrl = getApiUrl();
package/docs/tdd-mode.md CHANGED
@@ -388,6 +388,62 @@ rm -rf .vizzly/baselines/
388
388
  npx vizzly tdd run "npm test"
389
389
  ```
390
390
 
391
+ ## Baseline Signature Properties
392
+
393
+ Vizzly matches screenshots to baselines using a **signature**:
394
+
395
+ ```
396
+ name | viewport_width | browser
397
+ ```
398
+
399
+ For example: `homepage|1920|chromium`
400
+
401
+ ### Custom Properties vs Baseline Signature Properties
402
+
403
+ You can pass custom properties when capturing screenshots:
404
+
405
+ ```javascript
406
+ await vizzlyScreenshot('dashboard', screenshot, {
407
+ theme: 'dark',
408
+ locale: 'en-US',
409
+ mobile: true
410
+ });
411
+ ```
412
+
413
+ By default, these properties help you organize and filter in the dashboard—they don't affect baseline matching. Two screenshots with the same name but different `theme` values would match the same baseline.
414
+
415
+ ### Making Properties Affect Matching
416
+
417
+ To create separate baselines for different variants, configure **Baseline Signature Properties** in your project settings:
418
+
419
+ 1. Go to your project on [app.vizzly.dev](https://app.vizzly.dev)
420
+ 2. Navigate to **Settings** → **Baseline Signature Properties**
421
+ 3. Add the property names (e.g., `theme`, `mobile`)
422
+
423
+ With `theme` configured:
424
+
425
+ ```
426
+ dashboard|1920|chromium|dark → separate baseline
427
+ dashboard|1920|chromium|light → separate baseline
428
+ ```
429
+
430
+ ### TDD Mode Support
431
+
432
+ When you download cloud baselines, the CLI automatically:
433
+
434
+ 1. Fetches your project's baseline signature properties
435
+ 2. Downloads baselines with variant-aware filenames
436
+ 3. Uses matching signatures during comparison
437
+
438
+ This keeps TDD mode behavior consistent with cloud comparisons.
439
+
440
+ ### Quick Reference
441
+
442
+ | Type | Purpose | Affects Matching? |
443
+ |------|---------|-------------------|
444
+ | Custom Properties | Organize and filter in dashboard | No |
445
+ | Baseline Signature Properties | Create separate baselines per variant | Yes |
446
+
391
447
  ## Advanced Usage
392
448
 
393
449
  ### Conditional TDD Mode
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizzly-testing/cli",
3
- "version": "0.16.2",
3
+ "version": "0.16.3",
4
4
  "description": "Visual review platform for UI developers and designers",
5
5
  "keywords": [
6
6
  "visual-testing",