From 487e3bc381cb79aac9d5e7b8a6de1826d3cf64e4 Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Sat, 13 Dec 2025 13:24:54 -0600 Subject: [PATCH 1/2] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor=20TddService?= =?UTF-8?q?=20into=20functional=20decomposition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract pure functions and focused services from monolithic TddService: - src/tdd/core/signature.js - Pure signature/filename generation - src/tdd/core/hotspot-coverage.js - Pure hotspot math functions - src/tdd/metadata/baseline-metadata.js - Baseline metadata I/O - src/tdd/metadata/hotspot-metadata.js - Hotspot metadata I/O - src/tdd/services/baseline-manager.js - Local baseline CRUD - src/tdd/services/comparison-service.js - Honeydiff wrapper - src/tdd/services/result-service.js - Result aggregation New tests use real temp directories and actual honeydiff - no mocking. Old TddService path re-exports for backwards compatibility. --- src/services/tdd-service.js | 1769 +---------------- src/tdd/core/hotspot-coverage.js | 106 + src/tdd/core/signature.js | 120 ++ src/tdd/index.js | 80 + src/tdd/metadata/baseline-metadata.js | 113 ++ src/tdd/metadata/hotspot-metadata.js | 93 + src/tdd/services/baseline-downloader.js | 163 ++ src/tdd/services/baseline-manager.js | 163 ++ src/tdd/services/comparison-service.js | 234 +++ src/tdd/services/hotspot-service.js | 61 + src/tdd/services/result-service.js | 126 ++ src/tdd/tdd-service.js | 1241 ++++++++++++ tests/contracts/signature-parity.spec.js | 2 +- tests/tdd/core/hotspot-coverage.spec.js | 255 +++ tests/tdd/core/signature.spec.js | 278 +++ .../tdd-service.integration.spec.js | 199 ++ tests/tdd/metadata/baseline-metadata.spec.js | 237 +++ tests/tdd/metadata/hotspot-metadata.spec.js | 210 ++ tests/tdd/services/baseline-manager.spec.js | 219 ++ tests/tdd/services/comparison-service.spec.js | 260 +++ tests/tdd/services/result-service.spec.js | 228 +++ 21 files changed, 4394 insertions(+), 1763 deletions(-) create mode 100644 src/tdd/core/hotspot-coverage.js create mode 100644 src/tdd/core/signature.js create mode 100644 src/tdd/index.js create mode 100644 src/tdd/metadata/baseline-metadata.js create mode 100644 src/tdd/metadata/hotspot-metadata.js create mode 100644 src/tdd/services/baseline-downloader.js create mode 100644 src/tdd/services/baseline-manager.js create mode 100644 src/tdd/services/comparison-service.js create mode 100644 src/tdd/services/hotspot-service.js create mode 100644 src/tdd/services/result-service.js create mode 100644 src/tdd/tdd-service.js create mode 100644 tests/tdd/core/hotspot-coverage.spec.js create mode 100644 tests/tdd/core/signature.spec.js create mode 100644 tests/tdd/integration/tdd-service.integration.spec.js create mode 100644 tests/tdd/metadata/baseline-metadata.spec.js create mode 100644 tests/tdd/metadata/hotspot-metadata.spec.js create mode 100644 tests/tdd/services/baseline-manager.spec.js create mode 100644 tests/tdd/services/comparison-service.spec.js create mode 100644 tests/tdd/services/result-service.spec.js diff --git a/src/services/tdd-service.js b/src/services/tdd-service.js index 5efed663..d60361da 100644 --- a/src/services/tdd-service.js +++ b/src/services/tdd-service.js @@ -1,1769 +1,14 @@ /** * TDD Service - Local Visual Testing * - * āš ļø CRITICAL: Signature/filename generation MUST stay in sync with the cloud! + * This file re-exports from the refactored TDD module for backwards compatibility. + * The actual implementation now lives in src/tdd/tdd-service.js * - * 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'; -import { - existsSync, - mkdirSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs'; -import { join } from 'node:path'; -import { compare } from '@vizzly-testing/honeydiff'; -import { NetworkError } from '../errors/vizzly-error.js'; -import { ApiService } from '../services/api-service.js'; -import { colors } from '../utils/colors.js'; -import { fetchWithTimeout } from '../utils/fetch-utils.js'; -import { getDefaultBranch } from '../utils/git.js'; -import * as output from '../utils/output.js'; -import { - safePath, - sanitizeScreenshotName, - validatePathSecurity, - validateScreenshotProperties, -} from '../utils/security.js'; -import { HtmlReportGenerator } from './html-report-generator.js'; - -/** - * 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} customProperties - Custom property names from project settings - * @returns {string} Signature like "Login|1920|chrome|iPhone 15 Pro" - */ -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 - const defaultProperties = ['name', 'viewport_width', 'browser']; - const allProperties = [...defaultProperties, ...customProperties]; - - const 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 + * CRITICAL: Signature/filename generation MUST stay in sync with the cloud! + * See src/tdd/core/signature.js for details. * - * @param {string} name - Screenshot name - * @param {string} signature - Full signature string - * @returns {string} Filename like "homepage_a1b2c3d4e5f6.png" - */ -function generateBaselineFilename(name, signature) { - const hash = crypto - .createHash('sha256') - .update(signature) - .digest('hex') - .slice(0, 12); - - // Sanitize the name for filesystem safety - const 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 - */ -function generateComparisonId(signature) { - return crypto - .createHash('sha256') - .update(signature) - .digest('hex') - .slice(0, 16); -} - -/** - * Create a new TDD service instance + * Cloud counterpart: vizzly/src/utils/screenshot-identity.js + * Contract tests: tests/contracts/signature-parity.spec.js */ -export function createTDDService(config, options = {}) { - return new TddService( - config, - options.workingDir, - options.setBaseline, - options.authService - ); -} - -export class TddService { - constructor( - config, - workingDir = process.cwd(), - setBaseline = false, - authService = null - ) { - this.config = config; - this.setBaseline = setBaseline; - this.authService = authService; - this.api = new ApiService({ - baseUrl: config.apiUrl, - token: config.apiKey, - command: 'tdd', - allowNoToken: true, // TDD can run without a token to create new screenshots - }); - - // Validate and secure the working directory - try { - this.workingDir = validatePathSecurity(workingDir, workingDir); - } catch (error) { - output.error(`Invalid working directory: ${error.message}`); - throw new Error(`Working directory validation failed: ${error.message}`); - } - - // Use safe path construction for subdirectories - this.baselinePath = safePath(this.workingDir, '.vizzly', 'baselines'); - this.currentPath = safePath(this.workingDir, '.vizzly', 'current'); - this.diffPath = safePath(this.workingDir, '.vizzly', 'diffs'); - this.baselineData = null; - this.comparisons = []; - this.threshold = config.comparison?.threshold || 2.0; - this.minClusterSize = config.comparison?.minClusterSize ?? 2; // Filter single-pixel noise by default - this.signatureProperties = config.signatureProperties ?? []; // Custom properties from project's baseline_signature_properties - - // Check if we're in baseline update mode - if (this.setBaseline) { - output.info( - '🐻 Baseline update mode - will overwrite existing baselines with new ones' - ); - } - - // Ensure directories exist - [this.baselinePath, this.currentPath, this.diffPath].forEach(dir => { - if (!existsSync(dir)) { - try { - mkdirSync(dir, { recursive: true }); - } catch (error) { - output.error(`Failed to create directory ${dir}: ${error.message}`); - throw new Error(`Directory creation failed: ${error.message}`); - } - } - }); - } - - async downloadBaselines( - environment = 'test', - branch = null, - buildId = null, - comparisonId = null - ) { - // If no branch specified, try to detect the default branch - if (!branch) { - branch = await getDefaultBranch(); - if (!branch) { - // If we can't detect a default branch, use 'main' as fallback - branch = 'main'; - output.warn( - `āš ļø Could not detect default branch, using 'main' as fallback` - ); - } else { - output.debug('tdd', `detected default branch: ${branch}`); - } - } - - try { - let baselineBuild; - - if (buildId) { - // Use the tdd-baselines endpoint which returns pre-computed filenames - let apiResponse = await this.api.getTddBaselines(buildId); - - if (!apiResponse) { - throw new Error(`Build ${buildId} not found or API returned null`); - } - - // When downloading baselines, always start with a clean slate - // This handles signature property changes, build switches, and any stale state - output.info('Clearing local state before downloading baselines...'); - try { - // Clear everything - baselines, current screenshots, diffs, and metadata - // This ensures we start fresh with the new baseline build - rmSync(this.baselinePath, { recursive: true, force: true }); - rmSync(this.currentPath, { recursive: true, force: true }); - rmSync(this.diffPath, { recursive: true, force: true }); - mkdirSync(this.baselinePath, { recursive: true }); - mkdirSync(this.currentPath, { recursive: true }); - mkdirSync(this.diffPath, { recursive: true }); - - // Clear baseline metadata file (will be regenerated with new baseline) - const baselineMetadataPath = safePath( - this.workingDir, - '.vizzly', - 'baseline-metadata.json' - ); - if (existsSync(baselineMetadataPath)) { - rmSync(baselineMetadataPath, { force: true }); - } - } catch (error) { - output.error(`Failed to clear local state: ${error.message}`); - } - - // Extract signature properties from API response (for variant support) - if ( - apiResponse.signatureProperties && - Array.isArray(apiResponse.signatureProperties) - ) { - this.signatureProperties = apiResponse.signatureProperties; - if (this.signatureProperties.length > 0) { - output.info( - `Using signature properties: ${this.signatureProperties.join(', ')}` - ); - } - } - - baselineBuild = apiResponse.build; - - // Check build status and warn if it's not successful - if (baselineBuild.status === 'failed') { - output.warn( - `āš ļø Build ${buildId} is marked as FAILED - falling back to local baselines` - ); - output.info( - `šŸ’” To use remote baselines, specify a successful build ID instead` - ); - return await this.handleLocalBaselines(); - } else if (baselineBuild.status !== 'completed') { - output.warn( - `āš ļø Build ${buildId} has status: ${baselineBuild.status} (expected: completed)` - ); - } - - // Attach screenshots to build for unified processing below - baselineBuild.screenshots = apiResponse.screenshots; - } else if (comparisonId) { - // Use specific comparison ID - download only this comparison's baseline screenshot - output.info(`Using comparison: ${comparisonId}`); - const comparison = await this.api.getComparison(comparisonId); - - // A comparison doesn't have baselineBuild directly - we need to get it - // The comparison has baseline_screenshot which contains the build_id - if (!comparison.baseline_screenshot) { - throw new Error( - `Comparison ${comparisonId} has no baseline screenshot. This comparison may be a "new" screenshot with no baseline to compare against.` - ); - } - - // The original_url might be in baseline_screenshot.original_url or comparison.baseline_screenshot_url - const baselineUrl = - comparison.baseline_screenshot.original_url || - comparison.baseline_screenshot_url; - - if (!baselineUrl) { - throw new Error( - `Baseline screenshot for comparison ${comparisonId} has no download URL` - ); - } - - // Extract properties from the current screenshot to ensure signature matching - // The baseline should use the same properties (viewport/browser) as the current screenshot - // so that generateScreenshotSignature produces the correct filename - // Use current screenshot properties since we're downloading baseline to compare against current - const screenshotProperties = {}; - - // Build properties from comparison API fields (added in backend update) - // Use current_* fields since we're matching against the current screenshot being tested - if (comparison.current_viewport_width || comparison.current_browser) { - if (comparison.current_viewport_width) { - screenshotProperties.viewport = { - width: comparison.current_viewport_width, - height: comparison.current_viewport_height, - }; - } - if (comparison.current_browser) { - screenshotProperties.browser = comparison.current_browser; - } - } else if ( - comparison.baseline_viewport_width || - comparison.baseline_browser - ) { - // Fallback to baseline properties if current not available - if (comparison.baseline_viewport_width) { - screenshotProperties.viewport = { - width: comparison.baseline_viewport_width, - height: comparison.baseline_viewport_height, - }; - } - if (comparison.baseline_browser) { - screenshotProperties.browser = comparison.baseline_browser; - } - } - - output.info( - `šŸ“Š Extracted properties for signature: ${JSON.stringify(screenshotProperties)}` - ); - - // Generate filename locally for comparison path (we don't have API-provided filename) - const screenshotName = - comparison.baseline_name || comparison.current_name; - const signature = generateScreenshotSignature( - screenshotName, - screenshotProperties, - this.signatureProperties - ); - const filename = generateBaselineFilename(screenshotName, signature); - - // For a specific comparison, we only download that one baseline screenshot - // Create a mock build structure with just this one screenshot - baselineBuild = { - id: comparison.baseline_screenshot.build_id || 'comparison-baseline', - name: `Comparison ${comparisonId.substring(0, 8)}`, - screenshots: [ - { - id: comparison.baseline_screenshot.id, - name: screenshotName, - original_url: baselineUrl, - metadata: screenshotProperties, - properties: screenshotProperties, - filename: filename, // Generated locally for comparison path - }, - ], - }; - } else { - // Get the latest passed build for this environment and branch - const builds = await this.api.getBuilds({ - environment, - branch, - status: 'passed', - limit: 1, - }); - - if (!builds.data || builds.data.length === 0) { - output.warn( - `āš ļø No baseline builds found for ${environment}/${branch}` - ); - output.info( - 'šŸ’” Run a build in normal mode first to create baselines' - ); - return null; - } - - // Use getTddBaselines to get screenshots with pre-computed filenames - const apiResponse = await this.api.getTddBaselines(builds.data[0].id); - - if (!apiResponse) { - throw new Error( - `Build ${builds.data[0].id} not found or API returned null` - ); - } - - // Extract signature properties from API response (for variant support) - if ( - apiResponse.signatureProperties && - Array.isArray(apiResponse.signatureProperties) - ) { - this.signatureProperties = apiResponse.signatureProperties; - if (this.signatureProperties.length > 0) { - output.info( - `Using custom signature properties: ${this.signatureProperties.join(', ')}` - ); - } - } - - baselineBuild = apiResponse.build; - baselineBuild.screenshots = apiResponse.screenshots; - } - - // For both buildId and getBuilds paths, we now have screenshots with filenames - // For comparisonId, we created a mock build with just the one screenshot - let buildDetails = baselineBuild; - - if (!buildDetails.screenshots || buildDetails.screenshots.length === 0) { - output.warn('āš ļø No screenshots found in baseline build'); - return null; - } - - output.info( - `Using baseline from build: ${colors.cyan(baselineBuild.name || 'Unknown')} (${baselineBuild.id || 'Unknown ID'})` - ); - output.info( - `Checking ${colors.cyan(buildDetails.screenshots.length)} baseline screenshots...` - ); - - // Check existing baseline metadata for efficient SHA comparison - let existingBaseline = await this.loadBaseline(); - let existingShaMap = new Map(); - - if (existingBaseline) { - existingBaseline.screenshots.forEach(s => { - if (s.sha256 && s.filename) { - existingShaMap.set(s.filename, s.sha256); - } - }); - } - - // Download screenshots in batches with progress indication - let downloadedCount = 0; - let skippedCount = 0; - let errorCount = 0; - const totalScreenshots = buildDetails.screenshots.length; - const batchSize = 5; // Download up to 5 screenshots concurrently - - // Filter screenshots that need to be downloaded - const screenshotsToProcess = []; - for (const screenshot of buildDetails.screenshots) { - // Sanitize screenshot name for security - let sanitizedName; - try { - sanitizedName = sanitizeScreenshotName(screenshot.name); - } catch (error) { - output.warn( - `Skipping screenshot with invalid name '${screenshot.name}': ${error.message}` - ); - errorCount++; - continue; - } - - // Use API-provided filename (required from tdd-baselines endpoint) - // This ensures filenames match between cloud and local TDD - let filename = screenshot.filename; - if (!filename) { - output.warn( - `āš ļø Screenshot ${sanitizedName} has no filename from API - skipping` - ); - errorCount++; - continue; - } - - let imagePath = safePath(this.baselinePath, filename); - - // Check if we already have this file with the same SHA - if (existsSync(imagePath) && screenshot.sha256) { - let storedSha = existingShaMap.get(filename); - if (storedSha === screenshot.sha256) { - downloadedCount++; - skippedCount++; - continue; - } - } - - // Use original_url as the download URL - const downloadUrl = screenshot.original_url || screenshot.url; - - if (!downloadUrl) { - output.warn( - `āš ļø Screenshot ${sanitizedName} has no download URL - skipping` - ); - errorCount++; - continue; - } - - screenshotsToProcess.push({ - screenshot, - sanitizedName, - imagePath, - downloadUrl, - filename, - }); - } - - // Process downloads in batches - const actualDownloadsNeeded = screenshotsToProcess.length; - if (actualDownloadsNeeded > 0) { - output.info( - `šŸ“„ Downloading ${actualDownloadsNeeded} new/updated screenshots in batches of ${batchSize}...` - ); - - for (let i = 0; i < screenshotsToProcess.length; i += batchSize) { - const batch = screenshotsToProcess.slice(i, i + batchSize); - const batchNum = Math.floor(i / batchSize) + 1; - const totalBatches = Math.ceil( - screenshotsToProcess.length / batchSize - ); - - output.info( - `šŸ“¦ Processing batch ${batchNum}/${totalBatches} (${batch.length} screenshots)` - ); - - // Download batch concurrently - const downloadPromises = batch.map( - async ({ sanitizedName, imagePath, downloadUrl }) => { - try { - const response = await fetchWithTimeout(downloadUrl); - if (!response.ok) { - throw new NetworkError( - `Failed to download ${sanitizedName}: ${response.statusText}` - ); - } - - const arrayBuffer = await response.arrayBuffer(); - const imageBuffer = Buffer.from(arrayBuffer); - writeFileSync(imagePath, imageBuffer); - - return { success: true, name: sanitizedName }; - } catch (error) { - output.warn( - `āš ļø Failed to download ${sanitizedName}: ${error.message}` - ); - return { - success: false, - name: sanitizedName, - error: error.message, - }; - } - } - ); - - const batchResults = await Promise.all(downloadPromises); - const batchSuccesses = batchResults.filter(r => r.success).length; - const batchFailures = batchResults.filter(r => !r.success).length; - - downloadedCount += batchSuccesses; - errorCount += batchFailures; - - // Show progress - const totalProcessed = downloadedCount + skippedCount + errorCount; - const progressPercent = Math.round( - (totalProcessed / totalScreenshots) * 100 - ); - - output.info( - `šŸ“Š Progress: ${totalProcessed}/${totalScreenshots} (${progressPercent}%) - ${batchSuccesses} downloaded, ${batchFailures} failed in this batch` - ); - } - } - - // Check if we actually downloaded any screenshots - if (downloadedCount === 0 && skippedCount === 0) { - output.error( - 'āŒ No screenshots were successfully downloaded from the baseline build' - ); - if (errorCount > 0) { - output.info( - `šŸ’” ${errorCount} screenshots had errors - check download URLs and network connection` - ); - } - output.info( - 'šŸ’” This usually means the build failed or screenshots have no download URLs' - ); - output.info( - 'šŸ’” Try using a successful build ID, or run without --baseline-build to create local baselines' - ); - return null; - } - - // Store enhanced baseline metadata with SHA hashes and build info - this.baselineData = { - buildId: baselineBuild.id, - buildName: baselineBuild.name, - environment, - branch, - threshold: this.threshold, - signatureProperties: this.signatureProperties, // Store for TDD comparison - createdAt: new Date().toISOString(), - buildInfo: { - commitSha: baselineBuild.commit_sha, - commitMessage: baselineBuild.commit_message, - approvalStatus: baselineBuild.approval_status, - completedAt: baselineBuild.completed_at, - }, - screenshots: buildDetails.screenshots - .filter(s => s.filename) // Only include screenshots with filenames - .map(s => ({ - name: sanitizeScreenshotName(s.name), - originalName: s.name, - sha256: s.sha256, - id: s.id, - filename: s.filename, - path: safePath(this.baselinePath, s.filename), - browser: s.browser, - viewport_width: s.viewport_width, - originalUrl: s.original_url, - fileSize: s.file_size_bytes, - dimensions: { - width: s.width, - height: s.height, - }, - })), - }; - - const metadataPath = join(this.baselinePath, 'metadata.json'); - writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2)); - - // Download hotspot data for noise filtering - await this.downloadHotspots(buildDetails.screenshots); - - // Save baseline build metadata for MCP plugin - const baselineMetadataPath = safePath( - this.workingDir, - '.vizzly', - 'baseline-metadata.json' - ); - const buildMetadata = { - buildId: baselineBuild.id, - buildName: baselineBuild.name, - branch: branch, - environment: environment, - commitSha: baselineBuild.commit_sha, - commitMessage: baselineBuild.commit_message, - approvalStatus: baselineBuild.approval_status, - completedAt: baselineBuild.completed_at, - downloadedAt: new Date().toISOString(), - }; - writeFileSync( - baselineMetadataPath, - JSON.stringify(buildMetadata, null, 2) - ); - - // Final summary - const actualDownloads = downloadedCount - skippedCount; - - if (skippedCount > 0) { - // All skipped (up-to-date) - if (actualDownloads === 0) { - output.info( - `āœ… All ${skippedCount} baselines up-to-date (matching local SHA)` - ); - } else { - // Mixed: some downloaded, some skipped - output.info( - `āœ… Downloaded ${actualDownloads} new screenshots, ${skippedCount} already up-to-date` - ); - } - } else { - // Fresh download - output.info( - `āœ… Downloaded ${downloadedCount}/${buildDetails.screenshots.length} screenshots successfully` - ); - } - - if (errorCount > 0) { - output.warn(`āš ļø ${errorCount} screenshots failed to download`); - } - return this.baselineData; - } catch (error) { - output.error(`āŒ Failed to download baseline: ${error.message}`); - throw error; - } - } - - /** - * Download hotspot data for screenshots from the cloud - * Hotspots identify regions that frequently change (timestamps, IDs, etc.) - * Used to filter out known dynamic content during comparisons - * @param {Array} screenshots - Array of screenshot objects with name property - */ - async downloadHotspots(screenshots) { - // Only attempt if we have an API token - if (!this.config.apiKey) { - output.debug( - 'tdd', - 'Skipping hotspot download - no API token configured' - ); - return; - } - - try { - // Get unique screenshot names - const screenshotNames = [...new Set(screenshots.map(s => s.name))]; - - if (screenshotNames.length === 0) { - return; - } - - output.info( - `šŸ”„ Fetching hotspot data for ${screenshotNames.length} screenshots...` - ); - - // Use batch endpoint for efficiency - const response = await this.api.getBatchHotspots(screenshotNames); - - if (!response.hotspots || Object.keys(response.hotspots).length === 0) { - output.debug('tdd', 'No hotspot data available from cloud'); - return; - } - - // Store hotspots in a separate file for easy access during comparisons - this.hotspotData = response.hotspots; - - const hotspotsPath = safePath( - this.workingDir, - '.vizzly', - 'hotspots.json' - ); - writeFileSync( - hotspotsPath, - JSON.stringify( - { - downloadedAt: new Date().toISOString(), - summary: response.summary, - hotspots: response.hotspots, - }, - null, - 2 - ) - ); - - const hotspotCount = Object.keys(response.hotspots).length; - const totalRegions = Object.values(response.hotspots).reduce( - (sum, h) => sum + (h.regions?.length || 0), - 0 - ); - - output.info( - `āœ… Downloaded hotspot data for ${hotspotCount} screenshots (${totalRegions} regions total)` - ); - } catch (error) { - // Don't fail baseline download if hotspot fetch fails - output.debug('tdd', `Hotspot download failed: ${error.message}`); - output.warn( - 'āš ļø Could not fetch hotspot data - comparisons will run without noise filtering' - ); - } - } - - /** - * Load hotspot data from disk - * @returns {Object|null} Hotspot data keyed by screenshot name, or null if not available - */ - loadHotspots() { - try { - const hotspotsPath = safePath( - this.workingDir, - '.vizzly', - 'hotspots.json' - ); - if (!existsSync(hotspotsPath)) { - return null; - } - const data = JSON.parse(readFileSync(hotspotsPath, 'utf8')); - return data.hotspots || null; - } catch (error) { - output.debug('tdd', `Failed to load hotspots: ${error.message}`); - return null; - } - } - - /** - * Get hotspot analysis for a specific screenshot - * @param {string} screenshotName - Name of the screenshot - * @returns {Object|null} Hotspot analysis or null if not available - */ - getHotspotForScreenshot(screenshotName) { - // Check memory cache first - if (this.hotspotData?.[screenshotName]) { - return this.hotspotData[screenshotName]; - } - - // Try loading from disk - if (!this.hotspotData) { - this.hotspotData = this.loadHotspots(); - } - - return this.hotspotData?.[screenshotName] || null; - } - - /** - * Calculate what percentage of diff falls within hotspot regions - * Uses 1D Y-coordinate matching (same algorithm as cloud) - * @param {Array} diffClusters - Array of diff clusters from honeydiff - * @param {Object} hotspotAnalysis - Hotspot data with regions array - * @returns {Object} Coverage info { coverage, linesInHotspots, totalLines } - */ - 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 (const cluster of diffClusters) { - if (cluster.boundingBox) { - const { 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 (const line of diffLines) { - const inHotspot = hotspotAnalysis.regions.some( - region => line >= region.y1 && line <= region.y2 - ); - if (inHotspot) { - linesInHotspots++; - } - } - - const coverage = linesInHotspots / diffLines.length; - - return { - coverage, - linesInHotspots, - totalLines: diffLines.length, - }; - } - - /** - * Handle local baseline logic (either load existing or prepare for new baselines) - * @returns {Promise} Baseline data or null if no local baselines exist - */ - async handleLocalBaselines() { - // Check if we're in baseline update mode - skip loading existing baselines - if (this.setBaseline) { - output.info( - 'šŸ“ Ready for new baseline creation - all screenshots will be treated as new baselines' - ); - - // Reset baseline data since we're creating new ones - this.baselineData = null; - return null; - } - - const baseline = await this.loadBaseline(); - - if (!baseline) { - if (this.config.apiKey) { - output.info( - 'šŸ“„ No local baseline found, but API key available for future remote fetching' - ); - output.info('šŸ†• Current run will create new local baselines'); - } else { - output.info( - 'šŸ“ No local baseline found and no API token - all screenshots will be marked as new' - ); - } - return null; - } else { - output.info( - `āœ… Using existing baseline: ${colors.cyan(baseline.buildName)}` - ); - return baseline; - } - } - - async loadBaseline() { - // In baseline update mode, never load existing baselines - if (this.setBaseline) { - output.debug('tdd', 'baseline update mode - skipping loading'); - return null; - } - - const metadataPath = join(this.baselinePath, 'metadata.json'); - - if (!existsSync(metadataPath)) { - return null; - } - - try { - const metadata = JSON.parse(readFileSync(metadataPath, 'utf8')); - this.baselineData = metadata; - this.threshold = metadata.threshold || this.threshold; - - // Restore signature properties from saved metadata (for variant support) - this.signatureProperties = - metadata.signatureProperties || this.signatureProperties; - if (this.signatureProperties.length > 0) { - output.debug( - 'tdd', - `loaded signature properties: ${this.signatureProperties.join(', ')}` - ); - } - - return metadata; - } catch (error) { - output.error(`āŒ Failed to load baseline metadata: ${error.message}`); - return null; - } - } - - async compareScreenshot(name, imageBuffer, properties = {}) { - // Sanitize screenshot name and validate properties - let sanitizedName; - try { - sanitizedName = sanitizeScreenshotName(name); - } catch (error) { - output.error(`Invalid screenshot name '${name}': ${error.message}`); - throw new Error(`Screenshot name validation failed: ${error.message}`); - } - - let validatedProperties; - try { - validatedProperties = validateScreenshotProperties(properties); - } catch (error) { - output.warn( - `Property validation failed for '${sanitizedName}': ${error.message}` - ); - validatedProperties = {}; - } - - // Preserve metadata object through validation (validateScreenshotProperties strips non-primitives) - // This is needed because signature generation checks properties.metadata.* for custom properties - if (properties.metadata && typeof properties.metadata === 'object') { - validatedProperties.metadata = properties.metadata; - } - - // Normalize properties to match backend format (viewport_width at top level) - // This ensures signature generation matches backend's screenshot-identity.js - if ( - validatedProperties.viewport?.width && - !validatedProperties.viewport_width - ) { - validatedProperties.viewport_width = validatedProperties.viewport.width; - } - - // Generate signature for baseline matching (name + viewport_width + browser + custom props) - const signature = generateScreenshotSignature( - sanitizedName, - validatedProperties, - this.signatureProperties - ); - // Use hash-based filename for reliable matching (matches cloud format) - const filename = generateBaselineFilename(sanitizedName, signature); - - const currentImagePath = safePath(this.currentPath, filename); - const baselineImagePath = safePath(this.baselinePath, filename); - const diffImagePath = safePath(this.diffPath, filename); - - // Save current screenshot - writeFileSync(currentImagePath, imageBuffer); - - // Check if we're in baseline update mode - treat as first run, no comparisons - if (this.setBaseline) { - return this.createNewBaseline( - sanitizedName, - imageBuffer, - validatedProperties, - currentImagePath, - baselineImagePath - ); - } - - // Check if baseline exists - const baselineExists = existsSync(baselineImagePath); - if (!baselineExists) { - // Copy current screenshot to baseline directory for future comparisons - writeFileSync(baselineImagePath, imageBuffer); - - // Update or create baseline metadata - if (!this.baselineData) { - this.baselineData = { - buildId: 'local-baseline', - buildName: 'Local TDD Baseline', - environment: 'test', - branch: 'local', - threshold: this.threshold, - screenshots: [], - }; - } - - // Add screenshot to baseline metadata - const screenshotEntry = { - name: sanitizedName, - properties: validatedProperties, - path: baselineImagePath, - signature: signature, - }; - - const existingIndex = this.baselineData.screenshots.findIndex( - s => s.signature === signature - ); - if (existingIndex >= 0) { - this.baselineData.screenshots[existingIndex] = screenshotEntry; - } else { - this.baselineData.screenshots.push(screenshotEntry); - } - - // Save updated metadata - const metadataPath = join(this.baselinePath, 'metadata.json'); - writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2)); - - // Baseline creation tracked by event handler - - const result = { - id: generateComparisonId(signature), - name: sanitizedName, - status: 'new', - baseline: baselineImagePath, - current: currentImagePath, - diff: null, - properties: validatedProperties, - signature, - }; - - this.comparisons.push(result); - return result; - } - - // Baseline exists - compare with it - try { - // Per-screenshot threshold/minClusterSize override support - // Priority: screenshot-level > config > defaults - // Validate overrides before using them - const effectiveThreshold = - typeof validatedProperties.threshold === 'number' && - validatedProperties.threshold >= 0 - ? validatedProperties.threshold - : this.threshold; - const effectiveMinClusterSize = - Number.isInteger(validatedProperties.minClusterSize) && - validatedProperties.minClusterSize >= 1 - ? validatedProperties.minClusterSize - : this.minClusterSize; - - // Try to compare - honeydiff will throw if dimensions don't match - const result = await compare(baselineImagePath, currentImagePath, { - threshold: effectiveThreshold, // CIEDE2000 Delta E (2.0 = recommended default) - antialiasing: true, - diffPath: diffImagePath, - overwrite: true, - includeClusters: true, // Enable spatial clustering analysis - minClusterSize: effectiveMinClusterSize, // Filter single-pixel noise (default: 2) - }); - - if (!result.isDifferent) { - // Images match - const comparison = { - id: generateComparisonId(signature), - name: sanitizedName, - status: 'passed', - baseline: baselineImagePath, - current: currentImagePath, - diff: null, - properties: validatedProperties, - signature, - threshold: effectiveThreshold, - minClusterSize: effectiveMinClusterSize, - // Include honeydiff metrics even for passing comparisons - totalPixels: result.totalPixels, - aaPixelsIgnored: result.aaPixelsIgnored, - aaPercentage: result.aaPercentage, - }; - - // Result tracked by event handler - this.comparisons.push(comparison); - return comparison; - } else { - // Images differ - check if differences are in known hotspot regions - const hotspotAnalysis = this.getHotspotForScreenshot(name); - let hotspotCoverage = null; - let isHotspotFiltered = false; - - if ( - hotspotAnalysis && - result.diffClusters && - result.diffClusters.length > 0 - ) { - hotspotCoverage = this.calculateHotspotCoverage( - result.diffClusters, - hotspotAnalysis - ); - - // Consider it filtered if: - // 1. High confidence hotspot data (score >= 70) - // 2. 80%+ of the diff is within hotspot regions - const isHighConfidence = - hotspotAnalysis.confidence === 'high' || - (hotspotAnalysis.confidence_score && - hotspotAnalysis.confidence_score >= 70); - - if (isHighConfidence && hotspotCoverage.coverage >= 0.8) { - isHotspotFiltered = true; - } - } - - let diffInfo = ` (${result.diffPercentage.toFixed(2)}% different, ${result.diffPixels} pixels)`; - - // Add cluster info to log if available - if (result.diffClusters && result.diffClusters.length > 0) { - diffInfo += `, ${result.diffClusters.length} region${result.diffClusters.length > 1 ? 's' : ''}`; - } - - // Add hotspot info if applicable - if (hotspotCoverage && hotspotCoverage.coverage > 0) { - diffInfo += `, ${Math.round(hotspotCoverage.coverage * 100)}% in hotspots`; - } - - const comparison = { - id: generateComparisonId(signature), - name: sanitizedName, - status: isHotspotFiltered ? 'passed' : 'failed', - baseline: baselineImagePath, - current: currentImagePath, - diff: diffImagePath, - properties: validatedProperties, - signature, - threshold: effectiveThreshold, - minClusterSize: effectiveMinClusterSize, - diffPercentage: result.diffPercentage, - diffCount: result.diffPixels, - reason: isHotspotFiltered ? 'hotspot-filtered' : 'pixel-diff', - // Honeydiff metrics - totalPixels: result.totalPixels, - aaPixelsIgnored: result.aaPixelsIgnored, - aaPercentage: result.aaPercentage, - boundingBox: result.boundingBox, - heightDiff: result.heightDiff, - intensityStats: result.intensityStats, - diffClusters: result.diffClusters, - // Hotspot analysis data - hotspotAnalysis: hotspotCoverage - ? { - coverage: hotspotCoverage.coverage, - linesInHotspots: hotspotCoverage.linesInHotspots, - totalLines: hotspotCoverage.totalLines, - confidence: hotspotAnalysis?.confidence, - confidenceScore: hotspotAnalysis?.confidence_score, - regionCount: hotspotAnalysis?.regions?.length || 0, - isFiltered: isHotspotFiltered, - } - : null, - }; - - if (isHotspotFiltered) { - output.info( - `āœ… ${colors.green('PASSED')} ${sanitizedName} - differences in known hotspots${diffInfo}` - ); - output.debug( - 'tdd', - `Hotspot filtered: ${Math.round(hotspotCoverage.coverage * 100)}% coverage, confidence: ${hotspotAnalysis.confidence}` - ); - } else { - output.warn( - `āŒ ${colors.red('FAILED')} ${sanitizedName} - differences detected${diffInfo}` - ); - output.info(` Diff saved to: ${diffImagePath}`); - } - - this.comparisons.push(comparison); - return comparison; - } - } catch (error) { - // Check if error is due to dimension mismatch - const isDimensionMismatch = error.message?.includes( - "Image dimensions don't match" - ); - - if (isDimensionMismatch) { - // Different dimensions = different screenshot signature - // This shouldn't happen if signatures are working correctly, but handle gracefully - output.warn( - `āš ļø Dimension mismatch for ${sanitizedName} - baseline file exists but has different dimensions` - ); - output.warn( - ` This indicates a signature collision. Creating new baseline with correct signature.` - ); - output.debug('tdd', 'dimension mismatch', { error: error.message }); - - // Create a new baseline for this screenshot (overwriting the incorrect one) - writeFileSync(baselineImagePath, imageBuffer); - - // Update baseline metadata - if (!this.baselineData) { - this.baselineData = { - buildId: 'local-baseline', - buildName: 'Local TDD Baseline', - environment: 'test', - branch: 'local', - threshold: this.threshold, - screenshots: [], - }; - } - - const screenshotEntry = { - name: sanitizedName, - properties: validatedProperties, - path: baselineImagePath, - signature: signature, - }; - - const existingIndex = this.baselineData.screenshots.findIndex( - s => s.signature === signature - ); - if (existingIndex >= 0) { - this.baselineData.screenshots[existingIndex] = screenshotEntry; - } else { - this.baselineData.screenshots.push(screenshotEntry); - } - - const metadataPath = join(this.baselinePath, 'metadata.json'); - writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2)); - - output.info( - `āœ… Created new baseline for ${sanitizedName} (different dimensions)` - ); - - const comparison = { - id: generateComparisonId(signature), - name: sanitizedName, - status: 'new', - baseline: baselineImagePath, - current: currentImagePath, - diff: null, - properties: validatedProperties, - signature, - }; - - this.comparisons.push(comparison); - return comparison; - } - - // Handle other file errors or issues - output.error(`āŒ Error comparing ${sanitizedName}: ${error.message}`); - - const comparison = { - id: generateComparisonId(signature), - name: sanitizedName, - status: 'error', - baseline: baselineImagePath, - current: currentImagePath, - diff: null, - properties: validatedProperties, - signature, - error: error.message, - }; - - this.comparisons.push(comparison); - return comparison; - } - } - - getResults() { - const passed = this.comparisons.filter(c => c.status === 'passed').length; - const failed = this.comparisons.filter(c => c.status === 'failed').length; - const newScreenshots = this.comparisons.filter( - c => c.status === 'new' - ).length; - const errors = this.comparisons.filter(c => c.status === 'error').length; - - return { - total: this.comparisons.length, - passed, - failed, - new: newScreenshots, - errors, - comparisons: this.comparisons, - baseline: this.baselineData, - }; - } - - async printResults() { - const results = this.getResults(); - - output.info('\nšŸ“Š TDD Results:'); - output.info(`Total: ${colors.cyan(results.total)}`); - output.info(`Passed: ${colors.green(results.passed)}`); - - if (results.failed > 0) { - output.info(`Failed: ${colors.red(results.failed)}`); - } - - if (results.new > 0) { - output.info(`New: ${colors.yellow(results.new)}`); - } - - if (results.errors > 0) { - output.info(`Errors: ${colors.red(results.errors)}`); - } - - // Show failed comparisons - const failedComparisons = results.comparisons.filter( - c => c.status === 'failed' - ); - if (failedComparisons.length > 0) { - output.info('\nāŒ Failed comparisons:'); - failedComparisons.forEach(comp => { - output.info(` • ${comp.name}`); - }); - } - - // Show new screenshots - const newComparisons = results.comparisons.filter(c => c.status === 'new'); - if (newComparisons.length > 0) { - output.info('\nšŸ“ø New screenshots:'); - newComparisons.forEach(comp => { - output.info(` • ${comp.name}`); - }); - } - - // Generate HTML report - await this.generateHtmlReport(results); - - return results; - } - - /** - * Generate HTML report for TDD results - * @param {Object} results - TDD comparison results - */ - async generateHtmlReport(results) { - try { - const reportGenerator = new HtmlReportGenerator( - this.workingDir, - this.config - ); - const reportPath = await reportGenerator.generateReport(results, { - baseline: this.baselineData, - threshold: this.threshold, - }); - - // Show report path (always clickable) - output.info( - `\n🐻 View detailed report: ${colors.cyan(`file://${reportPath}`)}` - ); - - // Auto-open if configured - if (this.config.tdd?.openReport) { - await this.openReport(reportPath); - } - - return reportPath; - } catch (error) { - output.warn(`Failed to generate HTML report: ${error.message}`); - } - } - - /** - * Open HTML report in default browser - * @param {string} reportPath - Path to HTML report - */ - async openReport(reportPath) { - try { - const { exec } = await import('node:child_process'); - const { promisify } = await import('node:util'); - const execAsync = promisify(exec); - - let command; - switch (process.platform) { - case 'darwin': // macOS - command = `open "${reportPath}"`; - break; - case 'win32': // Windows - command = `start "" "${reportPath}"`; - break; - default: // Linux and others - command = `xdg-open "${reportPath}"`; - break; - } - - await execAsync(command); - output.info('šŸ“– Report opened in browser'); - } catch { - // Browser open may fail silently - } - } - - /** - * Update baselines with current screenshots (accept changes) - * @returns {number} Number of baselines updated - */ - updateBaselines() { - if (this.comparisons.length === 0) { - output.warn('No comparisons found - nothing to update'); - return 0; - } - - let updatedCount = 0; - - // Initialize baseline data if it doesn't exist - if (!this.baselineData) { - this.baselineData = { - buildId: 'local-baseline', - buildName: 'Local TDD Baseline', - environment: 'test', - branch: 'local', - threshold: this.threshold, - screenshots: [], - }; - } - - for (const comparison of this.comparisons) { - const { name, current } = comparison; - - if (!current || !existsSync(current)) { - output.warn(`Current screenshot not found for ${name}, skipping`); - continue; - } - - // Sanitize screenshot name for security - let sanitizedName; - try { - sanitizedName = sanitizeScreenshotName(name); - } catch (error) { - output.warn( - `Skipping baseline update for invalid name '${name}': ${error.message}` - ); - continue; - } - - const validatedProperties = validateScreenshotProperties( - comparison.properties || {} - ); - const signature = generateScreenshotSignature( - sanitizedName, - validatedProperties, - this.signatureProperties - ); - const filename = generateBaselineFilename(sanitizedName, signature); - - const baselineImagePath = safePath(this.baselinePath, filename); - - try { - // Copy current screenshot to baseline - const currentBuffer = readFileSync(current); - writeFileSync(baselineImagePath, currentBuffer); - - // Update baseline metadata - const screenshotEntry = { - name: sanitizedName, - properties: validatedProperties, - path: baselineImagePath, - signature: signature, - }; - - const existingIndex = this.baselineData.screenshots.findIndex( - s => s.signature === signature - ); - if (existingIndex >= 0) { - this.baselineData.screenshots[existingIndex] = screenshotEntry; - } else { - this.baselineData.screenshots.push(screenshotEntry); - } - - updatedCount++; - output.info(`āœ… Updated baseline for ${sanitizedName}`); - } catch (error) { - output.error( - `āŒ Failed to update baseline for ${sanitizedName}: ${error.message}` - ); - } - } - - // Save updated metadata - if (updatedCount > 0) { - try { - const metadataPath = join(this.baselinePath, 'metadata.json'); - writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2)); - output.info(`āœ… Updated ${updatedCount} baseline(s)`); - } catch (error) { - output.error(`āŒ Failed to save baseline metadata: ${error.message}`); - } - } - - return updatedCount; - } - - /** - * Create a new baseline (used during --set-baseline mode) - * @private - */ - createNewBaseline( - name, - imageBuffer, - properties, - currentImagePath, - baselineImagePath - ) { - output.info(`🐻 Creating baseline for ${name}`); - - // Copy current screenshot to baseline directory - writeFileSync(baselineImagePath, imageBuffer); - - // Update or create baseline metadata - if (!this.baselineData) { - this.baselineData = { - buildId: 'local-baseline', - buildName: 'Local TDD Baseline', - environment: 'test', - branch: 'local', - threshold: this.threshold, - screenshots: [], - }; - } - - // Generate signature for this screenshot - const signature = generateScreenshotSignature( - name, - properties || {}, - this.signatureProperties - ); - - // Add screenshot to baseline metadata - const screenshotEntry = { - name, - properties: properties || {}, - path: baselineImagePath, - signature: signature, - }; - - const existingIndex = this.baselineData.screenshots.findIndex( - s => s.signature === signature - ); - if (existingIndex >= 0) { - this.baselineData.screenshots[existingIndex] = screenshotEntry; - } else { - this.baselineData.screenshots.push(screenshotEntry); - } - - // Save updated metadata - const metadataPath = join(this.baselinePath, 'metadata.json'); - writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2)); - - const result = { - id: generateComparisonId(signature), - name, - status: 'new', - baseline: baselineImagePath, - current: currentImagePath, - diff: null, - properties, - signature, - }; - - this.comparisons.push(result); - output.info(`āœ… Baseline created for ${name}`); - return result; - } - - /** - * Update a single baseline with current screenshot - * @private - */ - updateSingleBaseline( - name, - imageBuffer, - properties, - currentImagePath, - baselineImagePath - ) { - output.info(`🐻 Setting baseline for ${name}`); - - // Copy current screenshot to baseline directory - writeFileSync(baselineImagePath, imageBuffer); - - // Update or create baseline metadata - if (!this.baselineData) { - this.baselineData = { - buildId: 'local-baseline', - buildName: 'Local TDD Baseline', - environment: 'test', - branch: 'local', - threshold: this.threshold, - screenshots: [], - }; - } - - // Generate signature for this screenshot - const signature = generateScreenshotSignature( - name, - properties || {}, - this.signatureProperties - ); - - // Add screenshot to baseline metadata - const screenshotEntry = { - name, - properties: properties || {}, - path: baselineImagePath, - signature: signature, - }; - - const existingIndex = this.baselineData.screenshots.findIndex( - s => s.signature === signature - ); - if (existingIndex >= 0) { - this.baselineData.screenshots[existingIndex] = screenshotEntry; - } else { - this.baselineData.screenshots.push(screenshotEntry); - } - - // Save updated metadata - const metadataPath = join(this.baselinePath, 'metadata.json'); - writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2)); - - const result = { - id: generateComparisonId(signature), - name, - status: 'baseline-updated', - baseline: baselineImagePath, - current: currentImagePath, - diff: null, - properties, - signature, - }; - - this.comparisons.push(result); - output.info(`🐻 Baseline set for ${name}`); - return result; - } - - /** - * Accept a current screenshot as the new baseline - * @param {string|Object} idOrComparison - Comparison ID or comparison object - * @returns {Object} Result object - */ - async acceptBaseline(idOrComparison) { - let comparison; - - // Support both ID lookup and direct comparison object - if (typeof idOrComparison === 'string') { - // Find the comparison by ID in memory - comparison = this.comparisons.find(c => c.id === idOrComparison); - if (!comparison) { - throw new Error(`No comparison found with ID: ${idOrComparison}`); - } - } else { - // Use the provided comparison object directly - comparison = idOrComparison; - } - - const sanitizedName = comparison.name; - - const properties = comparison.properties || {}; - const signature = generateScreenshotSignature( - sanitizedName, - properties, - this.signatureProperties - ); - const filename = generateBaselineFilename(sanitizedName, signature); - - // Find the current screenshot file - const currentImagePath = safePath(this.currentPath, filename); - - if (!existsSync(currentImagePath)) { - output.error(`Current screenshot not found at: ${currentImagePath}`); - throw new Error( - `Current screenshot not found: ${sanitizedName} (looked at ${currentImagePath})` - ); - } - - // Read the current image - const imageBuffer = readFileSync(currentImagePath); - - // Create baseline directory if it doesn't exist - if (!existsSync(this.baselinePath)) { - mkdirSync(this.baselinePath, { recursive: true }); - } - - // Update the baseline - const baselineImagePath = safePath(this.baselinePath, `${filename}.png`); - - // Write the baseline image directly - writeFileSync(baselineImagePath, imageBuffer); - - // Verify the write - if (!existsSync(baselineImagePath)) { - output.error(`Baseline file does not exist after write!`); - } - - // Update baseline metadata - if (!this.baselineData) { - this.baselineData = { - buildId: 'local-baseline', - buildName: 'Local TDD Baseline', - environment: 'test', - branch: 'local', - threshold: this.threshold, - screenshots: [], - }; - } - - // Add or update screenshot in baseline metadata - const screenshotEntry = { - name: sanitizedName, - properties: properties, - path: baselineImagePath, - signature: signature, - }; - - const existingIndex = this.baselineData.screenshots.findIndex( - s => s.signature === signature - ); - if (existingIndex >= 0) { - this.baselineData.screenshots[existingIndex] = screenshotEntry; - } else { - this.baselineData.screenshots.push(screenshotEntry); - } - // Save updated metadata - const metadataPath = join(this.baselinePath, 'metadata.json'); - writeFileSync(metadataPath, JSON.stringify(this.baselineData, null, 2)); - return { - name: sanitizedName, - status: 'accepted', - message: 'Screenshot accepted as new baseline', - }; - } -} +export { TddService, createTDDService } from '../tdd/tdd-service.js'; diff --git a/src/tdd/core/hotspot-coverage.js b/src/tdd/core/hotspot-coverage.js new file mode 100644 index 00000000..5b15ce37 --- /dev/null +++ b/src/tdd/core/hotspot-coverage.js @@ -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; +} diff --git a/src/tdd/core/signature.js b/src/tdd/core/signature.js new file mode 100644 index 00000000..ea0482e9 --- /dev/null +++ b/src/tdd/core/signature.js @@ -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} 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); +} diff --git a/src/tdd/index.js b/src/tdd/index.js new file mode 100644 index 00000000..88094ad6 --- /dev/null +++ b/src/tdd/index.js @@ -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'; diff --git a/src/tdd/metadata/baseline-metadata.js b/src/tdd/metadata/baseline-metadata.js new file mode 100644 index 00000000..f75ffd8a --- /dev/null +++ b/src/tdd/metadata/baseline-metadata.js @@ -0,0 +1,113 @@ +/** + * Baseline Metadata I/O + * + * Functions for reading and writing baseline metadata.json files. + * These handle the local storage of baseline information. + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Load baseline metadata from disk + * + * @param {string} baselinePath - Path to baselines directory + * @returns {Object|null} Baseline metadata or null if not found + */ +export function loadBaselineMetadata(baselinePath) { + let metadataPath = join(baselinePath, 'metadata.json'); + + if (!existsSync(metadataPath)) { + return null; + } + + try { + let content = readFileSync(metadataPath, 'utf8'); + return JSON.parse(content); + } catch { + // Return null for parse errors - caller can handle + return null; + } +} + +/** + * Save baseline metadata to disk + * + * @param {string} baselinePath - Path to baselines directory + * @param {Object} metadata - Metadata object to save + */ +export function saveBaselineMetadata(baselinePath, metadata) { + // Ensure directory exists + if (!existsSync(baselinePath)) { + mkdirSync(baselinePath, { recursive: true }); + } + + let metadataPath = join(baselinePath, 'metadata.json'); + writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); +} + +/** + * Create empty baseline metadata structure + * + * @param {Object} options - Options for the baseline + * @param {number} options.threshold - Comparison threshold + * @param {string[]} options.signatureProperties - Custom signature properties + * @returns {Object} Empty baseline metadata + */ +export function createEmptyBaselineMetadata(options = {}) { + return { + buildId: 'local-baseline', + buildName: 'Local TDD Baseline', + environment: 'test', + branch: 'local', + threshold: options.threshold ?? 2.0, + signatureProperties: options.signatureProperties ?? [], + createdAt: new Date().toISOString(), + screenshots: [], + }; +} + +/** + * Update or add a screenshot entry in the metadata + * + * @param {Object} metadata - Baseline metadata object (mutated) + * @param {Object} screenshotEntry - Screenshot entry to upsert + * @param {string} signature - Signature to match for updates + * @returns {Object} The updated metadata (same reference) + */ +export function upsertScreenshotInMetadata( + metadata, + screenshotEntry, + signature +) { + if (!metadata.screenshots) { + metadata.screenshots = []; + } + + let existingIndex = metadata.screenshots.findIndex( + s => s.signature === signature + ); + + if (existingIndex >= 0) { + metadata.screenshots[existingIndex] = screenshotEntry; + } else { + metadata.screenshots.push(screenshotEntry); + } + + return metadata; +} + +/** + * Find a screenshot in metadata by signature + * + * @param {Object} metadata - Baseline metadata object + * @param {string} signature - Signature to find + * @returns {Object|null} Screenshot entry or null if not found + */ +export function findScreenshotBySignature(metadata, signature) { + if (!metadata?.screenshots) { + return null; + } + + return metadata.screenshots.find(s => s.signature === signature) || null; +} diff --git a/src/tdd/metadata/hotspot-metadata.js b/src/tdd/metadata/hotspot-metadata.js new file mode 100644 index 00000000..a0df9d0b --- /dev/null +++ b/src/tdd/metadata/hotspot-metadata.js @@ -0,0 +1,93 @@ +/** + * Hotspot Metadata I/O + * + * Functions for reading and writing hotspot data files. + * Hotspots identify regions of screenshots that frequently change + * due to dynamic content (timestamps, animations, etc.). + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Load hotspot data from disk + * + * @param {string} workingDir - Working directory containing .vizzly folder + * @returns {Object|null} Hotspot data keyed by screenshot name, or null if not found + */ +export function loadHotspotMetadata(workingDir) { + let hotspotsPath = join(workingDir, '.vizzly', 'hotspots.json'); + + if (!existsSync(hotspotsPath)) { + return null; + } + + try { + let content = readFileSync(hotspotsPath, 'utf8'); + let data = JSON.parse(content); + return data.hotspots || null; + } catch { + // Return null for parse/read errors + return null; + } +} + +/** + * Save hotspot data to disk + * + * @param {string} workingDir - Working directory containing .vizzly folder + * @param {Object} hotspotData - Hotspot data keyed by screenshot name + * @param {Object} summary - Summary information about the hotspots + */ +export function saveHotspotMetadata(workingDir, hotspotData, summary = {}) { + let vizzlyDir = join(workingDir, '.vizzly'); + + // Ensure directory exists + if (!existsSync(vizzlyDir)) { + mkdirSync(vizzlyDir, { recursive: true }); + } + + let hotspotsPath = join(vizzlyDir, 'hotspots.json'); + let content = { + downloadedAt: new Date().toISOString(), + summary, + hotspots: hotspotData, + }; + + writeFileSync(hotspotsPath, JSON.stringify(content, null, 2)); +} + +/** + * Get hotspot 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} Hotspot analysis or null if not available + */ +export function getHotspotForScreenshot(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 = loadHotspotMetadata(workingDir); + cache.loaded = true; + } + + return cache.data?.[screenshotName] || null; +} + +/** + * Create an empty hotspot cache object + * + * @returns {{ data: null, loaded: boolean }} + */ +export function createHotspotCache() { + return { data: null, loaded: false }; +} diff --git a/src/tdd/services/baseline-downloader.js b/src/tdd/services/baseline-downloader.js new file mode 100644 index 00000000..4b98ee85 --- /dev/null +++ b/src/tdd/services/baseline-downloader.js @@ -0,0 +1,163 @@ +/** + * Baseline Downloader + * + * Functions for downloading baseline images from the cloud API. + * These are lower-level utilities - orchestration happens in TddService. + */ + +import { existsSync, writeFileSync } from 'node:fs'; +import { fetchWithTimeout } from '../../utils/fetch-utils.js'; + +/** + * Download a single baseline image + * + * @param {string} url - URL to download from + * @param {string} destPath - Destination file path + * @param {Object} options - Options + * @param {number} options.timeout - Request timeout in ms (default: 30000) + * @returns {Promise<{ success: boolean, error?: string }>} + */ +export async function downloadBaselineImage(url, destPath, options = {}) { + let { timeout = 30000 } = options; + + try { + let response = await fetchWithTimeout(url, { timeout }); + + if (!response.ok) { + return { + success: false, + error: `HTTP ${response.status}: ${response.statusText}`, + }; + } + + let buffer = Buffer.from(await response.arrayBuffer()); + writeFileSync(destPath, buffer); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error.message, + }; + } +} + +/** + * Check if a baseline already exists with matching SHA + * + * @param {string} filePath - Path to the baseline file + * @param {string} expectedSha - Expected SHA256 hash + * @param {Map} shaMap - Map of filename -> sha256 + * @returns {boolean} + */ +export function baselineMatchesSha(filePath, expectedSha, shaMap) { + if (!existsSync(filePath) || !expectedSha) { + return false; + } + + let filename = filePath.split('/').pop(); + let storedSha = shaMap.get(filename); + + return storedSha === expectedSha; +} + +/** + * Download multiple baselines in batches + * + * @param {Array} screenshots - Screenshots to download + * @param {Object} options - Options + * @param {string} options.baselinePath - Path to baselines directory + * @param {Map} options.existingShaMap - Existing SHA map for skip logic + * @param {number} options.batchSize - Concurrent downloads (default: 5) + * @param {Function} options.onProgress - Progress callback (downloaded, skipped, errors, total) + * @param {Function} options.getFilePath - Function to get file path for a screenshot + * @returns {Promise<{ downloaded: number, skipped: number, errors: number }>} + */ +export async function downloadBaselinesInBatches(screenshots, options = {}) { + let { + existingShaMap = new Map(), + batchSize = 5, + onProgress, + getFilePath, + } = options; + + let downloaded = 0; + let skipped = 0; + let errors = 0; + let total = screenshots.length; + + // Process in batches + for (let i = 0; i < screenshots.length; i += batchSize) { + let batch = screenshots.slice(i, i + batchSize); + + let batchPromises = batch.map(async screenshot => { + let filePath = getFilePath(screenshot); + let url = screenshot.original_url; + + if (!url) { + errors++; + return; + } + + // Skip if SHA matches + if (baselineMatchesSha(filePath, screenshot.sha256, existingShaMap)) { + skipped++; + downloaded++; + return; + } + + let result = await downloadBaselineImage(url, filePath); + + if (result.success) { + downloaded++; + } else { + errors++; + } + }); + + await Promise.all(batchPromises); + + if (onProgress) { + onProgress(downloaded, skipped, errors, total); + } + } + + return { downloaded, skipped, errors }; +} + +/** + * Build baseline metadata entry for a downloaded screenshot + * + * @param {Object} screenshot - Screenshot data from API + * @param {string} filename - Local filename + * @param {string} filePath - Full file path + * @param {Object} buildInfo - Build information + * @returns {Object} Metadata entry + */ +export function buildBaselineMetadataEntry( + screenshot, + filename, + filePath, + buildInfo = {} +) { + return { + name: screenshot.name, + originalName: screenshot.name, + sha256: screenshot.sha256, + id: screenshot.id, + filename, + path: filePath, + browser: screenshot.browser || screenshot.metadata?.browser, + viewport_width: + screenshot.viewport_width || + screenshot.metadata?.viewport?.width || + screenshot.properties?.viewport?.width, + originalUrl: screenshot.original_url, + fileSize: screenshot.file_size, + dimensions: screenshot.dimensions, + // Build info for tracking + buildId: buildInfo.buildId, + commitSha: buildInfo.commitSha, + approvalStatus: screenshot.approval_status, + }; +} diff --git a/src/tdd/services/baseline-manager.js b/src/tdd/services/baseline-manager.js new file mode 100644 index 00000000..26167b56 --- /dev/null +++ b/src/tdd/services/baseline-manager.js @@ -0,0 +1,163 @@ +/** + * Baseline Manager + * + * Local baseline CRUD operations - manages the file system aspects + * of baseline storage without any network operations. + */ + +import { + existsSync, + mkdirSync, + writeFileSync, + readFileSync, + rmSync, + copyFileSync, +} from 'node:fs'; +import { join } from 'node:path'; + +/** + * Initialize TDD directory structure + * + * @param {string} workingDir - Working directory + * @returns {{ baselinePath: string, currentPath: string, diffPath: string }} + */ +export function initializeDirectories(workingDir) { + let vizzlyDir = join(workingDir, '.vizzly'); + let baselinePath = join(vizzlyDir, 'baselines'); + let currentPath = join(vizzlyDir, 'current'); + let diffPath = join(vizzlyDir, 'diffs'); + + for (let dir of [baselinePath, currentPath, diffPath]) { + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + } + + return { baselinePath, currentPath, diffPath }; +} + +/** + * Clear all baseline data for fresh download + * + * @param {{ baselinePath: string, currentPath: string, diffPath: string }} paths + */ +export function clearBaselineData(paths) { + let { baselinePath, currentPath, diffPath } = paths; + + for (let dir of [baselinePath, currentPath, diffPath]) { + if (existsSync(dir)) { + rmSync(dir, { recursive: true, force: true }); + mkdirSync(dir, { recursive: true }); + } + } +} + +/** + * Save an image as baseline + * + * @param {string} baselinePath - Path to baselines directory + * @param {string} filename - Filename for the baseline + * @param {Buffer} imageBuffer - Image data + */ +export function saveBaseline(baselinePath, filename, imageBuffer) { + let filePath = join(baselinePath, filename); + writeFileSync(filePath, imageBuffer); +} + +/** + * Save current screenshot + * + * @param {string} currentPath - Path to current screenshots directory + * @param {string} filename - Filename for the screenshot + * @param {Buffer} imageBuffer - Image data + * @returns {string} Full path to saved file + */ +export function saveCurrent(currentPath, filename, imageBuffer) { + let filePath = join(currentPath, filename); + writeFileSync(filePath, imageBuffer); + return filePath; +} + +/** + * Check if baseline exists for a filename + * + * @param {string} baselinePath - Path to baselines directory + * @param {string} filename - Filename to check + * @returns {boolean} + */ +export function baselineExists(baselinePath, filename) { + return existsSync(join(baselinePath, filename)); +} + +/** + * Get full path to a baseline file + * + * @param {string} baselinePath - Path to baselines directory + * @param {string} filename - Filename + * @returns {string} + */ +export function getBaselinePath(baselinePath, filename) { + return join(baselinePath, filename); +} + +/** + * Get full path to a current file + * + * @param {string} currentPath - Path to current screenshots directory + * @param {string} filename - Filename + * @returns {string} + */ +export function getCurrentPath(currentPath, filename) { + return join(currentPath, filename); +} + +/** + * Get full path to a diff file + * + * @param {string} diffPath - Path to diffs directory + * @param {string} filename - Filename + * @returns {string} + */ +export function getDiffPath(diffPath, filename) { + return join(diffPath, filename); +} + +/** + * Promote current screenshot to baseline (accept as new baseline) + * + * @param {string} currentPath - Path to current screenshots directory + * @param {string} baselinePath - Path to baselines directory + * @param {string} filename - Filename + */ +export function promoteCurrentToBaseline(currentPath, baselinePath, filename) { + let currentFile = join(currentPath, filename); + let baselineFile = join(baselinePath, filename); + + if (!existsSync(currentFile)) { + throw new Error(`Current screenshot not found: ${currentFile}`); + } + + copyFileSync(currentFile, baselineFile); +} + +/** + * Read baseline image + * + * @param {string} baselinePath - Path to baselines directory + * @param {string} filename - Filename + * @returns {Buffer} + */ +export function readBaseline(baselinePath, filename) { + return readFileSync(join(baselinePath, filename)); +} + +/** + * Read current screenshot + * + * @param {string} currentPath - Path to current screenshots directory + * @param {string} filename - Filename + * @returns {Buffer} + */ +export function readCurrent(currentPath, filename) { + return readFileSync(join(currentPath, filename)); +} diff --git a/src/tdd/services/comparison-service.js b/src/tdd/services/comparison-service.js new file mode 100644 index 00000000..b7ffec5b --- /dev/null +++ b/src/tdd/services/comparison-service.js @@ -0,0 +1,234 @@ +/** + * Comparison Service + * + * Wraps honeydiff for image comparison and builds comparison result objects. + */ + +import { compare } from '@vizzly-testing/honeydiff'; +import { generateComparisonId } from '../core/signature.js'; +import { calculateHotspotCoverage } from '../core/hotspot-coverage.js'; + +/** + * Compare two images using honeydiff + * + * @param {string} baselinePath - Path to baseline image + * @param {string} currentPath - Path to current image + * @param {string} diffPath - Path to save diff image + * @param {Object} options - Comparison options + * @param {number} options.threshold - CIEDE2000 Delta E threshold (default: 2.0) + * @param {number} options.minClusterSize - Minimum cluster size (default: 2) + * @returns {Promise} Honeydiff result + */ +export async function compareImages( + baselinePath, + currentPath, + diffPath, + options = {} +) { + let { threshold = 2.0, minClusterSize = 2 } = options; + + return compare(baselinePath, currentPath, { + threshold, + antialiasing: true, + diffPath, + overwrite: true, + includeClusters: true, + minClusterSize, + }); +} + +/** + * Build a comparison result object for a passing comparison (no diff) + * + * @param {Object} params + * @param {string} params.name - Screenshot name + * @param {string} params.signature - Screenshot signature + * @param {string} params.baselinePath - Path to baseline image + * @param {string} params.currentPath - Path to current image + * @param {Object} params.properties - Screenshot properties + * @param {number} params.threshold - Effective threshold used + * @param {number} params.minClusterSize - Effective minClusterSize used + * @param {Object} params.honeydiffResult - Result from honeydiff (optional, for metrics) + * @returns {Object} Comparison result + */ +export function buildPassedComparison(params) { + let { + name, + signature, + baselinePath, + currentPath, + properties, + threshold, + minClusterSize, + honeydiffResult, + } = params; + + return { + id: generateComparisonId(signature), + name, + status: 'passed', + baseline: baselinePath, + current: currentPath, + diff: null, + properties, + signature, + threshold, + minClusterSize, + totalPixels: honeydiffResult?.totalPixels, + aaPixelsIgnored: honeydiffResult?.aaPixelsIgnored, + aaPercentage: honeydiffResult?.aaPercentage, + }; +} + +/** + * Build a comparison result object for a new baseline + * + * @param {Object} params + * @param {string} params.name - Screenshot name + * @param {string} params.signature - Screenshot signature + * @param {string} params.baselinePath - Path to baseline image + * @param {string} params.currentPath - Path to current image + * @param {Object} params.properties - Screenshot properties + * @returns {Object} Comparison result + */ +export function buildNewComparison(params) { + let { name, signature, baselinePath, currentPath, properties } = params; + + return { + id: generateComparisonId(signature), + name, + status: 'new', + baseline: baselinePath, + current: currentPath, + diff: null, + properties, + signature, + }; +} + +/** + * Build a comparison result object for a failed comparison (with diff) + * + * @param {Object} params + * @param {string} params.name - Screenshot name + * @param {string} params.signature - Screenshot signature + * @param {string} params.baselinePath - Path to baseline image + * @param {string} params.currentPath - Path to current image + * @param {string} params.diffPath - Path to diff image + * @param {Object} params.properties - Screenshot properties + * @param {number} params.threshold - Effective threshold used + * @param {number} params.minClusterSize - Effective minClusterSize used + * @param {Object} params.honeydiffResult - Result from honeydiff + * @param {Object} params.hotspotAnalysis - Hotspot data for this screenshot (optional) + * @returns {Object} Comparison result + */ +export function buildFailedComparison(params) { + let { + name, + signature, + baselinePath, + currentPath, + diffPath, + properties, + threshold, + minClusterSize, + honeydiffResult, + hotspotAnalysis, + } = params; + + // Calculate hotspot coverage if we have hotspot data + let hotspotCoverage = null; + let isHotspotFiltered = false; + + if (hotspotAnalysis && honeydiffResult.diffClusters?.length > 0) { + hotspotCoverage = calculateHotspotCoverage( + honeydiffResult.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' || + (hotspotAnalysis.confidence_score !== undefined && + hotspotAnalysis.confidence_score >= 70); + + if (isHighConfidence && hotspotCoverage.coverage >= 0.8) { + isHotspotFiltered = true; + } + } + + return { + id: generateComparisonId(signature), + name, + status: isHotspotFiltered ? 'passed' : 'failed', + baseline: baselinePath, + current: currentPath, + diff: diffPath, + properties, + signature, + threshold, + minClusterSize, + diffPercentage: honeydiffResult.diffPercentage, + diffCount: honeydiffResult.diffPixels, + reason: isHotspotFiltered ? 'hotspot-filtered' : 'pixel-diff', + totalPixels: honeydiffResult.totalPixels, + aaPixelsIgnored: honeydiffResult.aaPixelsIgnored, + aaPercentage: honeydiffResult.aaPercentage, + boundingBox: honeydiffResult.boundingBox, + heightDiff: honeydiffResult.heightDiff, + intensityStats: honeydiffResult.intensityStats, + diffClusters: honeydiffResult.diffClusters, + hotspotAnalysis: hotspotCoverage + ? { + coverage: hotspotCoverage.coverage, + linesInHotspots: hotspotCoverage.linesInHotspots, + totalLines: hotspotCoverage.totalLines, + confidence: hotspotAnalysis?.confidence, + confidenceScore: hotspotAnalysis?.confidence_score, + regionCount: hotspotAnalysis?.regions?.length || 0, + isFiltered: isHotspotFiltered, + } + : null, + }; +} + +/** + * Build a comparison result object for an error + * + * @param {Object} params + * @param {string} params.name - Screenshot name + * @param {string} params.signature - Screenshot signature + * @param {string} params.baselinePath - Path to baseline image + * @param {string} params.currentPath - Path to current image + * @param {Object} params.properties - Screenshot properties + * @param {string} params.errorMessage - Error message + * @returns {Object} Comparison result + */ +export function buildErrorComparison(params) { + let { name, signature, baselinePath, currentPath, properties, errorMessage } = + params; + + return { + id: generateComparisonId(signature), + name, + status: 'error', + baseline: baselinePath, + current: currentPath, + diff: null, + properties, + signature, + error: errorMessage, + }; +} + +/** + * Check if an error is a dimension mismatch from honeydiff + * + * @param {Error} error + * @returns {boolean} + */ +export function isDimensionMismatchError(error) { + return error.message?.includes("Image dimensions don't match") ?? false; +} diff --git a/src/tdd/services/hotspot-service.js b/src/tdd/services/hotspot-service.js new file mode 100644 index 00000000..3ffa48a9 --- /dev/null +++ b/src/tdd/services/hotspot-service.js @@ -0,0 +1,61 @@ +/** + * Hotspot Service + * + * Functions for downloading and managing hotspot data from the cloud. + * Hotspots identify regions that frequently change due to dynamic content. + */ + +import { saveHotspotMetadata } from '../metadata/hotspot-metadata.js'; + +/** + * Download hotspots for screenshots 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 hotspots for + * @returns {Promise<{ success: boolean, count: number, regionCount: number, error?: string }>} + */ +export async function downloadHotspots(options) { + let { api, workingDir, screenshotNames } = options; + + if (!screenshotNames || screenshotNames.length === 0) { + return { success: true, count: 0, regionCount: 0 }; + } + + try { + let response = await api.getHotspots(screenshotNames); + + if (!response || !response.hotspots) { + return { success: false, error: 'API returned no hotspot data' }; + } + + // Save hotspots to disk + saveHotspotMetadata(workingDir, response.hotspots, response.summary); + + // Calculate stats + let count = Object.keys(response.hotspots).length; + let regionCount = Object.values(response.hotspots).reduce( + (sum, h) => sum + (h.regions?.length || 0), + 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/services/result-service.js b/src/tdd/services/result-service.js new file mode 100644 index 00000000..3aaea908 --- /dev/null +++ b/src/tdd/services/result-service.js @@ -0,0 +1,126 @@ +/** + * Result Service + * + * Aggregates comparison results and provides summary statistics. + */ + +/** + * Calculate summary statistics from comparisons + * + * @param {Array} comparisons - Array of comparison results + * @returns {{ total: number, passed: number, failed: number, new: number, errors: number }} + */ +export function calculateSummary(comparisons) { + let passed = 0; + let failed = 0; + let newScreenshots = 0; + let errors = 0; + + for (let c of comparisons) { + switch (c.status) { + case 'passed': + passed++; + break; + case 'failed': + failed++; + break; + case 'new': + newScreenshots++; + break; + case 'error': + errors++; + break; + } + } + + return { + total: comparisons.length, + passed, + failed, + new: newScreenshots, + errors, + }; +} + +/** + * Build complete results object + * + * @param {Array} comparisons - Array of comparison results + * @param {Object} baselineData - Baseline metadata + * @returns {Object} Results object with summary and comparisons + */ +export function buildResults(comparisons, baselineData) { + let summary = calculateSummary(comparisons); + + return { + ...summary, + comparisons, + baseline: baselineData, + }; +} + +/** + * Get failed comparisons from results + * + * @param {Array} comparisons - Array of comparison results + * @returns {Array} Failed comparisons + */ +export function getFailedComparisons(comparisons) { + return comparisons.filter(c => c.status === 'failed'); +} + +/** + * Get new comparisons from results + * + * @param {Array} comparisons - Array of comparison results + * @returns {Array} New comparisons + */ +export function getNewComparisons(comparisons) { + return comparisons.filter(c => c.status === 'new'); +} + +/** + * Get error comparisons from results + * + * @param {Array} comparisons - Array of comparison results + * @returns {Array} Error comparisons + */ +export function getErrorComparisons(comparisons) { + return comparisons.filter(c => c.status === 'error'); +} + +/** + * Check if results indicate overall success (no failures or errors) + * + * @param {Array} comparisons - Array of comparison results + * @returns {boolean} + */ +export function isSuccessful(comparisons) { + return !comparisons.some(c => c.status === 'failed' || c.status === 'error'); +} + +/** + * Find comparison by ID + * + * @param {Array} comparisons - Array of comparison results + * @param {string} id - Comparison ID + * @returns {Object|null} + */ +export function findComparisonById(comparisons, id) { + return comparisons.find(c => c.id === id) || null; +} + +/** + * Find comparison by name and signature + * + * @param {Array} comparisons - Array of comparison results + * @param {string} name - Screenshot name + * @param {string} signature - Screenshot signature (optional) + * @returns {Object|null} + */ +export function findComparison(comparisons, name, signature = null) { + if (signature) { + return comparisons.find(c => c.signature === signature) || null; + } + return comparisons.find(c => c.name === name) || null; +} diff --git a/src/tdd/tdd-service.js b/src/tdd/tdd-service.js new file mode 100644 index 00000000..b527e742 --- /dev/null +++ b/src/tdd/tdd-service.js @@ -0,0 +1,1241 @@ +/** + * TDD Service - Local Visual Testing + * + * Orchestrates visual testing by composing the extracted modules. + * This is a thin orchestration layer - most logic lives in the modules. + * + * CRITICAL: Signature/filename generation MUST stay in sync with the cloud! + * See src/tdd/core/signature.js for details. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { NetworkError } from '../errors/vizzly-error.js'; +import { ApiService } from '../services/api-service.js'; +import { colors } from '../utils/colors.js'; +import { fetchWithTimeout } from '../utils/fetch-utils.js'; +import { getDefaultBranch } from '../utils/git.js'; +import * as output from '../utils/output.js'; +import { + safePath, + sanitizeScreenshotName, + validatePathSecurity, + validateScreenshotProperties, +} from '../utils/security.js'; +import { HtmlReportGenerator } from '../services/html-report-generator.js'; + +// Import from extracted modules +import { + generateScreenshotSignature, + generateBaselineFilename, + generateComparisonId, +} from './core/signature.js'; + +import { calculateHotspotCoverage } from './core/hotspot-coverage.js'; + +import { + loadBaselineMetadata, + saveBaselineMetadata, + createEmptyBaselineMetadata, + upsertScreenshotInMetadata, +} from './metadata/baseline-metadata.js'; + +import { + loadHotspotMetadata, + saveHotspotMetadata, +} from './metadata/hotspot-metadata.js'; + +import { + initializeDirectories, + clearBaselineData, + saveBaseline, + saveCurrent, + baselineExists, + getBaselinePath, + getCurrentPath, + getDiffPath, +} from './services/baseline-manager.js'; + +import { + compareImages, + buildPassedComparison, + buildNewComparison, + buildFailedComparison, + buildErrorComparison, + isDimensionMismatchError, +} from './services/comparison-service.js'; + +import { + buildResults, + getFailedComparisons, + getNewComparisons, +} from './services/result-service.js'; + +/** + * Create a new TDD service instance + */ +export function createTDDService(config, options = {}) { + return new TddService( + config, + options.workingDir, + options.setBaseline, + options.authService + ); +} + +export class TddService { + constructor( + config, + workingDir = process.cwd(), + setBaseline = false, + authService = null + ) { + this.config = config; + this.setBaseline = setBaseline; + this.authService = authService; + this.api = new ApiService({ + baseUrl: config.apiUrl, + token: config.apiKey, + command: 'tdd', + allowNoToken: true, + }); + + // Validate and secure the working directory + try { + this.workingDir = validatePathSecurity(workingDir, workingDir); + } catch (error) { + output.error(`Invalid working directory: ${error.message}`); + throw new Error(`Working directory validation failed: ${error.message}`); + } + + // Initialize directories using extracted module + let paths = initializeDirectories(this.workingDir); + this.baselinePath = paths.baselinePath; + this.currentPath = paths.currentPath; + this.diffPath = paths.diffPath; + + // State + this.baselineData = null; + this.comparisons = []; + this.threshold = config.comparison?.threshold || 2.0; + this.minClusterSize = config.comparison?.minClusterSize ?? 2; + this.signatureProperties = config.signatureProperties ?? []; + + // Hotspot data (loaded lazily from disk or downloaded from cloud) + this.hotspotData = null; + + if (this.setBaseline) { + output.info( + '🐻 Baseline update mode - will overwrite existing baselines with new ones' + ); + } + } + + /** + * Download baselines from cloud + */ + async downloadBaselines( + environment = 'test', + branch = null, + buildId = null, + comparisonId = null + ) { + // If no branch specified, detect default branch + if (!branch) { + branch = await getDefaultBranch(); + if (!branch) { + branch = 'main'; + output.warn( + `āš ļø Could not detect default branch, using 'main' as fallback` + ); + } else { + output.debug('tdd', `detected default branch: ${branch}`); + } + } + + try { + let baselineBuild; + + if (buildId) { + let apiResponse = await this.api.getTddBaselines(buildId); + + if (!apiResponse) { + throw new Error(`Build ${buildId} not found or API returned null`); + } + + // Clear local state before downloading + output.info('Clearing local state before downloading baselines...'); + clearBaselineData({ + baselinePath: this.baselinePath, + currentPath: this.currentPath, + diffPath: this.diffPath, + }); + + // Extract signature properties + if ( + apiResponse.signatureProperties && + Array.isArray(apiResponse.signatureProperties) + ) { + this.signatureProperties = apiResponse.signatureProperties; + if (this.signatureProperties.length > 0) { + output.info( + `Using signature properties: ${this.signatureProperties.join(', ')}` + ); + } + } + + baselineBuild = apiResponse.build; + + if (baselineBuild.status === 'failed') { + output.warn( + `āš ļø Build ${buildId} is marked as FAILED - falling back to local baselines` + ); + return await this.handleLocalBaselines(); + } else if (baselineBuild.status !== 'completed') { + output.warn( + `āš ļø Build ${buildId} has status: ${baselineBuild.status} (expected: completed)` + ); + } + + baselineBuild.screenshots = apiResponse.screenshots; + } else if (comparisonId) { + // Handle specific comparison download + output.info(`Using comparison: ${comparisonId}`); + let comparison = await this.api.getComparison(comparisonId); + + if (!comparison.baseline_screenshot) { + throw new Error( + `Comparison ${comparisonId} has no baseline screenshot. This comparison may be a "new" screenshot.` + ); + } + + let baselineUrl = + comparison.baseline_screenshot.original_url || + comparison.baseline_screenshot_url; + + if (!baselineUrl) { + throw new Error( + `Baseline screenshot for comparison ${comparisonId} has no download URL` + ); + } + + let screenshotProperties = {}; + if (comparison.current_viewport_width || comparison.current_browser) { + if (comparison.current_viewport_width) { + screenshotProperties.viewport = { + width: comparison.current_viewport_width, + height: comparison.current_viewport_height, + }; + } + if (comparison.current_browser) { + screenshotProperties.browser = comparison.current_browser; + } + } else if ( + comparison.baseline_viewport_width || + comparison.baseline_browser + ) { + if (comparison.baseline_viewport_width) { + screenshotProperties.viewport = { + width: comparison.baseline_viewport_width, + height: comparison.baseline_viewport_height, + }; + } + if (comparison.baseline_browser) { + screenshotProperties.browser = comparison.baseline_browser; + } + } + + let screenshotName = + comparison.baseline_name || comparison.current_name; + let signature = generateScreenshotSignature( + screenshotName, + screenshotProperties, + this.signatureProperties + ); + let filename = generateBaselineFilename(screenshotName, signature); + + baselineBuild = { + id: comparison.baseline_screenshot.build_id || 'comparison-baseline', + name: `Comparison ${comparisonId.substring(0, 8)}`, + screenshots: [ + { + id: comparison.baseline_screenshot.id, + name: screenshotName, + original_url: baselineUrl, + metadata: screenshotProperties, + properties: screenshotProperties, + filename: filename, + }, + ], + }; + } else { + // Get latest passed build + let builds = await this.api.getBuilds({ + environment, + branch, + status: 'passed', + limit: 1, + }); + + if (!builds.data || builds.data.length === 0) { + output.warn( + `āš ļø No baseline builds found for ${environment}/${branch}` + ); + output.info( + 'šŸ’” Run a build in normal mode first to create baselines' + ); + return null; + } + + let apiResponse = await this.api.getTddBaselines(builds.data[0].id); + + if (!apiResponse) { + throw new Error( + `Build ${builds.data[0].id} not found or API returned null` + ); + } + + if ( + apiResponse.signatureProperties && + Array.isArray(apiResponse.signatureProperties) + ) { + this.signatureProperties = apiResponse.signatureProperties; + if (this.signatureProperties.length > 0) { + output.info( + `Using custom signature properties: ${this.signatureProperties.join(', ')}` + ); + } + } + + baselineBuild = apiResponse.build; + baselineBuild.screenshots = apiResponse.screenshots; + } + + let buildDetails = baselineBuild; + + if (!buildDetails.screenshots || buildDetails.screenshots.length === 0) { + output.warn('āš ļø No screenshots found in baseline build'); + return null; + } + + output.info( + `Using baseline from build: ${colors.cyan(baselineBuild.name || 'Unknown')} (${baselineBuild.id || 'Unknown ID'})` + ); + output.info( + `Checking ${colors.cyan(buildDetails.screenshots.length)} baseline screenshots...` + ); + + // Check existing baseline metadata for SHA comparison + let existingBaseline = await this.loadBaseline(); + let existingShaMap = new Map(); + + if (existingBaseline) { + existingBaseline.screenshots.forEach(s => { + if (s.sha256 && s.filename) { + existingShaMap.set(s.filename, s.sha256); + } + }); + } + + // Download screenshots + let downloadedCount = 0; + let skippedCount = 0; + let errorCount = 0; + let batchSize = 5; + + let screenshotsToProcess = []; + for (let screenshot of buildDetails.screenshots) { + let sanitizedName; + try { + sanitizedName = sanitizeScreenshotName(screenshot.name); + } catch (error) { + output.warn( + `Skipping screenshot with invalid name '${screenshot.name}': ${error.message}` + ); + errorCount++; + continue; + } + + let filename = screenshot.filename; + if (!filename) { + output.warn( + `āš ļø Screenshot ${sanitizedName} has no filename from API - skipping` + ); + errorCount++; + continue; + } + + let imagePath = safePath(this.baselinePath, filename); + + // Check SHA + if (existsSync(imagePath) && screenshot.sha256) { + let storedSha = existingShaMap.get(filename); + if (storedSha === screenshot.sha256) { + downloadedCount++; + skippedCount++; + continue; + } + } + + let downloadUrl = screenshot.original_url || screenshot.url; + if (!downloadUrl) { + output.warn( + `āš ļø Screenshot ${sanitizedName} has no download URL - skipping` + ); + errorCount++; + continue; + } + + screenshotsToProcess.push({ + screenshot, + sanitizedName, + imagePath, + downloadUrl, + filename, + }); + } + + // Process downloads in batches + if (screenshotsToProcess.length > 0) { + output.info( + `šŸ“„ Downloading ${screenshotsToProcess.length} new/updated screenshots...` + ); + + for (let i = 0; i < screenshotsToProcess.length; i += batchSize) { + let batch = screenshotsToProcess.slice(i, i + batchSize); + let batchNum = Math.floor(i / batchSize) + 1; + let totalBatches = Math.ceil(screenshotsToProcess.length / batchSize); + + output.info(`šŸ“¦ Processing batch ${batchNum}/${totalBatches}`); + + let downloadPromises = batch.map( + async ({ sanitizedName, imagePath, downloadUrl }) => { + try { + let response = await fetchWithTimeout(downloadUrl); + if (!response.ok) { + throw new NetworkError( + `Failed to download ${sanitizedName}: ${response.statusText}` + ); + } + + let arrayBuffer = await response.arrayBuffer(); + let imageBuffer = Buffer.from(arrayBuffer); + writeFileSync(imagePath, imageBuffer); + + return { success: true, name: sanitizedName }; + } catch (error) { + output.warn( + `āš ļø Failed to download ${sanitizedName}: ${error.message}` + ); + return { + success: false, + name: sanitizedName, + error: error.message, + }; + } + } + ); + + let batchResults = await Promise.all(downloadPromises); + let batchSuccesses = batchResults.filter(r => r.success).length; + let batchFailures = batchResults.filter(r => !r.success).length; + + downloadedCount += batchSuccesses; + errorCount += batchFailures; + } + } + + if (downloadedCount === 0 && skippedCount === 0) { + output.error('āŒ No screenshots were successfully downloaded'); + return null; + } + + // Store baseline metadata + this.baselineData = { + buildId: baselineBuild.id, + buildName: baselineBuild.name, + environment, + branch, + threshold: this.threshold, + signatureProperties: this.signatureProperties, + createdAt: new Date().toISOString(), + buildInfo: { + commitSha: baselineBuild.commit_sha, + commitMessage: baselineBuild.commit_message, + approvalStatus: baselineBuild.approval_status, + completedAt: baselineBuild.completed_at, + }, + screenshots: buildDetails.screenshots + .filter(s => s.filename) + .map(s => ({ + name: sanitizeScreenshotName(s.name), + originalName: s.name, + sha256: s.sha256, + id: s.id, + filename: s.filename, + path: safePath(this.baselinePath, s.filename), + browser: s.browser, + viewport_width: s.viewport_width, + originalUrl: s.original_url, + fileSize: s.file_size_bytes, + dimensions: { width: s.width, height: s.height }, + })), + }; + + saveBaselineMetadata(this.baselinePath, this.baselineData); + + // Download hotspots + await this.downloadHotspots(buildDetails.screenshots); + + // Save baseline build metadata for MCP plugin + let baselineMetadataPath = safePath( + this.workingDir, + '.vizzly', + 'baseline-metadata.json' + ); + writeFileSync( + baselineMetadataPath, + JSON.stringify( + { + buildId: baselineBuild.id, + buildName: baselineBuild.name, + branch, + environment, + commitSha: baselineBuild.commit_sha, + commitMessage: baselineBuild.commit_message, + approvalStatus: baselineBuild.approval_status, + completedAt: baselineBuild.completed_at, + downloadedAt: new Date().toISOString(), + }, + null, + 2 + ) + ); + + // Summary + let actualDownloads = downloadedCount - skippedCount; + if (skippedCount > 0) { + if (actualDownloads === 0) { + output.info(`āœ… All ${skippedCount} baselines up-to-date`); + } else { + output.info( + `āœ… Downloaded ${actualDownloads} new screenshots, ${skippedCount} already up-to-date` + ); + } + } else { + output.info( + `āœ… Downloaded ${downloadedCount}/${buildDetails.screenshots.length} screenshots successfully` + ); + } + + if (errorCount > 0) { + output.warn(`āš ļø ${errorCount} screenshots failed to download`); + } + + return this.baselineData; + } catch (error) { + output.error(`āŒ Failed to download baseline: ${error.message}`); + throw error; + } + } + + /** + * Download hotspot data for screenshots + */ + async downloadHotspots(screenshots) { + if (!this.config.apiKey) { + output.debug( + 'tdd', + 'Skipping hotspot download - no API token configured' + ); + return; + } + + try { + let screenshotNames = [...new Set(screenshots.map(s => s.name))]; + + if (screenshotNames.length === 0) { + return; + } + + output.info( + `šŸ”„ Fetching hotspot data for ${screenshotNames.length} screenshots...` + ); + + let response = await this.api.getBatchHotspots(screenshotNames); + + if (!response.hotspots || Object.keys(response.hotspots).length === 0) { + output.debug('tdd', 'No hotspot data available from cloud'); + return; + } + + // Update memory cache + this.hotspotData = response.hotspots; + + // Save to disk using extracted module + saveHotspotMetadata(this.workingDir, response.hotspots, response.summary); + + let hotspotCount = Object.keys(response.hotspots).length; + let totalRegions = Object.values(response.hotspots).reduce( + (sum, h) => sum + (h.regions?.length || 0), + 0 + ); + + output.info( + `āœ… Downloaded hotspot data for ${hotspotCount} screenshots (${totalRegions} regions total)` + ); + } catch (error) { + output.debug('tdd', `Hotspot download failed: ${error.message}`); + output.warn( + 'āš ļø Could not fetch hotspot data - comparisons will run without noise filtering' + ); + } + } + + /** + * Load hotspot data from disk + */ + loadHotspots() { + return loadHotspotMetadata(this.workingDir); + } + + /** + * Get hotspot for a specific screenshot + */ + getHotspotForScreenshot(screenshotName) { + // Check memory cache first + if (this.hotspotData?.[screenshotName]) { + return this.hotspotData[screenshotName]; + } + + // Try loading from disk + if (!this.hotspotData) { + this.hotspotData = this.loadHotspots(); + } + + return this.hotspotData?.[screenshotName] || null; + } + + /** + * Calculate hotspot coverage (delegating to pure function) + */ + calculateHotspotCoverage(diffClusters, hotspotAnalysis) { + return calculateHotspotCoverage(diffClusters, hotspotAnalysis); + } + + /** + * Handle local baselines logic + */ + async handleLocalBaselines() { + if (this.setBaseline) { + output.info('šŸ“ Ready for new baseline creation'); + this.baselineData = null; + return null; + } + + let baseline = await this.loadBaseline(); + + if (!baseline) { + if (this.config.apiKey) { + output.info('šŸ“„ No local baseline found, but API key available'); + output.info('šŸ†• Current run will create new local baselines'); + } else { + output.info( + 'šŸ“ No local baseline found - all screenshots will be marked as new' + ); + } + return null; + } else { + output.info( + `āœ… Using existing baseline: ${colors.cyan(baseline.buildName)}` + ); + return baseline; + } + } + + /** + * Load baseline metadata + */ + async loadBaseline() { + if (this.setBaseline) { + output.debug('tdd', 'baseline update mode - skipping loading'); + return null; + } + + let metadata = loadBaselineMetadata(this.baselinePath); + + if (!metadata) { + return null; + } + + this.baselineData = metadata; + this.threshold = metadata.threshold || this.threshold; + this.signatureProperties = + metadata.signatureProperties || this.signatureProperties; + + if (this.signatureProperties.length > 0) { + output.debug( + 'tdd', + `loaded signature properties: ${this.signatureProperties.join(', ')}` + ); + } + + return metadata; + } + + /** + * Compare a screenshot against baseline + */ + async compareScreenshot(name, imageBuffer, properties = {}) { + // Sanitize and validate + let sanitizedName; + try { + sanitizedName = sanitizeScreenshotName(name); + } catch (error) { + output.error(`Invalid screenshot name '${name}': ${error.message}`); + throw new Error(`Screenshot name validation failed: ${error.message}`); + } + + let validatedProperties; + try { + validatedProperties = validateScreenshotProperties(properties); + } catch (error) { + output.warn( + `Property validation failed for '${sanitizedName}': ${error.message}` + ); + validatedProperties = {}; + } + + // Preserve metadata + if (properties.metadata && typeof properties.metadata === 'object') { + validatedProperties.metadata = properties.metadata; + } + + // Normalize viewport_width + if ( + validatedProperties.viewport?.width && + !validatedProperties.viewport_width + ) { + validatedProperties.viewport_width = validatedProperties.viewport.width; + } + + // Generate signature and filename + let signature = generateScreenshotSignature( + sanitizedName, + validatedProperties, + this.signatureProperties + ); + let filename = generateBaselineFilename(sanitizedName, signature); + + let currentImagePath = getCurrentPath(this.currentPath, filename); + let baselineImagePath = getBaselinePath(this.baselinePath, filename); + let diffImagePath = getDiffPath(this.diffPath, filename); + + // Save current screenshot + saveCurrent(this.currentPath, filename, imageBuffer); + + // Handle baseline update mode + if (this.setBaseline) { + return this.createNewBaseline( + sanitizedName, + imageBuffer, + validatedProperties, + currentImagePath, + baselineImagePath + ); + } + + // Check if baseline exists + if (!baselineExists(this.baselinePath, filename)) { + // Create new baseline + saveBaseline(this.baselinePath, filename, imageBuffer); + + // Update metadata + if (!this.baselineData) { + this.baselineData = createEmptyBaselineMetadata({ + threshold: this.threshold, + signatureProperties: this.signatureProperties, + }); + } + + let screenshotEntry = { + name: sanitizedName, + properties: validatedProperties, + path: baselineImagePath, + signature, + }; + + upsertScreenshotInMetadata(this.baselineData, screenshotEntry, signature); + saveBaselineMetadata(this.baselinePath, this.baselineData); + + let result = buildNewComparison({ + name: sanitizedName, + signature, + baselinePath: baselineImagePath, + currentPath: currentImagePath, + properties: validatedProperties, + }); + + this.comparisons.push(result); + return result; + } + + // Baseline exists - compare + try { + let effectiveThreshold = + typeof validatedProperties.threshold === 'number' && + validatedProperties.threshold >= 0 + ? validatedProperties.threshold + : this.threshold; + + let effectiveMinClusterSize = + Number.isInteger(validatedProperties.minClusterSize) && + validatedProperties.minClusterSize >= 1 + ? validatedProperties.minClusterSize + : this.minClusterSize; + + let honeydiffResult = await compareImages( + baselineImagePath, + currentImagePath, + diffImagePath, + { + threshold: effectiveThreshold, + minClusterSize: effectiveMinClusterSize, + } + ); + + if (!honeydiffResult.isDifferent) { + let result = buildPassedComparison({ + name: sanitizedName, + signature, + baselinePath: baselineImagePath, + currentPath: currentImagePath, + properties: validatedProperties, + threshold: effectiveThreshold, + minClusterSize: effectiveMinClusterSize, + honeydiffResult, + }); + + this.comparisons.push(result); + return result; + } else { + let hotspotAnalysis = this.getHotspotForScreenshot(name); + + let result = buildFailedComparison({ + name: sanitizedName, + signature, + baselinePath: baselineImagePath, + currentPath: currentImagePath, + diffPath: diffImagePath, + properties: validatedProperties, + threshold: effectiveThreshold, + minClusterSize: effectiveMinClusterSize, + honeydiffResult, + hotspotAnalysis, + }); + + // Log result + let diffInfo = ` (${honeydiffResult.diffPercentage.toFixed(2)}% different, ${honeydiffResult.diffPixels} pixels)`; + + if (honeydiffResult.diffClusters?.length > 0) { + diffInfo += `, ${honeydiffResult.diffClusters.length} region${honeydiffResult.diffClusters.length > 1 ? 's' : ''}`; + } + + if (result.hotspotAnalysis?.coverage > 0) { + diffInfo += `, ${Math.round(result.hotspotAnalysis.coverage * 100)}% in hotspots`; + } + + if (result.status === 'passed') { + output.info( + `āœ… ${colors.green('PASSED')} ${sanitizedName} - differences in known hotspots${diffInfo}` + ); + } else { + output.warn( + `āŒ ${colors.red('FAILED')} ${sanitizedName} - differences detected${diffInfo}` + ); + output.info(` Diff saved to: ${diffImagePath}`); + } + + this.comparisons.push(result); + return result; + } + } catch (error) { + if (isDimensionMismatchError(error)) { + output.warn( + `āš ļø Dimension mismatch for ${sanitizedName} - creating new baseline` + ); + + saveBaseline(this.baselinePath, filename, imageBuffer); + + if (!this.baselineData) { + this.baselineData = createEmptyBaselineMetadata({ + threshold: this.threshold, + signatureProperties: this.signatureProperties, + }); + } + + let screenshotEntry = { + name: sanitizedName, + properties: validatedProperties, + path: baselineImagePath, + signature, + }; + + upsertScreenshotInMetadata( + this.baselineData, + screenshotEntry, + signature + ); + saveBaselineMetadata(this.baselinePath, this.baselineData); + + output.info( + `āœ… Created new baseline for ${sanitizedName} (different dimensions)` + ); + + let result = buildNewComparison({ + name: sanitizedName, + signature, + baselinePath: baselineImagePath, + currentPath: currentImagePath, + properties: validatedProperties, + }); + + this.comparisons.push(result); + return result; + } + + output.error(`āŒ Error comparing ${sanitizedName}: ${error.message}`); + + let result = buildErrorComparison({ + name: sanitizedName, + signature, + baselinePath: baselineImagePath, + currentPath: currentImagePath, + properties: validatedProperties, + errorMessage: error.message, + }); + + this.comparisons.push(result); + return result; + } + } + + /** + * Get results summary + */ + getResults() { + return buildResults(this.comparisons, this.baselineData); + } + + /** + * Print results to console + */ + async printResults() { + let results = this.getResults(); + + output.info('\nšŸ“Š TDD Results:'); + output.info(`Total: ${colors.cyan(results.total)}`); + output.info(`Passed: ${colors.green(results.passed)}`); + + if (results.failed > 0) { + output.info(`Failed: ${colors.red(results.failed)}`); + } + + if (results.new > 0) { + output.info(`New: ${colors.yellow(results.new)}`); + } + + if (results.errors > 0) { + output.info(`Errors: ${colors.red(results.errors)}`); + } + + let failedComparisons = getFailedComparisons(this.comparisons); + if (failedComparisons.length > 0) { + output.info('\nāŒ Failed comparisons:'); + for (let comp of failedComparisons) { + output.info(` • ${comp.name}`); + } + } + + let newComparisons = getNewComparisons(this.comparisons); + if (newComparisons.length > 0) { + output.info('\nšŸ“ø New screenshots:'); + for (let comp of newComparisons) { + output.info(` • ${comp.name}`); + } + } + + await this.generateHtmlReport(results); + + return results; + } + + /** + * Generate HTML report + */ + async generateHtmlReport(results) { + try { + let reportGenerator = new HtmlReportGenerator( + this.workingDir, + this.config + ); + let reportPath = await reportGenerator.generateReport(results, { + baseline: this.baselineData, + threshold: this.threshold, + }); + + output.info( + `\n🐻 View detailed report: ${colors.cyan(`file://${reportPath}`)}` + ); + + if (this.config.tdd?.openReport) { + await this.openReport(reportPath); + } + + return reportPath; + } catch (error) { + output.warn(`Failed to generate HTML report: ${error.message}`); + } + } + + /** + * Open report in browser + */ + async openReport(reportPath) { + try { + let { exec } = await import('node:child_process'); + let { promisify } = await import('node:util'); + let execAsync = promisify(exec); + + let command; + switch (process.platform) { + case 'darwin': + command = `open "${reportPath}"`; + break; + case 'win32': + command = `start "" "${reportPath}"`; + break; + default: + command = `xdg-open "${reportPath}"`; + break; + } + + await execAsync(command); + output.info('šŸ“– Report opened in browser'); + } catch { + // Browser open may fail silently + } + } + + /** + * Update all baselines with current screenshots + */ + updateBaselines() { + if (this.comparisons.length === 0) { + output.warn('No comparisons found - nothing to update'); + return 0; + } + + let updatedCount = 0; + + if (!this.baselineData) { + this.baselineData = createEmptyBaselineMetadata({ + threshold: this.threshold, + signatureProperties: this.signatureProperties, + }); + } + + for (let comparison of this.comparisons) { + let { name, current } = comparison; + + if (!current || !existsSync(current)) { + output.warn(`Current screenshot not found for ${name}, skipping`); + continue; + } + + let sanitizedName; + try { + sanitizedName = sanitizeScreenshotName(name); + } catch (error) { + output.warn( + `Skipping baseline update for invalid name '${name}': ${error.message}` + ); + continue; + } + + let validatedProperties = validateScreenshotProperties( + comparison.properties || {} + ); + let signature = generateScreenshotSignature( + sanitizedName, + validatedProperties, + this.signatureProperties + ); + let filename = generateBaselineFilename(sanitizedName, signature); + let baselineImagePath = getBaselinePath(this.baselinePath, filename); + + try { + let currentBuffer = readFileSync(current); + writeFileSync(baselineImagePath, currentBuffer); + + let screenshotEntry = { + name: sanitizedName, + properties: validatedProperties, + path: baselineImagePath, + signature, + }; + + upsertScreenshotInMetadata( + this.baselineData, + screenshotEntry, + signature + ); + + updatedCount++; + output.info(`āœ… Updated baseline for ${sanitizedName}`); + } catch (error) { + output.error( + `āŒ Failed to update baseline for ${sanitizedName}: ${error.message}` + ); + } + } + + if (updatedCount > 0) { + try { + saveBaselineMetadata(this.baselinePath, this.baselineData); + output.info(`āœ… Updated ${updatedCount} baseline(s)`); + } catch (error) { + output.error(`āŒ Failed to save baseline metadata: ${error.message}`); + } + } + + return updatedCount; + } + + /** + * Accept a single baseline + */ + async acceptBaseline(idOrComparison) { + let comparison; + + if (typeof idOrComparison === 'string') { + comparison = this.comparisons.find(c => c.id === idOrComparison); + if (!comparison) { + throw new Error(`No comparison found with ID: ${idOrComparison}`); + } + } else { + comparison = idOrComparison; + } + + let sanitizedName = comparison.name; + let properties = comparison.properties || {}; + + // Generate signature from properties (don't rely on comparison.signature) + let signature = generateScreenshotSignature( + sanitizedName, + properties, + this.signatureProperties + ); + let filename = generateBaselineFilename(sanitizedName, signature); + + // Find the current screenshot file + let currentImagePath = safePath(this.currentPath, filename); + + if (!existsSync(currentImagePath)) { + output.error(`Current screenshot not found at: ${currentImagePath}`); + throw new Error( + `Current screenshot not found: ${sanitizedName} (looked at ${currentImagePath})` + ); + } + + // Read the current image + let imageBuffer = readFileSync(currentImagePath); + + // Create baseline directory if it doesn't exist + if (!existsSync(this.baselinePath)) { + mkdirSync(this.baselinePath, { recursive: true }); + } + + // Update the baseline + let baselineImagePath = safePath(this.baselinePath, `${filename}.png`); + + writeFileSync(baselineImagePath, imageBuffer); + + // Update baseline metadata + if (!this.baselineData) { + this.baselineData = createEmptyBaselineMetadata({ + threshold: this.threshold, + signatureProperties: this.signatureProperties, + }); + } + + let screenshotEntry = { + name: sanitizedName, + properties, + path: baselineImagePath, + signature, + }; + + upsertScreenshotInMetadata(this.baselineData, screenshotEntry, signature); + saveBaselineMetadata(this.baselinePath, this.baselineData); + + return { + name: sanitizedName, + status: 'accepted', + message: 'Screenshot accepted as new baseline', + }; + } + + /** + * Create new baseline (used during --set-baseline mode) + * @private + */ + createNewBaseline( + name, + imageBuffer, + properties, + currentImagePath, + baselineImagePath + ) { + output.info(`🐻 Creating baseline for ${name}`); + + writeFileSync(baselineImagePath, imageBuffer); + + if (!this.baselineData) { + this.baselineData = createEmptyBaselineMetadata({ + threshold: this.threshold, + signatureProperties: this.signatureProperties, + }); + } + + let signature = generateScreenshotSignature( + name, + properties || {}, + this.signatureProperties + ); + + let screenshotEntry = { + name, + properties: properties || {}, + path: baselineImagePath, + signature, + }; + + upsertScreenshotInMetadata(this.baselineData, screenshotEntry, signature); + saveBaselineMetadata(this.baselinePath, this.baselineData); + + let result = { + id: generateComparisonId(signature), + name, + status: 'new', + baseline: baselineImagePath, + current: currentImagePath, + diff: null, + properties, + signature, + }; + + this.comparisons.push(result); + output.info(`āœ… Baseline created for ${name}`); + return result; + } +} diff --git a/tests/contracts/signature-parity.spec.js b/tests/contracts/signature-parity.spec.js index 50e3f631..a6556885 100644 --- a/tests/contracts/signature-parity.spec.js +++ b/tests/contracts/signature-parity.spec.js @@ -10,7 +10,7 @@ * must produce. * * If you need to change signature/filename generation: - * 1. Update vizzly-cli/src/services/tdd-service.js + * 1. Update vizzly-cli/src/tdd/core/signature.js * 2. Update vizzly/src/utils/screenshot-identity.js (same logic) * 3. Update golden values in THIS file * 4. Update golden values in vizzly/tests/contracts/signature-parity.test.js diff --git a/tests/tdd/core/hotspot-coverage.spec.js b/tests/tdd/core/hotspot-coverage.spec.js new file mode 100644 index 00000000..c9eab947 --- /dev/null +++ b/tests/tdd/core/hotspot-coverage.spec.js @@ -0,0 +1,255 @@ +/** + * Tests for hotspot coverage calculation pure functions + * + * These tests require NO mocking - they test pure functions with input/output assertions. + */ + +import { describe, expect, it } from 'vitest'; +import { + calculateHotspotCoverage, + shouldFilterAsHotspot, +} from '../../../src/tdd/core/hotspot-coverage.js'; + +describe('hotspot-coverage', () => { + describe('calculateHotspotCoverage', () => { + it('returns zero coverage when diffClusters is empty', () => { + let result = calculateHotspotCoverage([], { + regions: [{ y1: 10, y2: 20 }], + }); + + expect(result).toEqual({ + coverage: 0, + linesInHotspots: 0, + totalLines: 0, + }); + }); + + it('returns zero coverage when diffClusters is null', () => { + let result = calculateHotspotCoverage(null, { + regions: [{ y1: 10, y2: 20 }], + }); + + expect(result).toEqual({ + coverage: 0, + linesInHotspots: 0, + totalLines: 0, + }); + }); + + it('returns zero coverage when hotspotAnalysis is null', () => { + let result = calculateHotspotCoverage( + [{ boundingBox: { y: 10, height: 5 } }], + null + ); + + expect(result).toEqual({ + coverage: 0, + linesInHotspots: 0, + totalLines: 0, + }); + }); + + it('returns zero coverage when hotspotAnalysis has no regions', () => { + let result = calculateHotspotCoverage( + [{ boundingBox: { y: 10, height: 5 } }], + { regions: [] } + ); + + expect(result).toEqual({ + coverage: 0, + linesInHotspots: 0, + totalLines: 0, + }); + }); + + it('returns zero coverage when clusters have no bounding boxes', () => { + let result = calculateHotspotCoverage([{ pixels: 100 }, { pixels: 50 }], { + regions: [{ y1: 10, y2: 20 }], + }); + + expect(result).toEqual({ + coverage: 0, + linesInHotspots: 0, + totalLines: 0, + }); + }); + + it('calculates 100% coverage when all diff is in hotspot', () => { + let result = calculateHotspotCoverage( + [{ boundingBox: { y: 15, height: 5 } }], // Lines 15-19 + { regions: [{ y1: 10, y2: 25 }] } // Covers lines 10-25 + ); + + expect(result.coverage).toBe(1); + expect(result.linesInHotspots).toBe(5); + expect(result.totalLines).toBe(5); + }); + + it('calculates 0% coverage when no diff is in hotspot', () => { + let result = calculateHotspotCoverage( + [{ boundingBox: { y: 100, height: 10 } }], // Lines 100-109 + { regions: [{ y1: 10, y2: 20 }] } // Covers lines 10-20 + ); + + expect(result.coverage).toBe(0); + expect(result.linesInHotspots).toBe(0); + expect(result.totalLines).toBe(10); + }); + + it('calculates partial coverage correctly', () => { + let result = calculateHotspotCoverage( + [{ boundingBox: { y: 15, height: 10 } }], // Lines 15-24 + { regions: [{ y1: 10, y2: 19 }] } // Covers lines 10-19 (5 of 10 lines) + ); + + expect(result.coverage).toBe(0.5); + expect(result.linesInHotspots).toBe(5); + expect(result.totalLines).toBe(10); + }); + + it('handles multiple diff clusters', () => { + let result = calculateHotspotCoverage( + [ + { boundingBox: { y: 10, height: 5 } }, // Lines 10-14 (in hotspot) + { boundingBox: { y: 100, height: 5 } }, // Lines 100-104 (not in hotspot) + ], + { regions: [{ y1: 0, y2: 20 }] } + ); + + expect(result.coverage).toBe(0.5); // 5 of 10 lines + expect(result.linesInHotspots).toBe(5); + expect(result.totalLines).toBe(10); + }); + + it('handles multiple hotspot regions', () => { + let result = calculateHotspotCoverage( + [ + { boundingBox: { y: 10, height: 5 } }, // Lines 10-14 + { boundingBox: { y: 50, height: 5 } }, // Lines 50-54 + ], + { + regions: [ + { y1: 0, y2: 20 }, // Covers first cluster + { y1: 45, y2: 60 }, // Covers second cluster + ], + } + ); + + expect(result.coverage).toBe(1); + expect(result.linesInHotspots).toBe(10); + expect(result.totalLines).toBe(10); + }); + + it('deduplicates overlapping diff lines', () => { + let result = calculateHotspotCoverage( + [ + { boundingBox: { y: 10, height: 10 } }, // Lines 10-19 + { boundingBox: { y: 15, height: 10 } }, // Lines 15-24 (overlaps) + ], + { regions: [{ y1: 0, y2: 50 }] } + ); + + // Should have 15 unique lines (10-24) + expect(result.totalLines).toBe(15); + expect(result.linesInHotspots).toBe(15); + expect(result.coverage).toBe(1); + }); + + it('handles boundary conditions for region matching', () => { + let result = calculateHotspotCoverage( + [{ boundingBox: { y: 10, height: 1 } }], // Just line 10 + { regions: [{ y1: 10, y2: 10 }] } // Region exactly at line 10 + ); + + expect(result.coverage).toBe(1); + expect(result.linesInHotspots).toBe(1); + expect(result.totalLines).toBe(1); + }); + }); + + describe('shouldFilterAsHotspot', () => { + it('returns false when hotspotAnalysis is null', () => { + let result = shouldFilterAsHotspot(null, { coverage: 0.9 }); + + expect(result).toBe(false); + }); + + it('returns false when coverageResult is null', () => { + let result = shouldFilterAsHotspot({ confidence: 'high' }, null); + + expect(result).toBe(false); + }); + + it('returns false when coverage is below 80%', () => { + let result = shouldFilterAsHotspot( + { confidence: 'high' }, + { coverage: 0.79 } + ); + + expect(result).toBe(false); + }); + + it('returns true when coverage >= 80% and confidence is high', () => { + let result = shouldFilterAsHotspot( + { confidence: 'high' }, + { coverage: 0.8 } + ); + + expect(result).toBe(true); + }); + + it('returns true when coverage >= 80% and confidenceScore > 0.7', () => { + let result = shouldFilterAsHotspot( + { confidence: 'medium', confidenceScore: 0.75 }, + { coverage: 0.85 } + ); + + expect(result).toBe(true); + }); + + it('returns false when coverage >= 80% but confidence is low and score <= 0.7', () => { + let result = shouldFilterAsHotspot( + { confidence: 'low', confidenceScore: 0.5 }, + { coverage: 0.9 } + ); + + expect(result).toBe(false); + }); + + it('returns false when coverage >= 80% but no confidence info', () => { + let result = shouldFilterAsHotspot( + { regions: [{ y1: 10, y2: 20 }] }, // No confidence fields + { coverage: 0.9 } + ); + + expect(result).toBe(false); + }); + + it('handles exactly 80% coverage threshold', () => { + let result = shouldFilterAsHotspot( + { confidence: 'high' }, + { coverage: 0.8 } + ); + + expect(result).toBe(true); + }); + + it('handles exactly 0.7 confidence score (not filtered)', () => { + let result = shouldFilterAsHotspot( + { confidence: 'medium', confidenceScore: 0.7 }, + { coverage: 0.9 } + ); + + expect(result).toBe(false); // Must be > 0.7, not >= + }); + + it('handles just above 0.7 confidence score', () => { + let result = shouldFilterAsHotspot( + { confidence: 'medium', confidenceScore: 0.71 }, + { coverage: 0.9 } + ); + + expect(result).toBe(true); + }); + }); +}); diff --git a/tests/tdd/core/signature.spec.js b/tests/tdd/core/signature.spec.js new file mode 100644 index 00000000..6321a1f7 --- /dev/null +++ b/tests/tdd/core/signature.spec.js @@ -0,0 +1,278 @@ +/** + * Tests for signature generation pure functions + * + * These tests require NO mocking - they test pure functions with input/output assertions. + * Contract tests in tests/contracts/signature-parity.spec.js verify cloud parity. + */ + +import { describe, expect, it } from 'vitest'; +import { + generateScreenshotSignature, + generateBaselineFilename, + generateComparisonId, +} from '../../../src/tdd/core/signature.js'; + +describe('signature', () => { + describe('generateScreenshotSignature', () => { + it('generates signature with all default properties', () => { + let result = generateScreenshotSignature('homepage', { + viewport_width: 1920, + browser: 'chrome', + }); + + expect(result).toBe('homepage|1920|chrome'); + }); + + it('uses empty string for null values', () => { + let result = generateScreenshotSignature('homepage', { + viewport_width: 1920, + browser: null, + }); + + expect(result).toBe('homepage|1920|'); + }); + + it('uses empty string for undefined values', () => { + let result = generateScreenshotSignature('homepage', { + viewport_width: 1920, + // browser is undefined + }); + + expect(result).toBe('homepage|1920|'); + }); + + it('handles nested viewport.width format (SDK format)', () => { + let result = generateScreenshotSignature('homepage', { + viewport: { width: 1280 }, + browser: 'firefox', + }); + + expect(result).toBe('homepage|1280|firefox'); + }); + + it('prefers top-level viewport_width over nested (backend format)', () => { + let result = generateScreenshotSignature('homepage', { + viewport_width: 1920, + viewport: { width: 1280 }, // Should be ignored + browser: 'chrome', + }); + + expect(result).toBe('homepage|1920|chrome'); + }); + + it('includes custom properties in order', () => { + let result = generateScreenshotSignature( + 'VBtn', + { + viewport_width: 1920, + browser: 'chromium', + theme: 'dark', + device: 'desktop', + }, + ['theme', 'device'] + ); + + expect(result).toBe('VBtn|1920|chromium|dark|desktop'); + }); + + it('uses empty string for missing custom properties', () => { + let result = generateScreenshotSignature( + 'VBtn', + { + viewport_width: 1920, + browser: 'chromium', + device: 'desktop', + // theme is missing + }, + ['theme', 'device'] + ); + + expect(result).toBe('VBtn|1920|chromium||desktop'); + }); + + it('finds custom properties in metadata object', () => { + let result = generateScreenshotSignature( + 'component', + { + viewport_width: 1920, + browser: 'chrome', + metadata: { theme: 'light' }, + }, + ['theme'] + ); + + expect(result).toBe('component|1920|chrome|light'); + }); + + it('finds custom properties in metadata.properties', () => { + let result = generateScreenshotSignature( + 'component', + { + viewport_width: 1920, + browser: 'chrome', + metadata: { properties: { variant: 'outlined' } }, + }, + ['variant'] + ); + + expect(result).toBe('component|1920|chrome|outlined'); + }); + + it('trims whitespace from values', () => { + let result = generateScreenshotSignature(' homepage ', { + viewport_width: 1920, + browser: ' chrome ', + }); + + expect(result).toBe('homepage|1920|chrome'); + }); + + it('converts numbers to strings', () => { + let result = generateScreenshotSignature( + 'test', + { + viewport_width: 1920, + browser: 'chrome', + count: 42, + }, + ['count'] + ); + + expect(result).toBe('test|1920|chrome|42'); + }); + + it('handles empty properties object', () => { + let result = generateScreenshotSignature('homepage', {}); + + expect(result).toBe('homepage||'); + }); + + it('handles no properties argument', () => { + let result = generateScreenshotSignature('homepage'); + + expect(result).toBe('homepage||'); + }); + }); + + describe('generateBaselineFilename', () => { + it('generates filename with hash', () => { + let signature = 'homepage|1920|chrome'; + let filename = generateBaselineFilename('homepage', signature); + + // Hash is deterministic + expect(filename).toBe('homepage_1796f76bcda3.png'); + }); + + it('generates different hash for different signatures', () => { + let file1 = generateBaselineFilename('homepage', 'homepage|1920|chrome'); + let file2 = generateBaselineFilename('homepage', 'homepage|1920|firefox'); + + expect(file1).not.toBe(file2); + }); + + it('removes unsafe filesystem characters', () => { + let filename = generateBaselineFilename( + 'test/path:name*?"<>|file', + 'test|1920|chrome' + ); + + // Should not contain /\:*?"<>| + expect(filename).not.toMatch(/[/\\:*?"<>|]/); + expect(filename).toMatch(/^testpathnamefile_[a-f0-9]{12}\.png$/); + }); + + it('converts spaces to hyphens', () => { + let filename = generateBaselineFilename( + 'my screenshot name', + 'my screenshot name|1920|chrome' + ); + + expect(filename).toMatch(/^my-screenshot-name_[a-f0-9]{12}\.png$/); + }); + + it('limits name length to 50 characters', () => { + let longName = 'a'.repeat(100); + let filename = generateBaselineFilename( + longName, + `${longName}|1920|chrome` + ); + + // Name part should be max 50 chars, plus _hash.png + let namePart = filename.split('_')[0]; + expect(namePart.length).toBe(50); + }); + + it('hash is always 12 characters', () => { + let filename = generateBaselineFilename('test', 'test|1920|chrome'); + let hashPart = filename.match(/_([a-f0-9]+)\.png$/)?.[1]; + + expect(hashPart).toHaveLength(12); + }); + }); + + describe('generateComparisonId', () => { + it('generates 16-char hex ID', () => { + let id = generateComparisonId('homepage|1920|chrome'); + + expect(id).toMatch(/^[a-f0-9]{16}$/); + }); + + it('is deterministic for same signature', () => { + let id1 = generateComparisonId('homepage|1920|chrome'); + let id2 = generateComparisonId('homepage|1920|chrome'); + + expect(id1).toBe(id2); + }); + + it('differs for different signatures', () => { + let id1 = generateComparisonId('homepage|1920|chrome'); + let id2 = generateComparisonId('homepage|1920|firefox'); + + expect(id1).not.toBe(id2); + }); + }); + + describe('golden values (contract verification)', () => { + // These values must match tests/contracts/signature-parity.spec.js + // If they fail, CLI and cloud are out of sync + + it('homepage|1920|chrome -> homepage_1796f76bcda3.png', () => { + let sig = generateScreenshotSignature('homepage', { + viewport_width: 1920, + browser: 'chrome', + }); + let filename = generateBaselineFilename('homepage', sig); + + expect(sig).toBe('homepage|1920|chrome'); + expect(filename).toBe('homepage_1796f76bcda3.png'); + }); + + it('homepage|1920| (null browser) -> homepage_8910e19f78bf.png', () => { + let sig = generateScreenshotSignature('homepage', { + viewport_width: 1920, + browser: null, + }); + let filename = generateBaselineFilename('homepage', sig); + + expect(sig).toBe('homepage|1920|'); + expect(filename).toBe('homepage_8910e19f78bf.png'); + }); + + it('VBtn with custom properties -> VBtn_fd88a64fe01b.png', () => { + let sig = generateScreenshotSignature( + 'VBtn', + { + viewport_width: 1920, + browser: 'chromium', + theme: 'dark', + device: 'desktop', + }, + ['theme', 'device'] + ); + let filename = generateBaselineFilename('VBtn', sig); + + expect(sig).toBe('VBtn|1920|chromium|dark|desktop'); + expect(filename).toBe('VBtn_fd88a64fe01b.png'); + }); + }); +}); diff --git a/tests/tdd/integration/tdd-service.integration.spec.js b/tests/tdd/integration/tdd-service.integration.spec.js new file mode 100644 index 00000000..92e9b2d3 --- /dev/null +++ b/tests/tdd/integration/tdd-service.integration.spec.js @@ -0,0 +1,199 @@ +/** + * Integration tests for TddService + * + * Uses real filesystem, real honeydiff binary - no mocking. + * Tests the full flow as users would experience it. + */ + +import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { createTDDService } from '../../../src/tdd/tdd-service.js'; + +// Use real test images from fixtures +let testImagePath = join( + import.meta.dirname, + '../../reporter/fixtures/images/baselines/homepage-desktop.png' +); + +/** + * Helper to create TddService with proper config structure + */ +function createService(tempDir, overrides = {}) { + let config = { + comparison: { + threshold: overrides.threshold ?? 0.1, + minClusterSize: overrides.minClusterSize ?? 2, + }, + signatureProperties: overrides.signatureProperties ?? [], + ...overrides.config, + }; + return createTDDService(config, { workingDir: tempDir }); +} + +describe('TddService Integration', () => { + let tempDir; + let testImage; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'vizzly-tdd-integration-')); + testImage = readFileSync(testImagePath); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('compareScreenshot', () => { + it('creates baseline on first screenshot', async () => { + let service = createService(tempDir); + + let result = await service.compareScreenshot('login-page', testImage, { + viewport_width: 1920, + browser: 'chrome', + }); + + expect(result.status).toBe('new'); + expect(result.signature).toBe('login-page|1920|chrome'); + // New baselines don't have baselinePath in the result until saved + expect(result.name).toBe('login-page'); + }); + + it('passes when screenshot matches baseline', async () => { + let service = createService(tempDir); + + // First call creates baseline + await service.compareScreenshot('dashboard', testImage, { + viewport_width: 1280, + browser: 'firefox', + }); + + // Second call compares - identical image should pass + let result = await service.compareScreenshot('dashboard', testImage, { + viewport_width: 1280, + browser: 'firefox', + }); + + expect(result.status).toBe('passed'); + expect(result.signature).toBe('dashboard|1280|firefox'); + }); + + it('uses signature to differentiate screenshots with same name but different properties', async () => { + let service = createService(tempDir); + + // Same name, different viewport = different signature + let desktop = await service.compareScreenshot('homepage', testImage, { + viewport_width: 1920, + browser: 'chrome', + }); + + let mobile = await service.compareScreenshot('homepage', testImage, { + viewport_width: 375, + browser: 'chrome', + }); + + expect(desktop.signature).toBe('homepage|1920|chrome'); + expect(mobile.signature).toBe('homepage|375|chrome'); + expect(desktop.signature).not.toBe(mobile.signature); + }); + + it('tracks all comparisons in results', async () => { + let service = createService(tempDir); + + await service.compareScreenshot('page-1', testImage, { + viewport_width: 1920, + }); + await service.compareScreenshot('page-2', testImage, { + viewport_width: 1920, + }); + await service.compareScreenshot('page-3', testImage, { + viewport_width: 1920, + }); + + let results = service.getResults(); + + expect(results.comparisons).toHaveLength(3); + expect(results.comparisons.map(c => c.name)).toEqual([ + 'page-1', + 'page-2', + 'page-3', + ]); + }); + }); + + describe('baseline management', () => { + it('saves baseline images to disk', async () => { + let service = createService(tempDir); + + await service.compareScreenshot('test', testImage, { + viewport_width: 1920, + browser: 'chrome', + }); + + // Check that baseline directory has files + let baselineDir = join(tempDir, '.vizzly', 'baselines'); + expect(existsSync(baselineDir)).toBe(true); + + // Find PNG files (baseline images) + let files = require('node:fs').readdirSync(baselineDir); + let pngFiles = files.filter(f => f.endsWith('.png')); + expect(pngFiles.length).toBeGreaterThan(0); + }); + + it('persists baselines across service instances', async () => { + // First instance creates baseline + let service1 = createService(tempDir); + await service1.compareScreenshot('persistent', testImage, { + viewport_width: 1920, + browser: 'chrome', + }); + + // New instance should find existing baseline + let service2 = createService(tempDir); + let result = await service2.compareScreenshot('persistent', testImage, { + viewport_width: 1920, + browser: 'chrome', + }); + + // Should pass (comparing against existing baseline), not create new + expect(result.status).toBe('passed'); + }); + }); + + describe('configuration', () => { + it('respects custom threshold from config', async () => { + let service = createService(tempDir, { threshold: 5.0 }); + + expect(service.threshold).toBe(5.0); + }); + + it('respects custom signature properties', async () => { + let service = createService(tempDir, { + signatureProperties: ['theme'], + }); + + let result = await service.compareScreenshot('themed', testImage, { + viewport_width: 1920, + browser: 'chrome', + theme: 'dark', + }); + + // Custom properties are appended to default (viewport_width, browser) + expect(result.signature).toBe('themed|1920|chrome|dark'); + }); + + it('uses default signature properties when not specified', async () => { + let service = createService(tempDir); + + let result = await service.compareScreenshot('default', testImage, { + viewport_width: 1920, + browser: 'chrome', + someOtherProp: 'ignored', + }); + + // Default is viewport_width and browser only + expect(result.signature).toBe('default|1920|chrome'); + }); + }); +}); diff --git a/tests/tdd/metadata/baseline-metadata.spec.js b/tests/tdd/metadata/baseline-metadata.spec.js new file mode 100644 index 00000000..f15a4d5a --- /dev/null +++ b/tests/tdd/metadata/baseline-metadata.spec.js @@ -0,0 +1,237 @@ +/** + * Tests for baseline metadata I/O + * + * Uses real temp directories - no fs mocking needed. + */ + +import { + mkdtempSync, + rmSync, + existsSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { + loadBaselineMetadata, + saveBaselineMetadata, + createEmptyBaselineMetadata, + upsertScreenshotInMetadata, + findScreenshotBySignature, +} from '../../../src/tdd/metadata/baseline-metadata.js'; + +describe('baseline-metadata', () => { + let tempDir; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'vizzly-test-baseline-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('loadBaselineMetadata', () => { + it('returns null when metadata file does not exist', () => { + let result = loadBaselineMetadata(tempDir); + + expect(result).toBeNull(); + }); + + it('loads valid metadata from disk', () => { + let metadata = { + buildId: 'test-build', + screenshots: [{ name: 'test', signature: 'test|1920|chrome' }], + }; + saveBaselineMetadata(tempDir, metadata); + + let result = loadBaselineMetadata(tempDir); + + expect(result).toEqual(metadata); + }); + + it('returns null for invalid JSON', () => { + let metadataPath = join(tempDir, 'metadata.json'); + writeFileSync(metadataPath, 'not valid json'); + + let result = loadBaselineMetadata(tempDir); + + expect(result).toBeNull(); + }); + }); + + describe('saveBaselineMetadata', () => { + it('saves metadata to disk', () => { + let metadata = { buildId: 'test', screenshots: [] }; + + saveBaselineMetadata(tempDir, metadata); + + let metadataPath = join(tempDir, 'metadata.json'); + expect(existsSync(metadataPath)).toBe(true); + + let saved = JSON.parse(readFileSync(metadataPath, 'utf8')); + expect(saved).toEqual(metadata); + }); + + it('creates directory if it does not exist', () => { + let nestedPath = join(tempDir, 'nested', 'baselines'); + let metadata = { buildId: 'test', screenshots: [] }; + + saveBaselineMetadata(nestedPath, metadata); + + expect(existsSync(join(nestedPath, 'metadata.json'))).toBe(true); + }); + + it('overwrites existing metadata', () => { + let metadata1 = { buildId: 'first', screenshots: [] }; + let metadata2 = { buildId: 'second', screenshots: [] }; + + saveBaselineMetadata(tempDir, metadata1); + saveBaselineMetadata(tempDir, metadata2); + + let result = loadBaselineMetadata(tempDir); + expect(result.buildId).toBe('second'); + }); + + it('formats JSON with 2-space indentation', () => { + let metadata = { buildId: 'test' }; + + saveBaselineMetadata(tempDir, metadata); + + let content = readFileSync(join(tempDir, 'metadata.json'), 'utf8'); + expect(content).toContain(' "buildId"'); // 2-space indent + }); + }); + + describe('createEmptyBaselineMetadata', () => { + it('creates metadata with default values', () => { + let result = createEmptyBaselineMetadata(); + + expect(result.buildId).toBe('local-baseline'); + expect(result.buildName).toBe('Local TDD Baseline'); + expect(result.environment).toBe('test'); + expect(result.branch).toBe('local'); + expect(result.threshold).toBe(2.0); + expect(result.signatureProperties).toEqual([]); + expect(result.screenshots).toEqual([]); + expect(result.createdAt).toBeDefined(); + }); + + it('uses provided threshold', () => { + let result = createEmptyBaselineMetadata({ threshold: 5.0 }); + + expect(result.threshold).toBe(5.0); + }); + + it('uses provided signature properties', () => { + let result = createEmptyBaselineMetadata({ + signatureProperties: ['theme', 'device'], + }); + + expect(result.signatureProperties).toEqual(['theme', 'device']); + }); + }); + + describe('upsertScreenshotInMetadata', () => { + it('adds new screenshot when not found', () => { + let metadata = { screenshots: [] }; + let entry = { name: 'test', signature: 'test|1920|chrome' }; + + let result = upsertScreenshotInMetadata( + metadata, + entry, + 'test|1920|chrome' + ); + + expect(result.screenshots).toHaveLength(1); + expect(result.screenshots[0]).toEqual(entry); + }); + + it('updates existing screenshot by signature', () => { + let metadata = { + screenshots: [ + { name: 'test', signature: 'test|1920|chrome', path: '/old/path' }, + ], + }; + let entry = { + name: 'test', + signature: 'test|1920|chrome', + path: '/new/path', + }; + + let result = upsertScreenshotInMetadata( + metadata, + entry, + 'test|1920|chrome' + ); + + expect(result.screenshots).toHaveLength(1); + expect(result.screenshots[0].path).toBe('/new/path'); + }); + + it('creates screenshots array if missing', () => { + let metadata = {}; + let entry = { name: 'test', signature: 'test|1920|chrome' }; + + let result = upsertScreenshotInMetadata( + metadata, + entry, + 'test|1920|chrome' + ); + + expect(result.screenshots).toHaveLength(1); + }); + + it('mutates and returns the same object', () => { + let metadata = { screenshots: [] }; + let entry = { name: 'test', signature: 'test|1920|chrome' }; + + let result = upsertScreenshotInMetadata( + metadata, + entry, + 'test|1920|chrome' + ); + + expect(result).toBe(metadata); + }); + }); + + describe('findScreenshotBySignature', () => { + it('finds screenshot by signature', () => { + let metadata = { + screenshots: [ + { name: 'one', signature: 'one|1920|chrome' }, + { name: 'two', signature: 'two|1920|chrome' }, + ], + }; + + let result = findScreenshotBySignature(metadata, 'two|1920|chrome'); + + expect(result).toEqual({ name: 'two', signature: 'two|1920|chrome' }); + }); + + it('returns null when not found', () => { + let metadata = { + screenshots: [{ name: 'test', signature: 'test|1920|chrome' }], + }; + + let result = findScreenshotBySignature(metadata, 'other|1920|chrome'); + + expect(result).toBeNull(); + }); + + it('returns null when metadata is null', () => { + let result = findScreenshotBySignature(null, 'test|1920|chrome'); + + expect(result).toBeNull(); + }); + + it('returns null when screenshots array is missing', () => { + let result = findScreenshotBySignature({}, 'test|1920|chrome'); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/tests/tdd/metadata/hotspot-metadata.spec.js b/tests/tdd/metadata/hotspot-metadata.spec.js new file mode 100644 index 00000000..fab16ab1 --- /dev/null +++ b/tests/tdd/metadata/hotspot-metadata.spec.js @@ -0,0 +1,210 @@ +/** + * Tests for hotspot metadata I/O + * + * Uses real temp directories - no fs mocking needed. + */ + +import { + mkdtempSync, + rmSync, + mkdirSync, + writeFileSync, + existsSync, + readFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { + loadHotspotMetadata, + saveHotspotMetadata, + getHotspotForScreenshot, + createHotspotCache, +} from '../../../src/tdd/metadata/hotspot-metadata.js'; + +describe('hotspot-metadata', () => { + let tempDir; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'vizzly-test-hotspot-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('loadHotspotMetadata', () => { + it('returns null when hotspots file does not exist', () => { + let result = loadHotspotMetadata(tempDir); + + expect(result).toBeNull(); + }); + + it('loads hotspots from disk', () => { + let vizzlyDir = join(tempDir, '.vizzly'); + mkdirSync(vizzlyDir, { recursive: true }); + + let hotspotData = { + hotspots: { + homepage: { regions: [{ y1: 10, y2: 20 }], confidence: 'high' }, + }, + }; + writeFileSync( + join(vizzlyDir, 'hotspots.json'), + JSON.stringify(hotspotData) + ); + + let result = loadHotspotMetadata(tempDir); + + expect(result).toEqual({ + homepage: { regions: [{ y1: 10, y2: 20 }], confidence: 'high' }, + }); + }); + + it('returns null for invalid JSON', () => { + let vizzlyDir = join(tempDir, '.vizzly'); + mkdirSync(vizzlyDir, { recursive: true }); + writeFileSync(join(vizzlyDir, 'hotspots.json'), 'not valid json'); + + let result = loadHotspotMetadata(tempDir); + + expect(result).toBeNull(); + }); + + it('returns null if hotspots key is missing', () => { + let vizzlyDir = join(tempDir, '.vizzly'); + mkdirSync(vizzlyDir, { recursive: true }); + writeFileSync( + join(vizzlyDir, 'hotspots.json'), + JSON.stringify({ downloadedAt: '2024-01-01' }) + ); + + let result = loadHotspotMetadata(tempDir); + + expect(result).toBeNull(); + }); + }); + + describe('saveHotspotMetadata', () => { + it('saves hotspot data to disk', () => { + let hotspotData = { + homepage: { regions: [{ y1: 10, y2: 20 }] }, + }; + + saveHotspotMetadata(tempDir, hotspotData); + + let result = loadHotspotMetadata(tempDir); + expect(result).toEqual(hotspotData); + }); + + it('creates .vizzly directory if missing', () => { + let hotspotData = { test: { regions: [] } }; + + saveHotspotMetadata(tempDir, hotspotData); + + expect(existsSync(join(tempDir, '.vizzly'))).toBe(true); + }); + + it('includes downloadedAt timestamp', () => { + saveHotspotMetadata(tempDir, {}); + + let content = JSON.parse( + readFileSync(join(tempDir, '.vizzly', 'hotspots.json'), 'utf8') + ); + + expect(content.downloadedAt).toBeDefined(); + expect(new Date(content.downloadedAt).getTime()).toBeGreaterThan(0); + }); + + it('includes summary if provided', () => { + let summary = { totalRegions: 5, screenshotCount: 2 }; + + saveHotspotMetadata(tempDir, {}, summary); + + let content = JSON.parse( + readFileSync(join(tempDir, '.vizzly', 'hotspots.json'), 'utf8') + ); + + expect(content.summary).toEqual(summary); + }); + }); + + describe('getHotspotForScreenshot', () => { + it('returns hotspot from cache if available', () => { + let cache = { + data: { + homepage: { regions: [{ y1: 10, y2: 20 }], confidence: 'high' }, + }, + loaded: true, + }; + + let result = getHotspotForScreenshot(cache, tempDir, 'homepage'); + + expect(result).toEqual({ + regions: [{ y1: 10, y2: 20 }], + confidence: 'high', + }); + }); + + it('loads from disk when cache is empty', () => { + // Save hotspots to disk + let hotspotData = { + homepage: { regions: [{ y1: 5, y2: 15 }] }, + }; + saveHotspotMetadata(tempDir, hotspotData); + + let cache = createHotspotCache(); + + let result = getHotspotForScreenshot(cache, tempDir, 'homepage'); + + expect(result).toEqual({ regions: [{ y1: 5, y2: 15 }] }); + expect(cache.loaded).toBe(true); + expect(cache.data).toEqual(hotspotData); + }); + + it('returns null when screenshot not found', () => { + let cache = { + data: { other: { regions: [] } }, + loaded: true, + }; + + let result = getHotspotForScreenshot(cache, tempDir, 'homepage'); + + expect(result).toBeNull(); + }); + + it('returns null when no hotspots exist on disk', () => { + let cache = createHotspotCache(); + + let result = getHotspotForScreenshot(cache, tempDir, 'homepage'); + + expect(result).toBeNull(); + expect(cache.loaded).toBe(true); + }); + + it('only loads from disk once', () => { + let cache = createHotspotCache(); + + // First call - loads from disk (empty) + getHotspotForScreenshot(cache, tempDir, 'homepage'); + expect(cache.loaded).toBe(true); + + // Now add hotspots to disk + saveHotspotMetadata(tempDir, { homepage: { regions: [] } }); + + // Second call - should NOT reload from disk + let result = getHotspotForScreenshot(cache, tempDir, 'homepage'); + + // Still null because cache.loaded is true and data was null + expect(result).toBeNull(); + }); + }); + + describe('createHotspotCache', () => { + it('creates empty cache object', () => { + let cache = createHotspotCache(); + + expect(cache).toEqual({ data: null, loaded: false }); + }); + }); +}); diff --git a/tests/tdd/services/baseline-manager.spec.js b/tests/tdd/services/baseline-manager.spec.js new file mode 100644 index 00000000..96e24546 --- /dev/null +++ b/tests/tdd/services/baseline-manager.spec.js @@ -0,0 +1,219 @@ +/** + * Tests for baseline manager + * + * Uses real temp directories - no fs mocking needed. + */ + +import { + mkdtempSync, + rmSync, + existsSync, + writeFileSync, + readFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { + initializeDirectories, + clearBaselineData, + saveBaseline, + saveCurrent, + baselineExists, + getBaselinePath, + getCurrentPath, + getDiffPath, + promoteCurrentToBaseline, + readBaseline, + readCurrent, +} from '../../../src/tdd/services/baseline-manager.js'; + +describe('baseline-manager', () => { + let tempDir; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'vizzly-test-manager-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('initializeDirectories', () => { + it('creates all required directories', () => { + let paths = initializeDirectories(tempDir); + + expect(existsSync(paths.baselinePath)).toBe(true); + expect(existsSync(paths.currentPath)).toBe(true); + expect(existsSync(paths.diffPath)).toBe(true); + }); + + it('returns correct paths under .vizzly', () => { + let paths = initializeDirectories(tempDir); + + expect(paths.baselinePath).toBe(join(tempDir, '.vizzly', 'baselines')); + expect(paths.currentPath).toBe(join(tempDir, '.vizzly', 'current')); + expect(paths.diffPath).toBe(join(tempDir, '.vizzly', 'diffs')); + }); + + it('is idempotent (can be called multiple times)', () => { + initializeDirectories(tempDir); + let paths = initializeDirectories(tempDir); + + expect(existsSync(paths.baselinePath)).toBe(true); + }); + }); + + describe('clearBaselineData', () => { + it('clears all directories', () => { + let paths = initializeDirectories(tempDir); + + // Add some files + writeFileSync(join(paths.baselinePath, 'test.png'), 'baseline'); + writeFileSync(join(paths.currentPath, 'test.png'), 'current'); + writeFileSync(join(paths.diffPath, 'test.png'), 'diff'); + + clearBaselineData(paths); + + // Directories should exist but be empty + expect(existsSync(paths.baselinePath)).toBe(true); + expect(existsSync(paths.currentPath)).toBe(true); + expect(existsSync(paths.diffPath)).toBe(true); + + // Files should be gone + expect(existsSync(join(paths.baselinePath, 'test.png'))).toBe(false); + expect(existsSync(join(paths.currentPath, 'test.png'))).toBe(false); + expect(existsSync(join(paths.diffPath, 'test.png'))).toBe(false); + }); + }); + + describe('saveBaseline', () => { + it('saves image buffer to baselines directory', () => { + let paths = initializeDirectories(tempDir); + let imageBuffer = Buffer.from('fake image data'); + + saveBaseline(paths.baselinePath, 'test.png', imageBuffer); + + expect(existsSync(join(paths.baselinePath, 'test.png'))).toBe(true); + expect(readFileSync(join(paths.baselinePath, 'test.png'))).toEqual( + imageBuffer + ); + }); + }); + + describe('saveCurrent', () => { + it('saves image buffer to current directory and returns path', () => { + let paths = initializeDirectories(tempDir); + let imageBuffer = Buffer.from('current image data'); + + let savedPath = saveCurrent(paths.currentPath, 'test.png', imageBuffer); + + expect(savedPath).toBe(join(paths.currentPath, 'test.png')); + expect(existsSync(savedPath)).toBe(true); + expect(readFileSync(savedPath)).toEqual(imageBuffer); + }); + }); + + describe('baselineExists', () => { + it('returns true when baseline file exists', () => { + let paths = initializeDirectories(tempDir); + writeFileSync(join(paths.baselinePath, 'exists.png'), 'data'); + + expect(baselineExists(paths.baselinePath, 'exists.png')).toBe(true); + }); + + it('returns false when baseline file does not exist', () => { + let paths = initializeDirectories(tempDir); + + expect(baselineExists(paths.baselinePath, 'missing.png')).toBe(false); + }); + }); + + describe('path helpers', () => { + it('getBaselinePath returns correct path', () => { + let paths = initializeDirectories(tempDir); + let result = getBaselinePath(paths.baselinePath, 'test.png'); + + expect(result).toBe(join(paths.baselinePath, 'test.png')); + }); + + it('getCurrentPath returns correct path', () => { + let paths = initializeDirectories(tempDir); + let result = getCurrentPath(paths.currentPath, 'test.png'); + + expect(result).toBe(join(paths.currentPath, 'test.png')); + }); + + it('getDiffPath returns correct path', () => { + let paths = initializeDirectories(tempDir); + let result = getDiffPath(paths.diffPath, 'test.png'); + + expect(result).toBe(join(paths.diffPath, 'test.png')); + }); + }); + + describe('promoteCurrentToBaseline', () => { + it('copies current to baseline', () => { + let paths = initializeDirectories(tempDir); + let imageData = Buffer.from('current image'); + writeFileSync(join(paths.currentPath, 'test.png'), imageData); + + promoteCurrentToBaseline( + paths.currentPath, + paths.baselinePath, + 'test.png' + ); + + expect(existsSync(join(paths.baselinePath, 'test.png'))).toBe(true); + expect(readFileSync(join(paths.baselinePath, 'test.png'))).toEqual( + imageData + ); + }); + + it('throws when current file does not exist', () => { + let paths = initializeDirectories(tempDir); + + expect(() => + promoteCurrentToBaseline( + paths.currentPath, + paths.baselinePath, + 'missing.png' + ) + ).toThrow('Current screenshot not found'); + }); + + it('overwrites existing baseline', () => { + let paths = initializeDirectories(tempDir); + writeFileSync(join(paths.baselinePath, 'test.png'), 'old baseline'); + writeFileSync(join(paths.currentPath, 'test.png'), 'new current'); + + promoteCurrentToBaseline( + paths.currentPath, + paths.baselinePath, + 'test.png' + ); + + expect(readFileSync(join(paths.baselinePath, 'test.png'), 'utf8')).toBe( + 'new current' + ); + }); + }); + + describe('readBaseline / readCurrent', () => { + it('readBaseline returns file buffer', () => { + let paths = initializeDirectories(tempDir); + let data = Buffer.from('baseline data'); + writeFileSync(join(paths.baselinePath, 'test.png'), data); + + expect(readBaseline(paths.baselinePath, 'test.png')).toEqual(data); + }); + + it('readCurrent returns file buffer', () => { + let paths = initializeDirectories(tempDir); + let data = Buffer.from('current data'); + writeFileSync(join(paths.currentPath, 'test.png'), data); + + expect(readCurrent(paths.currentPath, 'test.png')).toEqual(data); + }); + }); +}); diff --git a/tests/tdd/services/comparison-service.spec.js b/tests/tdd/services/comparison-service.spec.js new file mode 100644 index 00000000..fff66b87 --- /dev/null +++ b/tests/tdd/services/comparison-service.spec.js @@ -0,0 +1,260 @@ +/** + * Tests for comparison service + * + * Pure builder function tests - no mocking needed. + * The compareImages function would need honeydiff which requires real images. + */ + +import { describe, expect, it } from 'vitest'; +import { + buildPassedComparison, + buildNewComparison, + buildFailedComparison, + buildErrorComparison, + isDimensionMismatchError, +} from '../../../src/tdd/services/comparison-service.js'; + +describe('comparison-service', () => { + describe('buildPassedComparison', () => { + it('builds a passed comparison result', () => { + let result = buildPassedComparison({ + name: 'homepage', + signature: 'homepage|1920|chrome', + baselinePath: '/path/to/baseline.png', + currentPath: '/path/to/current.png', + properties: { viewport_width: 1920, browser: 'chrome' }, + threshold: 2.0, + minClusterSize: 2, + }); + + expect(result.status).toBe('passed'); + expect(result.name).toBe('homepage'); + expect(result.baseline).toBe('/path/to/baseline.png'); + expect(result.current).toBe('/path/to/current.png'); + expect(result.diff).toBeNull(); + expect(result.id).toMatch(/^[a-f0-9]{16}$/); + expect(result.threshold).toBe(2.0); + expect(result.minClusterSize).toBe(2); + }); + + it('includes honeydiff metrics when provided', () => { + let result = buildPassedComparison({ + name: 'homepage', + signature: 'homepage|1920|chrome', + baselinePath: '/path/to/baseline.png', + currentPath: '/path/to/current.png', + properties: {}, + threshold: 2.0, + minClusterSize: 2, + honeydiffResult: { + totalPixels: 1000000, + aaPixelsIgnored: 500, + aaPercentage: 0.05, + }, + }); + + expect(result.totalPixels).toBe(1000000); + expect(result.aaPixelsIgnored).toBe(500); + expect(result.aaPercentage).toBe(0.05); + }); + }); + + describe('buildNewComparison', () => { + it('builds a new comparison result', () => { + let result = buildNewComparison({ + name: 'new-page', + signature: 'new-page|1920|chrome', + baselinePath: '/path/to/baseline.png', + currentPath: '/path/to/current.png', + properties: { viewport_width: 1920 }, + }); + + expect(result.status).toBe('new'); + expect(result.name).toBe('new-page'); + expect(result.diff).toBeNull(); + expect(result.id).toMatch(/^[a-f0-9]{16}$/); + }); + }); + + describe('buildFailedComparison', () => { + it('builds a failed comparison result', () => { + let result = buildFailedComparison({ + name: 'changed-page', + signature: 'changed-page|1920|chrome', + baselinePath: '/path/to/baseline.png', + currentPath: '/path/to/current.png', + diffPath: '/path/to/diff.png', + properties: {}, + threshold: 2.0, + minClusterSize: 2, + honeydiffResult: { + isDifferent: true, + diffPercentage: 5.5, + diffPixels: 1000, + totalPixels: 100000, + aaPixelsIgnored: 50, + aaPercentage: 0.05, + boundingBox: { x: 10, y: 20, width: 100, height: 50 }, + diffClusters: [], + }, + }); + + expect(result.status).toBe('failed'); + expect(result.name).toBe('changed-page'); + expect(result.diff).toBe('/path/to/diff.png'); + expect(result.diffPercentage).toBe(5.5); + expect(result.diffCount).toBe(1000); + expect(result.reason).toBe('pixel-diff'); + expect(result.hotspotAnalysis).toBeNull(); + }); + + it('filters as passed when in high-confidence hotspot region', () => { + let result = buildFailedComparison({ + name: 'hotspot-page', + signature: 'hotspot-page|1920|chrome', + baselinePath: '/path/to/baseline.png', + currentPath: '/path/to/current.png', + diffPath: '/path/to/diff.png', + properties: {}, + threshold: 2.0, + minClusterSize: 2, + honeydiffResult: { + isDifferent: true, + diffPercentage: 2.0, + diffPixels: 100, + diffClusters: [{ boundingBox: { y: 50, height: 10 } }], + }, + hotspotAnalysis: { + confidence: 'high', + regions: [{ y1: 45, y2: 65 }], // Covers the diff region + }, + }); + + expect(result.status).toBe('passed'); + expect(result.reason).toBe('hotspot-filtered'); + expect(result.hotspotAnalysis.isFiltered).toBe(true); + expect(result.hotspotAnalysis.coverage).toBe(1); + }); + + it('stays failed when coverage below 80%', () => { + let result = buildFailedComparison({ + name: 'partial-hotspot', + signature: 'partial-hotspot|1920|chrome', + baselinePath: '/path/to/baseline.png', + currentPath: '/path/to/current.png', + diffPath: '/path/to/diff.png', + properties: {}, + threshold: 2.0, + minClusterSize: 2, + honeydiffResult: { + isDifferent: true, + diffPercentage: 2.0, + diffPixels: 100, + diffClusters: [{ boundingBox: { y: 50, height: 10 } }], + }, + hotspotAnalysis: { + confidence: 'high', + regions: [{ y1: 50, y2: 52 }], // Only covers 3 of 10 lines (30%) + }, + }); + + expect(result.status).toBe('failed'); + expect(result.reason).toBe('pixel-diff'); + expect(result.hotspotAnalysis.isFiltered).toBe(false); + }); + + it('stays failed when confidence is low', () => { + let result = buildFailedComparison({ + name: 'low-confidence', + signature: 'low-confidence|1920|chrome', + baselinePath: '/path/to/baseline.png', + currentPath: '/path/to/current.png', + diffPath: '/path/to/diff.png', + properties: {}, + threshold: 2.0, + minClusterSize: 2, + honeydiffResult: { + isDifferent: true, + diffPercentage: 2.0, + diffPixels: 100, + diffClusters: [{ boundingBox: { y: 50, height: 10 } }], + }, + hotspotAnalysis: { + confidence: 'low', + confidence_score: 30, + regions: [{ y1: 45, y2: 65 }], // Covers 100% + }, + }); + + expect(result.status).toBe('failed'); + expect(result.hotspotAnalysis.isFiltered).toBe(false); + }); + + it('filters as passed when confidence_score >= 70', () => { + let result = buildFailedComparison({ + name: 'score-filtered', + signature: 'score-filtered|1920|chrome', + baselinePath: '/path/to/baseline.png', + currentPath: '/path/to/current.png', + diffPath: '/path/to/diff.png', + properties: {}, + threshold: 2.0, + minClusterSize: 2, + honeydiffResult: { + isDifferent: true, + diffPercentage: 2.0, + diffPixels: 100, + diffClusters: [{ boundingBox: { y: 50, height: 10 } }], + }, + hotspotAnalysis: { + confidence: 'medium', + confidence_score: 75, + regions: [{ y1: 45, y2: 65 }], + }, + }); + + expect(result.status).toBe('passed'); + expect(result.reason).toBe('hotspot-filtered'); + }); + }); + + describe('buildErrorComparison', () => { + it('builds an error comparison result', () => { + let result = buildErrorComparison({ + name: 'broken-page', + signature: 'broken-page|1920|chrome', + baselinePath: '/path/to/baseline.png', + currentPath: '/path/to/current.png', + properties: {}, + errorMessage: 'Something went wrong', + }); + + expect(result.status).toBe('error'); + expect(result.name).toBe('broken-page'); + expect(result.error).toBe('Something went wrong'); + expect(result.diff).toBeNull(); + }); + }); + + describe('isDimensionMismatchError', () => { + it('returns true for dimension mismatch errors', () => { + let error = new Error( + "Image dimensions don't match: 1920x1080 vs 1280x720" + ); + + expect(isDimensionMismatchError(error)).toBe(true); + }); + + it('returns false for other errors', () => { + let error = new Error('File not found'); + + expect(isDimensionMismatchError(error)).toBe(false); + }); + + it('handles error without message', () => { + let error = {}; + + expect(isDimensionMismatchError(error)).toBe(false); + }); + }); +}); diff --git a/tests/tdd/services/result-service.spec.js b/tests/tdd/services/result-service.spec.js new file mode 100644 index 00000000..624b51d9 --- /dev/null +++ b/tests/tdd/services/result-service.spec.js @@ -0,0 +1,228 @@ +/** + * Tests for result service + * + * Pure function tests - no mocking needed. + */ + +import { describe, expect, it } from 'vitest'; +import { + calculateSummary, + buildResults, + getFailedComparisons, + getNewComparisons, + getErrorComparisons, + isSuccessful, + findComparisonById, + findComparison, +} from '../../../src/tdd/services/result-service.js'; + +describe('result-service', () => { + describe('calculateSummary', () => { + it('calculates counts for each status', () => { + let comparisons = [ + { status: 'passed' }, + { status: 'passed' }, + { status: 'failed' }, + { status: 'new' }, + { status: 'error' }, + ]; + + let result = calculateSummary(comparisons); + + expect(result).toEqual({ + total: 5, + passed: 2, + failed: 1, + new: 1, + errors: 1, + }); + }); + + it('handles empty array', () => { + let result = calculateSummary([]); + + expect(result).toEqual({ + total: 0, + passed: 0, + failed: 0, + new: 0, + errors: 0, + }); + }); + + it('handles all passed', () => { + let comparisons = [{ status: 'passed' }, { status: 'passed' }]; + + let result = calculateSummary(comparisons); + + expect(result.total).toBe(2); + expect(result.passed).toBe(2); + expect(result.failed).toBe(0); + }); + + it('handles unknown status gracefully', () => { + let comparisons = [{ status: 'unknown' }, { status: 'passed' }]; + + let result = calculateSummary(comparisons); + + expect(result.total).toBe(2); + expect(result.passed).toBe(1); + }); + }); + + describe('buildResults', () => { + it('combines summary with comparisons and baseline', () => { + let comparisons = [ + { id: '1', status: 'passed' }, + { id: '2', status: 'failed' }, + ]; + let baselineData = { buildId: 'test-build' }; + + let result = buildResults(comparisons, baselineData); + + expect(result.total).toBe(2); + expect(result.passed).toBe(1); + expect(result.failed).toBe(1); + expect(result.comparisons).toBe(comparisons); + expect(result.baseline).toBe(baselineData); + }); + }); + + describe('getFailedComparisons', () => { + it('filters only failed comparisons', () => { + let comparisons = [ + { id: '1', status: 'passed' }, + { id: '2', status: 'failed', name: 'test1' }, + { id: '3', status: 'failed', name: 'test2' }, + { id: '4', status: 'new' }, + ]; + + let result = getFailedComparisons(comparisons); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe('test1'); + expect(result[1].name).toBe('test2'); + }); + + it('returns empty array when no failures', () => { + let comparisons = [{ status: 'passed' }, { status: 'new' }]; + + expect(getFailedComparisons(comparisons)).toEqual([]); + }); + }); + + describe('getNewComparisons', () => { + it('filters only new comparisons', () => { + let comparisons = [ + { id: '1', status: 'passed' }, + { id: '2', status: 'new', name: 'new1' }, + { id: '3', status: 'failed' }, + ]; + + let result = getNewComparisons(comparisons); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('new1'); + }); + }); + + describe('getErrorComparisons', () => { + it('filters only error comparisons', () => { + let comparisons = [ + { id: '1', status: 'passed' }, + { id: '2', status: 'error', error: 'something broke' }, + ]; + + let result = getErrorComparisons(comparisons); + + expect(result).toHaveLength(1); + expect(result[0].error).toBe('something broke'); + }); + }); + + describe('isSuccessful', () => { + it('returns true when no failures or errors', () => { + let comparisons = [ + { status: 'passed' }, + { status: 'new' }, + { status: 'passed' }, + ]; + + expect(isSuccessful(comparisons)).toBe(true); + }); + + it('returns false when there are failures', () => { + let comparisons = [{ status: 'passed' }, { status: 'failed' }]; + + expect(isSuccessful(comparisons)).toBe(false); + }); + + it('returns false when there are errors', () => { + let comparisons = [{ status: 'passed' }, { status: 'error' }]; + + expect(isSuccessful(comparisons)).toBe(false); + }); + + it('returns true for empty array', () => { + expect(isSuccessful([])).toBe(true); + }); + }); + + describe('findComparisonById', () => { + it('finds comparison by id', () => { + let comparisons = [ + { id: 'abc123', name: 'first' }, + { id: 'def456', name: 'second' }, + ]; + + let result = findComparisonById(comparisons, 'def456'); + + expect(result.name).toBe('second'); + }); + + it('returns null when not found', () => { + let comparisons = [{ id: 'abc123', name: 'first' }]; + + expect(findComparisonById(comparisons, 'notfound')).toBeNull(); + }); + }); + + describe('findComparison', () => { + it('finds by name when signature not provided', () => { + let comparisons = [ + { name: 'homepage', signature: 'homepage|1920|chrome' }, + { name: 'login', signature: 'login|1920|chrome' }, + ]; + + let result = findComparison(comparisons, 'login'); + + expect(result.signature).toBe('login|1920|chrome'); + }); + + it('finds by signature when provided', () => { + let comparisons = [ + { name: 'homepage', signature: 'homepage|1920|chrome' }, + { name: 'homepage', signature: 'homepage|1920|firefox' }, + ]; + + let result = findComparison( + comparisons, + 'homepage', + 'homepage|1920|firefox' + ); + + expect(result.signature).toBe('homepage|1920|firefox'); + }); + + it('returns null when not found', () => { + let comparisons = [ + { name: 'homepage', signature: 'homepage|1920|chrome' }, + ]; + + expect(findComparison(comparisons, 'login')).toBeNull(); + expect( + findComparison(comparisons, 'homepage', 'homepage|1920|safari') + ).toBeNull(); + }); + }); +}); From c4e20283687fd7d04e7edf0d1f4b7fbc94bad1b4 Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Sat, 13 Dec 2025 14:03:00 -0600 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=9D=20Address=20PR=20review=20feed?= =?UTF-8?q?back?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add debug logging for metadata parse errors - Add @throws documentation to compareImages - Clarify hotspot loading behavior with comment --- src/tdd/metadata/baseline-metadata.js | 5 +++-- src/tdd/services/comparison-service.js | 1 + src/tdd/tdd-service.js | 6 +++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/tdd/metadata/baseline-metadata.js b/src/tdd/metadata/baseline-metadata.js index f75ffd8a..de016d9b 100644 --- a/src/tdd/metadata/baseline-metadata.js +++ b/src/tdd/metadata/baseline-metadata.js @@ -24,8 +24,9 @@ export function loadBaselineMetadata(baselinePath) { try { let content = readFileSync(metadataPath, 'utf8'); return JSON.parse(content); - } catch { - // Return null for parse errors - caller can handle + } catch (error) { + // Log for debugging but return null - caller can handle missing metadata + console.debug?.(`Failed to parse baseline metadata: ${error.message}`); return null; } } diff --git a/src/tdd/services/comparison-service.js b/src/tdd/services/comparison-service.js index b7ffec5b..8c29a1e1 100644 --- a/src/tdd/services/comparison-service.js +++ b/src/tdd/services/comparison-service.js @@ -18,6 +18,7 @@ import { calculateHotspotCoverage } from '../core/hotspot-coverage.js'; * @param {number} options.threshold - CIEDE2000 Delta E threshold (default: 2.0) * @param {number} options.minClusterSize - Minimum cluster size (default: 2) * @returns {Promise} Honeydiff result + * @throws {Error} When honeydiff binary fails (e.g., corrupt images, dimension mismatch) */ export async function compareImages( baselinePath, diff --git a/src/tdd/tdd-service.js b/src/tdd/tdd-service.js index b527e742..39ef7866 100644 --- a/src/tdd/tdd-service.js +++ b/src/tdd/tdd-service.js @@ -600,6 +600,10 @@ export class TddService { /** * Get hotspot for a specific screenshot + * + * Note: Once hotspotData is loaded (from disk or cloud), we don't reload. + * This is intentional - hotspots are downloaded once per session and cached. + * If a screenshot isn't in the cache, it means no hotspot data exists for it. */ getHotspotForScreenshot(screenshotName) { // Check memory cache first @@ -607,7 +611,7 @@ export class TddService { return this.hotspotData[screenshotName]; } - // Try loading from disk + // Try loading from disk (only if we haven't loaded yet) if (!this.hotspotData) { this.hotspotData = this.loadHotspots(); }