diff --git a/.github/workflows/sdk-e2e.yml b/.github/workflows/sdk-e2e.yml
index 6960189..b23dba7 100644
--- a/.github/workflows/sdk-e2e.yml
+++ b/.github/workflows/sdk-e2e.yml
@@ -269,7 +269,7 @@ jobs:
id: playwright-cache
with:
path: ~/.cache/ms-playwright
- key: playwright-${{ steps.playwright-version.outputs.version }}-chromium
+ key: playwright-${{ steps.playwright-version.outputs.version }}-ember-chromium-v2
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
diff --git a/package-lock.json b/package-lock.json
index ff642f1..b81704a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@vizzly-testing/cli",
- "version": "0.23.1",
+ "version": "0.23.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@vizzly-testing/cli",
- "version": "0.23.1",
+ "version": "0.23.2",
"license": "MIT",
"dependencies": {
"@vizzly-testing/honeydiff": "^0.9.0",
@@ -35,7 +35,7 @@
"@tanstack/react-query": "^5.90.11",
"@types/node": "^25.0.2",
"@vitejs/plugin-react": "^5.0.3",
- "@vizzly-testing/observatory": "^0.3.0",
+ "@vizzly-testing/observatory": "^0.3.3",
"autoprefixer": "^10.4.21",
"babel-plugin-transform-remove-console": "^6.9.4",
"postcss": "^8.5.6",
@@ -195,6 +195,7 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@@ -3464,6 +3465,7 @@
"integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -3506,9 +3508,9 @@
}
},
"node_modules/@vizzly-testing/observatory": {
- "version": "0.3.0",
- "resolved": "https://registry.npmjs.org/@vizzly-testing/observatory/-/observatory-0.3.0.tgz",
- "integrity": "sha512-1kFOE2KXoRgT75elhq+qS5DHZ6wrE4ZXXi29iOKiwCUyb7crSqOAR0YEvaulXcT1OScOkHDlgApAvC/kz8rFFQ==",
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/@vizzly-testing/observatory/-/observatory-0.3.3.tgz",
+ "integrity": "sha512-5I6Q5x3KmT+hnMMQtNHDSLNLEtAZXh6F1ftcA6K1mWfsy2rxnk4FY68dBiPl4ucoyxeKaYhSois0QEG4kK14Sg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3923,6 +3925,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -6629,6 +6632,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -6866,6 +6870,7 @@
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -6876,6 +6881,7 @@
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -7724,7 +7730,8 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/tapable": {
"version": "2.3.0",
@@ -7813,6 +7820,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -7982,6 +7990,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -8127,6 +8136,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -8220,6 +8230,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -8287,6 +8298,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/package.json b/package.json
index 718f25a..4453c58 100644
--- a/package.json
+++ b/package.json
@@ -119,7 +119,7 @@
"@tanstack/react-query": "^5.90.11",
"@types/node": "^25.0.2",
"@vitejs/plugin-react": "^5.0.3",
- "@vizzly-testing/observatory": "^0.3.0",
+ "@vizzly-testing/observatory": "^0.3.3",
"autoprefixer": "^10.4.21",
"babel-plugin-transform-remove-console": "^6.9.4",
"postcss": "^8.5.6",
diff --git a/src/reporter/src/components/comparison/fullscreen-viewer.jsx b/src/reporter/src/components/comparison/fullscreen-viewer.jsx
index 1838416..dfe3395 100644
--- a/src/reporter/src/components/comparison/fullscreen-viewer.jsx
+++ b/src/reporter/src/components/comparison/fullscreen-viewer.jsx
@@ -19,6 +19,7 @@ import {
DocumentMagnifyingGlassIcon,
InformationCircleIcon,
ListBulletIcon,
+ MapPinIcon,
} from '@heroicons/react/24/outline';
import {
ApprovalButtonGroup,
@@ -101,6 +102,7 @@ function FullscreenViewerInner({
let [showInspector, setShowInspector] = useState(false);
let [queueFilter, setQueueFilter] = useState('needs-review');
let [_showBaseline, setShowBaseline] = useState(true);
+ let [showRegions, setShowRegions] = useState(false);
let { zoom, setZoom } = useZoom('fit');
let { isActive: isReviewMode } = useReviewMode();
@@ -111,7 +113,7 @@ function FullscreenViewerInner({
// Toggle inspector (closes other panels)
let toggleInspector = useCallback(() => {
setShowInspector(prev => !prev);
- }, []);
+ }, [setShowInspector]);
// Transform comparisons for queue display
// Map CLI status to Observatory result format
@@ -291,7 +293,7 @@ function FullscreenViewerInner({
setViewMode(current =>
current === VIEW_MODES.OVERLAY ? VIEW_MODES.TOGGLE : VIEW_MODES.OVERLAY
);
- }, []);
+ }, [setViewMode]);
// Toggle handler for 'd' - toggles diff overlay or baseline/current
let handleDiffToggle = useCallback(() => {
@@ -300,7 +302,7 @@ function FullscreenViewerInner({
} else {
setShowDiffOverlay(prev => !prev);
}
- }, [viewMode]);
+ }, [viewMode, setShowBaseline, setShowDiffOverlay]);
// Review mode shortcuts
let reviewModeShortcuts = useMemo(
@@ -320,6 +322,7 @@ function FullscreenViewerInner({
onReject,
cycleViewMode,
handleDiffToggle,
+ setViewMode,
]
);
@@ -352,12 +355,25 @@ function FullscreenViewerInner({
toggleInspector();
}
break;
+ case 'g':
+ if (!e.metaKey && !e.ctrlKey) {
+ e.preventDefault();
+ setShowRegions(prev => !prev);
+ }
+ break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
- }, [canNavigate, handlePrevious, handleNext, onClose, toggleInspector]);
+ }, [
+ canNavigate,
+ handlePrevious,
+ handleNext,
+ onClose,
+ toggleInspector,
+ setShowRegions,
+ ]);
// Scroll active queue item into view
useEffect(() => {
@@ -381,7 +397,7 @@ function FullscreenViewerInner({
behavior: 'smooth',
});
}
- }, []);
+ }, [activeQueueItemRef.current]);
if (!comparison) {
return (
@@ -523,6 +539,20 @@ function FullscreenViewerInner({
+ {/* Regions toggle - only show if comparison has regions */}
+ {comparison?.confirmedRegions?.length > 0 && (
+
+ )}
+
@@ -793,6 +824,19 @@ function FullscreenViewerInner({
+ {/* Regions toggle - mobile */}
+ {comparison?.confirmedRegions?.length > 0 && (
+
+ )}
+
{canDelete && onDelete && (
)}
+
+ {/* Region overlay - shows confirmed regions (green boxes) */}
+ {showRegions && comparison?.confirmedRegions?.length > 0 && (
+
+ )}
diff --git a/src/reporter/src/components/dashboard/dashboard-filters.jsx b/src/reporter/src/components/dashboard/dashboard-filters.jsx
index 10ac380..fa98a39 100644
--- a/src/reporter/src/components/dashboard/dashboard-filters.jsx
+++ b/src/reporter/src/components/dashboard/dashboard-filters.jsx
@@ -55,15 +55,18 @@ function IconDropdown({
onChange(val);
setIsOpen(false);
},
- [onChange]
+ [onChange, setIsOpen]
);
// Close on outside click
- let handleBlur = useCallback(e => {
- if (!dropdownRef.current?.contains(e.relatedTarget)) {
- setIsOpen(false);
- }
- }, []);
+ let handleBlur = useCallback(
+ e => {
+ if (!dropdownRef.current?.contains(e.relatedTarget)) {
+ setIsOpen(false);
+ }
+ },
+ [dropdownRef.current?.contains, setIsOpen]
+ );
return (
// biome-ignore lint/a11y/noStaticElementInteractions: dropdown container needs blur handler
diff --git a/src/reporter/src/components/views/comparisons-view.jsx b/src/reporter/src/components/views/comparisons-view.jsx
index 6180fc8..0a29ccf 100644
--- a/src/reporter/src/components/views/comparisons-view.jsx
+++ b/src/reporter/src/components/views/comparisons-view.jsx
@@ -162,7 +162,7 @@ export default function ComparisonsView() {
},
});
},
- [acceptMutation, addToast]
+ [acceptMutation, addToast, setLoadingStates]
);
// Reject a single comparison
@@ -179,7 +179,7 @@ export default function ComparisonsView() {
},
});
},
- [rejectMutation, addToast]
+ [rejectMutation, addToast, setLoadingStates]
);
let handleAcceptAll = useCallback(async () => {
diff --git a/src/reporter/src/components/views/settings-view.jsx b/src/reporter/src/components/views/settings-view.jsx
index b534f4b..983d462 100644
--- a/src/reporter/src/components/views/settings-view.jsx
+++ b/src/reporter/src/components/views/settings-view.jsx
@@ -51,14 +51,14 @@ function SourceBadge({ source }) {
);
}
-function SettingSection({ title, source, description, children }) {
+function SettingSection({ title, source, description, children, noSource }) {
return (
{title}
-
+ {!noSource && }
{description &&
{description}
}
{children}
@@ -71,15 +71,18 @@ function SettingsForm({ config, sources, onSave, isSaving }) {
let [formData, setFormData] = useState(initialFormData);
let [hasChanges, setHasChanges] = useState(false);
- let handleFieldChange = useCallback((name, value) => {
- setFormData(prev => ({ ...prev, [name]: value }));
- setHasChanges(true);
- }, []);
+ let handleFieldChange = useCallback(
+ (name, value) => {
+ setFormData(prev => ({ ...prev, [name]: value }));
+ setHasChanges(true);
+ },
+ [setFormData, setHasChanges]
+ );
let handleReset = useCallback(() => {
setFormData(getInitialFormData(config));
setHasChanges(false);
- }, [config]);
+ }, [config, setFormData, setHasChanges]);
let handleSave = useCallback(() => {
let updates = {
@@ -99,7 +102,7 @@ function SettingsForm({ config, sources, onSave, isSaving }) {
},
};
onSave(updates, () => setHasChanges(false));
- }, [formData, onSave]);
+ }, [formData, onSave, setHasChanges]);
return (
diff --git a/src/server/handlers/tdd-handler.js b/src/server/handlers/tdd-handler.js
index 099ae8d..514b0b8 100644
--- a/src/server/handlers/tdd-handler.js
+++ b/src/server/handlers/tdd-handler.js
@@ -462,18 +462,16 @@ export const createTddHandler = (
const vizzlyDir = join(workingDir, '.vizzly');
// Record the comparison for the dashboard
+ // Spread the full comparison to include regionAnalysis, confirmedRegions, hotspotAnalysis, etc.
const newComparison = {
- id: comparison.id, // Include unique ID for variant identification
- name: comparison.name,
+ ...comparison,
originalName: name,
- status: comparison.status,
+ // Convert absolute file paths to web-accessible URLs
baseline: convertPathToUrl(comparison.baseline, vizzlyDir),
current: convertPathToUrl(comparison.current, vizzlyDir),
diff: convertPathToUrl(comparison.diff, vizzlyDir),
- diffPercentage: comparison.diffPercentage,
- threshold: comparison.threshold,
- properties: extractedProperties, // Use extracted properties with top-level viewport_width/browser
- signature: comparison.signature, // Include signature for debugging
+ // Use extracted properties with top-level viewport_width/browser
+ properties: extractedProperties,
timestamp: Date.now(),
};
diff --git a/src/tdd/core/region-coverage.js b/src/tdd/core/region-coverage.js
new file mode 100644
index 0000000..eb503e7
--- /dev/null
+++ b/src/tdd/core/region-coverage.js
@@ -0,0 +1,123 @@
+/**
+ * Region Coverage Calculation
+ *
+ * Pure functions for calculating how much of a visual diff falls within
+ * user-defined "region" areas - 2D bounding boxes that users have confirmed
+ * as dynamic content areas (e.g., timestamps, animations, user avatars).
+ *
+ * Unlike hotspots (1D Y-bands from historical analysis), regions are explicit
+ * 2D boxes that users have manually confirmed via the cloud UI.
+ */
+
+/**
+ * Check if a diff cluster intersects with a region (2D box intersection)
+ *
+ * @param {Object} cluster - Diff cluster with boundingBox { x, y, width, height }
+ * @param {Object} region - Region with { x1, y1, x2, y2 }
+ * @returns {boolean} True if the cluster overlaps the region
+ */
+export function clusterIntersectsRegion(cluster, region) {
+ if (!cluster?.boundingBox || !region) {
+ return false;
+ }
+
+ let { x, y, width, height } = cluster.boundingBox;
+
+ // Convert cluster to x1,y1,x2,y2 format
+ let clusterX1 = x;
+ let clusterY1 = y;
+ let clusterX2 = x + width;
+ let clusterY2 = y + height;
+
+ // Box intersection: NOT (one is completely outside the other)
+ // A is left of B: clusterX2 < region.x1
+ // A is right of B: clusterX1 > region.x2
+ // A is above B: clusterY2 < region.y1
+ // A is below B: clusterY1 > region.y2
+ let noOverlap =
+ clusterX2 < region.x1 ||
+ clusterX1 > region.x2 ||
+ clusterY2 < region.y1 ||
+ clusterY1 > region.y2;
+
+ return !noOverlap;
+}
+
+/**
+ * Calculate what percentage of diff clusters fall within region boxes
+ *
+ * @param {Array} diffClusters - Array of diff clusters from honeydiff
+ * @param {Array} regions - Array of confirmed regions { x1, y1, x2, y2 }
+ * @returns {{ coverage: number, clustersInRegions: number, totalClusters: number, matchedRegions: string[] }}
+ */
+export function calculateRegionCoverage(diffClusters, regions) {
+ if (!diffClusters || diffClusters.length === 0) {
+ return {
+ coverage: 0,
+ clustersInRegions: 0,
+ totalClusters: 0,
+ matchedRegions: [],
+ };
+ }
+
+ if (!regions || regions.length === 0) {
+ return {
+ coverage: 0,
+ clustersInRegions: 0,
+ totalClusters: diffClusters.length,
+ matchedRegions: [],
+ };
+ }
+
+ let clustersInRegions = 0;
+ let matchedRegionIds = new Set();
+
+ for (let cluster of diffClusters) {
+ // Check if this cluster intersects any region
+ let intersectsAnyRegion = false;
+
+ for (let region of regions) {
+ if (clusterIntersectsRegion(cluster, region)) {
+ intersectsAnyRegion = true;
+ // Track which regions were matched (for debugging/display)
+ if (region.id) {
+ matchedRegionIds.add(region.id);
+ } else if (region.label) {
+ matchedRegionIds.add(region.label);
+ }
+ }
+ }
+
+ if (intersectsAnyRegion) {
+ clustersInRegions++;
+ }
+ }
+
+ let coverage = clustersInRegions / diffClusters.length;
+
+ return {
+ coverage,
+ clustersInRegions,
+ totalClusters: diffClusters.length,
+ matchedRegions: [...matchedRegionIds],
+ };
+}
+
+/**
+ * Determine if a comparison should auto-pass based on region coverage
+ *
+ * Unlike hotspots which require confidence scoring, user-defined regions
+ * are already confirmed by humans, so we only need the 80% threshold.
+ *
+ * @param {Array} regions - Confirmed regions (already filtered to confirmed status)
+ * @param {{ coverage: number }} coverageResult - Result from calculateRegionCoverage
+ * @returns {boolean} True if diff should auto-pass as region-filtered
+ */
+export function shouldAutoApproveFromRegions(regions, coverageResult) {
+ if (!regions || regions.length === 0 || !coverageResult) {
+ return false;
+ }
+
+ // Need at least 80% of diff clusters in confirmed regions
+ return coverageResult.coverage >= 0.8;
+}
diff --git a/src/tdd/core/signature.js b/src/tdd/core/signature.js
index ea0482e..3b6712b 100644
--- a/src/tdd/core/signature.js
+++ b/src/tdd/core/signature.js
@@ -58,7 +58,9 @@ export function generateScreenshotSignature(
value = properties.viewport?.width;
}
} else if (propName === 'browser') {
- value = properties.browser;
+ // Normalize browser to lowercase for consistent matching
+ // (Playwright reports "firefox", but cloud may store "Firefox")
+ value = properties.browser?.toLowerCase?.() ?? properties.browser;
} else {
// Custom property - check multiple locations
value =
diff --git a/src/tdd/metadata/region-metadata.js b/src/tdd/metadata/region-metadata.js
new file mode 100644
index 0000000..b639e08
--- /dev/null
+++ b/src/tdd/metadata/region-metadata.js
@@ -0,0 +1,93 @@
+/**
+ * Region Metadata I/O
+ *
+ * Functions for reading and writing user-defined hotspot region data.
+ * Regions are 2D bounding boxes that users have confirmed as dynamic content areas.
+ * Unlike historical hotspots (1D Y-bands), these are explicit definitions.
+ */
+
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+
+/**
+ * Load region data from disk
+ *
+ * @param {string} workingDir - Working directory containing .vizzly folder
+ * @returns {Object|null} Region data keyed by screenshot name, or null if not found
+ */
+export function loadRegionMetadata(workingDir) {
+ let regionsPath = join(workingDir, '.vizzly', 'regions.json');
+
+ if (!existsSync(regionsPath)) {
+ return null;
+ }
+
+ try {
+ let content = readFileSync(regionsPath, 'utf8');
+ let data = JSON.parse(content);
+ return data.regions || null;
+ } catch {
+ // Return null for parse/read errors
+ return null;
+ }
+}
+
+/**
+ * Save region data to disk
+ *
+ * @param {string} workingDir - Working directory containing .vizzly folder
+ * @param {Object} regionData - Region data keyed by screenshot name
+ * @param {Object} summary - Summary information about the regions
+ */
+export function saveRegionMetadata(workingDir, regionData, summary = {}) {
+ let vizzlyDir = join(workingDir, '.vizzly');
+
+ // Ensure directory exists
+ if (!existsSync(vizzlyDir)) {
+ mkdirSync(vizzlyDir, { recursive: true });
+ }
+
+ let regionsPath = join(vizzlyDir, 'regions.json');
+ let content = {
+ downloadedAt: new Date().toISOString(),
+ summary,
+ regions: regionData,
+ };
+
+ writeFileSync(regionsPath, JSON.stringify(content, null, 2));
+}
+
+/**
+ * Get regions for a specific screenshot with caching support
+ *
+ * This is a pure function that takes a cache object as parameter
+ * for stateless operation. The cache is mutated if data needs to be loaded.
+ *
+ * @param {Object} cache - Cache object { data: Object|null, loaded: boolean }
+ * @param {string} workingDir - Working directory
+ * @param {string} screenshotName - Name of the screenshot
+ * @returns {Object|null} Region data or null if not available
+ */
+export function getRegionsForScreenshot(cache, workingDir, screenshotName) {
+ // Check cache first
+ if (cache.data?.[screenshotName]) {
+ return cache.data[screenshotName];
+ }
+
+ // Load from disk if not yet loaded
+ if (!cache.loaded) {
+ cache.data = loadRegionMetadata(workingDir);
+ cache.loaded = true;
+ }
+
+ return cache.data?.[screenshotName] || null;
+}
+
+/**
+ * Create an empty region cache object
+ *
+ * @returns {{ data: null, loaded: boolean }}
+ */
+export function createRegionCache() {
+ return { data: null, loaded: false };
+}
diff --git a/src/tdd/services/comparison-service.js b/src/tdd/services/comparison-service.js
index fa36aa6..1bed3ef 100644
--- a/src/tdd/services/comparison-service.js
+++ b/src/tdd/services/comparison-service.js
@@ -6,6 +6,10 @@
import { compare } from '@vizzly-testing/honeydiff';
import { calculateHotspotCoverage } from '../core/hotspot-coverage.js';
+import {
+ calculateRegionCoverage,
+ shouldAutoApproveFromRegions,
+} from '../core/region-coverage.js';
import { generateComparisonId } from '../core/signature.js';
/**
@@ -121,6 +125,7 @@ export function buildNewComparison(params) {
* @param {number} params.minClusterSize - Effective minClusterSize used
* @param {Object} params.honeydiffResult - Result from honeydiff
* @param {Object} params.hotspotAnalysis - Hotspot data for this screenshot (optional)
+ * @param {Object} params.regionData - User-defined region data { confirmed: [], candidates: [] } (optional)
* @returns {Object} Comparison result
*/
export function buildFailedComparison(params) {
@@ -135,20 +140,36 @@ export function buildFailedComparison(params) {
minClusterSize,
honeydiffResult,
hotspotAnalysis,
+ regionData,
} = params;
- // Calculate hotspot coverage if we have hotspot data
+ let diffClusters = honeydiffResult.diffClusters || [];
+ let isFiltered = false;
+ let filterReason = 'pixel-diff';
+
+ // Region analysis (user-confirmed 2D boxes) - check FIRST (takes priority)
+ let regionCoverage = null;
+ let isRegionFiltered = false;
+ let confirmedRegions = regionData?.confirmed || [];
+
+ if (confirmedRegions.length > 0 && diffClusters.length > 0) {
+ regionCoverage = calculateRegionCoverage(diffClusters, confirmedRegions);
+
+ if (shouldAutoApproveFromRegions(confirmedRegions, regionCoverage)) {
+ isRegionFiltered = true;
+ isFiltered = true;
+ filterReason = 'region-filtered';
+ }
+ }
+
+ // Hotspot analysis (1D Y-bands from historical data) - check SECOND
let hotspotCoverage = null;
let isHotspotFiltered = false;
- if (hotspotAnalysis && honeydiffResult.diffClusters?.length > 0) {
- hotspotCoverage = calculateHotspotCoverage(
- honeydiffResult.diffClusters,
- hotspotAnalysis
- );
+ if (!isFiltered && hotspotAnalysis && diffClusters.length > 0) {
+ hotspotCoverage = calculateHotspotCoverage(diffClusters, hotspotAnalysis);
// Check if diff should be filtered as hotspot noise
- // Using shouldFilterAsHotspot helper but also checking confidence_score
// (cloud uses confidence_score >= 70 which is >0.7 when normalized)
let isHighConfidence =
hotspotAnalysis.confidence === 'high' ||
@@ -157,13 +178,15 @@ export function buildFailedComparison(params) {
if (isHighConfidence && hotspotCoverage.coverage >= 0.8) {
isHotspotFiltered = true;
+ isFiltered = true;
+ filterReason = 'hotspot-filtered';
}
}
return {
id: generateComparisonId(signature),
name,
- status: isHotspotFiltered ? 'passed' : 'failed',
+ status: isFiltered ? 'passed' : 'failed',
baseline: baselinePath,
current: currentPath,
diff: diffPath,
@@ -173,14 +196,28 @@ export function buildFailedComparison(params) {
minClusterSize,
diffPercentage: honeydiffResult.diffPercentage,
diffCount: honeydiffResult.diffPixels,
- reason: isHotspotFiltered ? 'hotspot-filtered' : 'pixel-diff',
+ reason: filterReason,
totalPixels: honeydiffResult.totalPixels,
aaPixelsIgnored: honeydiffResult.aaPixelsIgnored,
aaPercentage: honeydiffResult.aaPercentage,
boundingBox: honeydiffResult.boundingBox,
heightDiff: honeydiffResult.heightDiff,
intensityStats: honeydiffResult.intensityStats,
- diffClusters: honeydiffResult.diffClusters,
+ diffClusters,
+ // User-defined region analysis (2D boxes)
+ regionAnalysis: regionCoverage
+ ? {
+ coverage: regionCoverage.coverage,
+ clustersInRegions: regionCoverage.clustersInRegions,
+ totalClusters: regionCoverage.totalClusters,
+ matchedRegions: regionCoverage.matchedRegions,
+ confirmedCount: confirmedRegions.length,
+ isFiltered: isRegionFiltered,
+ }
+ : null,
+ // Include confirmed regions for visualization in UI
+ confirmedRegions: confirmedRegions.length > 0 ? confirmedRegions : null,
+ // Historical hotspot analysis (1D Y-bands)
hotspotAnalysis: hotspotCoverage
? {
coverage: hotspotCoverage.coverage,
diff --git a/src/tdd/services/region-service.js b/src/tdd/services/region-service.js
new file mode 100644
index 0000000..51059cf
--- /dev/null
+++ b/src/tdd/services/region-service.js
@@ -0,0 +1,59 @@
+/**
+ * Region Service
+ *
+ * Functions for downloading and managing user-defined hotspot regions from the cloud.
+ * Regions are 2D bounding boxes that users have confirmed as dynamic content areas.
+ */
+
+import { saveRegionMetadata } from '../metadata/region-metadata.js';
+
+/**
+ * Download user-defined regions from cloud API
+ *
+ * @param {Object} options
+ * @param {Object} options.api - ApiService instance
+ * @param {string} options.workingDir - Working directory
+ * @param {string[]} options.screenshotNames - Names of screenshots to get regions for
+ * @param {boolean} options.includeCandidates - Include candidate regions (default: false)
+ * @returns {Promise<{ success: boolean, count: number, regionCount: number, error?: string }>}
+ */
+export async function downloadRegions(options) {
+ let { api, workingDir, screenshotNames, includeCandidates = false } = options;
+
+ if (!screenshotNames || screenshotNames.length === 0) {
+ return { success: true, count: 0, regionCount: 0 };
+ }
+
+ try {
+ let response = await api.getRegions(screenshotNames, { includeCandidates });
+
+ if (!response || !response.regions) {
+ return { success: false, error: 'API returned no region data' };
+ }
+
+ // Save regions to disk
+ saveRegionMetadata(workingDir, response.regions, response.summary);
+
+ // Calculate stats
+ let count = Object.keys(response.regions).length;
+ let regionCount = response.summary?.total_regions || 0;
+
+ return { success: true, count, regionCount };
+ } catch (error) {
+ return { success: false, error: error.message };
+ }
+}
+
+/**
+ * Extract screenshot names from a list of screenshots
+ *
+ * @param {Array} screenshots - Screenshots with name property
+ * @returns {string[]}
+ */
+export function extractScreenshotNames(screenshots) {
+ if (!screenshots || !Array.isArray(screenshots)) {
+ return [];
+ }
+
+ return screenshots.map(s => s.name).filter(Boolean);
+}
diff --git a/src/tdd/tdd-service.js b/src/tdd/tdd-service.js
index 397a1e4..dba9fe6 100644
--- a/src/tdd/tdd-service.js
+++ b/src/tdd/tdd-service.js
@@ -52,6 +52,11 @@ import {
saveHotspotMetadata as defaultSaveHotspotMetadata,
} from './metadata/hotspot-metadata.js';
+import {
+ loadRegionMetadata as defaultLoadRegionMetadata,
+ saveRegionMetadata as defaultSaveRegionMetadata,
+} from './metadata/region-metadata.js';
+
import {
baselineExists as defaultBaselineExists,
clearBaselineData as defaultClearBaselineData,
@@ -165,6 +170,8 @@ export class TddService {
upsertScreenshotInMetadata: defaultUpsertScreenshotInMetadata,
loadHotspotMetadata: defaultLoadHotspotMetadata,
saveHotspotMetadata: defaultSaveHotspotMetadata,
+ loadRegionMetadata: defaultLoadRegionMetadata,
+ saveRegionMetadata: defaultSaveRegionMetadata,
...metadata,
};
@@ -256,6 +263,9 @@ export class TddService {
// Hotspot data (loaded lazily from disk or downloaded from cloud)
this.hotspotData = null;
+ // Region data (user-defined 2D bounding boxes, loaded lazily)
+ this.regionData = null;
+
// Track whether results have been printed (to avoid duplicate output)
this._resultsPrinted = false;
@@ -461,6 +471,30 @@ export class TddService {
baselineBuild = apiResponse.build;
baselineBuild.screenshots = apiResponse.screenshots;
+
+ // Store bundled hotspots and regions from API response
+ if (
+ apiResponse.hotspots &&
+ Object.keys(apiResponse.hotspots).length > 0
+ ) {
+ this.hotspotData = apiResponse.hotspots;
+ saveHotspotMetadata(
+ this.workingDir,
+ apiResponse.hotspots,
+ apiResponse.summary
+ );
+ }
+ if (
+ apiResponse.regions &&
+ Object.keys(apiResponse.regions).length > 0
+ ) {
+ this.regionData = apiResponse.regions;
+ saveRegionMetadata(
+ this.workingDir,
+ apiResponse.regions,
+ apiResponse.summary
+ );
+ }
}
let buildDetails = baselineBuild;
@@ -636,8 +670,8 @@ export class TddService {
saveBaselineMetadata(this.baselinePath, this.baselineData);
- // Download hotspots
- await this.downloadHotspots(buildDetails.screenshots);
+ // Hotspots and regions are now bundled in the tdd-baselines API response
+ // and saved earlier when processing the API response
// Save baseline build metadata for MCP plugin
let baselineMetadataPath = safePath(
@@ -735,6 +769,25 @@ export class TddService {
let baselineBuild = apiResponse.build;
+ // Store bundled hotspots and regions from API response
+ let { saveHotspotMetadata, saveRegionMetadata } = this._deps;
+ if (apiResponse.hotspots && Object.keys(apiResponse.hotspots).length > 0) {
+ this.hotspotData = apiResponse.hotspots;
+ saveHotspotMetadata(
+ this.workingDir,
+ apiResponse.hotspots,
+ apiResponse.summary
+ );
+ }
+ if (apiResponse.regions && Object.keys(apiResponse.regions).length > 0) {
+ this.regionData = apiResponse.regions;
+ saveRegionMetadata(
+ this.workingDir,
+ apiResponse.regions,
+ apiResponse.summary
+ );
+ }
+
if (baselineBuild.status === 'failed') {
output.warn(
`Build ${buildId} is marked as FAILED - falling back to local baselines`
@@ -921,11 +974,8 @@ export class TddService {
saveBaselineMetadata(this.baselinePath, this.baselineData);
- // Download hotspots if API key is available (requires SDK auth)
- // OAuth-only users won't get hotspots since the hotspot endpoint requires project token
- if (this.config.apiKey && buildDetails.screenshots?.length > 0) {
- await this.downloadHotspots(buildDetails.screenshots);
- }
+ // Hotspots and regions are now bundled in the tdd-baselines API response
+ // and saved earlier when processing the API response
// Save baseline build metadata for MCP plugin
let baselineMetadataPath = safePath(
@@ -1059,6 +1109,38 @@ export class TddService {
return this.hotspotData?.[screenshotName] || null;
}
+ /**
+ * Load region data from disk
+ */
+ loadRegions() {
+ let { loadRegionMetadata } = this._deps;
+ return loadRegionMetadata(this.workingDir);
+ }
+
+ /**
+ * Get user-defined regions for a specific screenshot
+ *
+ * Note: Once regionData is loaded (from disk or cloud), we don't reload.
+ * This is intentional - regions are downloaded once per session and cached.
+ * If a screenshot isn't in the cache, it means no region data exists for it.
+ *
+ * @param {string} screenshotName - Name of the screenshot
+ * @returns {Object|null} Region data { confirmed: [], candidates: [] } or null
+ */
+ getRegionsForScreenshot(screenshotName) {
+ // Check memory cache first
+ if (this.regionData?.[screenshotName]) {
+ return this.regionData[screenshotName];
+ }
+
+ // Try loading from disk (only if we haven't loaded yet)
+ if (!this.regionData) {
+ this.regionData = this.loadRegions();
+ }
+
+ return this.regionData?.[screenshotName] || null;
+ }
+
/**
* Calculate hotspot coverage (delegating to pure function)
*/
@@ -1306,6 +1388,7 @@ export class TddService {
return result;
} else {
let hotspotAnalysis = this.getHotspotForScreenshot(name);
+ let regionData = this.getRegionsForScreenshot(name);
let result = buildFailedComparison({
name: sanitizedName,
@@ -1318,6 +1401,7 @@ export class TddService {
minClusterSize: effectiveMinClusterSize,
honeydiffResult,
hotspotAnalysis,
+ regionData,
});
// Log at debug level only (shown with --verbose)
diff --git a/tests/tdd/core/region-coverage.test.js b/tests/tdd/core/region-coverage.test.js
new file mode 100644
index 0000000..0664e69
--- /dev/null
+++ b/tests/tdd/core/region-coverage.test.js
@@ -0,0 +1,276 @@
+import assert from 'node:assert';
+import { describe, it } from 'node:test';
+import {
+ calculateRegionCoverage,
+ clusterIntersectsRegion,
+ shouldAutoApproveFromRegions,
+} from '../../../src/tdd/core/region-coverage.js';
+
+describe('tdd/core/region-coverage', () => {
+ describe('clusterIntersectsRegion', () => {
+ it('returns false for null cluster', () => {
+ let result = clusterIntersectsRegion(null, {
+ x1: 0,
+ y1: 0,
+ x2: 100,
+ y2: 100,
+ });
+ assert.strictEqual(result, false);
+ });
+
+ it('returns false for cluster without boundingBox', () => {
+ let result = clusterIntersectsRegion(
+ {},
+ { x1: 0, y1: 0, x2: 100, y2: 100 }
+ );
+ assert.strictEqual(result, false);
+ });
+
+ it('returns false for null region', () => {
+ let result = clusterIntersectsRegion(
+ { boundingBox: { x: 10, y: 10, width: 20, height: 20 } },
+ null
+ );
+ assert.strictEqual(result, false);
+ });
+
+ it('detects cluster fully inside region', () => {
+ let result = clusterIntersectsRegion(
+ { boundingBox: { x: 20, y: 20, width: 10, height: 10 } },
+ { x1: 0, y1: 0, x2: 100, y2: 100 }
+ );
+ assert.strictEqual(result, true);
+ });
+
+ it('detects cluster overlapping region edge', () => {
+ // Cluster from x=90-110, region ends at x=100
+ let result = clusterIntersectsRegion(
+ { boundingBox: { x: 90, y: 50, width: 20, height: 10 } },
+ { x1: 0, y1: 0, x2: 100, y2: 100 }
+ );
+ assert.strictEqual(result, true);
+ });
+
+ it('returns false when cluster is completely left of region', () => {
+ let result = clusterIntersectsRegion(
+ { boundingBox: { x: 0, y: 50, width: 10, height: 10 } },
+ { x1: 50, y1: 0, x2: 100, y2: 100 }
+ );
+ assert.strictEqual(result, false);
+ });
+
+ it('returns false when cluster is completely right of region', () => {
+ let result = clusterIntersectsRegion(
+ { boundingBox: { x: 150, y: 50, width: 10, height: 10 } },
+ { x1: 0, y1: 0, x2: 100, y2: 100 }
+ );
+ assert.strictEqual(result, false);
+ });
+
+ it('returns false when cluster is completely above region', () => {
+ let result = clusterIntersectsRegion(
+ { boundingBox: { x: 50, y: 0, width: 10, height: 10 } },
+ { x1: 0, y1: 50, x2: 100, y2: 100 }
+ );
+ assert.strictEqual(result, false);
+ });
+
+ it('returns false when cluster is completely below region', () => {
+ let result = clusterIntersectsRegion(
+ { boundingBox: { x: 50, y: 150, width: 10, height: 10 } },
+ { x1: 0, y1: 0, x2: 100, y2: 100 }
+ );
+ assert.strictEqual(result, false);
+ });
+
+ it('detects corner overlap', () => {
+ // Cluster overlaps bottom-right corner of region
+ let result = clusterIntersectsRegion(
+ { boundingBox: { x: 95, y: 95, width: 20, height: 20 } },
+ { x1: 0, y1: 0, x2: 100, y2: 100 }
+ );
+ assert.strictEqual(result, true);
+ });
+ });
+
+ describe('calculateRegionCoverage', () => {
+ it('returns zero coverage for null diffClusters', () => {
+ let result = calculateRegionCoverage(null, [
+ { x1: 0, y1: 0, x2: 100, y2: 100 },
+ ]);
+
+ assert.deepStrictEqual(result, {
+ coverage: 0,
+ clustersInRegions: 0,
+ totalClusters: 0,
+ matchedRegions: [],
+ });
+ });
+
+ it('returns zero coverage for empty diffClusters', () => {
+ let result = calculateRegionCoverage(
+ [],
+ [{ x1: 0, y1: 0, x2: 100, y2: 100 }]
+ );
+
+ assert.deepStrictEqual(result, {
+ coverage: 0,
+ clustersInRegions: 0,
+ totalClusters: 0,
+ matchedRegions: [],
+ });
+ });
+
+ it('returns zero coverage for null regions', () => {
+ let result = calculateRegionCoverage(
+ [{ boundingBox: { x: 50, y: 50, width: 10, height: 10 } }],
+ null
+ );
+
+ assert.strictEqual(result.coverage, 0);
+ assert.strictEqual(result.totalClusters, 1);
+ });
+
+ it('returns zero coverage for empty regions', () => {
+ let result = calculateRegionCoverage(
+ [{ boundingBox: { x: 50, y: 50, width: 10, height: 10 } }],
+ []
+ );
+
+ assert.strictEqual(result.coverage, 0);
+ assert.strictEqual(result.totalClusters, 1);
+ });
+
+ it('calculates 100% coverage when all clusters in region', () => {
+ let result = calculateRegionCoverage(
+ [
+ { boundingBox: { x: 10, y: 10, width: 5, height: 5 } },
+ { boundingBox: { x: 20, y: 20, width: 5, height: 5 } },
+ ],
+ [{ x1: 0, y1: 0, x2: 100, y2: 100 }]
+ );
+
+ assert.strictEqual(result.coverage, 1);
+ assert.strictEqual(result.clustersInRegions, 2);
+ assert.strictEqual(result.totalClusters, 2);
+ });
+
+ it('calculates 0% coverage when no clusters in region', () => {
+ let result = calculateRegionCoverage(
+ [
+ { boundingBox: { x: 200, y: 200, width: 5, height: 5 } },
+ { boundingBox: { x: 300, y: 300, width: 5, height: 5 } },
+ ],
+ [{ x1: 0, y1: 0, x2: 100, y2: 100 }]
+ );
+
+ assert.strictEqual(result.coverage, 0);
+ assert.strictEqual(result.clustersInRegions, 0);
+ assert.strictEqual(result.totalClusters, 2);
+ });
+
+ it('calculates 50% coverage when half clusters in region', () => {
+ let result = calculateRegionCoverage(
+ [
+ { boundingBox: { x: 10, y: 10, width: 5, height: 5 } }, // Inside
+ { boundingBox: { x: 200, y: 200, width: 5, height: 5 } }, // Outside
+ ],
+ [{ x1: 0, y1: 0, x2: 100, y2: 100 }]
+ );
+
+ assert.strictEqual(result.coverage, 0.5);
+ assert.strictEqual(result.clustersInRegions, 1);
+ assert.strictEqual(result.totalClusters, 2);
+ });
+
+ it('handles multiple regions', () => {
+ let result = calculateRegionCoverage(
+ [
+ { boundingBox: { x: 10, y: 10, width: 5, height: 5 } }, // In region 1
+ { boundingBox: { x: 210, y: 210, width: 5, height: 5 } }, // In region 2
+ ],
+ [
+ { id: 'region-1', x1: 0, y1: 0, x2: 100, y2: 100 },
+ { id: 'region-2', x1: 200, y1: 200, x2: 300, y2: 300 },
+ ]
+ );
+
+ assert.strictEqual(result.coverage, 1);
+ assert.strictEqual(result.clustersInRegions, 2);
+ assert.deepStrictEqual(result.matchedRegions.sort(), [
+ 'region-1',
+ 'region-2',
+ ]);
+ });
+
+ it('tracks matched region ids', () => {
+ let result = calculateRegionCoverage(
+ [{ boundingBox: { x: 10, y: 10, width: 5, height: 5 } }],
+ [{ id: 'timestamp-region', x1: 0, y1: 0, x2: 100, y2: 100 }]
+ );
+
+ assert.deepStrictEqual(result.matchedRegions, ['timestamp-region']);
+ });
+
+ it('tracks matched region labels when no id', () => {
+ let result = calculateRegionCoverage(
+ [{ boundingBox: { x: 10, y: 10, width: 5, height: 5 } }],
+ [{ label: 'avatar', x1: 0, y1: 0, x2: 100, y2: 100 }]
+ );
+
+ assert.deepStrictEqual(result.matchedRegions, ['avatar']);
+ });
+ });
+
+ describe('shouldAutoApproveFromRegions', () => {
+ it('returns false for null regions', () => {
+ let result = shouldAutoApproveFromRegions(null, { coverage: 1 });
+ assert.strictEqual(result, false);
+ });
+
+ it('returns false for empty regions', () => {
+ let result = shouldAutoApproveFromRegions([], { coverage: 1 });
+ assert.strictEqual(result, false);
+ });
+
+ it('returns false for null coverageResult', () => {
+ let result = shouldAutoApproveFromRegions(
+ [{ x1: 0, y1: 0, x2: 100, y2: 100 }],
+ null
+ );
+ assert.strictEqual(result, false);
+ });
+
+ it('returns false when coverage below 80%', () => {
+ let result = shouldAutoApproveFromRegions(
+ [{ x1: 0, y1: 0, x2: 100, y2: 100 }],
+ { coverage: 0.79 }
+ );
+ assert.strictEqual(result, false);
+ });
+
+ it('returns true when coverage exactly 80%', () => {
+ let result = shouldAutoApproveFromRegions(
+ [{ x1: 0, y1: 0, x2: 100, y2: 100 }],
+ { coverage: 0.8 }
+ );
+ assert.strictEqual(result, true);
+ });
+
+ it('returns true when coverage above 80%', () => {
+ let result = shouldAutoApproveFromRegions(
+ [{ x1: 0, y1: 0, x2: 100, y2: 100 }],
+ { coverage: 0.95 }
+ );
+ assert.strictEqual(result, true);
+ });
+
+ it('returns true when coverage is 100%', () => {
+ let result = shouldAutoApproveFromRegions(
+ [{ x1: 0, y1: 0, x2: 100, y2: 100 }],
+ { coverage: 1.0 }
+ );
+ assert.strictEqual(result, true);
+ });
+ });
+});
diff --git a/tests/tdd/tdd-service.test.js b/tests/tdd/tdd-service.test.js
index 58a7a34..7698986 100644
--- a/tests/tdd/tdd-service.test.js
+++ b/tests/tdd/tdd-service.test.js
@@ -1684,8 +1684,9 @@ describe('tdd/tdd-service', () => {
assert.ok(savedMetadata.screenshots.length > 0);
});
- it('downloads hotspots when apiKey is configured', async () => {
- let hotspotsCalled = false;
+ it('saves bundled hotspots from API response', async () => {
+ let hotspotsSaved = false;
+ let savedHotspots = null;
let mockDeps = createMockDeps({
baseline: { clearBaselineData: () => {} },
fs: {
@@ -1695,17 +1696,17 @@ describe('tdd/tdd-service', () => {
metadata: {
loadBaselineMetadata: () => null,
saveBaselineMetadata: () => {},
- saveHotspotMetadata: () => {},
+ saveHotspotMetadata: (_dir, hotspots) => {
+ hotspotsSaved = true;
+ savedHotspots = hotspots;
+ },
+ saveRegionMetadata: () => {},
},
api: {
fetchWithTimeout: async () => ({
ok: true,
arrayBuffer: async () => new ArrayBuffer(10),
}),
- getBatchHotspots: async () => {
- hotspotsCalled = true;
- return { hotspots: {} };
- },
},
});
let service = new TddService(
@@ -1725,15 +1726,23 @@ describe('tdd/tdd-service', () => {
original_url: 'http://example.com/1.png',
},
],
+ hotspots: {
+ test: { regions: [{ y1: 0, y2: 100 }], confidence: 'high' },
+ },
+ summary: { hotspotsCount: 1 },
};
await service.processDownloadedBaselines(apiResponse, 'build-1');
- assert.ok(hotspotsCalled, 'Should call hotspots API when apiKey present');
+ assert.ok(
+ hotspotsSaved,
+ 'Should save bundled hotspots from API response'
+ );
+ assert.deepStrictEqual(savedHotspots, apiResponse.hotspots);
});
- it('skips hotspot download when no apiKey configured', async () => {
- let hotspotsCalled = false;
+ it('skips hotspot save when not in API response', async () => {
+ let hotspotsSaved = false;
let mockDeps = createMockDeps({
baseline: { clearBaselineData: () => {} },
fs: {
@@ -1743,16 +1752,16 @@ describe('tdd/tdd-service', () => {
metadata: {
loadBaselineMetadata: () => null,
saveBaselineMetadata: () => {},
+ saveHotspotMetadata: () => {
+ hotspotsSaved = true;
+ },
+ saveRegionMetadata: () => {},
},
api: {
fetchWithTimeout: async () => ({
ok: true,
arrayBuffer: async () => new ArrayBuffer(10),
}),
- getBatchHotspots: async () => {
- hotspotsCalled = true;
- return { hotspots: {} };
- },
},
});
let service = new TddService({}, '/test', false, null, mockDeps);
@@ -1766,11 +1775,15 @@ describe('tdd/tdd-service', () => {
original_url: 'http://example.com/1.png',
},
],
+ // No hotspots in response
};
await service.processDownloadedBaselines(apiResponse, 'build-1');
- assert.ok(!hotspotsCalled, 'Should NOT call hotspots API without apiKey');
+ assert.ok(
+ !hotspotsSaved,
+ 'Should NOT save hotspots when not in API response'
+ );
});
it('returns null when all downloads fail', async () => {