@vizzly-testing/cli 0.35.3-beta.3 → 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.
@@ -102,7 +102,10 @@ export async function fetchBuildUrl({
102
102
  }
103
103
 
104
104
  /**
105
- * Finalize a build
105
+ * Flush captures before saving the build's final status.
106
+ * Cloud builds fail when tests or uploads fail. Finalization errors are reported
107
+ * without replacing the test result; collected upload counts are still returned.
108
+ *
106
109
  * @param {Object} options - Options
107
110
  * @param {string} options.buildId - Build ID
108
111
  * @param {boolean} options.tdd - Whether in TDD mode
@@ -115,6 +118,8 @@ export async function fetchBuildUrl({
115
118
  * @param {Function} options.deps.finalizeApiBuild - API finalize function
116
119
  * @param {Object} options.deps.output - Output utilities
117
120
  * @param {Function} [options.deps.onFinalizeFailed] - Callback for finalize failure
121
+ * @returns {Promise<Object|null|undefined>} Cumulative cloud upload results when
122
+ * available; no upload results for local TDD or a run without a build.
118
123
  */
119
124
  export async function finalizeBuild({
120
125
  buildId,
@@ -134,6 +139,7 @@ export async function finalizeBuild({
134
139
  if (!buildId) {
135
140
  return;
136
141
  }
142
+ let uploadStats = null;
137
143
  try {
138
144
  if (tdd) {
139
145
  // TDD mode: use server handler to finalize (local-only)
@@ -146,16 +152,18 @@ export async function finalizeBuild({
146
152
  } else {
147
153
  // API mode: flush uploads first, then finalize build
148
154
  if (serverManager.server?.finishBuild) {
149
- await serverManager.server.finishBuild(buildId);
155
+ uploadStats = await serverManager.server.finishBuild(buildId);
150
156
  }
151
157
 
152
158
  // Then update build status via API
153
159
  let clientOptions = buildClientOptions(config);
154
160
  if (clientOptions) {
155
161
  let client = createApiClient(clientOptions);
156
- await finalizeApiBuild(client, buildId, success, executionTime);
162
+ let uploadsFailed = uploadStats?.failed > 0;
163
+ let buildSucceeded = success && !uploadsFailed;
164
+ await finalizeApiBuild(client, buildId, buildSucceeded, executionTime, uploadsFailed ? `${uploadStats.failed} screenshot(s) failed to upload. Visual testing is incomplete.` : undefined);
157
165
  output.debug('build', 'finalized via api', {
158
- success
166
+ success: buildSucceeded
159
167
  });
160
168
  } else {
161
169
  output.warn(`No API service available to finalize build ${buildId}`);
@@ -172,6 +180,7 @@ export async function finalizeBuild({
172
180
  });
173
181
  }
174
182
  }
183
+ return uploadStats;
175
184
  }
176
185
 
177
186
  // ============================================================================
@@ -261,7 +270,10 @@ async function executeDisabledTestRun({
261
270
  * @param {Object} options.runOptions - Run options (testCommand, tdd, etc.)
262
271
  * @param {Object} options.config - Configuration object
263
272
  * @param {Object} options.deps - Dependencies
264
- * @returns {Promise<Object>} Run result
273
+ * @returns {Promise<Object>} Test result with cumulative cloud upload results
274
+ * when available. Upload failures alone do not reject the run.
275
+ * @throws {Error} Test execution failure, with upload results attached when
276
+ * available so callers can report both without losing the test exit code.
265
277
  */
266
278
  export async function runTests({
267
279
  runOptions,
@@ -315,6 +327,7 @@ export async function runTests({
315
327
  let screenshotCount = 0;
316
328
  let testSuccess = false;
317
329
  let testError = null;
330
+ let uploadStats = null;
318
331
  let setBaseline;
319
332
  try {
320
333
  buildId = await createBuild({
@@ -425,7 +438,7 @@ export async function runTests({
425
438
  let executionTime = Date.now() - startTime;
426
439
  if (buildId) {
427
440
  try {
428
- await finalizeBuild({
441
+ uploadStats = await finalizeBuild({
429
442
  buildId,
430
443
  tdd,
431
444
  success: testSuccess,
@@ -467,6 +480,7 @@ export async function runTests({
467
480
  if (!runOptions.json) {
468
481
  output.error('Test run failed:', testError);
469
482
  }
483
+ if (uploadStats) testError.uploads = uploadStats;
470
484
  throw testError;
471
485
  }
472
486
  return buildRunResult({
@@ -474,7 +488,8 @@ export async function runTests({
474
488
  buildUrl,
475
489
  testSuccess,
476
490
  screenshotCount,
477
- tddResults
491
+ tddResults,
492
+ uploadStats
478
493
  });
479
494
  }
480
495
 
@@ -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;
@@ -401,7 +401,11 @@ export interface TddService {
401
401
  compareScreenshot(
402
402
  name: string,
403
403
  imageBuffer: Buffer,
404
- properties?: Record<string, unknown>
404
+ properties?: Record<string, unknown>,
405
+ screenshotOptions?: Pick<
406
+ ScreenshotOptions,
407
+ 'threshold' | 'minClusterSize' | 'fullPage'
408
+ >
405
409
  ): Promise<ComparisonResult>;
406
410
 
407
411
  getResults(): TddResults;
@@ -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,35 +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
- fullPage: {
13
- message: 'Move "fullPage" out of properties; properties is only for user metadata.'
14
- },
15
- buildId: {
16
- message: 'Move "buildId" out of properties; properties is only for user metadata.'
17
- },
18
- requestTimeout: {
19
- message: 'Move "requestTimeout" out of properties; properties is only for user metadata.'
20
- }
21
- });
22
- function createReservedPropertyWarning(option) {
23
- return {
24
- code: 'reserved-property-option',
25
- option,
26
- message: RESERVED_PROPERTY_OPTIONS[option].message
27
- };
1
+ export let SCREENSHOT_OPTION_NAMES = Object.freeze(['threshold', 'minClusterSize', 'fullPage', 'captureMode', 'deviceScaleFactor', 'selector']);
2
+ export function getScreenshotOptionsPayload(options = {}) {
3
+ return Object.fromEntries(SCREENSHOT_OPTION_NAMES.filter(option => options[option] !== undefined).map(option => [option, options[option]]));
28
4
  }
29
5
 
30
6
  /**
31
- * Normalize screenshot SDK options into the properties payload consumed by the
32
- * local TDD server and cloud-compatible comparison path.
7
+ * Keep user properties separate from the Vizzly options sent beside them.
33
8
  */
34
9
  export function normalizeScreenshotOptions(options = {}) {
35
10
  let {
@@ -38,40 +13,24 @@ export function normalizeScreenshotOptions(options = {}) {
38
13
  requestTimeout,
39
14
  threshold,
40
15
  minClusterSize,
41
- fullPage
16
+ fullPage,
17
+ captureMode,
18
+ deviceScaleFactor,
19
+ selector
42
20
  } = options;
43
- let warnings = [];
44
- let normalizedProperties = {};
45
- for (let [key, value] of Object.entries(properties || {})) {
46
- if (RESERVED_PROPERTY_OPTIONS[key]) {
47
- warnings.push(createReservedPropertyWarning(key));
48
- if (key === 'threshold' && threshold === undefined) threshold = value;
49
- if (key === 'minClusterSize' && minClusterSize === undefined) {
50
- minClusterSize = value;
51
- }
52
- if (key === 'fullPage' && fullPage === undefined) fullPage = value;
53
- if (key === 'buildId' && buildId === undefined) buildId = value;
54
- if (key === 'requestTimeout' && requestTimeout === undefined) {
55
- requestTimeout = value;
56
- }
57
- continue;
58
- }
59
- normalizedProperties[key] = value;
60
- }
61
- if (threshold !== undefined) {
62
- normalizedProperties.threshold = threshold;
63
- }
64
- if (minClusterSize !== undefined) {
65
- normalizedProperties.minClusterSize = minClusterSize;
66
- }
67
- if (fullPage !== undefined) {
68
- normalizedProperties.fullPage = fullPage;
69
- }
21
+ let sourceProperties = properties && typeof properties === 'object' && !Array.isArray(properties) ? properties : {};
70
22
  return {
71
23
  buildId,
72
24
  requestTimeout,
73
- properties: normalizedProperties,
74
- warnings
25
+ threshold,
26
+ minClusterSize,
27
+ fullPage,
28
+ captureMode,
29
+ deviceScaleFactor,
30
+ selector,
31
+ properties: {
32
+ ...sourceProperties
33
+ }
75
34
  };
76
35
  }
77
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.3",
3
+ "version": "0.36.0",
4
4
  "description": "Visual regression testing from your terminal",
5
5
  "keywords": [
6
6
  "llm-context",