@vizzly-testing/cli 0.35.3-beta.4 → 0.36.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.
@@ -33,6 +33,8 @@ export function autoDiscoverTddServer(
33
33
  */
34
34
  export interface ScreenshotResult {
35
35
  success: boolean;
36
+ /** Cloud capture accepted locally; upload completion is reported by flush. */
37
+ queued?: boolean;
36
38
  status?:
37
39
  | 'passed'
38
40
  | 'failed'
@@ -67,16 +69,17 @@ export interface ScreenshotResult {
67
69
  * @example
68
70
  * // With properties and comparison settings
69
71
  * await vizzlyScreenshot('checkout-form', screenshot, {
70
- * properties: { browser: 'chrome', viewport: { width: 1920, height: 1080 } },
72
+ * properties: { browser: 'chrome', theme: 'dark', locale: 'en-US' },
71
73
  * threshold: 5,
72
74
  * minClusterSize: 10,
73
75
  * fullPage: true,
74
76
  * requestTimeout: 5000
75
77
  * });
76
78
  *
77
- * `properties` is the user metadata bag. Comparison options are normalized
78
- * into the server metadata payload, while `requestTimeout` stays on the
79
- * client request and `buildId` only routes the screenshot to a build.
79
+ * `properties` is the user metadata bag and every key is preserved. Vizzly
80
+ * reads width and height from the captured image. Comparison options are sent
81
+ * separately, while `requestTimeout` stays on the client request and `buildId`
82
+ * only routes the screenshot to a build.
80
83
  */
81
84
  export function vizzlyScreenshot(
82
85
  name: string,
@@ -94,11 +97,15 @@ export function vizzlyScreenshot(
94
97
  ): Promise<ScreenshotResult | null>;
95
98
 
96
99
  /**
97
- * Flush result summary returned by vizzlyFlush
100
+ * Results returned by vizzlyFlush. Cloud counts cover the whole run, including
101
+ * earlier flushes; success is false if any capture failed. Uploaded and reused
102
+ * counts are separate. TDD mode reports local comparison results instead.
98
103
  */
99
104
  export interface FlushResult {
100
105
  success: boolean;
101
106
  uploaded?: number;
107
+ reused?: number;
108
+ failures?: Array<{ name: string; error: string }>;
102
109
  flushed?: boolean;
103
110
  total?: number;
104
111
  passed?: number;
@@ -200,12 +200,13 @@ export function buildFileMetadata(filePath, buffer) {
200
200
  * @returns {Object} Screenshot format for SHA check
201
201
  */
202
202
  export function fileToScreenshotFormat(file) {
203
+ let browser = extractBrowserFromFilename(file.filename);
203
204
  return {
204
205
  sha256: file.sha256,
205
206
  name: file.filename.replace(/\.png$/, ''),
206
- browser: extractBrowserFromFilename(file.filename) || 'chrome',
207
- viewport_width: 1920,
208
- viewport_height: 1080
207
+ properties: browser ? {
208
+ browser
209
+ } : {}
209
210
  };
210
211
  }
211
212
 
@@ -1,82 +1,10 @@
1
- /**
2
- * Screenshot option names that are part of the SDK/config contract, not the
3
- * user's arbitrary metadata bag.
4
- */
5
- export let RESERVED_PROPERTY_OPTIONS = Object.freeze({
6
- threshold: {
7
- message: 'Move "threshold" out of properties; properties is only for user metadata.'
8
- },
9
- minClusterSize: {
10
- message: 'Move "minClusterSize" out of properties; properties is only for user metadata.'
11
- },
12
- min_cluster_size: {
13
- message: 'Move "min_cluster_size" out of properties; properties is only for user metadata.'
14
- },
15
- fullPage: {
16
- message: 'Move "fullPage" out of properties; properties is only for user metadata.'
17
- },
18
- full_page: {
19
- message: 'Move "full_page" out of properties; properties is only for user metadata.'
20
- },
21
- captureMode: {
22
- message: 'Move "captureMode" out of properties; properties is only for user metadata.'
23
- },
24
- capture_mode: {
25
- message: 'Move "capture_mode" out of properties; properties is only for user metadata.'
26
- },
27
- deviceScaleFactor: {
28
- message: 'Move "deviceScaleFactor" out of properties; properties is only for user metadata.'
29
- },
30
- device_scale_factor: {
31
- message: 'Move "device_scale_factor" out of properties; properties is only for user metadata.'
32
- },
33
- pixelRatio: {
34
- message: 'Move "pixelRatio" out of properties; properties is only for user metadata.'
35
- },
36
- dpr: {
37
- message: 'Move "dpr" out of properties; properties is only for user metadata.'
38
- },
39
- selector: {
40
- message: 'Move "selector" out of properties; properties is only for user metadata.'
41
- },
42
- component: {
43
- message: 'Move "component" out of properties; properties is only for user metadata.'
44
- },
45
- elementSelector: {
46
- message: 'Move "elementSelector" out of properties; properties is only for user metadata.'
47
- },
48
- element_selector: {
49
- message: 'Move "element_selector" out of properties; properties is only for user metadata.'
50
- },
51
- buildId: {
52
- message: 'Move "buildId" out of properties; properties is only for user metadata.'
53
- },
54
- build_id: {
55
- message: 'Move "build_id" out of properties; properties is only for user metadata.'
56
- },
57
- requestTimeout: {
58
- message: 'Move "requestTimeout" out of properties; properties is only for user metadata.'
59
- },
60
- request_timeout: {
61
- message: 'Move "request_timeout" out of properties; properties is only for user metadata.'
62
- }
63
- });
64
1
  export let SCREENSHOT_OPTION_NAMES = Object.freeze(['threshold', 'minClusterSize', 'fullPage', 'captureMode', 'deviceScaleFactor', 'selector']);
65
2
  export function getScreenshotOptionsPayload(options = {}) {
66
3
  return Object.fromEntries(SCREENSHOT_OPTION_NAMES.filter(option => options[option] !== undefined).map(option => [option, options[option]]));
67
4
  }
68
- function createReservedPropertyWarning(option) {
69
- return {
70
- code: 'reserved-property-option',
71
- option,
72
- message: RESERVED_PROPERTY_OPTIONS[option].message
73
- };
74
- }
75
5
 
76
6
  /**
77
- * Normalize screenshot SDK options into the local and cloud upload payload.
78
- * Reserved names found inside properties are discarded instead of being promoted
79
- * into options.
7
+ * Keep user properties separate from the Vizzly options sent beside them.
80
8
  */
81
9
  export function normalizeScreenshotOptions(options = {}) {
82
10
  let {
@@ -90,16 +18,7 @@ export function normalizeScreenshotOptions(options = {}) {
90
18
  deviceScaleFactor,
91
19
  selector
92
20
  } = options;
93
- let warnings = [];
94
- let normalizedProperties = {};
95
21
  let sourceProperties = properties && typeof properties === 'object' && !Array.isArray(properties) ? properties : {};
96
- for (let [key, value] of Object.entries(sourceProperties)) {
97
- if (RESERVED_PROPERTY_OPTIONS[key]) {
98
- warnings.push(createReservedPropertyWarning(key));
99
- continue;
100
- }
101
- normalizedProperties[key] = value;
102
- }
103
22
  return {
104
23
  buildId,
105
24
  requestTimeout,
@@ -109,8 +28,9 @@ export function normalizeScreenshotOptions(options = {}) {
109
28
  captureMode,
110
29
  deviceScaleFactor,
111
30
  selector,
112
- properties: normalizedProperties,
113
- warnings
31
+ properties: {
32
+ ...sourceProperties
33
+ }
114
34
  };
115
35
  }
116
36
  export function createScreenshotProperties(options = {}) {
@@ -166,55 +166,40 @@ export function safePath(workingDir, ...pathSegments) {
166
166
  }
167
167
 
168
168
  /**
169
- * Validates screenshot properties object for safe values
170
- * @param {Object} properties - Properties to validate
171
- * @returns {Object} Validated properties object
169
+ * Validate user metadata without interpreting names as Vizzly options or
170
+ * rewriting values. Rendering code must escape strings for its output context.
171
+ * Reject unsafe object keys and values that cannot be represented as JSON.
172
+ *
173
+ * @param {Object} [properties={}] - User screenshot metadata.
174
+ * @returns {Object} A copy preserving the supplied JSON values.
175
+ * @throws {Error} Metadata contains unsafe keys, cycles, or non-JSON values.
172
176
  */
173
177
  export function validateScreenshotProperties(properties = {}) {
174
- if (properties === null || typeof properties !== 'object') {
178
+ if (!properties || typeof properties !== 'object' || Array.isArray(properties)) {
175
179
  return {};
176
180
  }
177
- const validated = {};
178
-
179
- // Validate common properties with safe constraints
180
- if (properties.browser && typeof properties.browser === 'string') {
181
- try {
182
- // Extract browser name without version (e.g., "Chrome/139.0.7258.138" -> "Chrome")
183
- const browserName = properties.browser.split('/')[0];
184
- validated.browser = sanitizeScreenshotName(browserName, 50);
185
- } catch (error) {
186
- // Skip invalid browser names, don't include them
187
- output.warn(`Invalid browser name '${properties.browser}': ${error.message}`);
188
- }
189
- }
190
- if (properties.viewport && typeof properties.viewport === 'object') {
191
- const viewport = {};
192
- if (typeof properties.viewport.width === 'number' && properties.viewport.width > 0 && properties.viewport.width <= 10000) {
193
- viewport.width = Math.floor(properties.viewport.width);
194
- }
195
- if (typeof properties.viewport.height === 'number' && properties.viewport.height > 0 && properties.viewport.height <= 10000) {
196
- viewport.height = Math.floor(properties.viewport.height);
181
+ let ancestors = new Set();
182
+ function copy(value) {
183
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') {
184
+ return value;
197
185
  }
198
- if (Object.keys(viewport).length > 0) {
199
- validated.viewport = viewport;
186
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
187
+ if (typeof value !== 'object' || ancestors.has(value)) {
188
+ throw new Error('Screenshot properties must contain JSON values');
200
189
  }
201
- }
202
-
203
- // Allow other safe string properties but sanitize them
204
- for (const [key, value] of Object.entries(properties)) {
205
- if (key === 'browser' || key === 'viewport') continue; // Already handled
206
-
207
- if (typeof key === 'string' && key.length <= 50 && /^[a-zA-Z0-9_-]+$/.test(key)) {
208
- if (typeof value === 'string' && value.length <= 200) {
209
- // Preserve safe URL/query characters like '&' in metadata values.
210
- // Rendering layers should escape for HTML instead of mutating payload data here.
211
- validated[key] = value.replace(/[<>"']/g, '');
212
- } else if (typeof value === 'number' && !Number.isNaN(value) && Number.isFinite(value)) {
213
- validated[key] = value;
214
- } else if (typeof value === 'boolean') {
215
- validated[key] = value;
190
+ ancestors.add(value);
191
+ let result;
192
+ if (Array.isArray(value)) {
193
+ result = value.map(copy);
194
+ } else {
195
+ let entries = Object.entries(value);
196
+ if (entries.some(([key]) => ['__proto__', 'constructor', 'prototype'].includes(key))) {
197
+ throw new Error('Screenshot properties contain an unsafe key');
216
198
  }
199
+ result = Object.fromEntries(entries.map(([key, item]) => [key, copy(item)]));
217
200
  }
201
+ ancestors.delete(value);
202
+ return result;
218
203
  }
219
- return validated;
204
+ return copy(properties);
220
205
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizzly-testing/cli",
3
- "version": "0.35.3-beta.4",
3
+ "version": "0.36.0",
4
4
  "description": "Visual regression testing from your terminal",
5
5
  "keywords": [
6
6
  "llm-context",