@vizzly-testing/cli 0.23.2 → 0.24.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.
@@ -386,21 +386,16 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
386
386
  const vizzlyDir = join(workingDir, '.vizzly');
387
387
 
388
388
  // Record the comparison for the dashboard
389
+ // Spread the full comparison to include regionAnalysis, confirmedRegions, hotspotAnalysis, etc.
389
390
  const newComparison = {
390
- id: comparison.id,
391
- // Include unique ID for variant identification
392
- name: comparison.name,
391
+ ...comparison,
393
392
  originalName: name,
394
- status: comparison.status,
393
+ // Convert absolute file paths to web-accessible URLs
395
394
  baseline: convertPathToUrl(comparison.baseline, vizzlyDir),
396
395
  current: convertPathToUrl(comparison.current, vizzlyDir),
397
396
  diff: convertPathToUrl(comparison.diff, vizzlyDir),
398
- diffPercentage: comparison.diffPercentage,
399
- threshold: comparison.threshold,
400
- properties: extractedProperties,
401
397
  // Use extracted properties with top-level viewport_width/browser
402
- signature: comparison.signature,
403
- // Include signature for debugging
398
+ properties: extractedProperties,
404
399
  timestamp: Date.now()
405
400
  };
406
401
 
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Region Coverage Calculation
3
+ *
4
+ * Pure functions for calculating how much of a visual diff falls within
5
+ * user-defined "region" areas - 2D bounding boxes that users have confirmed
6
+ * as dynamic content areas (e.g., timestamps, animations, user avatars).
7
+ *
8
+ * Unlike hotspots (1D Y-bands from historical analysis), regions are explicit
9
+ * 2D boxes that users have manually confirmed via the cloud UI.
10
+ */
11
+
12
+ /**
13
+ * Check if a diff cluster intersects with a region (2D box intersection)
14
+ *
15
+ * @param {Object} cluster - Diff cluster with boundingBox { x, y, width, height }
16
+ * @param {Object} region - Region with { x1, y1, x2, y2 }
17
+ * @returns {boolean} True if the cluster overlaps the region
18
+ */
19
+ export function clusterIntersectsRegion(cluster, region) {
20
+ if (!cluster?.boundingBox || !region) {
21
+ return false;
22
+ }
23
+ let {
24
+ x,
25
+ y,
26
+ width,
27
+ height
28
+ } = cluster.boundingBox;
29
+
30
+ // Convert cluster to x1,y1,x2,y2 format
31
+ let clusterX1 = x;
32
+ let clusterY1 = y;
33
+ let clusterX2 = x + width;
34
+ let clusterY2 = y + height;
35
+
36
+ // Box intersection: NOT (one is completely outside the other)
37
+ // A is left of B: clusterX2 < region.x1
38
+ // A is right of B: clusterX1 > region.x2
39
+ // A is above B: clusterY2 < region.y1
40
+ // A is below B: clusterY1 > region.y2
41
+ let noOverlap = clusterX2 < region.x1 || clusterX1 > region.x2 || clusterY2 < region.y1 || clusterY1 > region.y2;
42
+ return !noOverlap;
43
+ }
44
+
45
+ /**
46
+ * Calculate what percentage of diff clusters fall within region boxes
47
+ *
48
+ * @param {Array} diffClusters - Array of diff clusters from honeydiff
49
+ * @param {Array} regions - Array of confirmed regions { x1, y1, x2, y2 }
50
+ * @returns {{ coverage: number, clustersInRegions: number, totalClusters: number, matchedRegions: string[] }}
51
+ */
52
+ export function calculateRegionCoverage(diffClusters, regions) {
53
+ if (!diffClusters || diffClusters.length === 0) {
54
+ return {
55
+ coverage: 0,
56
+ clustersInRegions: 0,
57
+ totalClusters: 0,
58
+ matchedRegions: []
59
+ };
60
+ }
61
+ if (!regions || regions.length === 0) {
62
+ return {
63
+ coverage: 0,
64
+ clustersInRegions: 0,
65
+ totalClusters: diffClusters.length,
66
+ matchedRegions: []
67
+ };
68
+ }
69
+ let clustersInRegions = 0;
70
+ let matchedRegionIds = new Set();
71
+ for (let cluster of diffClusters) {
72
+ // Check if this cluster intersects any region
73
+ let intersectsAnyRegion = false;
74
+ 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);
82
+ }
83
+ }
84
+ }
85
+ if (intersectsAnyRegion) {
86
+ clustersInRegions++;
87
+ }
88
+ }
89
+ let coverage = clustersInRegions / diffClusters.length;
90
+ return {
91
+ coverage,
92
+ clustersInRegions,
93
+ totalClusters: diffClusters.length,
94
+ matchedRegions: [...matchedRegionIds]
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Determine if a comparison should auto-pass based on region coverage
100
+ *
101
+ * Unlike hotspots which require confidence scoring, user-defined regions
102
+ * are already confirmed by humans, so we only need the 80% threshold.
103
+ *
104
+ * @param {Array} regions - Confirmed regions (already filtered to confirmed status)
105
+ * @param {{ coverage: number }} coverageResult - Result from calculateRegionCoverage
106
+ * @returns {boolean} True if diff should auto-pass as region-filtered
107
+ */
108
+ export function shouldAutoApproveFromRegions(regions, coverageResult) {
109
+ if (!regions || regions.length === 0 || !coverageResult) {
110
+ return false;
111
+ }
112
+
113
+ // Need at least 80% of diff clusters in confirmed regions
114
+ return coverageResult.coverage >= 0.8;
115
+ }
@@ -52,7 +52,9 @@ export function generateScreenshotSignature(name, properties = {}, customPropert
52
52
  value = properties.viewport?.width;
53
53
  }
54
54
  } else if (propName === 'browser') {
55
- value = properties.browser;
55
+ // Normalize browser to lowercase for consistent matching
56
+ // (Playwright reports "firefox", but cloud may store "Firefox")
57
+ value = properties.browser?.toLowerCase?.() ?? properties.browser;
56
58
  } else {
57
59
  // Custom property - check multiple locations
58
60
  value = properties[propName] ?? properties.metadata?.[propName] ?? properties.metadata?.properties?.[propName];
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Region Metadata I/O
3
+ *
4
+ * Functions for reading and writing user-defined hotspot region data.
5
+ * Regions are 2D bounding boxes that users have confirmed as dynamic content areas.
6
+ * Unlike historical hotspots (1D Y-bands), these are explicit definitions.
7
+ */
8
+
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+
12
+ /**
13
+ * Load region data from disk
14
+ *
15
+ * @param {string} workingDir - Working directory containing .vizzly folder
16
+ * @returns {Object|null} Region data keyed by screenshot name, or null if not found
17
+ */
18
+ export function loadRegionMetadata(workingDir) {
19
+ let regionsPath = join(workingDir, '.vizzly', 'regions.json');
20
+ if (!existsSync(regionsPath)) {
21
+ return null;
22
+ }
23
+ try {
24
+ let content = readFileSync(regionsPath, 'utf8');
25
+ let data = JSON.parse(content);
26
+ return data.regions || null;
27
+ } catch {
28
+ // Return null for parse/read errors
29
+ return null;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Save region data to disk
35
+ *
36
+ * @param {string} workingDir - Working directory containing .vizzly folder
37
+ * @param {Object} regionData - Region data keyed by screenshot name
38
+ * @param {Object} summary - Summary information about the regions
39
+ */
40
+ export function saveRegionMetadata(workingDir, regionData, summary = {}) {
41
+ let vizzlyDir = join(workingDir, '.vizzly');
42
+
43
+ // Ensure directory exists
44
+ if (!existsSync(vizzlyDir)) {
45
+ mkdirSync(vizzlyDir, {
46
+ recursive: true
47
+ });
48
+ }
49
+ let regionsPath = join(vizzlyDir, 'regions.json');
50
+ let content = {
51
+ downloadedAt: new Date().toISOString(),
52
+ summary,
53
+ regions: regionData
54
+ };
55
+ writeFileSync(regionsPath, JSON.stringify(content, null, 2));
56
+ }
57
+
58
+ /**
59
+ * Get regions for a specific screenshot with caching support
60
+ *
61
+ * This is a pure function that takes a cache object as parameter
62
+ * for stateless operation. The cache is mutated if data needs to be loaded.
63
+ *
64
+ * @param {Object} cache - Cache object { data: Object|null, loaded: boolean }
65
+ * @param {string} workingDir - Working directory
66
+ * @param {string} screenshotName - Name of the screenshot
67
+ * @returns {Object|null} Region data or null if not available
68
+ */
69
+ export function getRegionsForScreenshot(cache, workingDir, screenshotName) {
70
+ // Check cache first
71
+ if (cache.data?.[screenshotName]) {
72
+ return cache.data[screenshotName];
73
+ }
74
+
75
+ // Load from disk if not yet loaded
76
+ if (!cache.loaded) {
77
+ cache.data = loadRegionMetadata(workingDir);
78
+ cache.loaded = true;
79
+ }
80
+ return cache.data?.[screenshotName] || null;
81
+ }
82
+
83
+ /**
84
+ * Create an empty region cache object
85
+ *
86
+ * @returns {{ data: null, loaded: boolean }}
87
+ */
88
+ export function createRegionCache() {
89
+ return {
90
+ data: null,
91
+ loaded: false
92
+ };
93
+ }
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { compare } from '@vizzly-testing/honeydiff';
8
8
  import { calculateHotspotCoverage } from '../core/hotspot-coverage.js';
9
+ import { calculateRegionCoverage, shouldAutoApproveFromRegions } from '../core/region-coverage.js';
9
10
  import { generateComparisonId } from '../core/signature.js';
10
11
 
11
12
  /**
@@ -122,6 +123,7 @@ export function buildNewComparison(params) {
122
123
  * @param {number} params.minClusterSize - Effective minClusterSize used
123
124
  * @param {Object} params.honeydiffResult - Result from honeydiff
124
125
  * @param {Object} params.hotspotAnalysis - Hotspot data for this screenshot (optional)
126
+ * @param {Object} params.regionData - User-defined region data { confirmed: [], candidates: [] } (optional)
125
127
  * @returns {Object} Comparison result
126
128
  */
127
129
  export function buildFailedComparison(params) {
@@ -135,27 +137,45 @@ export function buildFailedComparison(params) {
135
137
  threshold,
136
138
  minClusterSize,
137
139
  honeydiffResult,
138
- hotspotAnalysis
140
+ hotspotAnalysis,
141
+ regionData
139
142
  } = params;
143
+ let diffClusters = honeydiffResult.diffClusters || [];
144
+ let isFiltered = false;
145
+ let filterReason = 'pixel-diff';
140
146
 
141
- // Calculate hotspot coverage if we have hotspot data
147
+ // Region analysis (user-confirmed 2D boxes) - check FIRST (takes priority)
148
+ let regionCoverage = null;
149
+ let isRegionFiltered = false;
150
+ let confirmedRegions = regionData?.confirmed || [];
151
+ if (confirmedRegions.length > 0 && diffClusters.length > 0) {
152
+ regionCoverage = calculateRegionCoverage(diffClusters, confirmedRegions);
153
+ if (shouldAutoApproveFromRegions(confirmedRegions, regionCoverage)) {
154
+ isRegionFiltered = true;
155
+ isFiltered = true;
156
+ filterReason = 'region-filtered';
157
+ }
158
+ }
159
+
160
+ // Hotspot analysis (1D Y-bands from historical data) - check SECOND
142
161
  let hotspotCoverage = null;
143
162
  let isHotspotFiltered = false;
144
- if (hotspotAnalysis && honeydiffResult.diffClusters?.length > 0) {
145
- hotspotCoverage = calculateHotspotCoverage(honeydiffResult.diffClusters, hotspotAnalysis);
163
+ if (!isFiltered && hotspotAnalysis && diffClusters.length > 0) {
164
+ hotspotCoverage = calculateHotspotCoverage(diffClusters, hotspotAnalysis);
146
165
 
147
166
  // Check if diff should be filtered as hotspot noise
148
- // Using shouldFilterAsHotspot helper but also checking confidence_score
149
167
  // (cloud uses confidence_score >= 70 which is >0.7 when normalized)
150
168
  let isHighConfidence = hotspotAnalysis.confidence === 'high' || hotspotAnalysis.confidence_score !== undefined && hotspotAnalysis.confidence_score >= 70;
151
169
  if (isHighConfidence && hotspotCoverage.coverage >= 0.8) {
152
170
  isHotspotFiltered = true;
171
+ isFiltered = true;
172
+ filterReason = 'hotspot-filtered';
153
173
  }
154
174
  }
155
175
  return {
156
176
  id: generateComparisonId(signature),
157
177
  name,
158
- status: isHotspotFiltered ? 'passed' : 'failed',
178
+ status: isFiltered ? 'passed' : 'failed',
159
179
  baseline: baselinePath,
160
180
  current: currentPath,
161
181
  diff: diffPath,
@@ -165,14 +185,26 @@ export function buildFailedComparison(params) {
165
185
  minClusterSize,
166
186
  diffPercentage: honeydiffResult.diffPercentage,
167
187
  diffCount: honeydiffResult.diffPixels,
168
- reason: isHotspotFiltered ? 'hotspot-filtered' : 'pixel-diff',
188
+ reason: filterReason,
169
189
  totalPixels: honeydiffResult.totalPixels,
170
190
  aaPixelsIgnored: honeydiffResult.aaPixelsIgnored,
171
191
  aaPercentage: honeydiffResult.aaPercentage,
172
192
  boundingBox: honeydiffResult.boundingBox,
173
193
  heightDiff: honeydiffResult.heightDiff,
174
194
  intensityStats: honeydiffResult.intensityStats,
175
- diffClusters: honeydiffResult.diffClusters,
195
+ diffClusters,
196
+ // User-defined region analysis (2D boxes)
197
+ regionAnalysis: regionCoverage ? {
198
+ coverage: regionCoverage.coverage,
199
+ clustersInRegions: regionCoverage.clustersInRegions,
200
+ totalClusters: regionCoverage.totalClusters,
201
+ matchedRegions: regionCoverage.matchedRegions,
202
+ confirmedCount: confirmedRegions.length,
203
+ isFiltered: isRegionFiltered
204
+ } : null,
205
+ // Include confirmed regions for visualization in UI
206
+ confirmedRegions: confirmedRegions.length > 0 ? confirmedRegions : null,
207
+ // Historical hotspot analysis (1D Y-bands)
176
208
  hotspotAnalysis: hotspotCoverage ? {
177
209
  coverage: hotspotCoverage.coverage,
178
210
  linesInHotspots: hotspotCoverage.linesInHotspots,
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Region Service
3
+ *
4
+ * Functions for downloading and managing user-defined hotspot regions from the cloud.
5
+ * Regions are 2D bounding boxes that users have confirmed as dynamic content areas.
6
+ */
7
+
8
+ import { saveRegionMetadata } from '../metadata/region-metadata.js';
9
+
10
+ /**
11
+ * Download user-defined regions from cloud API
12
+ *
13
+ * @param {Object} options
14
+ * @param {Object} options.api - ApiService instance
15
+ * @param {string} options.workingDir - Working directory
16
+ * @param {string[]} options.screenshotNames - Names of screenshots to get regions for
17
+ * @param {boolean} options.includeCandidates - Include candidate regions (default: false)
18
+ * @returns {Promise<{ success: boolean, count: number, regionCount: number, error?: string }>}
19
+ */
20
+ export async function downloadRegions(options) {
21
+ let {
22
+ api,
23
+ workingDir,
24
+ screenshotNames,
25
+ includeCandidates = false
26
+ } = options;
27
+ if (!screenshotNames || screenshotNames.length === 0) {
28
+ return {
29
+ success: true,
30
+ count: 0,
31
+ regionCount: 0
32
+ };
33
+ }
34
+ try {
35
+ let response = await api.getRegions(screenshotNames, {
36
+ includeCandidates
37
+ });
38
+ if (!response || !response.regions) {
39
+ return {
40
+ success: false,
41
+ error: 'API returned no region data'
42
+ };
43
+ }
44
+
45
+ // Save regions to disk
46
+ saveRegionMetadata(workingDir, response.regions, response.summary);
47
+
48
+ // Calculate stats
49
+ let count = Object.keys(response.regions).length;
50
+ let regionCount = response.summary?.total_regions || 0;
51
+ return {
52
+ success: true,
53
+ count,
54
+ regionCount
55
+ };
56
+ } catch (error) {
57
+ return {
58
+ success: false,
59
+ error: error.message
60
+ };
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Extract screenshot names from a list of screenshots
66
+ *
67
+ * @param {Array} screenshots - Screenshots with name property
68
+ * @returns {string[]}
69
+ */
70
+ export function extractScreenshotNames(screenshots) {
71
+ if (!screenshots || !Array.isArray(screenshots)) {
72
+ return [];
73
+ }
74
+ return screenshots.map(s => s.name).filter(Boolean);
75
+ }
@@ -21,6 +21,7 @@ import { calculateHotspotCoverage as defaultCalculateHotspotCoverage } from './c
21
21
  import { generateBaselineFilename as defaultGenerateBaselineFilename, generateComparisonId as defaultGenerateComparisonId, generateScreenshotSignature as defaultGenerateScreenshotSignature } from './core/signature.js';
22
22
  import { createEmptyBaselineMetadata as defaultCreateEmptyBaselineMetadata, loadBaselineMetadata as defaultLoadBaselineMetadata, saveBaselineMetadata as defaultSaveBaselineMetadata, upsertScreenshotInMetadata as defaultUpsertScreenshotInMetadata } from './metadata/baseline-metadata.js';
23
23
  import { loadHotspotMetadata as defaultLoadHotspotMetadata, saveHotspotMetadata as defaultSaveHotspotMetadata } from './metadata/hotspot-metadata.js';
24
+ import { loadRegionMetadata as defaultLoadRegionMetadata, saveRegionMetadata as defaultSaveRegionMetadata } from './metadata/region-metadata.js';
24
25
  import { baselineExists as defaultBaselineExists, clearBaselineData as defaultClearBaselineData, getBaselinePath as defaultGetBaselinePath, getCurrentPath as defaultGetCurrentPath, getDiffPath as defaultGetDiffPath, initializeDirectories as defaultInitializeDirectories, saveBaseline as defaultSaveBaseline, saveCurrent as defaultSaveCurrent } from './services/baseline-manager.js';
25
26
  import { buildErrorComparison as defaultBuildErrorComparison, buildFailedComparison as defaultBuildFailedComparison, buildNewComparison as defaultBuildNewComparison, buildPassedComparison as defaultBuildPassedComparison, compareImages as defaultCompareImages, isDimensionMismatchError as defaultIsDimensionMismatchError } from './services/comparison-service.js';
26
27
  import { buildResults as defaultBuildResults, getFailedComparisons as defaultGetFailedComparisons, getNewComparisons as defaultGetNewComparisons } from './services/result-service.js';
@@ -89,6 +90,8 @@ export class TddService {
89
90
  upsertScreenshotInMetadata: defaultUpsertScreenshotInMetadata,
90
91
  loadHotspotMetadata: defaultLoadHotspotMetadata,
91
92
  saveHotspotMetadata: defaultSaveHotspotMetadata,
93
+ loadRegionMetadata: defaultLoadRegionMetadata,
94
+ saveRegionMetadata: defaultSaveRegionMetadata,
92
95
  ...metadata
93
96
  };
94
97
  let baselineOps = {
@@ -175,6 +178,9 @@ export class TddService {
175
178
  // Hotspot data (loaded lazily from disk or downloaded from cloud)
176
179
  this.hotspotData = null;
177
180
 
181
+ // Region data (user-defined 2D bounding boxes, loaded lazily)
182
+ this.regionData = null;
183
+
178
184
  // Track whether results have been printed (to avoid duplicate output)
179
185
  this._resultsPrinted = false;
180
186
  if (this.setBaseline) {
@@ -319,6 +325,16 @@ export class TddService {
319
325
  }
320
326
  baselineBuild = apiResponse.build;
321
327
  baselineBuild.screenshots = apiResponse.screenshots;
328
+
329
+ // Store bundled hotspots and regions from API response
330
+ if (apiResponse.hotspots && Object.keys(apiResponse.hotspots).length > 0) {
331
+ this.hotspotData = apiResponse.hotspots;
332
+ saveHotspotMetadata(this.workingDir, apiResponse.hotspots, apiResponse.summary);
333
+ }
334
+ if (apiResponse.regions && Object.keys(apiResponse.regions).length > 0) {
335
+ this.regionData = apiResponse.regions;
336
+ saveRegionMetadata(this.workingDir, apiResponse.regions, apiResponse.summary);
337
+ }
322
338
  }
323
339
  let buildDetails = baselineBuild;
324
340
  if (!buildDetails.screenshots || buildDetails.screenshots.length === 0) {
@@ -466,8 +482,8 @@ export class TddService {
466
482
  };
467
483
  saveBaselineMetadata(this.baselinePath, this.baselineData);
468
484
 
469
- // Download hotspots
470
- await this.downloadHotspots(buildDetails.screenshots);
485
+ // Hotspots and regions are now bundled in the tdd-baselines API response
486
+ // and saved earlier when processing the API response
471
487
 
472
488
  // Save baseline build metadata for MCP plugin
473
489
  let baselineMetadataPath = safePath(this.workingDir, '.vizzly', 'baseline-metadata.json');
@@ -541,6 +557,20 @@ export class TddService {
541
557
  }
542
558
  }
543
559
  let baselineBuild = apiResponse.build;
560
+
561
+ // Store bundled hotspots and regions from API response
562
+ let {
563
+ saveHotspotMetadata,
564
+ saveRegionMetadata
565
+ } = this._deps;
566
+ if (apiResponse.hotspots && Object.keys(apiResponse.hotspots).length > 0) {
567
+ this.hotspotData = apiResponse.hotspots;
568
+ saveHotspotMetadata(this.workingDir, apiResponse.hotspots, apiResponse.summary);
569
+ }
570
+ if (apiResponse.regions && Object.keys(apiResponse.regions).length > 0) {
571
+ this.regionData = apiResponse.regions;
572
+ saveRegionMetadata(this.workingDir, apiResponse.regions, apiResponse.summary);
573
+ }
544
574
  if (baselineBuild.status === 'failed') {
545
575
  output.warn(`Build ${buildId} is marked as FAILED - falling back to local baselines`);
546
576
  return await this.handleLocalBaselines();
@@ -694,11 +724,8 @@ export class TddService {
694
724
  };
695
725
  saveBaselineMetadata(this.baselinePath, this.baselineData);
696
726
 
697
- // Download hotspots if API key is available (requires SDK auth)
698
- // OAuth-only users won't get hotspots since the hotspot endpoint requires project token
699
- if (this.config.apiKey && buildDetails.screenshots?.length > 0) {
700
- await this.downloadHotspots(buildDetails.screenshots);
701
- }
727
+ // Hotspots and regions are now bundled in the tdd-baselines API response
728
+ // and saved earlier when processing the API response
702
729
 
703
730
  // Save baseline build metadata for MCP plugin
704
731
  let baselineMetadataPath = safePath(this.workingDir, '.vizzly', 'baseline-metadata.json');
@@ -800,6 +827,39 @@ export class TddService {
800
827
  return this.hotspotData?.[screenshotName] || null;
801
828
  }
802
829
 
830
+ /**
831
+ * Load region data from disk
832
+ */
833
+ loadRegions() {
834
+ let {
835
+ loadRegionMetadata
836
+ } = this._deps;
837
+ return loadRegionMetadata(this.workingDir);
838
+ }
839
+
840
+ /**
841
+ * Get user-defined regions for a specific screenshot
842
+ *
843
+ * Note: Once regionData is loaded (from disk or cloud), we don't reload.
844
+ * This is intentional - regions are downloaded once per session and cached.
845
+ * If a screenshot isn't in the cache, it means no region data exists for it.
846
+ *
847
+ * @param {string} screenshotName - Name of the screenshot
848
+ * @returns {Object|null} Region data { confirmed: [], candidates: [] } or null
849
+ */
850
+ getRegionsForScreenshot(screenshotName) {
851
+ // Check memory cache first
852
+ if (this.regionData?.[screenshotName]) {
853
+ return this.regionData[screenshotName];
854
+ }
855
+
856
+ // Try loading from disk (only if we haven't loaded yet)
857
+ if (!this.regionData) {
858
+ this.regionData = this.loadRegions();
859
+ }
860
+ return this.regionData?.[screenshotName] || null;
861
+ }
862
+
803
863
  /**
804
864
  * Calculate hotspot coverage (delegating to pure function)
805
865
  */
@@ -1000,6 +1060,7 @@ export class TddService {
1000
1060
  return result;
1001
1061
  } else {
1002
1062
  let hotspotAnalysis = this.getHotspotForScreenshot(name);
1063
+ let regionData = this.getRegionsForScreenshot(name);
1003
1064
  let result = buildFailedComparison({
1004
1065
  name: sanitizedName,
1005
1066
  signature,
@@ -1010,7 +1071,8 @@ export class TddService {
1010
1071
  threshold: effectiveThreshold,
1011
1072
  minClusterSize: effectiveMinClusterSize,
1012
1073
  honeydiffResult,
1013
- hotspotAnalysis
1074
+ hotspotAnalysis,
1075
+ regionData
1014
1076
  });
1015
1077
 
1016
1078
  // Log at debug level only (shown with --verbose)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizzly-testing/cli",
3
- "version": "0.23.2",
3
+ "version": "0.24.0",
4
4
  "description": "Visual review platform for UI developers and designers",
5
5
  "keywords": [
6
6
  "visual-testing",
@@ -86,7 +86,7 @@
86
86
  "registry": "https://registry.npmjs.org/"
87
87
  },
88
88
  "dependencies": {
89
- "@vizzly-testing/honeydiff": "^0.8.0",
89
+ "@vizzly-testing/honeydiff": "^0.9.0",
90
90
  "ansis": "^4.2.0",
91
91
  "commander": "^14.0.0",
92
92
  "cosmiconfig": "^9.0.0",
@@ -119,7 +119,7 @@
119
119
  "@tanstack/react-query": "^5.90.11",
120
120
  "@types/node": "^25.0.2",
121
121
  "@vitejs/plugin-react": "^5.0.3",
122
- "@vizzly-testing/observatory": "^0.2.1",
122
+ "@vizzly-testing/observatory": "^0.3.3",
123
123
  "autoprefixer": "^10.4.21",
124
124
  "babel-plugin-transform-remove-console": "^6.9.4",
125
125
  "postcss": "^8.5.6",