@vizzly-testing/cli 0.35.1 → 0.35.3-beta.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 +2 -2
- package/dist/api/endpoints.js +24 -3
- package/dist/api/index.js +1 -1
- package/dist/cli.js +16 -16
- package/dist/commands/comparisons.js +52 -45
- package/dist/commands/context.js +399 -135
- package/dist/commands/run.js +84 -29
- package/dist/commands/status.js +276 -59
- package/dist/commands/tdd.js +19 -3
- package/dist/context/local-workspace-provider.js +17 -7
- package/dist/reporter/reporter-bundle.css +1 -1
- package/dist/reporter/reporter-bundle.iife.js +5 -5
- package/dist/test-runner/operations.js +70 -28
- package/dist/types/index.d.ts +4 -2
- package/dist/uploader/core.js +6 -6
- package/dist/uploader/operations.js +4 -1
- package/dist/utils/visual-context-normalizers.js +446 -0
- package/package.json +4 -5
- package/skills/vizzly/SKILL.md +68 -101
- package/skills/vizzly/agents/openai.yaml +2 -2
- package/skills/vizzly/references/cli-context.md +75 -27
- package/skills/vizzly/references/dynamic-content.md +28 -33
- package/skills/vizzly/references/sdks.md +26 -39
- package/skills/vizzly/references/setup-ci.md +44 -20
- package/skills/vizzly/references/public-screenshots.md +0 -44
|
@@ -57,6 +57,9 @@ export async function createBuild({
|
|
|
57
57
|
let client = createApiClient(clientOptions);
|
|
58
58
|
let payload = buildApiBuildPayload(runOptions, config.comparison);
|
|
59
59
|
let buildResult = await createApiBuild(client, payload);
|
|
60
|
+
if (!buildResult?.id) {
|
|
61
|
+
throw new Error('Vizzly API did not return a build ID');
|
|
62
|
+
}
|
|
60
63
|
output.debug('build', `created ${buildResult.id}`);
|
|
61
64
|
return buildResult.id;
|
|
62
65
|
}
|
|
@@ -227,6 +230,26 @@ export function executeTestCommand({
|
|
|
227
230
|
resolve.__process = testProcess;
|
|
228
231
|
});
|
|
229
232
|
}
|
|
233
|
+
async function executeDisabledTestRun({
|
|
234
|
+
testCommand,
|
|
235
|
+
json,
|
|
236
|
+
deps
|
|
237
|
+
}) {
|
|
238
|
+
let {
|
|
239
|
+
spawn,
|
|
240
|
+
createError
|
|
241
|
+
} = deps;
|
|
242
|
+
await executeTestCommand({
|
|
243
|
+
command: testCommand,
|
|
244
|
+
env: buildDisabledEnv(),
|
|
245
|
+
json,
|
|
246
|
+
deps: {
|
|
247
|
+
spawn,
|
|
248
|
+
createError
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
return buildDisabledRunResult();
|
|
252
|
+
}
|
|
230
253
|
|
|
231
254
|
// ============================================================================
|
|
232
255
|
// High-Level Run Operations
|
|
@@ -278,25 +301,22 @@ export async function runTests({
|
|
|
278
301
|
hasApiKey: hasApiKey(config),
|
|
279
302
|
tdd
|
|
280
303
|
})) {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
command: testCommand,
|
|
284
|
-
env,
|
|
304
|
+
return executeDisabledTestRun({
|
|
305
|
+
testCommand,
|
|
285
306
|
json: runOptions.json,
|
|
286
307
|
deps: {
|
|
287
308
|
spawn,
|
|
288
309
|
createError
|
|
289
310
|
}
|
|
290
311
|
});
|
|
291
|
-
return buildDisabledRunResult();
|
|
292
312
|
}
|
|
293
313
|
let buildId = null;
|
|
294
314
|
let buildUrl = null;
|
|
295
315
|
let screenshotCount = 0;
|
|
296
316
|
let testSuccess = false;
|
|
297
317
|
let testError = null;
|
|
318
|
+
let setBaseline;
|
|
298
319
|
try {
|
|
299
|
-
// Create build
|
|
300
320
|
buildId = await createBuild({
|
|
301
321
|
runOptions,
|
|
302
322
|
tdd,
|
|
@@ -332,7 +352,7 @@ export async function runTests({
|
|
|
332
352
|
}
|
|
333
353
|
|
|
334
354
|
// Start server
|
|
335
|
-
|
|
355
|
+
setBaseline = normalizeSetBaseline(runOptions);
|
|
336
356
|
await serverManager.start(buildId, tdd, setBaseline);
|
|
337
357
|
if (onServerReady) {
|
|
338
358
|
onServerReady({
|
|
@@ -341,32 +361,48 @@ export async function runTests({
|
|
|
341
361
|
tdd
|
|
342
362
|
});
|
|
343
363
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
buildId,
|
|
349
|
-
setBaseline,
|
|
350
|
-
failOnDiff: runOptions.failOnDiff || false
|
|
364
|
+
} catch (error) {
|
|
365
|
+
output.warn('Vizzly encountered an error. Running tests without visual testing.');
|
|
366
|
+
output.debug('run', 'setup failed; disabling Vizzly', {
|
|
367
|
+
error: error.message
|
|
351
368
|
});
|
|
352
369
|
try {
|
|
353
|
-
await
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
deps: {
|
|
358
|
-
spawn,
|
|
359
|
-
createError
|
|
360
|
-
}
|
|
370
|
+
await serverManager.stop();
|
|
371
|
+
} catch (stopError) {
|
|
372
|
+
output.debug('run', 'failed to stop Vizzly after setup error', {
|
|
373
|
+
error: stopError.message
|
|
361
374
|
});
|
|
362
|
-
testSuccess = true;
|
|
363
|
-
} catch (error) {
|
|
364
|
-
testError = error;
|
|
365
|
-
testSuccess = false;
|
|
366
375
|
}
|
|
376
|
+
return executeDisabledTestRun({
|
|
377
|
+
testCommand,
|
|
378
|
+
json: runOptions.json,
|
|
379
|
+
deps: {
|
|
380
|
+
spawn,
|
|
381
|
+
createError
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Execute the user's tests. This is the only error that can fail the run.
|
|
387
|
+
let env = buildTestEnv({
|
|
388
|
+
port: config.server?.port,
|
|
389
|
+
buildId,
|
|
390
|
+
setBaseline,
|
|
391
|
+
failOnDiff: runOptions.failOnDiff || false
|
|
392
|
+
});
|
|
393
|
+
try {
|
|
394
|
+
await executeTestCommand({
|
|
395
|
+
command: testCommand,
|
|
396
|
+
env,
|
|
397
|
+
json: runOptions.json,
|
|
398
|
+
deps: {
|
|
399
|
+
spawn,
|
|
400
|
+
createError
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
testSuccess = true;
|
|
367
404
|
} catch (error) {
|
|
368
405
|
testError = error;
|
|
369
|
-
testSuccess = false;
|
|
370
406
|
}
|
|
371
407
|
|
|
372
408
|
// Get TDD results before stopping the server
|
|
@@ -410,7 +446,13 @@ export async function runTests({
|
|
|
410
446
|
|
|
411
447
|
// In API mode, get actual screenshot count from handler after flush
|
|
412
448
|
if (!tdd && serverManager.server?.getScreenshotCount) {
|
|
413
|
-
|
|
449
|
+
try {
|
|
450
|
+
screenshotCount = serverManager.server.getScreenshotCount(buildId) || 0;
|
|
451
|
+
} catch (countError) {
|
|
452
|
+
output.debug('build', 'failed to get screenshot count', {
|
|
453
|
+
error: countError.message
|
|
454
|
+
});
|
|
455
|
+
}
|
|
414
456
|
}
|
|
415
457
|
} finally {
|
|
416
458
|
try {
|
package/dist/types/index.d.ts
CHANGED
|
@@ -205,10 +205,12 @@ export interface BuildResult {
|
|
|
205
205
|
status: 'completed' | 'failed' | 'pending';
|
|
206
206
|
build: unknown;
|
|
207
207
|
comparisons?: number;
|
|
208
|
-
totalComparisons
|
|
208
|
+
totalComparisons?: number;
|
|
209
209
|
passedComparisons?: number;
|
|
210
210
|
failedComparisons?: number;
|
|
211
|
-
newComparisons
|
|
211
|
+
newComparisons?: number;
|
|
212
|
+
identicalComparisons?: number;
|
|
213
|
+
approvalStatus?: string;
|
|
212
214
|
url?: string;
|
|
213
215
|
}
|
|
214
216
|
|
package/dist/uploader/core.js
CHANGED
|
@@ -340,15 +340,15 @@ export function buildUploadResult({
|
|
|
340
340
|
* @returns {Object} Wait result
|
|
341
341
|
*/
|
|
342
342
|
export function buildWaitResult(build) {
|
|
343
|
-
let totalComparisons = build.comparisonsTotal ?? build.total_comparisons
|
|
344
|
-
let passedComparisons = build.comparisonsPassed ?? build.passed_comparisons ?? build.identicalComparisons ?? build.identical_comparisons
|
|
345
|
-
let failedComparisons = build.comparisonsFailed ?? build.failed_comparisons ?? build.changedComparisons ?? build.changed_comparisons
|
|
346
|
-
let newComparisons = build.comparisonsNew ?? build.newComparisons ?? build.new_comparisons
|
|
343
|
+
let totalComparisons = build.comparisonsTotal ?? build.total_comparisons;
|
|
344
|
+
let passedComparisons = build.comparisonsPassed ?? build.passed_comparisons ?? build.identicalComparisons ?? build.identical_comparisons;
|
|
345
|
+
let failedComparisons = build.comparisonsFailed ?? build.failed_comparisons ?? build.changedComparisons ?? build.changed_comparisons;
|
|
346
|
+
let newComparisons = build.comparisonsNew ?? build.newComparisons ?? build.new_comparisons;
|
|
347
347
|
let identicalComparisons = build.identicalComparisons ?? build.identical_comparisons ?? passedComparisons;
|
|
348
|
-
let approvalStatus = build.approvalStatus ?? build.approval_status
|
|
348
|
+
let approvalStatus = build.approvalStatus ?? build.approval_status;
|
|
349
349
|
let hasComparisonCounts = build.comparisonsTotal !== undefined || build.total_comparisons !== undefined || build.comparisonsPassed !== undefined || build.passed_comparisons !== undefined || build.comparisonsFailed !== undefined || build.failed_comparisons !== undefined || build.comparisonsNew !== undefined || build.new_comparisons !== undefined || build.identicalComparisons !== undefined || build.identical_comparisons !== undefined;
|
|
350
350
|
let result = {
|
|
351
|
-
status:
|
|
351
|
+
status: build.status,
|
|
352
352
|
build,
|
|
353
353
|
passedComparisons,
|
|
354
354
|
failedComparisons,
|
|
@@ -281,7 +281,10 @@ export async function waitForBuild({
|
|
|
281
281
|
return buildWaitResult(build);
|
|
282
282
|
}
|
|
283
283
|
if (build.status === 'failed') {
|
|
284
|
-
throw createError(`Build failed: ${build.error || 'Unknown error'}`, 'BUILD_FAILED'
|
|
284
|
+
throw createError(`Build failed: ${build.error || 'Unknown error'}`, 'BUILD_FAILED', {
|
|
285
|
+
buildId,
|
|
286
|
+
build
|
|
287
|
+
});
|
|
285
288
|
}
|
|
286
289
|
let remaining = timeout - getElapsedTime(startTime, now());
|
|
287
290
|
if (remaining <= 0) {
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the API's canonical visual-review state with legacy approval fallbacks.
|
|
3
|
+
*
|
|
4
|
+
* @param {Object} record - Comparison or review record from the API.
|
|
5
|
+
* @returns {string|null} The review state when the API supplied one.
|
|
6
|
+
*/
|
|
7
|
+
export function getVisualReviewState(record = {}) {
|
|
8
|
+
return record.visual_review?.state || record.review_state || record.approval_status || null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Keep visual outcome separate from processing status when both are available.
|
|
13
|
+
*
|
|
14
|
+
* @param {Object} comparison - Comparison record from the API.
|
|
15
|
+
* @returns {string|null} The visual result, or the legacy status fallback.
|
|
16
|
+
*/
|
|
17
|
+
export function getComparisonResult(comparison = {}) {
|
|
18
|
+
return comparison.result || comparison.status || null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolve a screenshot name across current and legacy response shapes.
|
|
23
|
+
*
|
|
24
|
+
* @param {Object} comparison - Comparison record from the API.
|
|
25
|
+
* @returns {string|null} The first API-provided name or identity fallback.
|
|
26
|
+
*/
|
|
27
|
+
export function getComparisonName(comparison = {}) {
|
|
28
|
+
return comparison.name || comparison.screenshot_name || comparison.current_screenshot?.name || comparison.screenshot?.name || comparison.current_name || comparison.id || null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve CSS viewport dimensions without confusing them with bitmap size.
|
|
33
|
+
*
|
|
34
|
+
* @param {Object} comparison - Comparison record from the API.
|
|
35
|
+
* @returns {{width: number|null, height: number|null}|null} Viewport dimensions.
|
|
36
|
+
*/
|
|
37
|
+
export function getComparisonViewport(comparison = {}) {
|
|
38
|
+
let current = comparison.current_screenshot || comparison.screenshot || {};
|
|
39
|
+
let viewport = comparison.viewport || current.viewport || {};
|
|
40
|
+
let width = viewport.width ?? comparison.viewport_width ?? current.viewport_width ?? comparison.current_viewport_width ?? null;
|
|
41
|
+
let height = viewport.height ?? comparison.viewport_height ?? current.viewport_height ?? comparison.current_viewport_height ?? null;
|
|
42
|
+
return width != null || height != null ? {
|
|
43
|
+
width,
|
|
44
|
+
height
|
|
45
|
+
} : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the browser from nested current screenshot or legacy metadata fields.
|
|
50
|
+
*
|
|
51
|
+
* @param {Object} comparison - Comparison record from the API.
|
|
52
|
+
* @returns {string|null} The browser name when present.
|
|
53
|
+
*/
|
|
54
|
+
export function getComparisonBrowser(comparison = {}) {
|
|
55
|
+
return comparison.browser || comparison.current_screenshot?.browser || comparison.screenshot?.browser || comparison.current_browser || comparison.current_metadata?.browser || comparison.current_metadata?.properties?.browser || null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve bitmap dimensions from nested or flat screenshot fields.
|
|
60
|
+
*
|
|
61
|
+
* @param {Object} record - Preferred screenshot record.
|
|
62
|
+
* @param {Object} fallback - Legacy fields to use when the record is incomplete.
|
|
63
|
+
* @returns {{width: number|null, height: number|null}|null} Bitmap dimensions.
|
|
64
|
+
*/
|
|
65
|
+
function getBitmap(record = {}, fallback = {}) {
|
|
66
|
+
let bitmap = record.bitmap || fallback.bitmap || {};
|
|
67
|
+
let width = bitmap.width ?? record.bitmap_width ?? fallback.bitmap_width ?? null;
|
|
68
|
+
let height = bitmap.height ?? record.bitmap_height ?? fallback.bitmap_height ?? null;
|
|
69
|
+
return width != null || height != null ? {
|
|
70
|
+
width,
|
|
71
|
+
height
|
|
72
|
+
} : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve viewport dimensions from one screenshot-shaped record.
|
|
77
|
+
*
|
|
78
|
+
* @param {Object} record - Preferred screenshot record.
|
|
79
|
+
* @param {Object} fallback - Legacy fields to use when the record is incomplete.
|
|
80
|
+
* @returns {{width: number|null, height: number|null}|null} Viewport dimensions.
|
|
81
|
+
*/
|
|
82
|
+
function getRecordViewport(record = {}, fallback = {}) {
|
|
83
|
+
let viewport = record.viewport || fallback.viewport || {};
|
|
84
|
+
let width = viewport.width ?? record.viewport_width ?? fallback.viewport_width ?? null;
|
|
85
|
+
let height = viewport.height ?? record.viewport_height ?? fallback.viewport_height ?? null;
|
|
86
|
+
return width != null || height != null ? {
|
|
87
|
+
width,
|
|
88
|
+
height
|
|
89
|
+
} : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Normalize current screenshot identity, render facts, metadata, and asset URL.
|
|
94
|
+
*
|
|
95
|
+
* @param {Object} comparison - Comparison record from the API.
|
|
96
|
+
* @returns {Object} A stable current screenshot projection.
|
|
97
|
+
*/
|
|
98
|
+
function getCurrentScreenshot(comparison = {}) {
|
|
99
|
+
let screenshot = comparison.current_screenshot || comparison.screenshot || {};
|
|
100
|
+
return {
|
|
101
|
+
id: screenshot.id || comparison.current_screenshot_id || null,
|
|
102
|
+
name: screenshot.name || getComparisonName(comparison),
|
|
103
|
+
status: screenshot.status || comparison.screenshot_status || null,
|
|
104
|
+
browser: screenshot.browser || getComparisonBrowser(comparison),
|
|
105
|
+
device: screenshot.device || comparison.device || null,
|
|
106
|
+
viewport: getRecordViewport(screenshot, {
|
|
107
|
+
viewport: comparison.viewport,
|
|
108
|
+
viewport_width: comparison.current_viewport_width ?? comparison.viewport_width,
|
|
109
|
+
viewport_height: comparison.current_viewport_height ?? comparison.viewport_height
|
|
110
|
+
}) || getComparisonViewport(comparison),
|
|
111
|
+
bitmap: getBitmap(screenshot, {
|
|
112
|
+
bitmap: comparison.bitmap,
|
|
113
|
+
bitmap_width: comparison.current_width ?? comparison.bitmap_width,
|
|
114
|
+
bitmap_height: comparison.current_height ?? comparison.bitmap_height
|
|
115
|
+
}),
|
|
116
|
+
metadata: screenshot.metadata || comparison.current_metadata || comparison.metadata || null,
|
|
117
|
+
signature: screenshot.signature || comparison.current_signature || comparison.signature || null,
|
|
118
|
+
url: screenshot.url || screenshot.original_url || comparison.current_url || comparison.current_original_url || comparison.current_screenshot_url || null,
|
|
119
|
+
error_message: screenshot.error_message || comparison.error_message || null
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Normalize the baseline screenshot without inventing missing render facts.
|
|
125
|
+
*
|
|
126
|
+
* @param {Object} comparison - Comparison record from the API.
|
|
127
|
+
* @returns {Object} A stable baseline screenshot projection.
|
|
128
|
+
*/
|
|
129
|
+
function getBaselineScreenshot(comparison = {}) {
|
|
130
|
+
let current = comparison.current_screenshot || comparison.screenshot || {};
|
|
131
|
+
let baseline = comparison.baseline_screenshot || comparison.baseline || current.baseline || {};
|
|
132
|
+
return {
|
|
133
|
+
id: baseline.id || comparison.baseline_screenshot_id || null,
|
|
134
|
+
build_id: baseline.build_id || comparison.baseline_build_id || null,
|
|
135
|
+
name: baseline.name || comparison.baseline_name || null,
|
|
136
|
+
browser: baseline.browser || null,
|
|
137
|
+
viewport: getRecordViewport(baseline),
|
|
138
|
+
bitmap: getBitmap(baseline),
|
|
139
|
+
metadata: baseline.metadata || comparison.baseline_metadata || null,
|
|
140
|
+
signature: baseline.signature || comparison.baseline_signature || null,
|
|
141
|
+
url: baseline.url || baseline.original_url || comparison.baseline_url || comparison.baseline_original_url || comparison.baseline_screenshot_url || null
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Project compact Honeydiff facts without making raw geometry the default.
|
|
147
|
+
*
|
|
148
|
+
* Agents need stable counts, fingerprints, URLs, and the API projection for
|
|
149
|
+
* first-pass diagnosis. Raw regions and scoring details stay behind an
|
|
150
|
+
* explicit include because they can dominate an otherwise bounded handoff.
|
|
151
|
+
*
|
|
152
|
+
* @param {Object} comparison - Comparison record from the API.
|
|
153
|
+
* @param {boolean} includeDiffs - Whether to include raw Honeydiff diagnostics.
|
|
154
|
+
* @returns {Object} Compact diff evidence suitable for context summaries.
|
|
155
|
+
*/
|
|
156
|
+
function getComparisonDiff(comparison = {}, includeDiffs = false) {
|
|
157
|
+
let diff = comparison.diff || comparison.diff_image || comparison.analysis || {};
|
|
158
|
+
let analysis = comparison.analysis || {};
|
|
159
|
+
let regions = diff.regions || diff.diff_regions || analysis.diff_regions || comparison.diff_regions;
|
|
160
|
+
let projection = diff.projection || diff.analysis_projection || analysis.projection || analysis.analysis_projection || comparison.analysis_projection || comparison.projection || null;
|
|
161
|
+
let compact = {
|
|
162
|
+
percentage: diff.percentage ?? analysis.percentage ?? analysis.diff_percentage ?? comparison.diff_percentage ?? null,
|
|
163
|
+
changed_pixels: diff.changed_pixels ?? analysis.changed_pixels ?? comparison.changed_pixels ?? null,
|
|
164
|
+
total_pixels: diff.total_pixels ?? analysis.total_pixels ?? comparison.total_pixels ?? null,
|
|
165
|
+
threshold: diff.threshold ?? analysis.threshold ?? comparison.threshold ?? null,
|
|
166
|
+
fingerprint_hash: diff.fingerprint_hash || analysis.fingerprint_hash || comparison.fingerprint_hash || null,
|
|
167
|
+
region_count: diff.region_count ?? analysis.region_count ?? comparison.region_count ?? projection?.clusters?.count ?? (Array.isArray(regions) ? regions.length : null),
|
|
168
|
+
projection,
|
|
169
|
+
image_url: diff.image_url || diff.url || analysis.diff_image_url || comparison.diff_url || comparison.diff_image_url || null
|
|
170
|
+
};
|
|
171
|
+
if (includeDiffs) {
|
|
172
|
+
compact.regions = regions || [];
|
|
173
|
+
compact.cluster_metadata = diff.cluster_metadata || analysis.cluster_metadata || comparison.cluster_metadata || null;
|
|
174
|
+
compact.ssim_score = diff.ssim_score ?? analysis.ssim_score ?? comparison.ssim_score ?? null;
|
|
175
|
+
compact.gmsd_score = diff.gmsd_score ?? analysis.gmsd_score ?? comparison.gmsd_score ?? null;
|
|
176
|
+
compact.diff_lines = diff.diff_lines || analysis.diff_lines || comparison.diff_lines || [];
|
|
177
|
+
}
|
|
178
|
+
return compact;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Prefer explicit review need and return null when the API supplied no review fact.
|
|
183
|
+
*
|
|
184
|
+
* @param {Object} comparison - Comparison record from the API.
|
|
185
|
+
* @param {string|null} reviewState - Canonical review state for the record.
|
|
186
|
+
* @returns {boolean|null} Whether the comparison needs review, when known.
|
|
187
|
+
*/
|
|
188
|
+
function comparisonNeedsReview(comparison = {}, reviewState = null) {
|
|
189
|
+
if (comparison.needs_review != null) {
|
|
190
|
+
return comparison.needs_review === true;
|
|
191
|
+
}
|
|
192
|
+
if (reviewState == null) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
return reviewState === 'pending';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Normalize one comparison across grouped and legacy flat API response shapes.
|
|
200
|
+
*
|
|
201
|
+
* @param {Object} comparison - Comparison record from the API.
|
|
202
|
+
* @param {Object} options - Normalization options.
|
|
203
|
+
* @param {string|null} [options.fallbackName] - Group name for unnamed variants.
|
|
204
|
+
* @param {boolean} [options.includeDiffs] - Include raw Honeydiff diagnostics.
|
|
205
|
+
* @returns {Object} A stable comparison evidence record.
|
|
206
|
+
*/
|
|
207
|
+
export function normalizeComparisonRecord(comparison = {}, options = {}) {
|
|
208
|
+
let reviewState = getVisualReviewState(comparison);
|
|
209
|
+
let name = comparison.name || comparison.screenshot_name || comparison.current_screenshot?.name || comparison.screenshot?.name || comparison.current_name || options.fallbackName || comparison.id || null;
|
|
210
|
+
return {
|
|
211
|
+
id: comparison.id || null,
|
|
212
|
+
name,
|
|
213
|
+
result: getComparisonResult(comparison),
|
|
214
|
+
status: comparison.status || null,
|
|
215
|
+
review_state: reviewState,
|
|
216
|
+
visual_review: comparison.visual_review || null,
|
|
217
|
+
approval_status: comparison.approval_status || null,
|
|
218
|
+
build_branch: comparison.build_branch || null,
|
|
219
|
+
needs_review: comparisonNeedsReview(comparison, reviewState),
|
|
220
|
+
is_flaky: comparison.is_flaky == null ? null : comparison.is_flaky === true,
|
|
221
|
+
browser: getComparisonBrowser(comparison),
|
|
222
|
+
viewport: getComparisonViewport(comparison),
|
|
223
|
+
screenshot: getCurrentScreenshot(comparison),
|
|
224
|
+
baseline: getBaselineScreenshot(comparison),
|
|
225
|
+
diff: getComparisonDiff(comparison, options.includeDiffs === true)
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Read the server's total variant count across supported response shapes.
|
|
231
|
+
*
|
|
232
|
+
* @param {Object} group - Screenshot group from the API.
|
|
233
|
+
* @returns {number|null} The explicit total when present.
|
|
234
|
+
*/
|
|
235
|
+
function getExplicitVariantCount(group = {}) {
|
|
236
|
+
return group.variant_count ?? group.total_variants ?? group.totalVariants ?? null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Prove whether every variant is available before deriving aggregate facts.
|
|
241
|
+
*
|
|
242
|
+
* @param {Object} group - Screenshot group from the API.
|
|
243
|
+
* @param {Object[]} variants - Returned normalized variants.
|
|
244
|
+
* @param {boolean} forcedComplete - Whether the caller owns the complete set.
|
|
245
|
+
* @returns {boolean} Whether aggregate derivation is safe.
|
|
246
|
+
*/
|
|
247
|
+
function hasCompleteVariants(group = {}, variants = [], forcedComplete = false) {
|
|
248
|
+
if (forcedComplete || group.variants_complete === true) {
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
let explicitCount = getExplicitVariantCount(group);
|
|
252
|
+
return explicitCount != null && explicitCount === variants.length;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Derive only facts backed by a complete variant set and complete source fields.
|
|
257
|
+
*
|
|
258
|
+
* Unknown review, flaky, result, or diff values stay null instead of becoming
|
|
259
|
+
* client-authored false or zero values.
|
|
260
|
+
*
|
|
261
|
+
* @param {Object[]} variants - Complete normalized variants for one screenshot.
|
|
262
|
+
* @param {boolean} complete - Whether every variant is present.
|
|
263
|
+
* @returns {Object} Exact derived aggregate facts, or an empty object.
|
|
264
|
+
*/
|
|
265
|
+
function deriveAggregateFacts(variants = [], complete = false) {
|
|
266
|
+
if (!complete) {
|
|
267
|
+
return {};
|
|
268
|
+
}
|
|
269
|
+
let results = variants.map(variant => variant.result);
|
|
270
|
+
let reviewStates = variants.map(variant => variant.review_state);
|
|
271
|
+
let needsReview = variants.map(variant => variant.needs_review);
|
|
272
|
+
let flaky = variants.map(variant => variant.is_flaky);
|
|
273
|
+
let percentages = variants.map(variant => variant.diff.percentage).filter(value => value != null);
|
|
274
|
+
let allResultsKnown = results.every(value => value != null);
|
|
275
|
+
let allReviewStatesKnown = reviewStates.every(value => value != null);
|
|
276
|
+
let allNeedsReviewKnown = needsReview.every(value => value != null);
|
|
277
|
+
let allFlakyKnown = flaky.every(value => value != null);
|
|
278
|
+
return {
|
|
279
|
+
has_changes: results.includes('changed') ? true : allResultsKnown ? false : null,
|
|
280
|
+
has_new: results.includes('new') ? true : allResultsKnown ? false : null,
|
|
281
|
+
all_approved: variants.length > 0 && allReviewStatesKnown ? reviewStates.every(state => state === 'approved') : null,
|
|
282
|
+
needs_review: allNeedsReviewKnown ? needsReview.some(value => value === true) : null,
|
|
283
|
+
needs_review_count: allNeedsReviewKnown ? needsReview.filter(value => value === true).length : null,
|
|
284
|
+
failed_count: allResultsKnown ? results.filter(result => ['failed', 'error'].includes(result)).length : null,
|
|
285
|
+
has_rejected: reviewStates.includes('rejected') ? true : allReviewStatesKnown ? false : null,
|
|
286
|
+
has_flaky: flaky.includes(true) ? true : allFlakyKnown ? false : null,
|
|
287
|
+
max_diff_percentage: percentages.length === variants.length && percentages.length > 0 ? Math.max(...percentages) : null
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Normalize a screenshot group while keeping explicit server aggregates authoritative.
|
|
293
|
+
*
|
|
294
|
+
* @param {Object} group - Grouped visual-review record from the API.
|
|
295
|
+
* @param {Object} options - Normalization options.
|
|
296
|
+
* @param {boolean} [options.variantsComplete] - Marks a caller-owned complete set.
|
|
297
|
+
* @returns {Object} A normalized screenshot group and its variants.
|
|
298
|
+
*/
|
|
299
|
+
export function normalizeComparisonGroup(group = {}, options = {}) {
|
|
300
|
+
let rawVariants = group.variants || group.comparisons || [];
|
|
301
|
+
let groupName = group.name || group.test_name || group.testName || null;
|
|
302
|
+
let variants = rawVariants.map(variant => normalizeComparisonRecord(variant, {
|
|
303
|
+
fallbackName: groupName,
|
|
304
|
+
includeDiffs: options.includeDiffs
|
|
305
|
+
}));
|
|
306
|
+
let complete = hasCompleteVariants(group, variants, options.variantsComplete === true);
|
|
307
|
+
let derived = deriveAggregateFacts(variants, complete);
|
|
308
|
+
let aggregate = group.aggregate_status || {};
|
|
309
|
+
let needsReviewCount = aggregate.needs_review_count ?? derived.needs_review_count ?? null;
|
|
310
|
+
return {
|
|
311
|
+
name: groupName || variants[0]?.name || 'unknown screenshot',
|
|
312
|
+
variant_count: getExplicitVariantCount(group) ?? (complete ? variants.length : null),
|
|
313
|
+
variants_complete: complete,
|
|
314
|
+
variants,
|
|
315
|
+
aggregate_status: {
|
|
316
|
+
has_changes: aggregate.has_changes ?? derived.has_changes ?? null,
|
|
317
|
+
has_new: aggregate.has_new ?? derived.has_new ?? null,
|
|
318
|
+
all_approved: aggregate.all_approved ?? derived.all_approved ?? null,
|
|
319
|
+
needs_review: aggregate.needs_review ?? (aggregate.needs_review_count != null ? aggregate.needs_review_count > 0 : derived.needs_review ?? null),
|
|
320
|
+
needs_review_count: needsReviewCount,
|
|
321
|
+
failed_count: aggregate.failed_count ?? derived.failed_count ?? null,
|
|
322
|
+
has_rejected: aggregate.has_rejected ?? derived.has_rejected ?? null,
|
|
323
|
+
has_flaky: aggregate.has_flaky ?? derived.has_flaky ?? null,
|
|
324
|
+
max_diff_percentage: aggregate.max_diff_percentage ?? derived.max_diff_percentage ?? null
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Group a complete legacy flat comparison list by screenshot name.
|
|
331
|
+
*
|
|
332
|
+
* @param {Object[]} comparisons - Complete flat comparisons from build context.
|
|
333
|
+
* @param {Object} options - Normalization options passed to each comparison.
|
|
334
|
+
* @returns {Object[]} Normalized screenshot groups in API order.
|
|
335
|
+
*/
|
|
336
|
+
function groupFlatComparisons(comparisons = [], options = {}) {
|
|
337
|
+
let grouped = new Map();
|
|
338
|
+
for (let comparison of comparisons) {
|
|
339
|
+
let name = getComparisonName(comparison) || 'unknown screenshot';
|
|
340
|
+
let group = grouped.get(name) || {
|
|
341
|
+
name,
|
|
342
|
+
variants: []
|
|
343
|
+
};
|
|
344
|
+
group.variants.push(comparison);
|
|
345
|
+
grouped.set(name, group);
|
|
346
|
+
}
|
|
347
|
+
return [...grouped.values()].map(group => normalizeComparisonGroup(group, {
|
|
348
|
+
...options,
|
|
349
|
+
variantsComplete: true
|
|
350
|
+
}));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Join compact group variants to the exact comparison records in the same API
|
|
355
|
+
* response.
|
|
356
|
+
*
|
|
357
|
+
* The build context API deliberately keeps group variants small while also
|
|
358
|
+
* returning complete comparison evidence at the top level. Joining by the
|
|
359
|
+
* server-owned comparison ID keeps the useful group ordering and aggregates
|
|
360
|
+
* without dropping screenshot identity, baseline facts, or Honeydiff data.
|
|
361
|
+
*
|
|
362
|
+
* @param {Object[]} groups - Raw comparison groups from build context.
|
|
363
|
+
* @param {Object[]} comparisons - Exact top-level comparison records.
|
|
364
|
+
* @returns {Object[]} Groups whose variants include exact comparison evidence.
|
|
365
|
+
*/
|
|
366
|
+
function joinGroupComparisons(groups = [], comparisons = []) {
|
|
367
|
+
let comparisonsById = new Map(comparisons.filter(comparison => comparison?.id).map(comparison => [comparison.id, comparison]));
|
|
368
|
+
return groups.map(group => {
|
|
369
|
+
let variantKey = Array.isArray(group.variants) ? 'variants' : 'comparisons';
|
|
370
|
+
let variants = Array.isArray(group[variantKey]) ? group[variantKey] : [];
|
|
371
|
+
return {
|
|
372
|
+
...group,
|
|
373
|
+
[variantKey]: variants.map(variant => ({
|
|
374
|
+
...variant,
|
|
375
|
+
...(comparisonsById.get(variant.id) || {})
|
|
376
|
+
}))
|
|
377
|
+
};
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Keep failed capture identity, render evidence, and the API error together.
|
|
383
|
+
*
|
|
384
|
+
* A capture can fail before any comparison exists, so it must remain useful
|
|
385
|
+
* evidence without inheriting comparison-only assumptions.
|
|
386
|
+
*
|
|
387
|
+
* @param {Object} screenshot - Failed screenshot record from the API.
|
|
388
|
+
* @param {Object} options - Normalization options passed to the record.
|
|
389
|
+
* @returns {Object} A normalized failed-capture record.
|
|
390
|
+
*/
|
|
391
|
+
function normalizeFailedCapture(screenshot = {}, options = {}) {
|
|
392
|
+
let normalized = normalizeComparisonRecord({
|
|
393
|
+
...screenshot,
|
|
394
|
+
screenshot,
|
|
395
|
+
result: screenshot.result || screenshot.status || 'failed'
|
|
396
|
+
}, options);
|
|
397
|
+
return {
|
|
398
|
+
...normalized,
|
|
399
|
+
error_message: screenshot.error_message || normalized.screenshot.error_message || null
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Collect failed captures from explicit and screenshot-list response shapes.
|
|
405
|
+
*
|
|
406
|
+
* @param {Object} context - Raw build context response.
|
|
407
|
+
* @param {Object} options - Normalization options passed to each capture.
|
|
408
|
+
* @returns {Object[]} Deduplicated failed captures in API order.
|
|
409
|
+
*/
|
|
410
|
+
function getFailedCaptures(context = {}, options = {}) {
|
|
411
|
+
let explicit = Array.isArray(context.failed_captures) ? context.failed_captures : Array.isArray(context.failed_screenshots) ? context.failed_screenshots : [];
|
|
412
|
+
let screenshots = (context.screenshots || []).filter(screenshot => ['failed', 'error'].includes(screenshot.status));
|
|
413
|
+
let captures = [...explicit, ...screenshots];
|
|
414
|
+
let seen = new Set();
|
|
415
|
+
return captures.map(capture => normalizeFailedCapture(capture, options)).filter(capture => {
|
|
416
|
+
let key = capture.id || capture.screenshot.signature || capture.name;
|
|
417
|
+
if (seen.has(key)) {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
seen.add(key);
|
|
421
|
+
return true;
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Adapt raw build context for human presentation across current and legacy APIs.
|
|
427
|
+
*
|
|
428
|
+
* Raw JSON output intentionally bypasses this lossy presentation boundary.
|
|
429
|
+
*
|
|
430
|
+
* @param {Object} context - Raw build context response from the provider.
|
|
431
|
+
* @param {Object} options - Normalization options.
|
|
432
|
+
* @param {boolean} [options.includeDiffs] - Include raw Honeydiff diagnostics.
|
|
433
|
+
* @returns {Object} Normalized comparisons, groups, and failed captures.
|
|
434
|
+
*/
|
|
435
|
+
export function normalizeBuildContext(context = {}, options = {}) {
|
|
436
|
+
let rawComparisons = context.comparisons || [];
|
|
437
|
+
let comparisons = rawComparisons.map(comparison => normalizeComparisonRecord(comparison, options));
|
|
438
|
+
let joinedGroups = joinGroupComparisons(context.groups || [], rawComparisons);
|
|
439
|
+
let groups = joinedGroups.length > 0 ? joinedGroups.map(group => normalizeComparisonGroup(group, options)) : groupFlatComparisons(rawComparisons, options);
|
|
440
|
+
return {
|
|
441
|
+
...context,
|
|
442
|
+
comparisons,
|
|
443
|
+
groups,
|
|
444
|
+
failed_captures: getFailedCaptures(context, options)
|
|
445
|
+
};
|
|
446
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vizzly-testing/cli",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.3-beta.0",
|
|
4
4
|
"description": "Visual regression testing from your terminal",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"llm-context",
|
|
@@ -78,7 +78,6 @@
|
|
|
78
78
|
"test:swift:e2e": "pnpm run build && node clients/swift/scripts/run-e2e.js",
|
|
79
79
|
"test:tui": "node --test --test-reporter=spec tests/tui/*.test.js",
|
|
80
80
|
"test:tui:docker": "./tests/tui/run-tui-tests.sh",
|
|
81
|
-
"smoke:prod:pack": "node scripts/smoke-prod-pack.js",
|
|
82
81
|
"lint": "biome check src tests clients/storybook/src clients/storybook/tests clients/static-site/src clients/static-site/tests clients/vitest/src clients/vitest/tests clients/ember/src clients/ember/tests clients/ember/bin",
|
|
83
82
|
"lint:fix": "biome check --write --unsafe src tests clients/storybook/src clients/storybook/tests clients/static-site/src clients/static-site/tests clients/vitest/src clients/vitest/tests clients/ember/src clients/ember/tests clients/ember/bin",
|
|
84
83
|
"format": "biome format --write src tests clients/storybook/src clients/storybook/tests clients/static-site/src clients/static-site/tests clients/vitest/src clients/vitest/tests clients/ember/src clients/ember/tests clients/ember/bin",
|
|
@@ -93,10 +92,10 @@
|
|
|
93
92
|
"registry": "https://registry.npmjs.org/"
|
|
94
93
|
},
|
|
95
94
|
"dependencies": {
|
|
96
|
-
"@vizzly-testing/bear-den": "0.1.
|
|
97
|
-
"@vizzly-testing/honeydiff": "^0.
|
|
95
|
+
"@vizzly-testing/bear-den": "0.1.4",
|
|
96
|
+
"@vizzly-testing/honeydiff": "^0.11.1",
|
|
98
97
|
"ansis": "^4.2.0",
|
|
99
|
-
"commander": "^
|
|
98
|
+
"commander": "^15.0.0",
|
|
100
99
|
"cosmiconfig": "^9.0.0",
|
|
101
100
|
"dotenv": "^17.2.1",
|
|
102
101
|
"form-data": "^4.0.0",
|