@vizzly-testing/cli 0.35.3-beta.2 → 0.35.3-beta.3
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/api/endpoints.js +1 -46
- package/dist/api/index.js +1 -1
- package/dist/cli.js +4 -4
- package/dist/commands/api.js +3 -3
- package/dist/commands/builds.js +12 -15
- package/dist/commands/comparisons.js +7 -9
- package/dist/commands/context.js +3 -24
- package/dist/commands/review.js +122 -35
- package/dist/commands/run.js +1 -3
- package/dist/commands/status.js +35 -79
- package/dist/commands/upload.js +1 -1
- package/dist/context/local-workspace-provider.js +36 -126
- package/dist/reporter/reporter-bundle.iife.js +49 -81
- package/dist/server/handlers/tdd-handler.js +2 -7
- package/dist/server/routers/dashboard.js +5 -1
- package/dist/tdd/runtime-context.js +0 -2
- package/dist/tdd/services/comparison-service.js +4 -68
- package/dist/tdd/tdd-service.js +7 -205
- package/dist/types/index.d.ts +1 -1
- package/dist/uploader/core.js +7 -8
- package/dist/utils/visual-context-normalizers.js +86 -139
- package/package.json +1 -1
- package/skills/vizzly/SKILL.md +4 -4
- package/skills/vizzly/references/dynamic-content.md +2 -2
- package/dist/tdd/core/hotspot-coverage.js +0 -112
- package/dist/tdd/core/region-coverage.js +0 -203
- package/dist/tdd/metadata/hotspot-metadata.js +0 -93
- package/dist/tdd/metadata/region-metadata.js +0 -56
|
@@ -1,203 +0,0 @@
|
|
|
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
|
-
let REGION_CENTER_TOLERANCE = 10;
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Check if a diff cluster intersects with a region (2D box intersection)
|
|
16
|
-
*
|
|
17
|
-
* @param {Object} cluster - Diff cluster with boundingBox { x, y, width, height }
|
|
18
|
-
* @param {Object} region - Region with { x1, y1, x2, y2 }
|
|
19
|
-
* @returns {boolean} True if the cluster overlaps the region
|
|
20
|
-
*/
|
|
21
|
-
export function clusterIntersectsRegion(cluster, region) {
|
|
22
|
-
if (!cluster?.boundingBox || !region) {
|
|
23
|
-
return false;
|
|
24
|
-
}
|
|
25
|
-
let {
|
|
26
|
-
x,
|
|
27
|
-
y,
|
|
28
|
-
width,
|
|
29
|
-
height
|
|
30
|
-
} = cluster.boundingBox;
|
|
31
|
-
|
|
32
|
-
// Convert cluster to x1,y1,x2,y2 format
|
|
33
|
-
let clusterX1 = x;
|
|
34
|
-
let clusterY1 = y;
|
|
35
|
-
let clusterX2 = x + width;
|
|
36
|
-
let clusterY2 = y + height;
|
|
37
|
-
|
|
38
|
-
// Box intersection: NOT (one is completely outside the other)
|
|
39
|
-
// A is left of B: clusterX2 < region.x1
|
|
40
|
-
// A is right of B: clusterX1 > region.x2
|
|
41
|
-
// A is above B: clusterY2 < region.y1
|
|
42
|
-
// A is below B: clusterY1 > region.y2
|
|
43
|
-
let noOverlap = clusterX2 < region.x1 || clusterX1 > region.x2 || clusterY2 < region.y1 || clusterY1 > region.y2;
|
|
44
|
-
return !noOverlap;
|
|
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
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
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.
|
|
113
|
-
*
|
|
114
|
-
* @param {Array} diffClusters - Array of diff clusters from honeydiff
|
|
115
|
-
* @param {Array} regions - Array of confirmed regions { x1, y1, x2, y2 }
|
|
116
|
-
* @returns {{ coverage: number, clustersInRegions: number, totalClusters: number, matchedRegions: string[], pixelsInRegions: number, totalPixels: number }}
|
|
117
|
-
*/
|
|
118
|
-
export function calculateRegionCoverage(diffClusters, regions) {
|
|
119
|
-
if (!diffClusters || diffClusters.length === 0) {
|
|
120
|
-
return {
|
|
121
|
-
coverage: 0,
|
|
122
|
-
clustersInRegions: 0,
|
|
123
|
-
totalClusters: 0,
|
|
124
|
-
matchedRegions: [],
|
|
125
|
-
pixelsInRegions: 0,
|
|
126
|
-
totalPixels: 0
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
if (!regions || regions.length === 0) {
|
|
130
|
-
return {
|
|
131
|
-
coverage: 0,
|
|
132
|
-
clustersInRegions: 0,
|
|
133
|
-
totalClusters: diffClusters.length,
|
|
134
|
-
matchedRegions: [],
|
|
135
|
-
pixelsInRegions: 0,
|
|
136
|
-
totalPixels: 0
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
let clustersInRegions = 0;
|
|
140
|
-
let matchedRegionIds = new Set();
|
|
141
|
-
let pixelsInRegions = 0;
|
|
142
|
-
let totalPixels = 0;
|
|
143
|
-
for (let cluster of diffClusters) {
|
|
144
|
-
let pixelCount = estimateClusterPixels(cluster);
|
|
145
|
-
totalPixels += pixelCount;
|
|
146
|
-
let matchedAnyRegion = false;
|
|
147
|
-
for (let region of regions) {
|
|
148
|
-
if (clusterMatchesRegion(cluster, region)) {
|
|
149
|
-
matchedAnyRegion = true;
|
|
150
|
-
let regionLabel = getRegionLabel(region);
|
|
151
|
-
if (regionLabel) {
|
|
152
|
-
matchedRegionIds.add(regionLabel);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
if (matchedAnyRegion) {
|
|
157
|
-
clustersInRegions++;
|
|
158
|
-
pixelsInRegions += pixelCount;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
let coverage = totalPixels > 0 ? pixelsInRegions / totalPixels : 0;
|
|
162
|
-
return {
|
|
163
|
-
coverage,
|
|
164
|
-
clustersInRegions,
|
|
165
|
-
totalClusters: diffClusters.length,
|
|
166
|
-
matchedRegions: [...matchedRegionIds],
|
|
167
|
-
pixelsInRegions,
|
|
168
|
-
totalPixels
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Determine if a comparison should auto-pass based on region coverage
|
|
174
|
-
*
|
|
175
|
-
* Unlike hotspots which require confidence scoring, user-defined regions
|
|
176
|
-
* are already confirmed by humans. Cloud still requires strong structural
|
|
177
|
-
* similarity so a layout shift cannot hide inside a confirmed box.
|
|
178
|
-
*
|
|
179
|
-
* @param {Array} regions - Confirmed regions (already filtered to confirmed status)
|
|
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
|
|
185
|
-
* @returns {boolean} True if diff should auto-pass as region-filtered
|
|
186
|
-
*/
|
|
187
|
-
export function shouldAutoApproveFromRegions(regions, coverageResult, options = {}) {
|
|
188
|
-
if (!regions || regions.length === 0 || !coverageResult) {
|
|
189
|
-
return false;
|
|
190
|
-
}
|
|
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;
|
|
203
|
-
}
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Hotspot Metadata I/O
|
|
3
|
-
*
|
|
4
|
-
* Functions for reading and writing hotspot data files.
|
|
5
|
-
* Hotspots identify regions of screenshots that frequently change
|
|
6
|
-
* due to dynamic content (timestamps, animations, etc.).
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
-
import { join } from 'node:path';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Load hotspot data from disk
|
|
14
|
-
*
|
|
15
|
-
* @param {string} workingDir - Working directory containing .vizzly folder
|
|
16
|
-
* @returns {Object|null} Hotspot data keyed by screenshot name, or null if not found
|
|
17
|
-
*/
|
|
18
|
-
export function loadHotspotMetadata(workingDir) {
|
|
19
|
-
let hotspotsPath = join(workingDir, '.vizzly', 'hotspots.json');
|
|
20
|
-
if (!existsSync(hotspotsPath)) {
|
|
21
|
-
return null;
|
|
22
|
-
}
|
|
23
|
-
try {
|
|
24
|
-
let content = readFileSync(hotspotsPath, 'utf8');
|
|
25
|
-
let data = JSON.parse(content);
|
|
26
|
-
return data.hotspots || null;
|
|
27
|
-
} catch {
|
|
28
|
-
// Return null for parse/read errors
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Save hotspot data to disk
|
|
35
|
-
*
|
|
36
|
-
* @param {string} workingDir - Working directory containing .vizzly folder
|
|
37
|
-
* @param {Object} hotspotData - Hotspot data keyed by screenshot name
|
|
38
|
-
* @param {Object} summary - Summary information about the hotspots
|
|
39
|
-
*/
|
|
40
|
-
export function saveHotspotMetadata(workingDir, hotspotData, 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 hotspotsPath = join(vizzlyDir, 'hotspots.json');
|
|
50
|
-
let content = {
|
|
51
|
-
downloadedAt: new Date().toISOString(),
|
|
52
|
-
summary,
|
|
53
|
-
hotspots: hotspotData
|
|
54
|
-
};
|
|
55
|
-
writeFileSync(hotspotsPath, JSON.stringify(content, null, 2));
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Get hotspot 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} Hotspot analysis or null if not available
|
|
68
|
-
*/
|
|
69
|
-
export function getHotspotForScreenshot(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 = loadHotspotMetadata(workingDir);
|
|
78
|
-
cache.loaded = true;
|
|
79
|
-
}
|
|
80
|
-
return cache.data?.[screenshotName] || null;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Create an empty hotspot cache object
|
|
85
|
-
*
|
|
86
|
-
* @returns {{ data: null, loaded: boolean }}
|
|
87
|
-
*/
|
|
88
|
-
export function createHotspotCache() {
|
|
89
|
-
return {
|
|
90
|
-
data: null,
|
|
91
|
-
loaded: false
|
|
92
|
-
};
|
|
93
|
-
}
|
|
@@ -1,56 +0,0 @@
|
|
|
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
|
-
}
|