Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,769 changes: 7 additions & 1,762 deletions src/services/tdd-service.js

Large diffs are not rendered by default.

106 changes: 106 additions & 0 deletions src/tdd/core/hotspot-coverage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* Hotspot Coverage Calculation
*
* Pure functions for calculating how much of a visual diff falls within
* "hotspot" regions - areas of the UI that frequently change due to dynamic
* content (timestamps, animations, etc.).
*
* Uses 1D Y-coordinate matching (same algorithm as cloud).
*/

/**
* Calculate what percentage of diff falls within hotspot regions
*
* @param {Array} diffClusters - Array of diff clusters from honeydiff
* @param {Object} hotspotAnalysis - Hotspot data with regions array
* @returns {{ coverage: number, linesInHotspots: number, totalLines: number }}
*/
export function calculateHotspotCoverage(diffClusters, hotspotAnalysis) {
if (!diffClusters || diffClusters.length === 0) {
return { coverage: 0, linesInHotspots: 0, totalLines: 0 };
}

if (
!hotspotAnalysis ||
!hotspotAnalysis.regions ||
hotspotAnalysis.regions.length === 0
) {
return { coverage: 0, linesInHotspots: 0, totalLines: 0 };
}

// Extract Y-coordinates (diff lines) from clusters
// Each cluster has a boundingBox with y and height
let diffLines = [];
for (let cluster of diffClusters) {
if (cluster.boundingBox) {
let { y, height } = cluster.boundingBox;
// Add all Y lines covered by this cluster
for (let line = y; line < y + height; line++) {
diffLines.push(line);
}
}
}

if (diffLines.length === 0) {
return { coverage: 0, linesInHotspots: 0, totalLines: 0 };
}

// Remove duplicates and sort
diffLines = [...new Set(diffLines)].sort((a, b) => a - b);

// Check how many diff lines fall within hotspot regions
let linesInHotspots = 0;
for (let line of diffLines) {
let inHotspot = hotspotAnalysis.regions.some(
region => line >= region.y1 && line <= region.y2
);
if (inHotspot) {
linesInHotspots++;
}
}

let coverage = linesInHotspots / diffLines.length;

return {
coverage,
linesInHotspots,
totalLines: diffLines.length,
};
}

/**
* Determine if a comparison should be filtered as "passed" based on hotspot coverage
*
* A diff is filtered when:
* 1. Coverage is >= 80% (most diff in hotspot regions)
* 2. Confidence is "high" or confidence score > 0.7
*
* @param {Object} hotspotAnalysis - Hotspot data with confidence info
* @param {{ coverage: number }} coverageResult - Result from calculateHotspotCoverage
* @returns {boolean} True if diff should be filtered as hotspot noise
*/
export function shouldFilterAsHotspot(hotspotAnalysis, coverageResult) {
if (!hotspotAnalysis || !coverageResult) {
return false;
}

let { coverage } = coverageResult;

// Need at least 80% of diff in hotspot regions
if (coverage < 0.8) {
return false;
}

// Need high confidence in the hotspot analysis
let { confidence, confidenceScore } = hotspotAnalysis;

if (confidence === 'high') {
return true;
}

if (confidenceScore !== undefined && confidenceScore > 0.7) {
return true;
}

return false;
}
120 changes: 120 additions & 0 deletions src/tdd/core/signature.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* Screenshot Identity - Signature and Filename Generation
*
* CRITICAL: These functions MUST stay in sync with the cloud!
*
* Cloud counterpart: vizzly/src/utils/screenshot-identity.js
* - generateScreenshotSignature()
* - generateBaselineFilename()
*
* Contract tests: Both repos have golden tests that must produce identical values:
* - Cloud: tests/contracts/signature-parity.test.js
* - CLI: tests/contracts/signature-parity.spec.js
*
* If you modify signature or filename generation here, you MUST:
* 1. Make the same change in the cloud repo
* 2. Update golden test values in BOTH repos
* 3. Run contract tests in both repos to verify parity
*
* The signature format is: name|viewport_width|browser|custom1|custom2|...
* The filename format is: {sanitized-name}_{12-char-sha256-hash}.png
*/

import crypto from 'node:crypto';

/**
* Generate a screenshot signature for baseline matching
*
* SYNC WITH: vizzly/src/utils/screenshot-identity.js - generateScreenshotSignature()
*
* Uses same logic as cloud: name + viewport_width + browser + custom properties
*
* @param {string} name - Screenshot name
* @param {Object} properties - Screenshot properties (viewport, browser, metadata, etc.)
* @param {Array<string>} customProperties - Custom property names from project settings
* @returns {string} Signature like "Login|1920|chrome|iPhone 15 Pro"
*/
export function generateScreenshotSignature(
name,
properties = {},
customProperties = []
) {
// Match cloud screenshot-identity.js behavior exactly:
// Always include all default properties (name, viewport_width, browser)
// even if null/undefined, using empty string as placeholder
let defaultProperties = ['name', 'viewport_width', 'browser'];
let allProperties = [...defaultProperties, ...customProperties];

let parts = allProperties.map(propName => {
let value;

if (propName === 'name') {
value = name;
} else if (propName === 'viewport_width') {
// Check for viewport_width as top-level property first (backend format)
value = properties.viewport_width;
// Fallback to nested viewport.width (SDK format)
if (value === null || value === undefined) {
value = properties.viewport?.width;
}
} else if (propName === 'browser') {
value = properties.browser;
} else {
// Custom property - check multiple locations
value =
properties[propName] ??
properties.metadata?.[propName] ??
properties.metadata?.properties?.[propName];
}

// Handle null/undefined values consistently (match cloud behavior)
if (value === null || value === undefined) {
return '';
}

// Convert to string and normalize
return String(value).trim();
});

return parts.join('|');
}

/**
* Generate a stable, filesystem-safe filename for a screenshot baseline
* Uses a hash of the signature to avoid character encoding issues
* Matches the cloud's generateBaselineFilename implementation exactly
*
* @param {string} name - Screenshot name
* @param {string} signature - Full signature string
* @returns {string} Filename like "homepage_a1b2c3d4e5f6.png"
*/
export function generateBaselineFilename(name, signature) {
let hash = crypto
.createHash('sha256')
.update(signature)
.digest('hex')
.slice(0, 12);

// Sanitize the name for filesystem safety
let safeName = name
.replace(/[/\\:*?"<>|]/g, '') // Remove unsafe chars
.replace(/\s+/g, '-') // Spaces to hyphens
.slice(0, 50); // Limit length

return `${safeName}_${hash}.png`;
}

/**
* Generate a stable unique ID from signature for TDD comparisons
* This allows UI to reference specific variants without database IDs
*
* @param {string} signature - Full signature string
* @returns {string} 16-char hex hash
*/
export function generateComparisonId(signature) {
return crypto
.createHash('sha256')
.update(signature)
.digest('hex')
.slice(0, 16);
}
80 changes: 80 additions & 0 deletions src/tdd/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* TDD Module Exports
*
* Re-exports all TDD functionality for clean imports.
*/

// Core pure functions
export {
generateScreenshotSignature,
generateBaselineFilename,
generateComparisonId,
} from './core/signature.js';

export {
calculateHotspotCoverage,
shouldFilterAsHotspot,
} from './core/hotspot-coverage.js';

// Metadata I/O
export {
loadBaselineMetadata,
saveBaselineMetadata,
createEmptyBaselineMetadata,
upsertScreenshotInMetadata,
findScreenshotBySignature,
} from './metadata/baseline-metadata.js';

export {
loadHotspotMetadata,
saveHotspotMetadata,
getHotspotForScreenshot,
createHotspotCache,
} from './metadata/hotspot-metadata.js';

// Services
export {
initializeDirectories,
clearBaselineData,
saveBaseline,
saveCurrent,
baselineExists,
getBaselinePath,
getCurrentPath,
getDiffPath,
promoteCurrentToBaseline,
readBaseline,
readCurrent,
} from './services/baseline-manager.js';

export {
compareImages,
buildPassedComparison,
buildNewComparison,
buildFailedComparison,
buildErrorComparison,
isDimensionMismatchError,
} from './services/comparison-service.js';

export {
calculateSummary,
buildResults,
getFailedComparisons,
getNewComparisons,
getErrorComparisons,
isSuccessful,
findComparisonById,
findComparison,
} from './services/result-service.js';

export {
downloadBaselineImage,
baselineMatchesSha,
downloadBaselinesInBatches,
buildBaselineMetadataEntry,
} from './services/baseline-downloader.js';

export {
downloadHotspots,
extractScreenshotNames,
} from './services/hotspot-service.js';
Loading