@vizzly-testing/cli 0.31.0 → 0.32.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.
@@ -9,6 +9,8 @@
9
9
  * 2D boxes that users have manually confirmed via the cloud UI.
10
10
  */
11
11
 
12
+ let REGION_CENTER_TOLERANCE = 10;
13
+
12
14
  /**
13
15
  * Check if a diff cluster intersects with a region (2D box intersection)
14
16
  *
@@ -41,13 +43,77 @@ export function clusterIntersectsRegion(cluster, region) {
41
43
  let noOverlap = clusterX2 < region.x1 || clusterX1 > region.x2 || clusterY2 < region.y1 || clusterY1 > region.y2;
42
44
  return !noOverlap;
43
45
  }
46
+ function normalizeBoundingBox(cluster) {
47
+ let box = cluster?.boundingBox || cluster;
48
+ if (!box) {
49
+ return null;
50
+ }
51
+ if (Number.isFinite(box.x) && Number.isFinite(box.y) && Number.isFinite(box.width) && Number.isFinite(box.height)) {
52
+ return {
53
+ x1: box.x,
54
+ y1: box.y,
55
+ x2: box.x + box.width,
56
+ y2: box.y + box.height,
57
+ width: Math.abs(box.width),
58
+ height: Math.abs(box.height)
59
+ };
60
+ }
61
+ if (Number.isFinite(box.x1) && Number.isFinite(box.y1) && Number.isFinite(box.x2) && Number.isFinite(box.y2)) {
62
+ let width = Math.abs(box.x2 - box.x1);
63
+ let height = Math.abs(box.y2 - box.y1);
64
+ return {
65
+ x1: box.x1,
66
+ y1: box.y1,
67
+ x2: box.x2,
68
+ y2: box.y2,
69
+ width,
70
+ height
71
+ };
72
+ }
73
+ return null;
74
+ }
75
+ function getBoundingBoxCenter(box) {
76
+ return {
77
+ x: (box.x1 + box.x2) / 2,
78
+ y: (box.y1 + box.y2) / 2
79
+ };
80
+ }
81
+ function getRegionLabel(region) {
82
+ return region.id || region.label || null;
83
+ }
84
+ function clusterMatchesRegion(cluster, region) {
85
+ let clusterBox = normalizeBoundingBox(cluster);
86
+ let regionBox = normalizeBoundingBox(region);
87
+ if (!clusterBox || !regionBox) {
88
+ return false;
89
+ }
90
+ let clusterCenter = getBoundingBoxCenter(clusterBox);
91
+ let regionCenter = getBoundingBoxCenter(regionBox);
92
+ let distance = Math.sqrt((clusterCenter.x - regionCenter.x) ** 2 + (clusterCenter.y - regionCenter.y) ** 2);
93
+ return distance <= REGION_CENTER_TOLERANCE;
94
+ }
95
+ function estimateClusterPixels(cluster) {
96
+ if (Number.isFinite(cluster?.pixelCount)) {
97
+ return cluster.pixelCount;
98
+ }
99
+ let box = normalizeBoundingBox(cluster);
100
+ if (!box) {
101
+ return 0;
102
+ }
103
+ return box.width * box.height * 0.5;
104
+ }
44
105
 
45
106
  /**
46
- * Calculate what percentage of diff clusters fall within region boxes
107
+ * Calculate what percentage of changed pixels match confirmed regions.
108
+ *
109
+ * This mirrors cloud dynamic-region approval: clusters match confirmed regions
110
+ * by center proximity, then coverage is weighted by changed pixels. A huge
111
+ * unmatched change should not be hidden just because several tiny clusters
112
+ * match confirmed regions.
47
113
  *
48
114
  * @param {Array} diffClusters - Array of diff clusters from honeydiff
49
115
  * @param {Array} regions - Array of confirmed regions { x1, y1, x2, y2 }
50
- * @returns {{ coverage: number, clustersInRegions: number, totalClusters: number, matchedRegions: string[] }}
116
+ * @returns {{ coverage: number, clustersInRegions: number, totalClusters: number, matchedRegions: string[], pixelsInRegions: number, totalPixels: number }}
51
117
  */
52
118
  export function calculateRegionCoverage(diffClusters, regions) {
53
119
  if (!diffClusters || diffClusters.length === 0) {
@@ -55,7 +121,9 @@ export function calculateRegionCoverage(diffClusters, regions) {
55
121
  coverage: 0,
56
122
  clustersInRegions: 0,
57
123
  totalClusters: 0,
58
- matchedRegions: []
124
+ matchedRegions: [],
125
+ pixelsInRegions: 0,
126
+ totalPixels: 0
59
127
  };
60
128
  }
61
129
  if (!regions || regions.length === 0) {
@@ -63,35 +131,41 @@ export function calculateRegionCoverage(diffClusters, regions) {
63
131
  coverage: 0,
64
132
  clustersInRegions: 0,
65
133
  totalClusters: diffClusters.length,
66
- matchedRegions: []
134
+ matchedRegions: [],
135
+ pixelsInRegions: 0,
136
+ totalPixels: 0
67
137
  };
68
138
  }
69
139
  let clustersInRegions = 0;
70
140
  let matchedRegionIds = new Set();
141
+ let pixelsInRegions = 0;
142
+ let totalPixels = 0;
71
143
  for (let cluster of diffClusters) {
72
- // Check if this cluster intersects any region
73
- let intersectsAnyRegion = false;
144
+ let pixelCount = estimateClusterPixels(cluster);
145
+ totalPixels += pixelCount;
146
+ let matchedAnyRegion = false;
74
147
  for (let region of regions) {
75
- if (clusterIntersectsRegion(cluster, region)) {
76
- intersectsAnyRegion = true;
77
- // Track which regions were matched (for debugging/display)
78
- if (region.id) {
79
- matchedRegionIds.add(region.id);
80
- } else if (region.label) {
81
- matchedRegionIds.add(region.label);
148
+ if (clusterMatchesRegion(cluster, region)) {
149
+ matchedAnyRegion = true;
150
+ let regionLabel = getRegionLabel(region);
151
+ if (regionLabel) {
152
+ matchedRegionIds.add(regionLabel);
82
153
  }
83
154
  }
84
155
  }
85
- if (intersectsAnyRegion) {
156
+ if (matchedAnyRegion) {
86
157
  clustersInRegions++;
158
+ pixelsInRegions += pixelCount;
87
159
  }
88
160
  }
89
- let coverage = clustersInRegions / diffClusters.length;
161
+ let coverage = totalPixels > 0 ? pixelsInRegions / totalPixels : 0;
90
162
  return {
91
163
  coverage,
92
164
  clustersInRegions,
93
165
  totalClusters: diffClusters.length,
94
- matchedRegions: [...matchedRegionIds]
166
+ matchedRegions: [...matchedRegionIds],
167
+ pixelsInRegions,
168
+ totalPixels
95
169
  };
96
170
  }
97
171
 
@@ -99,17 +173,31 @@ export function calculateRegionCoverage(diffClusters, regions) {
99
173
  * Determine if a comparison should auto-pass based on region coverage
100
174
  *
101
175
  * Unlike hotspots which require confidence scoring, user-defined regions
102
- * are already confirmed by humans, so we only need the 80% threshold.
176
+ * are already confirmed by humans. Cloud still requires strong structural
177
+ * similarity so a layout shift cannot hide inside a confirmed box.
103
178
  *
104
179
  * @param {Array} regions - Confirmed regions (already filtered to confirmed status)
105
180
  * @param {{ coverage: number }} coverageResult - Result from calculateRegionCoverage
181
+ * @param {Object} options - Approval thresholds
182
+ * @param {number|null} options.ssimScore - Honeydiff SSIM/perceptual score
183
+ * @param {number} options.coverageThreshold - Required region coverage
184
+ * @param {number} options.ssimThreshold - Required structural similarity
106
185
  * @returns {boolean} True if diff should auto-pass as region-filtered
107
186
  */
108
- export function shouldAutoApproveFromRegions(regions, coverageResult) {
187
+ export function shouldAutoApproveFromRegions(regions, coverageResult, options = {}) {
109
188
  if (!regions || regions.length === 0 || !coverageResult) {
110
189
  return false;
111
190
  }
112
-
113
- // Need at least 80% of diff clusters in confirmed regions
114
- return coverageResult.coverage >= 0.8;
191
+ let {
192
+ ssimScore = null,
193
+ coverageThreshold = 0.9,
194
+ ssimThreshold = 0.95
195
+ } = options;
196
+ if (coverageResult.coverage < coverageThreshold) {
197
+ return false;
198
+ }
199
+ if (ssimScore === null || ssimScore === undefined) {
200
+ return false;
201
+ }
202
+ return ssimScore >= ssimThreshold;
115
203
  }
@@ -32,6 +32,9 @@ export async function compareImages(baselinePath, currentPath, diffPath, options
32
32
  diffPath,
33
33
  overwrite: true,
34
34
  includeClusters: true,
35
+ includeSSIM: true,
36
+ includeGMSD: true,
37
+ clusterMerge: true,
35
38
  minClusterSize
36
39
  });
37
40
  }
@@ -141,6 +144,7 @@ export function buildFailedComparison(params) {
141
144
  regionData
142
145
  } = params;
143
146
  let diffClusters = honeydiffResult.diffClusters || [];
147
+ let ssimScore = honeydiffResult.perceptualScore ?? honeydiffResult.ssimScore ?? honeydiffResult.ssim_score ?? null;
144
148
  let isFiltered = false;
145
149
  let filterReason = 'pixel-diff';
146
150
 
@@ -150,7 +154,9 @@ export function buildFailedComparison(params) {
150
154
  let confirmedRegions = regionData?.confirmed || [];
151
155
  if (confirmedRegions.length > 0 && diffClusters.length > 0) {
152
156
  regionCoverage = calculateRegionCoverage(diffClusters, confirmedRegions);
153
- if (shouldAutoApproveFromRegions(confirmedRegions, regionCoverage)) {
157
+ if (shouldAutoApproveFromRegions(confirmedRegions, regionCoverage, {
158
+ ssimScore
159
+ })) {
154
160
  isRegionFiltered = true;
155
161
  isFiltered = true;
156
162
  filterReason = 'region-filtered';
@@ -198,8 +204,11 @@ export function buildFailedComparison(params) {
198
204
  coverage: regionCoverage.coverage,
199
205
  clustersInRegions: regionCoverage.clustersInRegions,
200
206
  totalClusters: regionCoverage.totalClusters,
207
+ pixelsInRegions: regionCoverage.pixelsInRegions,
208
+ totalPixels: regionCoverage.totalPixels,
201
209
  matchedRegions: regionCoverage.matchedRegions,
202
210
  confirmedCount: confirmedRegions.length,
211
+ ssimScore,
203
212
  isFiltered: isRegionFiltered
204
213
  } : null,
205
214
  // Include confirmed regions for visualization in UI
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { existsSync as defaultExistsSync, mkdirSync as defaultMkdirSync, readFileSync as defaultReadFileSync, writeFileSync as defaultWriteFileSync } from 'node:fs';
12
- import { createApiClient as defaultCreateApiClient, getBatchHotspots as defaultGetBatchHotspots, getBuilds as defaultGetBuilds, getComparison as defaultGetComparison, getTddBaselines as defaultGetTddBaselines } from '../api/index.js';
12
+ import { createApiClient as defaultCreateApiClient, getBatchHotspots as defaultGetBatchHotspots, getBuilds as defaultGetBuilds, getComparison as defaultGetComparison, getComparisonContext as defaultGetComparisonContext, getTddBaselines as defaultGetTddBaselines } from '../api/index.js';
13
13
  import { NetworkError } from '../errors/vizzly-error.js';
14
14
  import { colors as defaultColors } from '../utils/colors.js';
15
15
  import { fetchWithTimeout as defaultFetchWithTimeout } from '../utils/fetch-utils.js';
@@ -58,6 +58,7 @@ export class TddService {
58
58
  getTddBaselines: defaultGetTddBaselines,
59
59
  getBuilds: defaultGetBuilds,
60
60
  getComparison: defaultGetComparison,
61
+ getComparisonContext: defaultGetComparisonContext,
61
62
  getBatchHotspots: defaultGetBatchHotspots,
62
63
  fetchWithTimeout: defaultFetchWithTimeout,
63
64
  getDefaultBranch: defaultGetDefaultBranch
@@ -169,6 +170,7 @@ export class TddService {
169
170
  getTddBaselines,
170
171
  getBuilds,
171
172
  getComparison,
173
+ getComparisonContext,
172
174
  clearBaselineData,
173
175
  generateScreenshotSignature,
174
176
  generateBaselineFilename,
@@ -221,10 +223,19 @@ export class TddService {
221
223
  output.warn(`Build ${buildId} has status: ${baselineBuild.status} (expected: completed)`);
222
224
  }
223
225
  baselineBuild.screenshots = apiResponse.screenshots;
226
+ this.saveDynamicMetadataFromBaselineResponse(apiResponse);
224
227
  } else if (comparisonId) {
225
228
  // Handle specific comparison download
226
229
  output.info(`Using comparison: ${comparisonId}`);
227
230
  let comparison = await getComparison(this.client, comparisonId);
231
+ let comparisonContext = null;
232
+ if (!comparison.dynamic_content && getComparisonContext) {
233
+ try {
234
+ comparisonContext = await getComparisonContext(this.client, comparisonId);
235
+ } catch (error) {
236
+ output.debug('tdd', `comparison context unavailable for ${comparisonId}: ${error.message}`);
237
+ }
238
+ }
228
239
  if (!comparison.baseline_screenshot) {
229
240
  throw new Error(`Comparison ${comparisonId} has no baseline screenshot. This comparison may be a "new" screenshot.`);
230
241
  }
@@ -255,6 +266,11 @@ export class TddService {
255
266
  }
256
267
  }
257
268
  let screenshotName = comparison.baseline_name || comparison.current_name;
269
+ let dynamicContent = comparison.dynamic_content || {
270
+ hotspot_analysis: comparisonContext?.history?.hotspot_analysis,
271
+ confirmed_regions: comparisonContext?.history?.confirmed_regions
272
+ };
273
+ this.saveDynamicMetadataForScreenshot(screenshotName, dynamicContent);
258
274
  let signature = generateScreenshotSignature(screenshotName, screenshotProperties, this.signatureProperties);
259
275
  let filename = generateBaselineFilename(screenshotName, signature);
260
276
  baselineBuild = {
@@ -294,16 +310,7 @@ export class TddService {
294
310
  }
295
311
  baselineBuild = apiResponse.build;
296
312
  baselineBuild.screenshots = apiResponse.screenshots;
297
-
298
- // Store bundled hotspots and regions from API response
299
- if (apiResponse.hotspots && Object.keys(apiResponse.hotspots).length > 0) {
300
- this.hotspotData = apiResponse.hotspots;
301
- saveHotspotMetadata(this.workingDir, apiResponse.hotspots, apiResponse.summary);
302
- }
303
- if (apiResponse.regions && Object.keys(apiResponse.regions).length > 0) {
304
- this.regionData = apiResponse.regions;
305
- saveRegionMetadata(this.workingDir, apiResponse.regions, apiResponse.summary);
306
- }
313
+ this.saveDynamicMetadataFromBaselineResponse(apiResponse);
307
314
  }
308
315
  let buildDetails = baselineBuild;
309
316
  if (!buildDetails.screenshots || buildDetails.screenshots.length === 0) {
@@ -488,6 +495,49 @@ export class TddService {
488
495
  throw error;
489
496
  }
490
497
  }
498
+ saveDynamicMetadataFromBaselineResponse(apiResponse = {}) {
499
+ let {
500
+ saveHotspotMetadata,
501
+ saveRegionMetadata
502
+ } = this._deps;
503
+ let hasHotspots = Object.keys(apiResponse.hotspots || {}).length > 0;
504
+ let hasRegions = Object.keys(apiResponse.regions || {}).length > 0;
505
+ if (hasHotspots) {
506
+ this.hotspotData = apiResponse.hotspots;
507
+ saveHotspotMetadata(this.workingDir, apiResponse.hotspots, apiResponse.summary);
508
+ }
509
+ if (hasRegions) {
510
+ this.regionData = apiResponse.regions;
511
+ saveRegionMetadata(this.workingDir, apiResponse.regions, apiResponse.summary);
512
+ }
513
+ }
514
+ saveDynamicMetadataForScreenshot(screenshotName, dynamicContent = {}) {
515
+ if (!screenshotName) {
516
+ return;
517
+ }
518
+ let hotspotAnalysis = dynamicContent.hotspot_analysis;
519
+ let confirmedRegions = dynamicContent.confirmed_regions || [];
520
+ let hasHotspotAnalysis = hotspotAnalysis?.total_builds_analyzed > 0;
521
+ let hasConfirmedRegions = confirmedRegions.length > 0;
522
+ let summary = {
523
+ screenshots: 1,
524
+ hotspots: hasHotspotAnalysis ? 1 : 0,
525
+ regions: hasConfirmedRegions ? 1 : 0,
526
+ total_regions: confirmedRegions.length
527
+ };
528
+ this.saveDynamicMetadataFromBaselineResponse({
529
+ hotspots: hasHotspotAnalysis ? {
530
+ [screenshotName]: hotspotAnalysis
531
+ } : {},
532
+ regions: hasConfirmedRegions ? {
533
+ [screenshotName]: {
534
+ confirmed: confirmedRegions,
535
+ candidates: []
536
+ }
537
+ } : {},
538
+ summary
539
+ });
540
+ }
491
541
 
492
542
  /**
493
543
  * Process already-fetched baseline data (for use when caller handles auth)
@@ -526,20 +576,7 @@ export class TddService {
526
576
  }
527
577
  }
528
578
  let baselineBuild = apiResponse.build;
529
-
530
- // Store bundled hotspots and regions from API response
531
- let {
532
- saveHotspotMetadata,
533
- saveRegionMetadata
534
- } = this._deps;
535
- if (apiResponse.hotspots && Object.keys(apiResponse.hotspots).length > 0) {
536
- this.hotspotData = apiResponse.hotspots;
537
- saveHotspotMetadata(this.workingDir, apiResponse.hotspots, apiResponse.summary);
538
- }
539
- if (apiResponse.regions && Object.keys(apiResponse.regions).length > 0) {
540
- this.regionData = apiResponse.regions;
541
- saveRegionMetadata(this.workingDir, apiResponse.regions, apiResponse.summary);
542
- }
579
+ this.saveDynamicMetadataFromBaselineResponse(apiResponse);
543
580
  if (baselineBuild.status === 'failed') {
544
581
  output.warn(`Build ${buildId} is marked as FAILED - falling back to local baselines`);
545
582
  return await this.handleLocalBaselines();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizzly-testing/cli",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "Visual review for agents and teams",
5
5
  "keywords": [
6
6
  "llm-context",
@@ -90,13 +90,15 @@
90
90
  "registry": "https://registry.npmjs.org/"
91
91
  },
92
92
  "dependencies": {
93
- "@vizzly-testing/honeydiff": "^0.10.0",
93
+ "@vizzly-testing/honeydiff": "^0.10.3",
94
94
  "ansis": "^4.2.0",
95
95
  "commander": "^14.0.0",
96
96
  "cosmiconfig": "^9.0.0",
97
97
  "dotenv": "^17.2.1",
98
98
  "form-data": "^4.0.0",
99
99
  "glob": "^13.0.0",
100
+ "react": "^19.1.1",
101
+ "react-dom": "^19.1.1",
100
102
  "zod": "^4.1.12"
101
103
  },
102
104
  "tsd": {
@@ -128,8 +130,6 @@
128
130
  "babel-plugin-react-compiler": "^1.0.0",
129
131
  "babel-plugin-transform-remove-console": "^6.9.4",
130
132
  "postcss": "^8.5.6",
131
- "react": "^19.1.1",
132
- "react-dom": "^19.1.1",
133
133
  "rimraf": "^6.0.1",
134
134
  "tailwindcss": "^4.1.13",
135
135
  "tsd": "^0.33.0",