@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.
package/dist/sdk/index.js CHANGED
@@ -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';
@@ -315,10 +309,6 @@ export class VizzlySDK extends EventEmitter {
315
309
  properties: normalizedOptions.properties
316
310
  };
317
311
  Object.assign(screenshotData, getScreenshotOptionsPayload(normalizedOptions));
318
- if (normalizedOptions.warnings.length > 0) {
319
- screenshotData.warnings = normalizedOptions.warnings;
320
- }
321
-
322
312
  // POST to the local screenshot server
323
313
  let serverUrl = `http://localhost:${this.config.server?.port || 3000}`;
324
314
  let fetchFn = this.services.fetch || fetch;
@@ -1,5 +1,5 @@
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';
@@ -7,49 +7,56 @@ import * as output from '../../utils/output.js';
7
7
  import { normalizeScreenshotOptions } from '../../utils/screenshot-options.js';
8
8
 
9
9
  /**
10
- * API Handler - Non-blocking screenshot upload
10
+ * Create a cloud screenshot handler for one run.
11
11
  *
12
- * Flow:
13
- * ┌─────────────────────────────────────────────────────────────┐
14
- * │ Test Suite │
15
- * │ ↓ vizzlyScreenshot() │
16
- * │ ↓ HTTP POST to localhost │
17
- * │ ↓ │
18
- * │ Screenshot Server │
19
- * │ ↓ handleScreenshot() │
20
- * │ ├─→ Convert base64 to Buffer │
21
- * │ ├─→ Fire upload promise (NO AWAIT) ─────┐ │
22
- * │ └─→ Return 200 immediately │ │
23
- * │ │ │
24
- * │ Test continues (NO BLOCKING) ✓ │ │
25
- * │ ↓ │
26
- * │ Background Upload │
27
- * │ (to Vizzly API) │
28
- * │ ↓ │
29
- * │ Promise resolves/rejects │
30
- * │ │
31
- * │ Build Finalization │
32
- * │ ↓ flush() │
33
- * │ └─→ await Promise.allSettled(uploadPromises) │
34
- * │ ↓ │
35
- * │ All uploads complete ✓ │
36
- * └─────────────────────────────────────────────────────────────┘
37
- */
38
-
39
- /**
40
- * Create an API handler for screenshot uploads.
41
- * @param {Object} client - API client with request method
42
- * @param {Object} options - Optional dependencies for testing
43
- * @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.
44
20
  */
45
- export const createApiHandler = (client, {
21
+ export let createApiHandler = (client, {
46
22
  uploadScreenshot = defaultUploadScreenshot
47
23
  } = {}) => {
48
- let vizzlyDisabled = false;
49
- let screenshotCount = 0;
24
+ let captures = [];
50
25
  let uploadPromises = [];
51
- const handleScreenshot = async (buildId, name, image, properties = {}, type, warnings = [], screenshotOptions = {}) => {
52
- let handlerStart = Date.now();
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
+ }
31
+
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);
43
+ }
44
+ }
45
+
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 = {}) {
53
60
  let normalizedOptions = normalizeScreenshotOptions({
54
61
  ...screenshotOptions,
55
62
  properties
@@ -64,187 +71,90 @@ export const createApiHandler = (client, {
64
71
  deviceScaleFactor: normalizedOptions.deviceScaleFactor,
65
72
  selector: normalizedOptions.selector
66
73
  };
67
- warnings = [...(warnings || []), ...normalizedOptions.warnings];
68
- output.debug('upload', `${name} received`, {
69
- buildId: buildId?.slice(0, 8)
70
- });
71
- if (vizzlyDisabled) {
72
- output.debug('upload', `${name} (disabled)`);
73
- return {
74
- statusCode: 200,
75
- body: {
76
- success: true,
77
- disabled: true,
78
- count: ++screenshotCount,
79
- message: `Vizzly disabled - ${screenshotCount} screenshots captured but not uploaded`
80
- }
81
- };
82
- }
83
-
84
- // buildId is optional - API will handle it appropriately
85
-
86
- if (!client) {
87
- return {
88
- statusCode: 500,
89
- body: {
90
- error: 'API client not available'
91
- }
92
- };
93
- }
94
-
95
- // Support both base64 encoded images and file paths
96
- // Use explicit type from client if provided (fast path), otherwise detect (slow path)
97
- // Only accept valid type values to prevent invalid types from bypassing detection
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);
98
81
  let imageBuffer;
99
- let validTypes = ['base64', 'file-path'];
100
- const inputType = type && validTypes.includes(type) ? type : detectImageInputType(image);
101
- if (inputType === 'file-path') {
102
- // It's a file path - resolve and read the file
103
- const filePath = resolve(image.replace('file://', ''));
104
- if (!existsSync(filePath)) {
105
- return {
106
- statusCode: 400,
107
- body: {
108
- error: `Screenshot file not found: ${filePath}`,
109
- originalPath: image
110
- }
111
- };
112
- }
113
- try {
114
- imageBuffer = readFileSync(filePath);
115
- } catch (error) {
116
- return {
117
- statusCode: 500,
118
- body: {
119
- error: `Failed to read screenshot file: ${error.message}`,
120
- filePath
121
- }
122
- };
123
- }
124
- } else if (inputType === 'base64') {
125
- // It's base64 encoded
126
- 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') {
127
92
  imageBuffer = Buffer.from(image, 'base64');
128
- } catch (error) {
129
- return {
130
- statusCode: 400,
131
- body: {
132
- error: `Invalid base64 image data: ${error.message}`
133
- }
134
- };
93
+ } else {
94
+ throw new Error('Invalid image input: must be a file path or base64 encoded image data');
135
95
  }
136
- } else {
137
- // Unknown input type
96
+ } catch (error) {
97
+ recordFailure(capture, error);
138
98
  return {
139
- statusCode: 400,
99
+ statusCode: client ? 400 : 500,
140
100
  body: {
141
- error: 'Invalid image input: must be a file path or base64 encoded image data',
142
- receivedType: typeof image
101
+ success: false,
102
+ name,
103
+ error: error.message
143
104
  }
144
105
  };
145
106
  }
146
- screenshotCount++;
147
- let uploadStart = Date.now();
148
-
149
- // Fire upload in background - DON'T AWAIT!
150
- let uploadPromise = uploadScreenshot(client, buildId, name, imageBuffer, properties ?? {}, false, screenshotOptions).then(result => {
151
- let duration = Date.now() - uploadStart;
152
- if (!result.skipped) {
153
- output.debug('upload', `${name} completed`, {
154
- ms: duration
155
- });
156
- } else {
157
- output.debug('upload', `${name} skipped (dedup)`, {
158
- ms: duration
159
- });
160
- }
161
- return {
162
- success: true,
163
- name,
164
- result,
165
- duration
166
- };
167
- }).catch(uploadError => {
168
- let duration = Date.now() - uploadStart;
169
- output.debug('upload', `${name} failed`, {
170
- error: uploadError.message,
171
- ms: duration
172
- });
173
- vizzlyDisabled = true;
174
- output.warn('Vizzly disabled due to upload error - continuing tests without visual testing');
175
- return {
176
- success: false,
177
- name,
178
- error: uploadError,
179
- duration
180
- };
181
- });
182
-
183
- // Collect promise for later flushing
184
- uploadPromises.push(uploadPromise);
185
-
186
- // Return immediately - test continues without waiting!
187
- let handlerMs = Date.now() - handlerStart;
188
- output.debug('upload', `${name} handler returning`, {
189
- ms: handlerMs
190
- });
107
+ uploadPromises.push(upload(capture, buildId, imageBuffer, properties, screenshotOptions));
191
108
  return {
192
109
  statusCode: 200,
193
110
  body: {
194
111
  success: true,
112
+ queued: true,
195
113
  name,
196
- count: screenshotCount,
114
+ count: captures.length,
197
115
  warnings
198
116
  }
199
117
  };
200
- };
201
- const getScreenshotCount = () => screenshotCount;
118
+ }
202
119
 
203
120
  /**
204
- * Wait for all background uploads to complete
205
- * 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 }]`.
206
127
  */
207
- const flush = async () => {
208
- if (uploadPromises.length === 0) {
209
- return {
210
- uploaded: 0,
211
- failed: 0,
212
- total: 0
213
- };
214
- }
215
- output.debug('upload', `flushing ${uploadPromises.length} uploads`);
216
- const results = await Promise.allSettled(uploadPromises);
217
- let uploaded = 0;
218
- let failed = 0;
219
- results.forEach(result => {
220
- if (result.status === 'fulfilled' && result.value.success) {
221
- uploaded++;
222
- } else {
223
- failed++;
224
- }
225
- });
226
- output.debug('upload', 'flush complete', {
227
- uploaded,
228
- failed
229
- });
230
-
231
- // Clear promises array
232
- 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
+ }));
233
143
  return {
234
144
  uploaded,
235
- failed,
236
- total: results.length
145
+ reused,
146
+ failed: failures.length,
147
+ total: captures.length,
148
+ failures
237
149
  };
238
- };
239
- const cleanup = () => {
240
- vizzlyDisabled = false;
241
- screenshotCount = 0;
150
+ }
151
+ function cleanup() {
152
+ captures = [];
242
153
  uploadPromises = [];
243
- // Silent cleanup
244
- };
154
+ }
245
155
  return {
246
156
  handleScreenshot,
247
- getScreenshotCount,
157
+ getScreenshotCount: () => captures.length,
248
158
  flush,
249
159
  cleanup
250
160
  };
@@ -5,41 +5,19 @@ import { getDimensionsSync as defaultGetDimensionsSync } from '@vizzly-testing/h
5
5
  import { TddService as DefaultTddService } from '../../tdd/tdd-service.js';
6
6
  import { detectImageInputType as defaultDetectImageInputType } from '../../utils/image-input-detector.js';
7
7
  import * as defaultOutput from '../../utils/output.js';
8
- import { normalizeScreenshotOptions } from '../../utils/screenshot-options.js';
9
8
  import { safePath as defaultSafePath, sanitizeScreenshotName as defaultSanitizeScreenshotName, validateScreenshotProperties as defaultValidateScreenshotProperties } from '../../utils/security.js';
10
9
 
11
10
  /**
12
- * Unwrap double-nested properties if needed
13
- * 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.
14
13
  */
15
- export const unwrapProperties = properties => {
16
- if (!properties) return {};
17
- if (properties.properties && typeof properties.properties === 'object') {
18
- // Merge top-level properties with nested properties
19
- let unwrapped = {
20
- ...properties,
21
- ...properties.properties
22
- };
23
- // Remove the nested properties field to avoid confusion
24
- delete unwrapped.properties;
25
- return unwrapped;
26
- }
27
- return properties;
28
- };
29
-
30
- /**
31
- * Extract properties to top-level format matching cloud API
32
- * Normalizes viewport to viewport_width/height, ensures browser is set
33
- */
34
- export const extractProperties = validatedProperties => {
14
+ export const extractProperties = (validatedProperties, imageDimensions = {}) => {
35
15
  if (!validatedProperties) return {};
36
16
  return {
37
17
  ...validatedProperties,
38
- // Normalize viewport to top-level viewport_width/height (cloud format)
39
- viewport_width: validatedProperties.viewport?.width ?? validatedProperties.viewport_width ?? null,
40
- viewport_height: validatedProperties.viewport?.height ?? validatedProperties.viewport_height ?? null,
18
+ viewport_width: imageDimensions.width ?? null,
19
+ viewport_height: imageDimensions.height ?? null,
41
20
  browser: validatedProperties.browser ?? null,
42
- // Preserve nested structure in metadata for backward compatibility
43
21
  metadata: validatedProperties
44
22
  };
45
23
  };
@@ -325,18 +303,10 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
325
303
  };
326
304
  }
327
305
 
328
- // Preserve the old nested wrapper for older clients, then filter user
329
- // properties before local comparison/report serialization.
330
- let unwrappedProperties = unwrapProperties(properties);
331
- let normalizedProperties = normalizeScreenshotOptions({
332
- properties: unwrappedProperties
333
- });
334
- warnings = [...(warnings || []), ...normalizedProperties.warnings];
335
-
336
- // Validate and sanitize properties
306
+ // Validate user metadata without unwrapping nested keys.
337
307
  let validatedProperties;
338
308
  try {
339
- validatedProperties = validateScreenshotProperties(normalizedProperties.properties);
309
+ validatedProperties = validateScreenshotProperties(properties);
340
310
  } catch (error) {
341
311
  return {
342
312
  statusCode: 400,
@@ -348,9 +318,6 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
348
318
  };
349
319
  }
350
320
 
351
- // Extract ALL properties to top-level (matching cloud API behavior)
352
- const extractedProperties = extractProperties(validatedProperties);
353
-
354
321
  // Support both base64 encoded images and file paths
355
322
  // Vitest browser mode returns file paths, so we need to handle both
356
323
  // Use explicit type from client if provided (fast path), otherwise detect (slow path)
@@ -407,21 +374,13 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
407
374
  }
408
375
  };
409
376
  }
410
-
411
- // Auto-detect image dimensions if viewport not provided
412
- if (!extractedProperties.viewport_width || !extractedProperties.viewport_height) {
413
- try {
414
- const dimensions = getDimensionsSync(imageBuffer);
415
- if (!extractedProperties.viewport_width) {
416
- extractedProperties.viewport_width = dimensions.width;
417
- }
418
- if (!extractedProperties.viewport_height) {
419
- extractedProperties.viewport_height = dimensions.height;
420
- }
421
- } catch {
422
- // Dimensions will use defaults
423
- }
377
+ let imageDimensions = {};
378
+ try {
379
+ imageDimensions = getDimensionsSync(imageBuffer);
380
+ } catch {
381
+ // Invalid images are handled by the comparison service.
424
382
  }
383
+ let extractedProperties = extractProperties(validatedProperties, imageDimensions);
425
384
 
426
385
  // Use the sanitized name as-is (no modification with browser/viewport)
427
386
  // Baseline matching uses signature logic (name + viewport_width + browser)
@@ -9,7 +9,10 @@ import { parseJsonBody } from '../middleware/json-parser.js';
9
9
  import { sendError, sendJson } from '../middleware/response.js';
10
10
 
11
11
  /**
12
- * 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
+ *
13
16
  * @param {Object} context - Router context
14
17
  * @param {Object} context.screenshotHandler - Screenshot handler
15
18
  * @param {string|null} context.defaultBuildId - Default build ID
@@ -58,7 +61,7 @@ export function createScreenshotRouter({
58
61
  deviceScaleFactor,
59
62
  selector
60
63
  });
61
- const result = await screenshotHandler.handleScreenshot(effectiveBuildId, name, image, normalizedOptions.properties, type, [...(warnings || []), ...normalizedOptions.warnings], {
64
+ const result = await screenshotHandler.handleScreenshot(effectiveBuildId, name, image, normalizedOptions.properties, type, warnings || [], {
62
65
  threshold: normalizedOptions.threshold,
63
66
  minClusterSize: normalizedOptions.minClusterSize,
64
67
  fullPage: normalizedOptions.fullPage,
@@ -84,7 +87,7 @@ export function createScreenshotRouter({
84
87
  if (screenshotHandler.flush) {
85
88
  let stats = await screenshotHandler.flush();
86
89
  sendJson(res, 200, {
87
- success: true,
90
+ success: stats.failed === 0,
88
91
  ...stats
89
92
  });
90
93
  } else if (screenshotHandler.getResults) {
@@ -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
  };
@@ -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