@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.
- package/dist/client/index.js +0 -1
- package/dist/services/tdd-service.js +55 -8
- package/dist/services/test-runner.js +3 -2
- package/dist/types/client.d.ts +4 -2
- package/dist/types/index.d.ts +5 -0
- package/dist/utils/config-schema.js +8 -3
- package/dist/utils/security.js +59 -2
- package/package.json +1 -1
package/dist/client/index.js
CHANGED
|
@@ -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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
}
|
package/dist/types/client.d.ts
CHANGED
|
@@ -23,10 +23,11 @@
|
|
|
23
23
|
* await vizzlyScreenshot('homepage', './screenshots/homepage.png');
|
|
24
24
|
*
|
|
25
25
|
* @example
|
|
26
|
-
* // With properties and
|
|
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>;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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
|
package/dist/utils/security.js
CHANGED
|
@@ -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)
|