@vizzly-testing/cli 0.35.3-beta.4 → 0.37.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/README.md +31 -24
- package/dist/api/client.js +14 -2
- package/dist/api/core.js +8 -8
- package/dist/api/endpoints.js +10 -7
- package/dist/cli.js +40 -15
- package/dist/client/index.js +0 -6
- package/dist/commands/api.js +72 -25
- package/dist/commands/init.js +4 -2
- package/dist/commands/run.js +60 -8
- package/dist/commands/status.js +2 -2
- package/dist/commands/tdd.js +1 -1
- package/dist/reporter/reporter-bundle.iife.js +51 -51
- package/dist/sdk/index.js +0 -10
- package/dist/server/handlers/api-handler.js +105 -195
- package/dist/server/handlers/tdd-handler.js +13 -54
- package/dist/server/routers/screenshot.js +6 -3
- package/dist/test-runner/core.js +7 -1
- package/dist/test-runner/operations.js +22 -7
- package/dist/types/client.d.ts +12 -5
- package/dist/uploader/core.js +4 -3
- package/dist/utils/screenshot-options.js +4 -84
- package/dist/utils/security.js +27 -42
- package/package.json +8 -7
- package/skills/vizzly/SKILL.md +17 -9
- package/skills/vizzly/references/cli-context.md +49 -15
- package/skills/vizzly/references/setup-ci.md +2 -2
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 {
|
|
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
|
-
*
|
|
10
|
+
* Create a cloud screenshot handler for one run.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
21
|
+
export let createApiHandler = (client, {
|
|
46
22
|
uploadScreenshot = defaultUploadScreenshot
|
|
47
23
|
} = {}) => {
|
|
48
|
-
let
|
|
49
|
-
let screenshotCount = 0;
|
|
24
|
+
let captures = [];
|
|
50
25
|
let uploadPromises = [];
|
|
51
|
-
|
|
52
|
-
|
|
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 =
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
}
|
|
129
|
-
|
|
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
|
-
}
|
|
137
|
-
|
|
96
|
+
} catch (error) {
|
|
97
|
+
recordFailure(capture, error);
|
|
138
98
|
return {
|
|
139
|
-
statusCode: 400,
|
|
99
|
+
statusCode: client ? 400 : 500,
|
|
140
100
|
body: {
|
|
141
|
-
|
|
142
|
-
|
|
101
|
+
success: false,
|
|
102
|
+
name,
|
|
103
|
+
error: error.message
|
|
143
104
|
}
|
|
144
105
|
};
|
|
145
106
|
}
|
|
146
|
-
|
|
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:
|
|
114
|
+
count: captures.length,
|
|
197
115
|
warnings
|
|
198
116
|
}
|
|
199
117
|
};
|
|
200
|
-
}
|
|
201
|
-
const getScreenshotCount = () => screenshotCount;
|
|
118
|
+
}
|
|
202
119
|
|
|
203
120
|
/**
|
|
204
|
-
* Wait for
|
|
205
|
-
*
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
236
|
-
|
|
145
|
+
reused,
|
|
146
|
+
failed: failures.length,
|
|
147
|
+
total: captures.length,
|
|
148
|
+
failures
|
|
237
149
|
};
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
screenshotCount = 0;
|
|
150
|
+
}
|
|
151
|
+
function cleanup() {
|
|
152
|
+
captures = [];
|
|
242
153
|
uploadPromises = [];
|
|
243
|
-
|
|
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
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
*
|
|
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,
|
|
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:
|
|
90
|
+
success: stats.failed === 0,
|
|
88
91
|
...stats
|
|
89
92
|
});
|
|
90
93
|
} else if (screenshotHandler.getResults) {
|
package/dist/test-runner/core.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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>}
|
|
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
|
|