@vizzly-testing/cli 0.19.1 → 0.20.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.
@@ -120,7 +120,6 @@ function createSimpleClient(serverUrl) {
120
120
  name,
121
121
  image,
122
122
  properties: options,
123
- threshold: options.threshold || 0,
124
123
  fullPage: options.fullPage || false
125
124
  }),
126
125
  signal: controller.signal
@@ -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';
@@ -142,7 +142,7 @@ export class TddService {
142
142
  this.comparisons = [];
143
143
  this.threshold = config.comparison?.threshold || 2.0;
144
144
  this.minClusterSize = config.comparison?.minClusterSize ?? 2; // Filter single-pixel noise by default
145
- this.signatureProperties = []; // Custom properties from project's baseline_signature_properties
145
+ this.signatureProperties = config.signatureProperties ?? []; // Custom properties from project's baseline_signature_properties
146
146
 
147
147
  // Check if we're in baseline update mode
148
148
  if (this.setBaseline) {
@@ -184,11 +184,50 @@ export class TddService {
184
184
  throw new Error(`Build ${buildId} not found or API returned null`);
185
185
  }
186
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
+
187
226
  // Extract signature properties from API response (for variant support)
188
227
  if (apiResponse.signatureProperties && Array.isArray(apiResponse.signatureProperties)) {
189
228
  this.signatureProperties = apiResponse.signatureProperties;
190
229
  if (this.signatureProperties.length > 0) {
191
- output.info(`Using custom signature properties: ${this.signatureProperties.join(', ')}`);
230
+ output.info(`Using signature properties: ${this.signatureProperties.join(', ')}`);
192
231
  }
193
232
  }
194
233
  baselineBuild = apiResponse.build;
@@ -709,7 +748,7 @@ export class TddService {
709
748
  this.threshold = metadata.threshold || this.threshold;
710
749
 
711
750
  // Restore signature properties from saved metadata (for variant support)
712
- this.signatureProperties = metadata.signatureProperties || [];
751
+ this.signatureProperties = metadata.signatureProperties || this.signatureProperties;
713
752
  if (this.signatureProperties.length > 0) {
714
753
  output.debug('tdd', `loaded signature properties: ${this.signatureProperties.join(', ')}`);
715
754
  }
@@ -818,16 +857,22 @@ export class TddService {
818
857
 
819
858
  // Baseline exists - compare with it
820
859
  try {
860
+ // Per-screenshot threshold/minClusterSize override support
861
+ // Priority: screenshot-level > config > defaults
862
+ // Validate overrides before using them
863
+ const effectiveThreshold = typeof validatedProperties.threshold === 'number' && validatedProperties.threshold >= 0 ? validatedProperties.threshold : this.threshold;
864
+ const effectiveMinClusterSize = Number.isInteger(validatedProperties.minClusterSize) && validatedProperties.minClusterSize >= 1 ? validatedProperties.minClusterSize : this.minClusterSize;
865
+
821
866
  // Try to compare - honeydiff will throw if dimensions don't match
822
867
  const result = await compare(baselineImagePath, currentImagePath, {
823
- threshold: this.threshold,
868
+ threshold: effectiveThreshold,
824
869
  // CIEDE2000 Delta E (2.0 = recommended default)
825
870
  antialiasing: true,
826
871
  diffPath: diffImagePath,
827
872
  overwrite: true,
828
873
  includeClusters: true,
829
874
  // Enable spatial clustering analysis
830
- minClusterSize: this.minClusterSize // Filter single-pixel noise (default: 2)
875
+ minClusterSize: effectiveMinClusterSize // Filter single-pixel noise (default: 2)
831
876
  });
832
877
  if (!result.isDifferent) {
833
878
  // Images match
@@ -840,7 +885,8 @@ export class TddService {
840
885
  diff: null,
841
886
  properties: validatedProperties,
842
887
  signature,
843
- threshold: this.threshold,
888
+ threshold: effectiveThreshold,
889
+ minClusterSize: effectiveMinClusterSize,
844
890
  // Include honeydiff metrics even for passing comparisons
845
891
  totalPixels: result.totalPixels,
846
892
  aaPixelsIgnored: result.aaPixelsIgnored,
@@ -886,7 +932,8 @@ export class TddService {
886
932
  diff: diffImagePath,
887
933
  properties: validatedProperties,
888
934
  signature,
889
- threshold: this.threshold,
935
+ threshold: effectiveThreshold,
936
+ minClusterSize: effectiveMinClusterSize,
890
937
  diffPercentage: result.diffPercentage,
891
938
  diffCount: result.diffPixels,
892
939
  reason: isHotspotFiltered ? 'hotspot-filtered' : 'pixel-diff',
@@ -189,10 +189,11 @@ export class TestRunner extends EventEmitter {
189
189
  };
190
190
 
191
191
  // Only include metadata if we have meaningful config to send
192
- if (this.config.comparison?.threshold != null) {
192
+ if (this.config.comparison?.threshold != null || this.config.comparison?.minClusterSize != null) {
193
193
  buildPayload.metadata = {
194
194
  comparison: {
195
- threshold: this.config.comparison.threshold
195
+ threshold: this.config.comparison.threshold,
196
+ minClusterSize: this.config.comparison.minClusterSize
196
197
  }
197
198
  };
198
199
  }
@@ -23,10 +23,11 @@
23
23
  * await vizzlyScreenshot('homepage', './screenshots/homepage.png');
24
24
  *
25
25
  * @example
26
- * // With properties and threshold
26
+ * // With properties and comparison settings
27
27
  * await vizzlyScreenshot('checkout-form', screenshot, {
28
28
  * properties: { browser: 'chrome', viewport: '1920x1080' },
29
- * threshold: 5
29
+ * threshold: 5,
30
+ * minClusterSize: 10
30
31
  * });
31
32
  */
32
33
  export function vizzlyScreenshot(
@@ -35,6 +36,7 @@ export function vizzlyScreenshot(
35
36
  options?: {
36
37
  properties?: Record<string, unknown>;
37
38
  threshold?: number;
39
+ minClusterSize?: number;
38
40
  fullPage?: boolean;
39
41
  }
40
42
  ): Promise<void>;
@@ -54,6 +54,8 @@ export interface VizzlyConfig {
54
54
  upload?: UploadConfig;
55
55
  comparison?: ComparisonConfig;
56
56
  tdd?: TddConfig;
57
+ /** Custom properties for baseline matching (e.g., ['theme', 'device']) */
58
+ signatureProperties?: string[];
57
59
  plugins?: string[];
58
60
  parallelId?: string;
59
61
  baselineBuildId?: string;
@@ -72,6 +74,7 @@ export interface VizzlyConfig {
72
74
  export interface ScreenshotOptions {
73
75
  properties?: Record<string, unknown>;
74
76
  threshold?: number;
77
+ minClusterSize?: number;
75
78
  fullPage?: boolean;
76
79
  buildId?: string;
77
80
  }
@@ -97,6 +100,7 @@ export interface ComparisonResult {
97
100
  properties: Record<string, unknown>;
98
101
  signature: string;
99
102
  threshold?: number;
103
+ minClusterSize?: number;
100
104
  diffPercentage?: number;
101
105
  diffCount?: number;
102
106
  error?: string;
@@ -465,6 +469,7 @@ export function vizzlyScreenshot(
465
469
  options?: {
466
470
  properties?: Record<string, unknown>;
467
471
  threshold?: number;
472
+ minClusterSize?: number;
468
473
  fullPage?: boolean;
469
474
  }
470
475
  ): Promise<void>;
@@ -36,9 +36,11 @@ const uploadSchema = z.object({
36
36
  /**
37
37
  * Comparison configuration schema
38
38
  * threshold: CIEDE2000 Delta E units (0.0 = exact, 1.0 = JND, 2.0 = recommended, 3.0+ = permissive)
39
+ * minClusterSize: pixels (1 = exact)
39
40
  */
40
41
  const comparisonSchema = z.object({
41
- threshold: z.number().min(0).default(2.0)
42
+ threshold: z.number().min(0).default(2.0),
43
+ minClusterSize: z.int().min(1).default(2)
42
44
  });
43
45
 
44
46
  /**
@@ -70,11 +72,13 @@ export const vizzlyConfigSchema = z.object({
70
72
  timeout: 30000
71
73
  }),
72
74
  comparison: comparisonSchema.default({
73
- threshold: 2.0
75
+ threshold: 2.0,
76
+ minClusterSize: 2
74
77
  }),
75
78
  tdd: tddSchema.default({
76
79
  openReport: false
77
80
  }),
81
+ signatureProperties: z.array(z.string()).default([]),
78
82
  plugins: z.array(z.string()).default([]),
79
83
  // Additional optional fields
80
84
  parallelId: z.string().optional(),
@@ -99,7 +103,8 @@ export const vizzlyConfigSchema = z.object({
99
103
  timeout: 30000
100
104
  },
101
105
  comparison: {
102
- threshold: 2.0
106
+ threshold: 2.0,
107
+ minClusterSize: 2
103
108
  },
104
109
  tdd: {
105
110
  openReport: false
@@ -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.1",
3
+ "version": "0.20.0",
4
4
  "description": "Visual review platform for UI developers and designers",
5
5
  "keywords": [
6
6
  "visual-testing",