@vizzly-testing/cli 0.23.2 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.js +5 -0
- package/dist/plugin-loader.js +10 -5
- package/dist/reporter/reporter-bundle.css +1 -1
- package/dist/reporter/reporter-bundle.iife.js +71 -63
- package/dist/server/handlers/api-handler.js +24 -4
- package/dist/server/handlers/tdd-handler.js +12 -9
- package/dist/tdd/core/region-coverage.js +115 -0
- package/dist/tdd/core/signature.js +3 -1
- package/dist/tdd/metadata/region-metadata.js +93 -0
- package/dist/tdd/services/comparison-service.js +40 -8
- package/dist/tdd/services/region-service.js +75 -0
- package/dist/tdd/tdd-service.js +70 -8
- package/package.json +3 -3
|
@@ -48,6 +48,10 @@ export const createApiHandler = (client, {
|
|
|
48
48
|
let screenshotCount = 0;
|
|
49
49
|
let uploadPromises = [];
|
|
50
50
|
const handleScreenshot = async (buildId, name, image, properties = {}, type) => {
|
|
51
|
+
let handlerStart = Date.now();
|
|
52
|
+
output.debug('upload', `${name} received`, {
|
|
53
|
+
buildId: buildId?.slice(0, 8)
|
|
54
|
+
});
|
|
51
55
|
if (vizzlyDisabled) {
|
|
52
56
|
output.debug('upload', `${name} (disabled)`);
|
|
53
57
|
return {
|
|
@@ -124,27 +128,39 @@ export const createApiHandler = (client, {
|
|
|
124
128
|
};
|
|
125
129
|
}
|
|
126
130
|
screenshotCount++;
|
|
131
|
+
let uploadStart = Date.now();
|
|
127
132
|
|
|
128
133
|
// Fire upload in background - DON'T AWAIT!
|
|
129
134
|
let uploadPromise = uploadScreenshot(client, buildId, name, imageBuffer, properties ?? {}).then(result => {
|
|
135
|
+
let duration = Date.now() - uploadStart;
|
|
130
136
|
if (!result.skipped) {
|
|
131
|
-
output.debug('upload', name
|
|
137
|
+
output.debug('upload', `${name} completed`, {
|
|
138
|
+
ms: duration
|
|
139
|
+
});
|
|
140
|
+
} else {
|
|
141
|
+
output.debug('upload', `${name} skipped (dedup)`, {
|
|
142
|
+
ms: duration
|
|
143
|
+
});
|
|
132
144
|
}
|
|
133
145
|
return {
|
|
134
146
|
success: true,
|
|
135
147
|
name,
|
|
136
|
-
result
|
|
148
|
+
result,
|
|
149
|
+
duration
|
|
137
150
|
};
|
|
138
151
|
}).catch(uploadError => {
|
|
152
|
+
let duration = Date.now() - uploadStart;
|
|
139
153
|
output.debug('upload', `${name} failed`, {
|
|
140
|
-
error: uploadError.message
|
|
154
|
+
error: uploadError.message,
|
|
155
|
+
ms: duration
|
|
141
156
|
});
|
|
142
157
|
vizzlyDisabled = true;
|
|
143
158
|
output.warn('Vizzly disabled due to upload error - continuing tests without visual testing');
|
|
144
159
|
return {
|
|
145
160
|
success: false,
|
|
146
161
|
name,
|
|
147
|
-
error: uploadError
|
|
162
|
+
error: uploadError,
|
|
163
|
+
duration
|
|
148
164
|
};
|
|
149
165
|
});
|
|
150
166
|
|
|
@@ -152,6 +168,10 @@ export const createApiHandler = (client, {
|
|
|
152
168
|
uploadPromises.push(uploadPromise);
|
|
153
169
|
|
|
154
170
|
// Return immediately - test continues without waiting!
|
|
171
|
+
let handlerMs = Date.now() - handlerStart;
|
|
172
|
+
output.debug('upload', `${name} handler returning`, {
|
|
173
|
+
ms: handlerMs
|
|
174
|
+
});
|
|
155
175
|
return {
|
|
156
176
|
statusCode: 200,
|
|
157
177
|
body: {
|
|
@@ -268,6 +268,9 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
|
|
|
268
268
|
}
|
|
269
269
|
};
|
|
270
270
|
const handleScreenshot = async (_buildId, name, image, properties = {}, type) => {
|
|
271
|
+
let handlerStart = Date.now();
|
|
272
|
+
output.debug('tdd', `${name} received`);
|
|
273
|
+
|
|
271
274
|
// Validate and sanitize screenshot name
|
|
272
275
|
let sanitizedName;
|
|
273
276
|
try {
|
|
@@ -386,21 +389,16 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
|
|
|
386
389
|
const vizzlyDir = join(workingDir, '.vizzly');
|
|
387
390
|
|
|
388
391
|
// Record the comparison for the dashboard
|
|
392
|
+
// Spread the full comparison to include regionAnalysis, confirmedRegions, hotspotAnalysis, etc.
|
|
389
393
|
const newComparison = {
|
|
390
|
-
|
|
391
|
-
// Include unique ID for variant identification
|
|
392
|
-
name: comparison.name,
|
|
394
|
+
...comparison,
|
|
393
395
|
originalName: name,
|
|
394
|
-
|
|
396
|
+
// Convert absolute file paths to web-accessible URLs
|
|
395
397
|
baseline: convertPathToUrl(comparison.baseline, vizzlyDir),
|
|
396
398
|
current: convertPathToUrl(comparison.current, vizzlyDir),
|
|
397
399
|
diff: convertPathToUrl(comparison.diff, vizzlyDir),
|
|
398
|
-
diffPercentage: comparison.diffPercentage,
|
|
399
|
-
threshold: comparison.threshold,
|
|
400
|
-
properties: extractedProperties,
|
|
401
400
|
// Use extracted properties with top-level viewport_width/browser
|
|
402
|
-
|
|
403
|
-
// Include signature for debugging
|
|
401
|
+
properties: extractedProperties,
|
|
404
402
|
timestamp: Date.now()
|
|
405
403
|
};
|
|
406
404
|
|
|
@@ -449,6 +447,11 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
|
|
|
449
447
|
}
|
|
450
448
|
|
|
451
449
|
// Match or new baseline
|
|
450
|
+
let handlerMs = Date.now() - handlerStart;
|
|
451
|
+
output.debug('tdd', `${name} handler returning`, {
|
|
452
|
+
ms: handlerMs,
|
|
453
|
+
status: comparison.status
|
|
454
|
+
});
|
|
452
455
|
return {
|
|
453
456
|
statusCode: 200,
|
|
454
457
|
body: {
|
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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 &&
|
|
145
|
-
hotspotCoverage = calculateHotspotCoverage(
|
|
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:
|
|
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:
|
|
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
|
|
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
|
+
}
|
package/dist/tdd/tdd-service.js
CHANGED
|
@@ -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
|
-
//
|
|
470
|
-
|
|
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
|
-
//
|
|
698
|
-
//
|
|
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.
|
|
3
|
+
"version": "0.24.1",
|
|
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.
|
|
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.
|
|
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",
|