@vizzly-testing/cli
Version:
Visual review platform for UI developers and designers
1,321 lines (1,252 loc) • 52.9 kB
JavaScript
/**
* 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 as defaultExistsSync, mkdirSync as defaultMkdirSync, readFileSync as defaultReadFileSync, writeFileSync as defaultWriteFileSync } from 'node:fs';
import { createApiClient as defaultCreateApiClient, getBatchHotspots as defaultGetBatchHotspots, getBuilds as defaultGetBuilds, getComparison as defaultGetComparison, getTddBaselines as defaultGetTddBaselines } from '../api/index.js';
import { NetworkError } from '../errors/vizzly-error.js';
import { colors as defaultColors } from '../utils/colors.js';
import { fetchWithTimeout as defaultFetchWithTimeout } from '../utils/fetch-utils.js';
import { getDefaultBranch as defaultGetDefaultBranch } from '../utils/git.js';
import * as defaultOutput from '../utils/output.js';
import { safePath as defaultSafePath, sanitizeScreenshotName as defaultSanitizeScreenshotName, validatePathSecurity as defaultValidatePathSecurity, validateScreenshotProperties as defaultValidateScreenshotProperties } from '../utils/security.js';
import { calculateHotspotCoverage as defaultCalculateHotspotCoverage } from './core/hotspot-coverage.js';
// Import from extracted modules
import { generateBaselineFilename as defaultGenerateBaselineFilename, generateComparisonId as defaultGenerateComparisonId, generateScreenshotSignature as defaultGenerateScreenshotSignature } from './core/signature.js';
import { createEmptyBaselineMetadata as defaultCreateEmptyBaselineMetadata, loadBaselineMetadata as defaultLoadBaselineMetadata, saveBaselineMetadata as defaultSaveBaselineMetadata, upsertScreenshotInMetadata as defaultUpsertScreenshotInMetadata } from './metadata/baseline-metadata.js';
import { loadHotspotMetadata as defaultLoadHotspotMetadata, saveHotspotMetadata as defaultSaveHotspotMetadata } from './metadata/hotspot-metadata.js';
import { baselineExists as defaultBaselineExists, clearBaselineData as defaultClearBaselineData, getBaselinePath as defaultGetBaselinePath, getCurrentPath as defaultGetCurrentPath, getDiffPath as defaultGetDiffPath, initializeDirectories as defaultInitializeDirectories, saveBaseline as defaultSaveBaseline, saveCurrent as defaultSaveCurrent } from './services/baseline-manager.js';
import { buildErrorComparison as defaultBuildErrorComparison, buildFailedComparison as defaultBuildFailedComparison, buildNewComparison as defaultBuildNewComparison, buildPassedComparison as defaultBuildPassedComparison, compareImages as defaultCompareImages, isDimensionMismatchError as defaultIsDimensionMismatchError } from './services/comparison-service.js';
import { buildResults as defaultBuildResults, getFailedComparisons as defaultGetFailedComparisons, getNewComparisons as defaultGetNewComparisons } from './services/result-service.js';
/**
* Create a new TDD service instance
* @param {Object} config - Configuration object
* @param {Object} options - Options
* @param {string} options.workingDir - Working directory
* @param {boolean} options.setBaseline - Whether to set baselines
* @param {Object} options.authService - Authentication service
* @param {Object} deps - Injectable dependencies for testing
*/
export function createTDDService(config, options = {}, deps = {}) {
return new TddService(config, options.workingDir, options.setBaseline, options.authService, deps);
}
export class TddService {
constructor(config, workingDir = process.cwd(), setBaseline = false, authService = null, deps = {}) {
// Grouped dependencies with defaults
let {
// Core utilities
output = defaultOutput,
colors = defaultColors,
validatePathSecurity = defaultValidatePathSecurity,
initializeDirectories = defaultInitializeDirectories,
// File system operations
fs = {},
// API operations
api = {},
// Baseline metadata operations
metadata = {},
// Baseline file management
baseline = {},
// Screenshot comparison
comparison = {},
// Signature generation and security
signature = {},
// Result building
results = {},
// Other
calculateHotspotCoverage = defaultCalculateHotspotCoverage
} = deps;
// Merge grouped deps with defaults
let fsOps = {
existsSync: defaultExistsSync,
mkdirSync: defaultMkdirSync,
readFileSync: defaultReadFileSync,
writeFileSync: defaultWriteFileSync,
...fs
};
let apiOps = {
createApiClient: defaultCreateApiClient,
getTddBaselines: defaultGetTddBaselines,
getBuilds: defaultGetBuilds,
getComparison: defaultGetComparison,
getBatchHotspots: defaultGetBatchHotspots,
fetchWithTimeout: defaultFetchWithTimeout,
getDefaultBranch: defaultGetDefaultBranch,
...api
};
let metadataOps = {
loadBaselineMetadata: defaultLoadBaselineMetadata,
saveBaselineMetadata: defaultSaveBaselineMetadata,
createEmptyBaselineMetadata: defaultCreateEmptyBaselineMetadata,
upsertScreenshotInMetadata: defaultUpsertScreenshotInMetadata,
loadHotspotMetadata: defaultLoadHotspotMetadata,
saveHotspotMetadata: defaultSaveHotspotMetadata,
...metadata
};
let baselineOps = {
baselineExists: defaultBaselineExists,
clearBaselineData: defaultClearBaselineData,
getBaselinePath: defaultGetBaselinePath,
getCurrentPath: defaultGetCurrentPath,
getDiffPath: defaultGetDiffPath,
saveBaseline: defaultSaveBaseline,
saveCurrent: defaultSaveCurrent,
...baseline
};
let comparisonOps = {
compareImages: defaultCompareImages,
buildPassedComparison: defaultBuildPassedComparison,
buildNewComparison: defaultBuildNewComparison,
buildFailedComparison: defaultBuildFailedComparison,
buildErrorComparison: defaultBuildErrorComparison,
isDimensionMismatchError: defaultIsDimensionMismatchError,
...comparison
};
let signatureOps = {
generateScreenshotSignature: defaultGenerateScreenshotSignature,
generateBaselineFilename: defaultGenerateBaselineFilename,
generateComparisonId: defaultGenerateComparisonId,
sanitizeScreenshotName: defaultSanitizeScreenshotName,
validateScreenshotProperties: defaultValidateScreenshotProperties,
safePath: defaultSafePath,
...signature
};
let resultsOps = {
buildResults: defaultBuildResults,
getFailedComparisons: defaultGetFailedComparisons,
getNewComparisons: defaultGetNewComparisons,
...results
};
// Store flattened dependencies for use in methods
this._deps = {
output,
colors,
validatePathSecurity,
initializeDirectories,
calculateHotspotCoverage,
...fsOps,
...apiOps,
...metadataOps,
...baselineOps,
...comparisonOps,
...signatureOps,
...resultsOps
};
this.config = config;
this.setBaseline = setBaseline;
this.authService = authService;
this.client = apiOps.createApiClient({
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;
// Track whether results have been printed (to avoid duplicate output)
this._resultsPrinted = false;
if (this.setBaseline) {
output.info('[vizzly] Baseline update mode - will overwrite existing baselines with new ones');
}
}
/**
* Download baselines from cloud
*/
async downloadBaselines(environment = 'test', branch = null, buildId = null, comparisonId = null) {
// Destructure dependencies
let {
output,
colors,
getDefaultBranch,
getTddBaselines,
getBuilds,
getComparison,
clearBaselineData,
generateScreenshotSignature,
generateBaselineFilename,
sanitizeScreenshotName,
safePath,
existsSync,
fetchWithTimeout,
writeFileSync,
saveBaselineMetadata
} = this._deps;
// 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 getTddBaselines(this.client, 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 getComparison(this.client, 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 getBuilds(this.client, {
environment,
branch,
status: 'passed',
limit: 1
});
if (!builds.data || builds.data.length === 0) {
output.warn(`No baseline builds found for ${environment}/${branch}`);
output.info('Tip: Run a build in normal mode first to create baselines');
return null;
}
let apiResponse = await getTddBaselines(this.client, 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;
}
}
/**
* Process already-fetched baseline data (for use when caller handles auth)
* This allows the baseline router to fetch with a project token and pass the response here
* @param {Object} apiResponse - Response from getTddBaselines API call
* @param {string} buildId - Build ID for reference
* @returns {Promise<Object>} Baseline data
*/
async processDownloadedBaselines(apiResponse, buildId) {
// Destructure dependencies
let {
output,
colors,
clearBaselineData,
sanitizeScreenshotName,
safePath,
existsSync,
fetchWithTimeout,
writeFileSync,
saveBaselineMetadata
} = this._deps;
// 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(', ')}`);
}
}
let 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;
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 Error(`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: 'test',
branch: null,
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 if API key is available (requires SDK auth)
// OAuth-only users won't get hotspots since the hotspot endpoint requires project token
if (this.config.apiKey && buildDetails.screenshots?.length > 0) {
await this.downloadHotspots(buildDetails.screenshots);
}
// 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: null,
environment: 'test',
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;
}
/**
* Download hotspot data for screenshots
*/
async downloadHotspots(screenshots) {
let {
output,
getBatchHotspots,
saveHotspotMetadata
} = this._deps;
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 getBatchHotspots(this.client, 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() {
let {
loadHotspotMetadata
} = this._deps;
return loadHotspotMetadata(this.workingDir);
}
/**
* 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
if (this.hotspotData?.[screenshotName]) {
return this.hotspotData[screenshotName];
}
// Try loading from disk (only if we haven't loaded yet)
if (!this.hotspotData) {
this.hotspotData = this.loadHotspots();
}
return this.hotspotData?.[screenshotName] || null;
}
/**
* Calculate hotspot coverage (delegating to pure function)
*/
calculateHotspotCoverage(diffClusters, hotspotAnalysis) {
let {
calculateHotspotCoverage
} = this._deps;
return calculateHotspotCoverage(diffClusters, hotspotAnalysis);
}
/**
* Handle local baselines logic
*/
async handleLocalBaselines() {
let {
output,
colors
} = this._deps;
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() {
let {
output,
loadBaselineMetadata
} = this._deps;
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;
}
/**
* Upsert a comparison result - replaces existing if same ID, otherwise appends.
* This prevents stale results from accumulating in daemon mode.
* @private
*/
_upsertComparison(result) {
let existingIndex = this.comparisons.findIndex(c => c.id === result.id);
if (existingIndex >= 0) {
this.comparisons[existingIndex] = result;
} else {
this.comparisons.push(result);
}
}
/**
* Compare a screenshot against baseline
*/
async compareScreenshot(name, imageBuffer, properties = {}) {
// Destructure dependencies
let {
output,
sanitizeScreenshotName,
validateScreenshotProperties,
generateScreenshotSignature,
generateBaselineFilename,
getCurrentPath,
getBaselinePath,
getDiffPath,
saveCurrent,
baselineExists,
saveBaseline,
createEmptyBaselineMetadata,
upsertScreenshotInMetadata,
saveBaselineMetadata,
buildNewComparison,
compareImages,
buildPassedComparison,
buildFailedComparison,
buildErrorComparison,
isDimensionMismatchError
} = this._deps;
// 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._upsertComparison(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._upsertComparison(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 at debug level only (shown with --verbose)
let diffInfo = `${honeydiffResult.diffPercentage.toFixed(2)}% diff, ${honeydiffResult.diffPixels} pixels`;
if (honeydiffResult.diffClusters?.length > 0) {
diffInfo += `, ${honeydiffResult.diffClusters.length} regions`;
}
output.debug('comparison', `${sanitizedName}: ${result.status}`, {
diff: diffInfo
});
this._upsertComparison(result);
return result;
}
} catch (error) {
if (isDimensionMismatchError(error)) {
output.debug('comparison', `${sanitizedName}: dimension mismatch, 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);
let result = buildNewComparison({
name: sanitizedName,
signature,
baselinePath: baselineImagePath,
currentPath: currentImagePath,
properties: validatedProperties
});
this._upsertComparison(result);
return result;
}
output.debug('comparison', `${sanitizedName}: error - ${error.message}`);
let result = buildErrorComparison({
name: sanitizedName,
signature,
baselinePath: baselineImagePath,
currentPath: currentImagePath,
properties: validatedProperties,
errorMessage: error.message
});
this._upsertComparison(result);
return result;
}
}
/**
* Get results summary
*/
getResults() {
let {
buildResults
} = this._deps;
return buildResults(this.comparisons, this.baselineData);
}
/**
* Print results to console
* Only prints once per test run to avoid duplicate output
*/
async printResults() {
// Skip if already printed (prevents duplicate output from vizzlyFlush)
if (this._resultsPrinted) {
return this.getResults();
}
this._resultsPrinted = true;
let {
output,
colors,
getFailedComparisons,
getNewComparisons,
existsSync,
readFileSync
} = this._deps;
let results = this.getResults();
let failedComparisons = getFailedComparisons(this.comparisons);
let newComparisons = getNewComparisons(this.comparisons);
let passedComparisons = this.comparisons.filter(c => c.status === 'passed' || c.status === 'baseline-created' || c.status === 'baseline-updated');
let hasChanges = failedComparisons.length > 0 || newComparisons.length > 0;
// Header with summary - use bear emoji as Vizzly mascot
output.blank();
output.print(`🐻 ${colors.bold(results.total)} screenshot${results.total !== 1 ? 's' : ''} compared`);
output.blank();
// Passed section - use Observatory success color
if (results.passed > 0) {
let successColor = colors.brand?.success || colors.green;
if (output.isVerbose()) {
// Verbose mode: show each screenshot
for (let comp of passedComparisons) {
output.print(` ${successColor('✓')} ${comp.name}`);
}
} else {
// Default mode: just show count with green checkmark and number
output.print(` ${successColor('✓')} ${successColor(results.passed)} passed`);
}
output.blank();
}
// Failed comparisons with diff bars - use Observatory warning/danger colors
if (failedComparisons.length > 0) {
let warningColor = colors.brand?.warning || colors.yellow;
let dangerColor = colors.brand?.danger || colors.red;
output.print(` ${warningColor('◐')} ${warningColor(failedComparisons.length)} visual change${failedComparisons.length !== 1 ? 's' : ''} detected`);
// Find longest name for alignment
let maxNameLen = Math.max(...failedComparisons.map(c => c.name.length));
let textMuted = colors.brand?.textMuted || colors.dim;
for (let comp of failedComparisons) {
let diffDisplay = '';
if (comp.diffPercentage !== undefined) {
// Use the new diffBar helper for visual representation
let bar = output.diffBar(comp.diffPercentage, 10);
let paddedName = comp.name.padEnd(maxNameLen);
diffDisplay = ` ${bar} ${textMuted(`${comp.diffPercentage.toFixed(1)}%`)}`;
output.print(` ${dangerColor('✗')} ${paddedName}${diffDisplay}`);
} else {
output.print(` ${dangerColor('✗')} ${comp.name}`);
}
}
output.blank();
}
// New screenshots - use Observatory info color
if (newComparisons.length > 0) {
let infoColor = colors.brand?.info || colors.cyan;
let textMuted = colors.brand?.textMuted || colors.dim;
output.print(` ${infoColor('+')} ${infoColor(newComparisons.length)} new screenshot${newComparisons.length !== 1 ? 's' : ''}`);
for (let comp of newComparisons) {
output.print(` ${textMuted('○')} ${comp.name}`);
}
output.blank();
}
// Errors - use Observatory danger color
if (results.errors > 0) {
let dangerColor = colors.brand?.danger || colors.red;
let errorComparisons = this.comparisons.filter(c => c.status === 'error');
output.print(` ${dangerColor('!')} ${dangerColor(results.errors)} error${results.errors !== 1 ? 's' : ''}`);
for (let comp of errorComparisons) {
output.print(` ${dangerColor('✗')} ${comp.name}`);
}
output.blank();
}
// Dashboard link with prominent styling - detect if server is running
if (hasChanges) {
let infoColor = colors.brand?.info || colors.cyan;
let textTertiary = colors.brand?.textTertiary || colors.dim;
// Check if TDD server is already running
let serverFile = `${this.workingDir}/.vizzly/server.json`;
let serverRunning = false;
let serverPort = 47392;
try {
if (existsSync(serverFile)) {
let serverInfo = JSON.parse(readFileSync(serverFile, 'utf8'));
if (serverInfo.port) {
serverPort = serverInfo.port;
serverRunning = true;
}
}
} catch {
// Ignore errors reading server file
}
if (serverRunning) {
// Server is running - show the dashboard URL
output.print(` ${textTertiary('→')} Review changes: ${infoColor(colors.underline(`http://localhost:${serverPort}`))}`);
} else {
// Server not running - suggest starting it
output.print(` ${textTertiary('→')} Review changes: ${infoColor(colors.underline('vizzly tdd start --open'))}`);
}
output.blank();
}
return results;
}
/**
* Update all baselines with current screenshots
*/
updateBaselines() {
// Destructure dependencies
let {
output,
generateScreenshotSignature,
generateBaselineFilename,
sanitizeScreenshotName,
validateScreenshotProperties,
getBaselinePath,
existsSync,
readFileSync,
writeFileSync,
createEmptyBaselineMetadata,
upsertScreenshotInMetadata,
saveBaselineMetadata
} = this._deps;
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) {
// Destructure dependencies
let {
output,
generateScreenshotSignature,
generateBaselineFilename,
sanitizeScreenshotName,
safePath,
existsSync,
readFileSync,
mkdirSync,
writeFileSync,
createEmptyBaselineMetadata,
upsertScreenshotInMetadata,
saveBaselineMetadata
} = this._deps;
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;
}
// Sanitize name for consistency, even though comparison.name is typically pre-sanitized
let sanitizedName;
try {
sanitizedName = sanitizeScreenshotName(comparison.name);
} catch (error) {
output.error(`Invalid screenshot name '${comparison.name}': ${error.message}`);
throw new Error(`Screenshot name validation failed: ${error.message}`);
}
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);