@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.
@@ -4,6 +4,8 @@
4
4
  * No I/O, no side effects - just data transformations.
5
5
  */
6
6
 
7
+ import { getScreenshotOptionsPayload } from '../utils/screenshot-options.js';
8
+
7
9
  // ============================================================================
8
10
  // Request Validation
9
11
  // ============================================================================
@@ -149,9 +151,11 @@ export function buildServerStoppedMessage() {
149
151
  * @returns {{ name: string, image: string, properties?: Object }}
150
152
  */
151
153
  export function extractScreenshotData(body) {
152
- return {
154
+ let screenshotData = {
153
155
  name: body.name,
154
156
  image: body.image,
155
157
  properties: body.properties
156
158
  };
159
+ Object.assign(screenshotData, getScreenshotOptionsPayload(body));
160
+ return screenshotData;
157
161
  }
package/dist/sdk/index.js CHANGED
@@ -19,7 +19,7 @@ import { createUploader } from '../uploader/index.js';
19
19
  import { loadConfig } from '../utils/config-loader.js';
20
20
  import { resolveImageBuffer } from '../utils/file-helpers.js';
21
21
  import * as output from '../utils/output.js';
22
- import { normalizeScreenshotOptions } from '../utils/screenshot-options.js';
22
+ import { getScreenshotOptionsPayload, normalizeScreenshotOptions } from '../utils/screenshot-options.js';
23
23
 
24
24
  /**
25
25
  * Create a new Vizzly instance with custom configuration
@@ -294,12 +294,6 @@ export class VizzlySDK extends EventEmitter {
294
294
  // Resolve Buffer or file path using shared utility
295
295
  let buffer = resolveImageBuffer(imageBuffer, 'screenshot');
296
296
  let normalizedOptions = normalizeScreenshotOptions(options);
297
- for (let warning of normalizedOptions.warnings) {
298
- output.warn(warning.message, {
299
- code: warning.code,
300
- option: warning.option
301
- });
302
- }
303
297
 
304
298
  // Generate or use provided build ID
305
299
  let buildId = normalizedOptions.buildId || this.currentBuildId || 'default';
@@ -314,10 +308,7 @@ export class VizzlySDK extends EventEmitter {
314
308
  type: 'base64',
315
309
  properties: normalizedOptions.properties
316
310
  };
317
- if (normalizedOptions.warnings.length > 0) {
318
- screenshotData.warnings = normalizedOptions.warnings;
319
- }
320
-
311
+ Object.assign(screenshotData, getScreenshotOptionsPayload(normalizedOptions));
321
312
  // POST to the local screenshot server
322
313
  let serverUrl = `http://localhost:${this.config.server?.port || 3000}`;
323
314
  let fetchFn = this.services.fetch || fetch;
@@ -1,234 +1,160 @@
1
1
  import { Buffer } from 'node:buffer';
2
- import { existsSync, readFileSync } from 'node:fs';
2
+ import { readFileSync } from 'node:fs';
3
3
  import { resolve } from 'node:path';
4
4
  import { uploadScreenshot as defaultUploadScreenshot } from '../../api/index.js';
5
5
  import { detectImageInputType } from '../../utils/image-input-detector.js';
6
6
  import * as output from '../../utils/output.js';
7
+ import { normalizeScreenshotOptions } from '../../utils/screenshot-options.js';
7
8
 
8
9
  /**
9
- * API Handler - Non-blocking screenshot upload
10
+ * Create a cloud screenshot handler for one run.
10
11
  *
11
- * Flow:
12
- * ┌─────────────────────────────────────────────────────────────┐
13
- * │ Test Suite │
14
- * │ ↓ vizzlyScreenshot() │
15
- * │ ↓ HTTP POST to localhost │
16
- * │ ↓ │
17
- * │ Screenshot Server │
18
- * │ ↓ handleScreenshot() │
19
- * │ ├─→ Convert base64 to Buffer │
20
- * │ ├─→ Fire upload promise (NO AWAIT) ─────┐ │
21
- * │ └─→ Return 200 immediately │ │
22
- * │ │ │
23
- * │ Test continues (NO BLOCKING) ✓ │ │
24
- * │ ↓ │
25
- * │ Background Upload │
26
- * │ (to Vizzly API) │
27
- * │ ↓ │
28
- * │ Promise resolves/rejects │
29
- * │ │
30
- * │ Build Finalization │
31
- * │ ↓ flush() │
32
- * │ └─→ await Promise.allSettled(uploadPromises) │
33
- * │ ↓ │
34
- * │ All uploads complete ✓ │
35
- * └─────────────────────────────────────────────────────────────┘
36
- */
37
-
38
- /**
39
- * Create an API handler for screenshot uploads.
40
- * @param {Object} client - API client with request method
41
- * @param {Object} options - Optional dependencies for testing
42
- * @param {Function} options.uploadScreenshot - Upload function (defaults to API uploadScreenshot)
12
+ * Captures queue uploads without waiting for the API. Each capture keeps its
13
+ * outcome so a rejection cannot disable later uploads or disappear after flush.
14
+ * Flush pending uploads before cleanup, which clears the run's records.
15
+ *
16
+ * @param {Object} client - API client used to upload screenshots.
17
+ * @param {Object} [options] - Upload dependencies.
18
+ * @param {typeof defaultUploadScreenshot} [options.uploadScreenshot] - Uploader.
19
+ * @returns {Object} Capture, count, flush, and cleanup methods for this run.
43
20
  */
44
- export const createApiHandler = (client, {
21
+ export let createApiHandler = (client, {
45
22
  uploadScreenshot = defaultUploadScreenshot
46
23
  } = {}) => {
47
- let vizzlyDisabled = false;
48
- let screenshotCount = 0;
24
+ let captures = [];
49
25
  let uploadPromises = [];
50
- const handleScreenshot = async (buildId, name, image, properties = {}, type, warnings = []) => {
51
- let handlerStart = Date.now();
52
- output.debug('upload', `${name} received`, {
53
- buildId: buildId?.slice(0, 8)
54
- });
55
- if (vizzlyDisabled) {
56
- output.debug('upload', `${name} (disabled)`);
57
- return {
58
- statusCode: 200,
59
- body: {
60
- success: true,
61
- disabled: true,
62
- count: ++screenshotCount,
63
- message: `Vizzly disabled - ${screenshotCount} screenshots captured but not uploaded`
64
- }
65
- };
66
- }
67
-
68
- // buildId is optional - API will handle it appropriately
26
+ function recordFailure(capture, error) {
27
+ capture.status = 'failed';
28
+ capture.error = error.message;
29
+ output.warn(`Screenshot "${capture.name}" failed to upload: ${error.message}`);
30
+ }
69
31
 
70
- if (!client) {
71
- return {
72
- statusCode: 500,
73
- body: {
74
- error: 'API client not available'
75
- }
76
- };
32
+ /**
33
+ * Upload one capture and record its outcome, including SHA reuse.
34
+ * API failures are recorded and logged rather than rejecting the background
35
+ * promise, so flush can collect every outcome and later uploads can continue.
36
+ */
37
+ async function upload(capture, buildId, imageBuffer, properties, screenshotOptions) {
38
+ try {
39
+ let result = await uploadScreenshot(client, buildId, capture.name, imageBuffer, properties ?? {}, false, screenshotOptions);
40
+ capture.status = result.skipped ? 'reused' : 'uploaded';
41
+ } catch (error) {
42
+ recordFailure(capture, error);
77
43
  }
44
+ }
78
45
 
79
- // Support both base64 encoded images and file paths
80
- // Use explicit type from client if provided (fast path), otherwise detect (slow path)
81
- // Only accept valid type values to prevent invalid types from bypassing detection
46
+ /**
47
+ * Read a capture and queue its cloud upload. Local input failures are included
48
+ * in the run's failed count; HTTP 200 means queued, not uploaded successfully.
49
+ *
50
+ * @param {string} buildId - Cloud build receiving the screenshot.
51
+ * @param {string} name - Stable screenshot name used for baseline matching.
52
+ * @param {string} image - Base64 image data or a file path.
53
+ * @param {Object} [properties={}] - Screenshot metadata.
54
+ * @param {string} [type] - Base64 or file-path hint; otherwise detected.
55
+ * @param {Object[]} [warnings=[]] - Warnings already collected by the router.
56
+ * @param {Object} [screenshotOptions={}] - Capture and comparison options.
57
+ * @returns {Promise<{statusCode: number, body: Object}>} Local HTTP response.
58
+ */
59
+ async function handleScreenshot(buildId, name, image, properties = {}, type, warnings = [], screenshotOptions = {}) {
60
+ let normalizedOptions = normalizeScreenshotOptions({
61
+ ...screenshotOptions,
62
+ properties
63
+ });
64
+ properties = normalizedOptions.properties;
65
+ screenshotOptions = {
66
+ ...screenshotOptions,
67
+ threshold: normalizedOptions.threshold,
68
+ minClusterSize: normalizedOptions.minClusterSize,
69
+ fullPage: normalizedOptions.fullPage,
70
+ captureMode: normalizedOptions.captureMode,
71
+ deviceScaleFactor: normalizedOptions.deviceScaleFactor,
72
+ selector: normalizedOptions.selector
73
+ };
74
+ warnings = warnings || [];
75
+ let capture = {
76
+ name,
77
+ status: 'pending'
78
+ };
79
+ captures.push(capture);
80
+ let inputType = ['base64', 'file-path'].includes(type) ? type : detectImageInputType(image);
82
81
  let imageBuffer;
83
- let validTypes = ['base64', 'file-path'];
84
- const inputType = type && validTypes.includes(type) ? type : detectImageInputType(image);
85
- if (inputType === 'file-path') {
86
- // It's a file path - resolve and read the file
87
- const filePath = resolve(image.replace('file://', ''));
88
- if (!existsSync(filePath)) {
89
- return {
90
- statusCode: 400,
91
- body: {
92
- error: `Screenshot file not found: ${filePath}`,
93
- originalPath: image
94
- }
95
- };
96
- }
97
- try {
98
- imageBuffer = readFileSync(filePath);
99
- } catch (error) {
100
- return {
101
- statusCode: 500,
102
- body: {
103
- error: `Failed to read screenshot file: ${error.message}`,
104
- filePath
105
- }
106
- };
107
- }
108
- } else if (inputType === 'base64') {
109
- // It's base64 encoded
110
- try {
82
+ try {
83
+ if (!client) throw new Error('API client not available');
84
+ if (inputType === 'file-path') {
85
+ let filePath = resolve(image.replace('file://', ''));
86
+ try {
87
+ imageBuffer = readFileSync(filePath);
88
+ } catch (error) {
89
+ throw new Error(error.code === 'ENOENT' ? `Screenshot file not found: ${filePath}` : `Failed to read screenshot file: ${error.message}`);
90
+ }
91
+ } else if (inputType === 'base64') {
111
92
  imageBuffer = Buffer.from(image, 'base64');
112
- } catch (error) {
113
- return {
114
- statusCode: 400,
115
- body: {
116
- error: `Invalid base64 image data: ${error.message}`
117
- }
118
- };
93
+ } else {
94
+ throw new Error('Invalid image input: must be a file path or base64 encoded image data');
119
95
  }
120
- } else {
121
- // Unknown input type
96
+ } catch (error) {
97
+ recordFailure(capture, error);
122
98
  return {
123
- statusCode: 400,
99
+ statusCode: client ? 400 : 500,
124
100
  body: {
125
- error: 'Invalid image input: must be a file path or base64 encoded image data',
126
- receivedType: typeof image
101
+ success: false,
102
+ name,
103
+ error: error.message
127
104
  }
128
105
  };
129
106
  }
130
- screenshotCount++;
131
- let uploadStart = Date.now();
132
-
133
- // Fire upload in background - DON'T AWAIT!
134
- let uploadPromise = uploadScreenshot(client, buildId, name, imageBuffer, properties ?? {}).then(result => {
135
- let duration = Date.now() - uploadStart;
136
- if (!result.skipped) {
137
- output.debug('upload', `${name} completed`, {
138
- ms: duration
139
- });
140
- } else {
141
- output.debug('upload', `${name} skipped (dedup)`, {
142
- ms: duration
143
- });
144
- }
145
- return {
146
- success: true,
147
- name,
148
- result,
149
- duration
150
- };
151
- }).catch(uploadError => {
152
- let duration = Date.now() - uploadStart;
153
- output.debug('upload', `${name} failed`, {
154
- error: uploadError.message,
155
- ms: duration
156
- });
157
- vizzlyDisabled = true;
158
- output.warn('Vizzly disabled due to upload error - continuing tests without visual testing');
159
- return {
160
- success: false,
161
- name,
162
- error: uploadError,
163
- duration
164
- };
165
- });
166
-
167
- // Collect promise for later flushing
168
- uploadPromises.push(uploadPromise);
169
-
170
- // Return immediately - test continues without waiting!
171
- let handlerMs = Date.now() - handlerStart;
172
- output.debug('upload', `${name} handler returning`, {
173
- ms: handlerMs
174
- });
107
+ uploadPromises.push(upload(capture, buildId, imageBuffer, properties, screenshotOptions));
175
108
  return {
176
109
  statusCode: 200,
177
110
  body: {
178
111
  success: true,
112
+ queued: true,
179
113
  name,
180
- count: screenshotCount,
114
+ count: captures.length,
181
115
  warnings
182
116
  }
183
117
  };
184
- };
185
- const getScreenshotCount = () => screenshotCount;
118
+ }
186
119
 
187
120
  /**
188
- * Wait for all background uploads to complete
189
- * Call this before build finalization to ensure all uploads finish
121
+ * Wait for pending uploads, including captures queued while waiting.
122
+ * Results cover the whole run and remain available across repeated flushes.
123
+ * Uploaded and reused counts are separate; failed includes local input errors.
124
+ *
125
+ * @returns {Promise<Object>} Uploaded, reused, failed, and total counts, plus
126
+ * failed captures as `failures: [{ name, error }]`.
190
127
  */
191
- const flush = async () => {
192
- if (uploadPromises.length === 0) {
193
- return {
194
- uploaded: 0,
195
- failed: 0,
196
- total: 0
197
- };
198
- }
199
- output.debug('upload', `flushing ${uploadPromises.length} uploads`);
200
- const results = await Promise.allSettled(uploadPromises);
201
- let uploaded = 0;
202
- let failed = 0;
203
- results.forEach(result => {
204
- if (result.status === 'fulfilled' && result.value.success) {
205
- uploaded++;
206
- } else {
207
- failed++;
208
- }
209
- });
210
- output.debug('upload', 'flush complete', {
211
- uploaded,
212
- failed
213
- });
214
-
215
- // Clear promises array
216
- uploadPromises = [];
128
+ async function flush() {
129
+ let count;
130
+ do {
131
+ count = uploadPromises.length;
132
+ await Promise.all(uploadPromises);
133
+ } while (count !== uploadPromises.length);
134
+ let uploaded = captures.filter(capture => capture.status === 'uploaded').length;
135
+ let reused = captures.filter(capture => capture.status === 'reused').length;
136
+ let failures = captures.filter(capture => capture.status === 'failed').map(({
137
+ name,
138
+ error
139
+ }) => ({
140
+ name,
141
+ error
142
+ }));
217
143
  return {
218
144
  uploaded,
219
- failed,
220
- total: results.length
145
+ reused,
146
+ failed: failures.length,
147
+ total: captures.length,
148
+ failures
221
149
  };
222
- };
223
- const cleanup = () => {
224
- vizzlyDisabled = false;
225
- screenshotCount = 0;
150
+ }
151
+ function cleanup() {
152
+ captures = [];
226
153
  uploadPromises = [];
227
- // Silent cleanup
228
- };
154
+ }
229
155
  return {
230
156
  handleScreenshot,
231
- getScreenshotCount,
157
+ getScreenshotCount: () => captures.length,
232
158
  flush,
233
159
  cleanup
234
160
  };
@@ -8,37 +8,16 @@ import * as defaultOutput from '../../utils/output.js';
8
8
  import { safePath as defaultSafePath, sanitizeScreenshotName as defaultSanitizeScreenshotName, validateScreenshotProperties as defaultValidateScreenshotProperties } from '../../utils/security.js';
9
9
 
10
10
  /**
11
- * Unwrap double-nested properties if needed
12
- * Client SDK wraps options in properties field, so we may get { properties: { properties: {...} } }
11
+ * Build the flat internal comparison shape. Width and height always come from
12
+ * the captured image. The untouched user bag remains available in metadata.
13
13
  */
14
- export const unwrapProperties = properties => {
15
- if (!properties) return {};
16
- if (properties.properties && typeof properties.properties === 'object') {
17
- // Merge top-level properties with nested properties
18
- let unwrapped = {
19
- ...properties,
20
- ...properties.properties
21
- };
22
- // Remove the nested properties field to avoid confusion
23
- delete unwrapped.properties;
24
- return unwrapped;
25
- }
26
- return properties;
27
- };
28
-
29
- /**
30
- * Extract properties to top-level format matching cloud API
31
- * Normalizes viewport to viewport_width/height, ensures browser is set
32
- */
33
- export const extractProperties = validatedProperties => {
14
+ export const extractProperties = (validatedProperties, imageDimensions = {}) => {
34
15
  if (!validatedProperties) return {};
35
16
  return {
36
17
  ...validatedProperties,
37
- // Normalize viewport to top-level viewport_width/height (cloud format)
38
- viewport_width: validatedProperties.viewport?.width ?? validatedProperties.viewport_width ?? null,
39
- viewport_height: validatedProperties.viewport?.height ?? validatedProperties.viewport_height ?? null,
18
+ viewport_width: imageDimensions.width ?? null,
19
+ viewport_height: imageDimensions.height ?? null,
40
20
  browser: validatedProperties.browser ?? null,
41
- // Preserve nested structure in metadata for backward compatibility
42
21
  metadata: validatedProperties
43
22
  };
44
23
  };
@@ -305,7 +284,7 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
305
284
  output.debug('tdd', `baseline: ${baseline.buildName}`);
306
285
  }
307
286
  };
308
- const handleScreenshot = async (_buildId, name, image, properties = {}, type, warnings = []) => {
287
+ const handleScreenshot = async (_buildId, name, image, properties = {}, type, warnings = [], screenshotOptions = {}) => {
309
288
  let handlerStart = Date.now();
310
289
  output.debug('tdd', `${name} received`);
311
290
 
@@ -324,13 +303,10 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
324
303
  };
325
304
  }
326
305
 
327
- // Unwrap double-nested properties if needed (client SDK wraps options in properties field)
328
- let unwrappedProperties = unwrapProperties(properties);
329
-
330
- // Validate and sanitize properties
306
+ // Validate user metadata without unwrapping nested keys.
331
307
  let validatedProperties;
332
308
  try {
333
- validatedProperties = validateScreenshotProperties(unwrappedProperties);
309
+ validatedProperties = validateScreenshotProperties(properties);
334
310
  } catch (error) {
335
311
  return {
336
312
  statusCode: 400,
@@ -342,9 +318,6 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
342
318
  };
343
319
  }
344
320
 
345
- // Extract ALL properties to top-level (matching cloud API behavior)
346
- const extractedProperties = extractProperties(validatedProperties);
347
-
348
321
  // Support both base64 encoded images and file paths
349
322
  // Vitest browser mode returns file paths, so we need to handle both
350
323
  // Use explicit type from client if provided (fast path), otherwise detect (slow path)
@@ -401,25 +374,17 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
401
374
  }
402
375
  };
403
376
  }
404
-
405
- // Auto-detect image dimensions if viewport not provided
406
- if (!extractedProperties.viewport_width || !extractedProperties.viewport_height) {
407
- try {
408
- const dimensions = getDimensionsSync(imageBuffer);
409
- if (!extractedProperties.viewport_width) {
410
- extractedProperties.viewport_width = dimensions.width;
411
- }
412
- if (!extractedProperties.viewport_height) {
413
- extractedProperties.viewport_height = dimensions.height;
414
- }
415
- } catch {
416
- // Dimensions will use defaults
417
- }
377
+ let imageDimensions = {};
378
+ try {
379
+ imageDimensions = getDimensionsSync(imageBuffer);
380
+ } catch {
381
+ // Invalid images are handled by the comparison service.
418
382
  }
383
+ let extractedProperties = extractProperties(validatedProperties, imageDimensions);
419
384
 
420
385
  // Use the sanitized name as-is (no modification with browser/viewport)
421
386
  // Baseline matching uses signature logic (name + viewport_width + browser)
422
- const comparison = await tddService.compareScreenshot(sanitizedName, imageBuffer, extractedProperties);
387
+ const comparison = await tddService.compareScreenshot(sanitizedName, imageBuffer, extractedProperties, screenshotOptions);
423
388
 
424
389
  // Comparison tracked by tdd.js event handler
425
390
 
@@ -4,11 +4,15 @@
4
4
  */
5
5
 
6
6
  import * as output from '../../utils/output.js';
7
+ import { normalizeScreenshotOptions } from '../../utils/screenshot-options.js';
7
8
  import { parseJsonBody } from '../middleware/json-parser.js';
8
9
  import { sendError, sendJson } from '../middleware/response.js';
9
10
 
10
11
  /**
11
- * Create screenshot router
12
+ * Route local capture and flush requests to the active cloud or TDD handler.
13
+ * A cloud flush returns HTTP 200 with cumulative results; its success field is
14
+ * false when any capture failed, even if that failure appeared in an earlier flush.
15
+ *
12
16
  * @param {Object} context - Router context
13
17
  * @param {Object} context.screenshotHandler - Screenshot handler
14
18
  * @param {string|null} context.defaultBuildId - Default build ID
@@ -33,7 +37,13 @@ export function createScreenshotRouter({
33
37
  properties,
34
38
  image,
35
39
  type,
36
- warnings
40
+ warnings,
41
+ threshold,
42
+ minClusterSize,
43
+ fullPage,
44
+ captureMode,
45
+ deviceScaleFactor,
46
+ selector
37
47
  } = body;
38
48
  if (!name || !image) {
39
49
  sendError(res, 400, 'name and image are required');
@@ -42,7 +52,23 @@ export function createScreenshotRouter({
42
52
 
43
53
  // Use buildId from request body, or fall back to server's buildId
44
54
  const effectiveBuildId = buildId || defaultBuildId;
45
- const result = await screenshotHandler.handleScreenshot(effectiveBuildId, name, image, properties, type, warnings);
55
+ const normalizedOptions = normalizeScreenshotOptions({
56
+ properties,
57
+ threshold,
58
+ minClusterSize,
59
+ fullPage,
60
+ captureMode,
61
+ deviceScaleFactor,
62
+ selector
63
+ });
64
+ const result = await screenshotHandler.handleScreenshot(effectiveBuildId, name, image, normalizedOptions.properties, type, warnings || [], {
65
+ threshold: normalizedOptions.threshold,
66
+ minClusterSize: normalizedOptions.minClusterSize,
67
+ fullPage: normalizedOptions.fullPage,
68
+ captureMode: normalizedOptions.captureMode,
69
+ deviceScaleFactor: normalizedOptions.deviceScaleFactor,
70
+ selector: normalizedOptions.selector
71
+ });
46
72
  sendJson(res, result.statusCode, result.body);
47
73
  return true;
48
74
  } catch (error) {
@@ -61,7 +87,7 @@ export function createScreenshotRouter({
61
87
  if (screenshotHandler.flush) {
62
88
  let stats = await screenshotHandler.flush();
63
89
  sendJson(res, 200, {
64
- success: true,
90
+ success: stats.failed === 0,
65
91
  ...stats
66
92
  });
67
93
  } else if (screenshotHandler.getResults) {
@@ -752,7 +752,7 @@ export class TddService {
752
752
  /**
753
753
  * Compare a screenshot against baseline
754
754
  */
755
- async compareScreenshot(name, imageBuffer, properties = {}) {
755
+ async compareScreenshot(name, imageBuffer, properties = {}, screenshotOptions = {}) {
756
756
  // Destructure dependencies
757
757
  let {
758
758
  output,
@@ -850,8 +850,8 @@ export class TddService {
850
850
  }
851
851
 
852
852
  // Baseline exists - compare
853
- let effectiveThreshold = typeof validatedProperties.threshold === 'number' && validatedProperties.threshold >= 0 ? validatedProperties.threshold : this.threshold;
854
- let effectiveMinClusterSize = Number.isInteger(validatedProperties.minClusterSize) && validatedProperties.minClusterSize >= 1 ? validatedProperties.minClusterSize : this.minClusterSize;
853
+ let effectiveThreshold = typeof screenshotOptions.threshold === 'number' && screenshotOptions.threshold >= 0 ? screenshotOptions.threshold : this.threshold;
854
+ let effectiveMinClusterSize = Number.isInteger(screenshotOptions.minClusterSize) && screenshotOptions.minClusterSize >= 1 ? screenshotOptions.minClusterSize : this.minClusterSize;
855
855
  try {
856
856
  let honeydiffResult = await compareImages(baselineImagePath, currentImagePath, diffImagePath, {
857
857
  threshold: effectiveThreshold,
@@ -133,6 +133,8 @@ export function buildDisabledRunResult() {
133
133
  * @param {boolean} options.testSuccess - Whether tests passed
134
134
  * @param {number} options.screenshotCount - Number of screenshots
135
135
  * @param {Object|null} options.tddResults - TDD results (comparisons, etc.)
136
+ * @param {Object|null} [options.uploadStats] - Cumulative cloud upload outcomes,
137
+ * exposed as `uploads` independently of test and local comparison results.
136
138
  * @returns {Object} Final result object
137
139
  */
138
140
  export function buildRunResult({
@@ -140,7 +142,8 @@ export function buildRunResult({
140
142
  buildUrl,
141
143
  testSuccess,
142
144
  screenshotCount,
143
- tddResults
145
+ tddResults,
146
+ uploadStats
144
147
  }) {
145
148
  return {
146
149
  buildId,
@@ -148,6 +151,9 @@ export function buildRunResult({
148
151
  testsPassed: testSuccess ? 1 : 0,
149
152
  testsFailed: testSuccess ? 0 : 1,
150
153
  screenshotsCaptured: screenshotCount,
154
+ ...(uploadStats ? {
155
+ uploads: uploadStats
156
+ } : {}),
151
157
  comparisons: tddResults?.comparisons || null,
152
158
  failed: (tddResults?.failed || 0) > 0
153
159
  };