From e96043bce9b7bae61d7c510a69f58f6dc4c82111 Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Mon, 18 May 2026 13:35:13 -0500 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20split=20TDD=20status=20plu?= =?UTF-8?q?mbing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the ops commands and their supporting runtime services into the next stacked review branch. --- src/cli.js | 106 +-- src/commands/doctor.js | 201 ++++-- src/commands/preview.js | 128 ++-- src/commands/run.js | 152 ++-- src/commands/status.js | 447 +++++++----- src/commands/tdd-daemon.js | 642 +++++++++++------ src/commands/tdd.js | 22 +- src/index.js | 17 +- src/sdk/index.js | 4 +- src/services/build-manager.js | 126 +--- src/services/index.js | 6 +- src/services/server-manager.js | 86 --- src/services/static-report-generator.js | 26 +- src/services/test-runner.js | 46 +- src/tdd/index.js | 5 +- src/tdd/metadata/region-metadata.js | 35 - src/tdd/server-registry.js | 61 +- src/tdd/services/hotspot-service.js | 14 - src/tdd/services/region-service.js | 59 -- src/tdd/tdd-service.js | 4 +- tests/commands/doctor.test.js | 294 ++++++++ tests/commands/preview.test.js | 71 ++ tests/commands/run.test.js | 162 ++++- tests/commands/status.test.js | 272 +++++++- tests/commands/tdd-daemon.test.js | 650 ++++++++++++++++++ tests/commands/tdd.test.js | 77 ++- tests/services/build-manager.test.js | 236 +------ .../services/static-report-generator.test.js | 200 +++--- tests/tdd/server-registry.test.js | 101 ++- tests/tdd/tdd-service.test.js | 35 + 30 files changed, 2910 insertions(+), 1375 deletions(-) delete mode 100644 src/services/server-manager.js delete mode 100644 src/tdd/services/region-service.js create mode 100644 tests/commands/doctor.test.js create mode 100644 tests/commands/tdd-daemon.test.js diff --git a/src/cli.js b/src/cli.js index 2aeb807d..d487a6c5 100644 --- a/src/cli.js +++ b/src/cli.js @@ -55,6 +55,7 @@ import { tddStartCommand, tddStatusCommand, tddStopCommand, + validateTddStartOptions, } from './commands/tdd-daemon.js'; import { uploadCommand, validateUploadOptions } from './commands/upload.js'; import { validateWhoamiOptions, whoamiCommand } from './commands/whoami.js'; @@ -65,6 +66,7 @@ import { generateStaticReport, getReportFileUrl, } from './services/static-report-generator.js'; +import { withTimeout } from './utils/async-utils.js'; import { openBrowser } from './utils/browser.js'; import { colors } from './utils/colors.js'; import { loadConfig } from './utils/config-loader.js'; @@ -376,24 +378,21 @@ let plugins = []; try { plugins = await loadPlugins(configPath, config); - for (const plugin of plugins) { + for (let plugin of plugins) { try { // Add timeout protection for plugin registration (5 seconds) - const registerPromise = plugin.register(program, { + let registerPromise = plugin.register(program, { config, services: pluginServices, output, - // Backwards compatibility alias for plugins using old API - logger: output, }); - const timeoutPromise = new Promise((_, reject) => - setTimeout( - () => reject(new Error('Plugin registration timeout (5s)')), - 5000 - ) + + await withTimeout( + registerPromise, + 5000, + 'Plugin registration timeout (5s)' ); - await Promise.race([registerPromise, timeoutPromise]); output.debug(`Registered plugin: ${plugin.name}`); } catch (error) { output.warn(`Failed to register plugin ${plugin.name}: ${error.message}`); @@ -418,15 +417,13 @@ program .argument('', 'Path to screenshots directory or file') .option('-b, --build-name ', 'Build name for grouping') .option('-m, --metadata ', 'Additional metadata as JSON') - .option('--batch-size ', 'Upload batch size', v => parseInt(v, 10)) - .option('--upload-timeout ', 'Upload timeout in milliseconds', v => - parseInt(v, 10) - ) + .option('--batch-size ', 'Upload batch size', Number) + .option('--upload-timeout ', 'Upload timeout in milliseconds', Number) .option('--branch ', 'Git branch') .option('--commit ', 'Git commit SHA') .option('--message ', 'Commit message') .option('--environment ', 'Environment name', 'test') - .option('--threshold ', 'Comparison threshold', parseFloat) + .option('--threshold ', 'Comparison threshold', Number) .option('--token ', 'API token override') .option('--wait', 'Wait for build completion') .option('--upload-all', 'Upload all screenshots without SHA deduplication') @@ -461,7 +458,12 @@ tddCmd .option('--baseline-build ', 'Use specific build as baseline') .option('--baseline-comparison ', 'Use specific comparison as baseline') .option('--environment ', 'Environment name', 'test') - .option('--threshold ', 'Comparison threshold', parseFloat) + .option('--threshold ', 'Comparison threshold', Number) + .option( + '--min-cluster-size ', + 'Minimum changed-pixel cluster size', + Number + ) .option('--timeout ', 'Server timeout in milliseconds', '30000') .option('--fail-on-diff', 'Fail tests when visual differences are detected') .option('--token ', 'API token override') @@ -475,6 +477,15 @@ tddCmd return; } + let validationErrors = validateTddStartOptions(options); + if (validationErrors.length > 0) { + output.error('Validation errors:'); + for (let error of validationErrors) { + output.printErr(` - ${error}`); + } + process.exit(1); + } + await tddStartCommand(options, globalOptions); }); @@ -512,7 +523,12 @@ tddCmd .option('--port ', 'Port for TDD server', '47392') .option('--branch ', 'Git branch override') .option('--environment ', 'Environment name', 'test') - .option('--threshold ', 'Comparison threshold', parseFloat) + .option('--threshold ', 'Comparison threshold', Number) + .option( + '--min-cluster-size ', + 'Minimum changed-pixel cluster size', + Number + ) .option('--token ', 'API token override') .option('--timeout ', 'Server timeout in milliseconds', '30000') .option('--baseline-build ', 'Use specific build as baseline') @@ -602,6 +618,14 @@ program .option('--commit ', 'Git commit SHA') .option('--message ', 'Commit message') .option('--environment ', 'Environment name', 'test') + .option('--threshold ', 'Comparison threshold', Number) + .option( + '--min-cluster-size ', + 'Minimum changed-pixel cluster size', + Number + ) + .option('--batch-size ', 'Upload batch size', Number) + .option('--upload-timeout ', 'Upload timeout in milliseconds', Number) .option('--token ', 'API token override') .option('--wait', 'Wait for build completion') .option('--timeout ', 'Server timeout in milliseconds', '30000') @@ -664,13 +688,8 @@ program .option('--environment ', 'Filter by environment') .option('-p, --project ', 'Filter by project slug') .option('--org ', 'Filter by organization slug') - .option( - '--limit ', - 'Maximum results to return (1-250)', - val => parseInt(val, 10), - 20 - ) - .option('--offset ', 'Skip first N results', val => parseInt(val, 10), 0) + .option('--limit ', 'Maximum results to return (1-250)', Number, 20) + .option('--offset ', 'Skip first N results', Number, 0) .option('--comparisons', 'Include comparisons when fetching a specific build') .addHelpText( 'after', @@ -710,13 +729,8 @@ program .option('--name ', 'Search comparisons by name (supports wildcards)') .option('--status ', 'Filter by status (identical, new, changed)') .option('--branch ', 'Filter by branch (for name search)') - .option( - '--limit ', - 'Maximum results to return (1-250)', - val => parseInt(val, 10), - 50 - ) - .option('--offset ', 'Skip first N results', val => parseInt(val, 10), 0) + .option('--limit ', 'Maximum results to return (1-250)', Number, 50) + .option('--offset ', 'Skip first N results', Number, 0) .option('-p, --project ', 'Filter by project slug') .option('--org ', 'Filter by organization slug') .addHelpText( @@ -789,17 +803,17 @@ contextCmd .option( '--similar-limit ', 'Maximum similar fingerprint matches to return (1-50)', - val => parseInt(val, 10) + Number ) .option( '--recent-limit ', 'Maximum recent same-name comparisons to return (1-50)', - val => parseInt(val, 10) + Number ) .option( '--window-size ', 'Historical hotspot analysis window size (1-50)', - val => parseInt(val, 10) + Number ) .addHelpText( 'after', @@ -835,12 +849,12 @@ contextCmd .option( '--recent-limit ', 'Maximum recent comparisons to return (1-50)', - val => parseInt(val, 10) + Number ) .option( '--window-size ', 'Historical hotspot analysis window size (1-50)', - val => parseInt(val, 10) + Number ) .addHelpText( 'after', @@ -873,9 +887,7 @@ contextCmd .option('--source ', 'Context source: auto, cloud, or local', 'auto') .option('-p, --project ', 'Project scope for user auth lookups') .option('--org ', 'Organization slug when project slug is ambiguous') - .option('--limit ', 'Maximum matches to return (1-50)', val => - parseInt(val, 10) - ) + .option('--limit ', 'Maximum matches to return (1-50)', Number) .addHelpText( 'after', ` @@ -905,10 +917,8 @@ contextCmd .option('--source ', 'Context source: auto, cloud, or local', 'auto') .option('-p, --project ', 'Project scope for user auth lookups') .option('--org ', 'Organization slug when project slug is ambiguous') - .option('--limit ', 'Maximum comparisons to return (1-100)', val => - parseInt(val, 10) - ) - .option('--offset ', 'Skip first N comparisons', val => parseInt(val, 10)) + .option('--limit ', 'Maximum comparisons to return (1-100)', Number) + .option('--offset ', 'Skip first N comparisons', Number) .addHelpText( 'after', ` @@ -1179,13 +1189,8 @@ program .command('projects') .description('List projects you have access to') .option('--org ', 'Filter by organization slug') - .option( - '--limit ', - 'Maximum results to return (1-250)', - val => parseInt(val, 10), - 50 - ) - .option('--offset ', 'Skip first N results', val => parseInt(val, 10), 0) + .option('--limit ', 'Maximum results to return (1-250)', Number, 50) + .option('--offset ', 'Skip first N results', Number, 0) .addHelpText( 'after', ` @@ -1240,7 +1245,6 @@ program .description('Upload static files as a preview for a build') .argument('[path]', 'Path to static files (dist/, build/, out/)') .option('-b, --build ', 'Build ID to attach preview to') - .option('-p, --parallel-id ', 'Look up build by parallel ID') .option('--base ', 'Override auto-detected base path') .option('--open', 'Open preview URL in browser after upload') .option('--dry-run', 'Show what would be uploaded without uploading') diff --git a/src/commands/doctor.js b/src/commands/doctor.js index 1755f32d..ec0b5117 100644 --- a/src/commands/doctor.js +++ b/src/commands/doctor.js @@ -1,24 +1,17 @@ import { URL } from 'node:url'; -import { createApiClient, getBuilds } from '../api/index.js'; -import { ConfigError } from '../errors/vizzly-error.js'; -import { loadConfig } from '../utils/config-loader.js'; -import { getContext } from '../utils/context.js'; -import { getApiToken } from '../utils/environment-config.js'; -import * as output from '../utils/output.js'; +import { + createApiClient as defaultCreateApiClient, + getBuilds as defaultGetBuilds, +} from '../api/index.js'; +import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js'; +import { getContext as defaultGetContext } from '../utils/context.js'; +import { getApiToken as defaultGetApiToken } from '../utils/environment-config.js'; +import * as defaultOutput from '../utils/output.js'; -/** - * Doctor command implementation - Run diagnostics to check environment - * @param {Object} options - Command options - * @param {Object} globalOptions - Global CLI options - */ -export async function doctorCommand(options = {}, globalOptions = {}) { - output.configure({ - json: globalOptions.json, - verbose: globalOptions.verbose, - color: !globalOptions.noColor, - }); +export let MIN_NODE_MAJOR = 22; - let diagnostics = { +export function createDoctorDiagnostics() { + return { environment: { nodeVersion: null, nodeVersionValid: null, @@ -36,73 +29,144 @@ export async function doctorCommand(options = {}, globalOptions = {}) { error: null, }, }; +} + +export function getNodeVersionCheck( + nodeVersion = process.version, + minMajor = MIN_NODE_MAJOR +) { + let nodeMajor = parseNodeMajorVersion(nodeVersion); + let ok = nodeMajor !== null && nodeMajor >= minMajor; + let value = ok + ? `${nodeVersion} (supported)` + : nodeMajor === null + ? `${nodeVersion} (unrecognized Node.js version)` + : `${nodeVersion} (requires >= ${minMajor})`; + + return { + diagnostic: { + nodeVersion, + nodeVersionValid: ok, + }, + check: { + name: 'Node.js', + value, + ok, + }, + }; +} + +function parseNodeMajorVersion(nodeVersion) { + let match = /^v?(\d+)\.\d+\.\d+$/.exec(nodeVersion); + return match ? Number(match[1]) : null; +} + +export function getApiUrlCheck(apiUrl) { + try { + let url = new URL(apiUrl); + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error('URL must use http or https'); + } + + return { + apiUrl, + apiUrlValid: true, + check: { name: 'API URL', value: apiUrl, ok: true }, + }; + } catch { + return { + apiUrl, + apiUrlValid: false, + check: { + name: 'API URL', + value: 'invalid (check VIZZLY_API_URL)', + ok: false, + }, + }; + } +} + +export function getThresholdCheck(thresholdValue) { + let threshold = Number(thresholdValue); + // CIEDE2000 threshold: 0 = exact, 1 = JND, 2 = recommended, 3+ = permissive + let thresholdValid = Number.isFinite(threshold) && threshold >= 0; + + return { + threshold, + thresholdValid, + check: thresholdValid + ? { + name: 'Threshold', + value: `${threshold} (CIEDE2000)`, + ok: true, + } + : { name: 'Threshold', value: 'invalid', ok: false }, + }; +} + +/** + * Doctor command implementation - Run diagnostics to check environment + * @param {Object} options - Command options + * @param {Object} globalOptions - Global CLI options + * @param {Object} deps - Dependencies for testing + */ +export async function doctorCommand( + options = {}, + globalOptions = {}, + deps = {} +) { + let { + createApiClient = defaultCreateApiClient, + getApiToken = defaultGetApiToken, + getBuilds = defaultGetBuilds, + getContext = defaultGetContext, + loadConfig = defaultLoadConfig, + nodeVersion = process.version, + output = defaultOutput, + exit = code => process.exit(code), + } = deps; + + output.configure({ + json: globalOptions.json, + verbose: globalOptions.verbose, + color: !globalOptions.noColor, + }); + let diagnostics = createDoctorDiagnostics(); let hasErrors = false; let checks = []; try { // Determine if we'll attempt remote checks (API connectivity) - let willCheckConnectivity = Boolean(options.api || getApiToken()); + let hasApiToken = Boolean(getApiToken()); + let willCheckConnectivity = Boolean(options.api || hasApiToken); // Show header output.header('doctor', willCheckConnectivity ? 'full' : 'local'); - // Node.js version check (require >= 20) - let nodeVersion = process.version; - let nodeMajor = parseInt(nodeVersion.slice(1).split('.')[0], 10); - diagnostics.environment.nodeVersion = nodeVersion; - diagnostics.environment.nodeVersionValid = nodeMajor >= 20; - if (nodeMajor >= 20) { - checks.push({ - name: 'Node.js', - value: `${nodeVersion} (supported)`, - ok: true, - }); - } else { - checks.push({ - name: 'Node.js', - value: `${nodeVersion} (requires >= 20)`, - ok: false, - }); + let nodeCheck = getNodeVersionCheck(nodeVersion); + diagnostics.environment = nodeCheck.diagnostic; + checks.push(nodeCheck.check); + if (!nodeCheck.check.ok) { hasErrors = true; } // Load configuration (apply global CLI overrides like --config only) let config = await loadConfig(globalOptions.config); - // Validate apiUrl - diagnostics.configuration.apiUrl = config.apiUrl; - try { - let url = new URL(config.apiUrl); - if (!['http:', 'https:'].includes(url.protocol)) { - throw new ConfigError('URL must use http or https'); - } - diagnostics.configuration.apiUrlValid = true; - checks.push({ name: 'API URL', value: config.apiUrl, ok: true }); - } catch (_e) { - diagnostics.configuration.apiUrlValid = false; - checks.push({ - name: 'API URL', - value: 'invalid (check VIZZLY_API_URL)', - ok: false, - }); + let apiUrlCheck = getApiUrlCheck(config.apiUrl); + diagnostics.configuration.apiUrl = apiUrlCheck.apiUrl; + diagnostics.configuration.apiUrlValid = apiUrlCheck.apiUrlValid; + checks.push(apiUrlCheck.check); + if (!apiUrlCheck.check.ok) { hasErrors = true; } - // Validate threshold (0..1 inclusive) - let threshold = Number(config?.comparison?.threshold); - diagnostics.configuration.threshold = threshold; - // CIEDE2000 threshold: 0 = exact, 1 = JND, 2 = recommended, 3+ = permissive - let thresholdValid = Number.isFinite(threshold) && threshold >= 0; - diagnostics.configuration.thresholdValid = thresholdValid; - if (thresholdValid) { - checks.push({ - name: 'Threshold', - value: `${threshold} (CIEDE2000)`, - ok: true, - }); - } else { - checks.push({ name: 'Threshold', value: 'invalid', ok: false }); + let thresholdCheck = getThresholdCheck(config?.comparison?.threshold); + diagnostics.configuration.threshold = thresholdCheck.threshold; + diagnostics.configuration.thresholdValid = thresholdCheck.thresholdValid; + checks.push(thresholdCheck.check); + if (!thresholdCheck.check.ok) { hasErrors = true; } @@ -112,8 +176,7 @@ export async function doctorCommand(options = {}, globalOptions = {}) { checks.push({ name: 'Port', value: String(port), ok: true }); // Optional: API connectivity check when --api is provided or VIZZLY_TOKEN is present - let autoApi = Boolean(getApiToken()); - if (options.api || autoApi) { + if (willCheckConnectivity) { diagnostics.connectivity.checked = true; if (!config.apiKey) { diagnostics.connectivity.ok = false; @@ -214,7 +277,7 @@ export async function doctorCommand(options = {}, globalOptions = {}) { output.error('Failed to run preflight', error); } finally { output.cleanup(); - if (hasErrors) process.exit(1); + if (hasErrors) exit(1); } } diff --git a/src/commands/preview.js b/src/commands/preview.js index ae79c12f..2bb02d4c 100644 --- a/src/commands/preview.js +++ b/src/commands/preview.js @@ -5,10 +5,10 @@ * The build is automatically detected from session file or environment. */ -import { exec, execSync } from 'node:child_process'; +import { execFile, execFileSync } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { existsSync, statSync } from 'node:fs'; -import { readFile, realpath, stat, unlink } from 'node:fs/promises'; +import { readdir, readFile, realpath, stat, unlink } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { promisify } from 'node:util'; @@ -21,27 +21,17 @@ import { openBrowser as defaultOpenBrowser } from '../utils/browser.js'; import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js'; import { detectBranch as defaultDetectBranch } from '../utils/git.js'; import * as defaultOutput from '../utils/output.js'; +import { createWildcardMatcher } from '../utils/patterns.js'; import { formatSessionAge as defaultFormatSessionAge, readSession as defaultReadSession, } from '../utils/session.js'; -let execAsync = promisify(exec); +let execFileAsync = promisify(execFile); // Maximum files to show in dry-run output (use --verbose for all) let DRY_RUN_FILE_LIMIT = 50; -/** - * Validate path for shell safety - prevents command injection - * @param {string} path - Path to validate - * @returns {boolean} true if path is safe for shell use - */ -function isPathSafe(path) { - // Reject paths with shell metacharacters that could enable command injection - let dangerousChars = /[`$;&|<>(){}[\]\\!*?'"]/; - return !dangerousChars.test(path); -} - /** * Check if a command exists on the system * @param {string} command - Command to check @@ -50,13 +40,17 @@ function isPathSafe(path) { function commandExists(command) { try { let checkCmd = process.platform === 'win32' ? 'where' : 'which'; - execSync(`${checkCmd} ${command}`, { stdio: 'ignore' }); + execFileSync(checkCmd, [command], { stdio: 'ignore' }); return true; } catch { return false; } } +function quotePowerShellString(value) { + return `'${String(value).replace(/'/g, "''")}'`; +} + /** * Get the appropriate zip command for the current platform * @returns {{ command: string, available: boolean }} @@ -134,11 +128,8 @@ let DEFAULT_EXCLUDED_FILES = [ function matchesPattern(filename, patterns) { for (let pattern of patterns) { if (pattern.includes('*')) { - // Simple glob matching - convert to regex - let regex = new RegExp( - `^${pattern.replace(/\./g, '\\.').replace(/\*/g, '.*')}$` - ); - if (regex.test(filename)) return true; + let matches = createWildcardMatcher(pattern, { anchored: true }); + if (matches(filename)) return true; } else { if (filename === pattern) return true; } @@ -165,68 +156,60 @@ async function createZipWithSystem(sourceDir, outputPath, exclusions = {}) { ); } - // Validate paths to prevent command injection - // Note: outputPath is internally generated (tmpdir + random), so always safe - // sourceDir comes from user input, so we validate it - if (!isPathSafe(sourceDir)) { - throw new Error( - 'Path contains unsupported characters. Please use a path without special shell characters.' - ); - } - - // Validate exclusion patterns to prevent command injection - // Only allow safe characters in patterns: alphanumeric, dots, asterisks, underscores, hyphens, slashes - let safePatternRegex = /^[a-zA-Z0-9.*_\-/]+$/; - for (let pattern of [...dirs, ...files]) { - if (!safePatternRegex.test(pattern)) { - throw new Error( - `Exclusion pattern contains unsafe characters: ${pattern}. Only alphanumeric, ., *, _, -, / are allowed.` - ); - } - } - if (command === 'zip') { // Standard zip command - create ZIP from directory contents - // Using cwd option is safe as it's not part of the command string // -r: recursive, -q: quiet, -x: exclude patterns - let excludeArgs = [ - ...dirs.map(dir => `-x "${dir}/*"`), - ...files.map(pattern => `-x "${pattern}"`), - ].join(' '); - await execAsync(`zip -r -q "${outputPath}" . ${excludeArgs}`, { + let excludeArgs = []; + for (let dir of dirs) { + excludeArgs.push('-x', `${dir}/*`); + } + for (let pattern of files) { + excludeArgs.push('-x', pattern); + } + + await execFileAsync('zip', ['-r', '-q', outputPath, '.', ...excludeArgs], { cwd: sourceDir, maxBuffer: 1024 * 1024 * 100, // 100MB buffer }); } else if (command === 'powershell') { // Windows PowerShell - Compress-Archive doesn't support exclusions, // so we create a temp directory with only the files we want - let safeSrcDir = sourceDir.replace(/'/g, "''"); - let safeOutPath = outputPath.replace(/'/g, "''"); + let safeSrcDir = quotePowerShellString(sourceDir); + let safeOutPath = quotePowerShellString(outputPath); // Build exclusion filter for PowerShell // We use Get-ChildItem with -Exclude and pipe to Compress-Archive - let excludePatterns = [...dirs, ...files].map(p => `'${p}'`).join(','); + let excludePatterns = [...dirs, ...files] + .map(quotePowerShellString) + .join(','); if (excludePatterns) { // Use robocopy to copy files excluding patterns, then zip // This is more reliable than PowerShell's native filtering - await execAsync( - `powershell -Command "` + - `$src = '${safeSrcDir}'; ` + - `$dst = '${safeOutPath}'; ` + - `$exclude = @(${excludePatterns}); ` + - `$items = Get-ChildItem -Path $src -Recurse -File | Where-Object { ` + - `$rel = $_.FullName.Substring($src.Length + 1); ` + - `$dominated = $false; ` + - `foreach ($ex in $exclude) { if ($rel -like $ex -or $rel -like \\"$ex/*\\" -or $_.Name -like $ex) { $dominated = $true; break } }; ` + - `-not $dominated ` + - `}; ` + - `if ($items) { $items | Compress-Archive -DestinationPath $dst -Force }"`, + await execFileAsync( + 'powershell', + [ + '-Command', + `$src = ${safeSrcDir}; ` + + `$dst = ${safeOutPath}; ` + + `$exclude = @(${excludePatterns}); ` + + `$items = Get-ChildItem -Path $src -Recurse -File | Where-Object { ` + + `$rel = $_.FullName.Substring($src.Length + 1); ` + + `$dominated = $false; ` + + `foreach ($ex in $exclude) { if ($rel -like $ex -or $rel -like "$ex/*" -or $_.Name -like $ex) { $dominated = $true; break } }; ` + + `-not $dominated ` + + `}; ` + + `if ($items) { $items | Compress-Archive -DestinationPath $dst -Force }`, + ], { maxBuffer: 1024 * 1024 * 100 } ); } else { - await execAsync( - `powershell -Command "Compress-Archive -LiteralPath '${safeSrcDir}\\*' -DestinationPath '${safeOutPath}' -Force"`, + await execFileAsync( + 'powershell', + [ + '-Command', + `Compress-Archive -LiteralPath ${quotePowerShellString(`${sourceDir}\\*`)} -DestinationPath ${safeOutPath} -Force`, + ], { maxBuffer: 1024 * 1024 * 100 } ); } @@ -244,7 +227,6 @@ async function createZipWithSystem(sourceDir, outputPath, exclusions = {}) { * @returns {Promise<{ count: number, totalSize: number, files?: Array<{path: string, size: number}> }>} */ async function countFiles(dir, options = {}) { - let { readdir } = await import('node:fs/promises'); let { collectPaths = false, excludedDirs = DEFAULT_EXCLUDED_DIRS, @@ -385,15 +367,6 @@ export async function previewCommand( let buildId = options.build; let buildSource = 'flag'; - if (!buildId && options.parallelId) { - // TODO: Look up build by parallel ID - output.error( - 'Parallel ID lookup not yet implemented. Use --build to specify build ID directly.' - ); - exit(1); - return { success: false, reason: 'parallel-id-not-implemented' }; - } - if (!buildId) { // Try to read from session let currentBranch = await detectBranch(); @@ -654,11 +627,10 @@ export async function previewCommand( await unlink(zipPath).catch(() => {}); } - let compressionRatio = ((1 - zipBuffer.length / totalSize) * 100).toFixed( - 0 - ); + let compressionRatio = 1 - zipBuffer.length / totalSize; + let compressionPercent = Math.round(compressionRatio * 100); output.updateSpinner( - `Compressed to ${formatBytes(zipBuffer.length)} (${compressionRatio}% smaller)` + `Compressed to ${formatBytes(zipBuffer.length)} (${compressionPercent}% smaller)` ); // Upload (reuse client created earlier) @@ -675,7 +647,7 @@ export async function previewCommand( files: result.uploaded || fileCount, bytes: totalSize, compressedBytes: zipBuffer.length, - compressionRatio: parseFloat(compressionRatio) / 100, + compressionRatio, newBytes: result.newBytes, reusedBlobs: result.reusedBlobs || 0, deduplicationRatio: result.deduplicationRatio, diff --git a/src/commands/run.js b/src/commands/run.js index 18ba2383..3185613b 100644 --- a/src/commands/run.js +++ b/src/commands/run.js @@ -14,11 +14,12 @@ import { import { VizzlyError } from '../errors/vizzly-error.js'; import { createServerManager as defaultCreateServerManager } from '../server-manager/index.js'; import { createBuildObject as defaultCreateBuildObject } from '../services/build-manager.js'; -import { createUploader as defaultCreateUploader } from '../uploader/index.js'; import { finalizeBuild as defaultFinalizeBuild, runTests as defaultRunTests, } from '../test-runner/index.js'; +import { createUploader as defaultCreateUploader } from '../uploader/index.js'; +import { getAppBaseUrl } from '../utils/api-url.js'; import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js'; import { detectBranch as defaultDetectBranch, @@ -30,6 +31,39 @@ import { import * as defaultOutput from '../utils/output.js'; import { writeSession as defaultWriteSession } from '../utils/session.js'; +export async function resolveBuildDisplayUrl({ + result, + config, + createApiClient = defaultCreateApiClient, + getTokenContext = defaultGetTokenContext, +}) { + if (result.url) { + return result.url; + } + + if (!config.apiKey) { + return undefined; + } + + let baseUrl = getAppBaseUrl(config.apiUrl); + + try { + let client = createApiClient({ + baseUrl: config.apiUrl, + token: config.apiKey, + command: 'run', + }); + let tokenContext = await getTokenContext(client); + if (tokenContext.organization?.slug && tokenContext.project?.slug) { + return `${baseUrl}/${tokenContext.organization.slug}/${tokenContext.project.slug}/builds/${result.buildId}`; + } + } catch { + return `${baseUrl}/builds/${result.buildId}`; + } + + return undefined; +} + /** * Run command implementation * @param {string} testCommand - Test command to execute @@ -311,27 +345,12 @@ export async function runCommand( // JSON output mode - output structured data and exit if (globalOptions.json) { let executionTimeMs = Date.now() - startTime; - - // Get URL from result, or construct one as fallback - let displayUrl = result.url; - if (!displayUrl && config.apiKey) { - try { - let client = createApiClient({ - baseUrl: config.apiUrl, - token: config.apiKey, - command: 'run', - }); - let tokenContext = await getTokenContext(client); - let baseUrl = config.apiUrl.replace(/\/api.*$/, ''); - if (tokenContext.organization?.slug && tokenContext.project?.slug) { - displayUrl = `${baseUrl}/${tokenContext.organization.slug}/${tokenContext.project.slug}/builds/${result.buildId}`; - } - } catch { - // Fallback to simple URL if context fetch fails - let baseUrl = config.apiUrl.replace(/\/api.*$/, ''); - displayUrl = `${baseUrl}/builds/${result.buildId}`; - } - } + let displayUrl = await resolveBuildDisplayUrl({ + result, + config, + createApiClient, + getTokenContext, + }); let jsonResult = { buildId: result.buildId, @@ -362,26 +381,12 @@ export async function runCommand( ` ${colors.brand.textTertiary('Screenshots')} ${colors.white(result.screenshotsCaptured)}` ); - // Get URL from result, or construct one as fallback - let displayUrl = result.url; - if (!displayUrl && config.apiKey) { - try { - let client = createApiClient({ - baseUrl: config.apiUrl, - token: config.apiKey, - command: 'run', - }); - let tokenContext = await getTokenContext(client); - let baseUrl = config.apiUrl.replace(/\/api.*$/, ''); - if (tokenContext.organization?.slug && tokenContext.project?.slug) { - displayUrl = `${baseUrl}/${tokenContext.organization.slug}/${tokenContext.project.slug}/builds/${result.buildId}`; - } - } catch { - // Fallback to simple URL if context fetch fails - let baseUrl = config.apiUrl.replace(/\/api.*$/, ''); - displayUrl = `${baseUrl}/builds/${result.buildId}`; - } - } + let displayUrl = await resolveBuildDisplayUrl({ + result, + config, + createApiClient, + getTokenContext, + }); if (displayUrl) { output.print( @@ -404,7 +409,7 @@ export async function runCommand( ) { // Extract exit code from error message if available let exitCodeMatch = error.message.match(/exited with code (\d+)/); - let exitCode = exitCodeMatch ? parseInt(exitCodeMatch[1], 10) : 1; + let exitCode = exitCodeMatch ? Number(exitCodeMatch[1]) : 1; // JSON output for test command failure if (globalOptions.json) { @@ -462,29 +467,12 @@ export async function runCommand( // JSON output for --wait mode if (globalOptions.json) { let executionTimeMs = Date.now() - startTime; - - // Get URL from result, or construct one as fallback - let displayUrl = result.url; - if (!displayUrl && config.apiKey) { - try { - let client = createApiClient({ - baseUrl: config.apiUrl, - token: config.apiKey, - command: 'run', - }); - let tokenContext = await getTokenContext(client); - let baseUrl = config.apiUrl.replace(/\/api.*$/, ''); - if ( - tokenContext.organization?.slug && - tokenContext.project?.slug - ) { - displayUrl = `${baseUrl}/${tokenContext.organization.slug}/${tokenContext.project.slug}/builds/${result.buildId}`; - } - } catch { - let baseUrl = config.apiUrl.replace(/\/api.*$/, ''); - displayUrl = `${baseUrl}/builds/${result.buildId}`; - } - } + let displayUrl = await resolveBuildDisplayUrl({ + result, + config, + createApiClient, + getTokenContext, + }); let exitCode = buildResult.failedComparisons > 0 ? 1 : 0; let jsonResult = { @@ -573,32 +561,48 @@ export function validateRunOptions(testCommand, options) { } if (options.port) { - let port = parseInt(options.port, 10); - if (Number.isNaN(port) || port < 1 || port > 65535) { + let port = Number(options.port); + if (!Number.isInteger(port) || port < 1 || port > 65535) { errors.push('Port must be a valid number between 1 and 65535'); } } if (options.timeout) { - let timeout = parseInt(options.timeout, 10); - if (Number.isNaN(timeout) || timeout < 1000) { + let timeout = Number(options.timeout); + if (!Number.isInteger(timeout) || timeout < 1000) { errors.push('Timeout must be at least 1000 milliseconds'); } } if (options.batchSize !== undefined) { - let n = parseInt(options.batchSize, 10); - if (!Number.isFinite(n) || n <= 0) { + let n = Number(options.batchSize); + if (!Number.isInteger(n) || n <= 0) { errors.push('Batch size must be a positive integer'); } } if (options.uploadTimeout !== undefined) { - let n = parseInt(options.uploadTimeout, 10); - if (!Number.isFinite(n) || n <= 0) { + let n = Number(options.uploadTimeout); + if (!Number.isInteger(n) || n <= 0) { errors.push('Upload timeout must be a positive integer (milliseconds)'); } } + if (options.threshold !== undefined) { + let threshold = Number(options.threshold); + if (!Number.isFinite(threshold) || threshold < 0) { + errors.push( + 'Threshold must be a non-negative number (CIEDE2000 Delta E)' + ); + } + } + + if (options.minClusterSize !== undefined) { + let minClusterSize = Number(options.minClusterSize); + if (!Number.isInteger(minClusterSize) || minClusterSize < 1) { + errors.push('Min cluster size must be a positive integer'); + } + } + return errors; } diff --git a/src/commands/status.js b/src/commands/status.js index 5d3028cc..abff6c39 100644 --- a/src/commands/status.js +++ b/src/commands/status.js @@ -8,10 +8,249 @@ import { getBuild as defaultGetBuild, getPreviewInfo as defaultGetPreviewInfo, } from '../api/index.js'; +import { getAppBaseUrl } from '../utils/api-url.js'; import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js'; import { getApiUrl as defaultGetApiUrl } from '../utils/environment-config.js'; import * as defaultOutput from '../utils/output.js'; +function createStatusDeps(deps = {}) { + return { + loadConfig: deps.loadConfig || defaultLoadConfig, + createApiClient: deps.createApiClient || defaultCreateApiClient, + getBuild: deps.getBuild || defaultGetBuild, + getPreviewInfo: deps.getPreviewInfo || defaultGetPreviewInfo, + getApiUrl: deps.getApiUrl || defaultGetApiUrl, + output: deps.output || defaultOutput, + exit: deps.exit || (code => process.exit(code)), + }; +} + +function configureOutput(output, globalOptions) { + output.configure({ + json: globalOptions.json, + verbose: globalOptions.verbose, + color: !globalOptions.noColor, + }); +} + +function createStatusClient({ createApiClient, config }) { + return createApiClient({ + baseUrl: config.apiUrl, + token: config.apiKey, + command: 'status', + }); +} + +export function normalizeBuildStatus(buildStatus) { + return buildStatus.build || buildStatus; +} + +export function createStatusData(build, previewInfo = null) { + return { + buildId: build.id, + status: build.status, + name: build.name, + createdAt: build.created_at, + updatedAt: build.updated_at, + completedAt: build.completed_at, + environment: build.environment, + branch: build.branch, + commit: build.commit_sha, + commitMessage: build.commit_message, + screenshotsTotal: build.screenshot_count || 0, + comparisonsTotal: build.total_comparisons || 0, + newComparisons: build.new_comparisons || 0, + changedComparisons: build.changed_comparisons || 0, + identicalComparisons: build.identical_comparisons || 0, + approvalStatus: build.approval_status, + executionTime: build.execution_time_ms, + isBaseline: build.is_baseline, + userAgent: build.user_agent, + preview: previewInfo + ? { + url: previewInfo.preview_url, + status: previewInfo.status, + fileCount: previewInfo.file_count, + expiresAt: previewInfo.expires_at, + } + : null, + }; +} + +export function createBuildInfo(build) { + let buildInfo = { + Name: build.name || build.id, + Status: build.status.toUpperCase(), + Environment: build.environment, + }; + + if (build.branch) { + buildInfo.Branch = build.branch; + } + + if (build.commit_sha) { + buildInfo.Commit = `${build.commit_sha.substring(0, 8)} - ${build.commit_message || 'No message'}`; + } + + return buildInfo; +} + +export function createComparisonStats(build, colors) { + let stats = []; + let newCount = build.new_comparisons || 0; + let changedCount = build.changed_comparisons || 0; + let identicalCount = build.identical_comparisons || 0; + + if (newCount > 0) { + stats.push(`${colors.brand.info(newCount)} new`); + } + if (changedCount > 0) { + stats.push(`${colors.brand.warning(changedCount)} changed`); + } + if (identicalCount > 0) { + stats.push(`${colors.brand.success(identicalCount)} identical`); + } + + return stats.join(colors.brand.textMuted(' · ')); +} + +export function createBuildUrl(baseUrl, build) { + if (!baseUrl || !build.project_id) { + return null; + } + + return `${getAppBaseUrl(baseUrl)}/projects/${build.project_id}/builds/${build.id}`; +} + +export function shouldFailStatus(build) { + return build.status === 'failed' || build.failed_jobs > 0; +} + +export function getProcessingProgress(build) { + if (build.status !== 'processing' && build.status !== 'pending') { + return null; + } + + let completedJobs = build.completed_jobs || 0; + let failedJobs = build.failed_jobs || 0; + let processingScreenshots = build.processing_screenshots || 0; + let totalJobs = completedJobs + failedJobs + processingScreenshots; + + if (totalJobs <= 0) { + return null; + } + + return ((completedJobs + failedJobs) / totalJobs) * 100; +} + +function writeStatusTiming({ build, output }) { + if (build.created_at) { + output.hint(`Created ${new Date(build.created_at).toLocaleString()}`); + } + + if (build.completed_at) { + output.hint(`Completed ${new Date(build.completed_at).toLocaleString()}`); + } else if (build.status !== 'completed' && build.status !== 'failed') { + output.hint( + `Started ${new Date(build.started_at || build.created_at).toLocaleString()}` + ); + } + + if (build.execution_time_ms) { + output.hint(`Took ${Math.round(build.execution_time_ms / 1000)}s`); + } +} + +function createVerboseInfo(build) { + let verboseInfo = {}; + + if ( + build.approved_screenshots > 0 || + build.rejected_screenshots > 0 || + build.pending_screenshots > 0 + ) { + verboseInfo.Approvals = `${build.approved_screenshots || 0} approved, ${build.rejected_screenshots || 0} rejected, ${build.pending_screenshots || 0} pending`; + } + + if (build.avg_diff_percentage != null) { + verboseInfo['Avg Diff'] = + `${(build.avg_diff_percentage * 100).toFixed(2)}%`; + } + + if (build.github_pull_request_number) { + verboseInfo['GitHub PR'] = `#${build.github_pull_request_number}`; + } + + if (build.is_baseline) { + verboseInfo.Baseline = 'Yes'; + } + + verboseInfo['User Agent'] = build.user_agent || 'Unknown'; + verboseInfo['Build ID'] = build.id; + verboseInfo['Project ID'] = build.project_id; + + return verboseInfo; +} + +function writeHumanStatus({ + build, + buildUrl, + globalOptions, + output, + previewInfo, +}) { + output.header('status', build.status); + output.keyValue(createBuildInfo(build)); + output.blank(); + + let colors = output.getColors(); + let comparisonStats = createComparisonStats(build, colors); + + output.labelValue('Screenshots', String(build.screenshot_count || 0)); + + if (comparisonStats) { + output.labelValue('Comparisons', comparisonStats); + } + + if (build.approval_status) { + output.labelValue('Approval', build.approval_status); + } + + output.blank(); + writeStatusTiming({ build, output }); + + if (buildUrl) { + output.blank(); + output.labelValue('View', output.link('Build', buildUrl)); + } + + if (previewInfo?.preview_url) { + output.labelValue( + 'Preview', + output.link('Preview', previewInfo.preview_url) + ); + if (previewInfo.expires_at) { + let expiresDate = new Date(previewInfo.expires_at); + output.hint(`Preview expires ${expiresDate.toLocaleDateString()}`); + } + } + + if (globalOptions.verbose) { + output.blank(); + output.divider(); + output.blank(); + output.keyValue(createVerboseInfo(build)); + } + + let progress = getProcessingProgress(build); + if (progress !== null) { + output.blank(); + output.print( + ` ${output.progressBar(progress, 100)} ${Math.round(progress)}%` + ); + } +} + /** * Status command implementation * @param {string} buildId - Build ID to check status for @@ -26,20 +265,16 @@ export async function statusCommand( deps = {} ) { let { - loadConfig = defaultLoadConfig, - createApiClient = defaultCreateApiClient, - getBuild = defaultGetBuild, - getPreviewInfo = defaultGetPreviewInfo, - getApiUrl = defaultGetApiUrl, - output = defaultOutput, - exit = code => process.exit(code), - } = deps; + loadConfig, + createApiClient, + getBuild, + getPreviewInfo, + getApiUrl, + output, + exit, + } = createStatusDeps(deps); - output.configure({ - json: globalOptions.json, - verbose: globalOptions.verbose, - color: !globalOptions.noColor, - }); + configureOutput(output, globalOptions); try { // Load configuration with CLI overrides @@ -51,16 +286,14 @@ export async function statusCommand( output.error( 'API token required. Use --token or set VIZZLY_TOKEN environment variable' ); + output.cleanup(); exit(1); + return { success: false, result: { reason: 'missing_token' } }; } // Get build details via functional API output.startSpinner('Fetching build status...'); - let client = createApiClient({ - baseUrl: config.apiUrl, - token: config.apiKey, - command: 'status', - }); + let client = createStatusClient({ createApiClient, config }); let buildStatus = await getBuild(client, buildId); // Also fetch preview info (if exists) @@ -68,193 +301,30 @@ export async function statusCommand( output.stopSpinner(); // Extract build data from API response - let build = buildStatus.build || buildStatus; + let build = normalizeBuildStatus(buildStatus); // Output in JSON mode if (globalOptions.json) { - let statusData = { - buildId: build.id, - status: build.status, - name: build.name, - createdAt: build.created_at, - updatedAt: build.updated_at, - completedAt: build.completed_at, - environment: build.environment, - branch: build.branch, - commit: build.commit_sha, - commitMessage: build.commit_message, - screenshotsTotal: build.screenshot_count || 0, - comparisonsTotal: build.total_comparisons || 0, - newComparisons: build.new_comparisons || 0, - changedComparisons: build.changed_comparisons || 0, - identicalComparisons: build.identical_comparisons || 0, - approvalStatus: build.approval_status, - executionTime: build.execution_time_ms, - isBaseline: build.is_baseline, - userAgent: build.user_agent, - preview: previewInfo - ? { - url: previewInfo.preview_url, - status: previewInfo.status, - fileCount: previewInfo.file_count, - expiresAt: previewInfo.expires_at, - } - : null, - }; - output.data(statusData); + output.data(createStatusData(build, previewInfo)); output.cleanup(); return; } // Human-readable output - output.header('status', build.status); - - // Build info section - let buildInfo = { - Name: build.name || build.id, - Status: build.status.toUpperCase(), - Environment: build.environment, - }; - - if (build.branch) { - buildInfo.Branch = build.branch; - } - - if (build.commit_sha) { - buildInfo.Commit = `${build.commit_sha.substring(0, 8)} - ${build.commit_message || 'No message'}`; - } - - output.keyValue(buildInfo); - output.blank(); - - // Comparison stats with visual indicators - let colors = output.getColors(); - let stats = []; - let newCount = build.new_comparisons || 0; - let changedCount = build.changed_comparisons || 0; - let identicalCount = build.identical_comparisons || 0; - let screenshotCount = build.screenshot_count || 0; - - output.labelValue('Screenshots', String(screenshotCount)); - - if (newCount > 0) { - stats.push(`${colors.brand.info(newCount)} new`); - } - if (changedCount > 0) { - stats.push(`${colors.brand.warning(changedCount)} changed`); - } - if (identicalCount > 0) { - stats.push(`${colors.brand.success(identicalCount)} identical`); - } - - if (stats.length > 0) { - output.labelValue( - 'Comparisons', - stats.join(colors.brand.textMuted(' · ')) - ); - } - - if (build.approval_status) { - output.labelValue('Approval', build.approval_status); - } - - output.blank(); - - // Timing info - if (build.created_at) { - output.hint(`Created ${new Date(build.created_at).toLocaleString()}`); - } - - if (build.completed_at) { - output.hint(`Completed ${new Date(build.completed_at).toLocaleString()}`); - } else if (build.status !== 'completed' && build.status !== 'failed') { - output.hint( - `Started ${new Date(build.started_at || build.created_at).toLocaleString()}` - ); - } - - if (build.execution_time_ms) { - output.hint(`Took ${Math.round(build.execution_time_ms / 1000)}s`); - } - // Show build URL if we can construct it let baseUrl = config.baseUrl || getApiUrl(); - if (baseUrl && build.project_id) { - let buildUrl = - baseUrl.replace('/api', '') + - `/projects/${build.project_id}/builds/${build.id}`; - output.blank(); - output.labelValue('View', output.link('Build', buildUrl)); - } - - // Show preview URL if available - if (previewInfo?.preview_url) { - output.labelValue( - 'Preview', - output.link('Preview', previewInfo.preview_url) - ); - if (previewInfo.expires_at) { - let expiresDate = new Date(previewInfo.expires_at); - output.hint(`Preview expires ${expiresDate.toLocaleDateString()}`); - } - } - - // Show additional info in verbose mode - if (globalOptions.verbose) { - output.blank(); - output.divider(); - output.blank(); - - let verboseInfo = {}; - - if ( - build.approved_screenshots > 0 || - build.rejected_screenshots > 0 || - build.pending_screenshots > 0 - ) { - verboseInfo.Approvals = `${build.approved_screenshots || 0} approved, ${build.rejected_screenshots || 0} rejected, ${build.pending_screenshots || 0} pending`; - } - - if (build.avg_diff_percentage !== null) { - verboseInfo['Avg Diff'] = - `${(build.avg_diff_percentage * 100).toFixed(2)}%`; - } - - if (build.github_pull_request_number) { - verboseInfo['GitHub PR'] = `#${build.github_pull_request_number}`; - } - - if (build.is_baseline) { - verboseInfo.Baseline = 'Yes'; - } - - verboseInfo['User Agent'] = build.user_agent || 'Unknown'; - verboseInfo['Build ID'] = build.id; - verboseInfo['Project ID'] = build.project_id; - - output.keyValue(verboseInfo); - } - - // Show progress if build is still processing - if (build.status === 'processing' || build.status === 'pending') { - let totalJobs = - build.completed_jobs + build.failed_jobs + build.processing_screenshots; - if (totalJobs > 0) { - let progress = (build.completed_jobs + build.failed_jobs) / totalJobs; - output.blank(); - output.print( - ` ${output.progressBar(progress * 100, 100)} ${Math.round(progress * 100)}%` - ); - } - } + let buildUrl = createBuildUrl(baseUrl, build); + writeHumanStatus({ build, buildUrl, globalOptions, output, previewInfo }); output.cleanup(); // Exit with appropriate code based on build status - if (build.status === 'failed' || build.failed_jobs > 0) { + if (shouldFailStatus(build)) { exit(1); } } catch (error) { + output.stopSpinner(); + // Don't fail CI for Vizzly infrastructure issues (5xx errors) let status = error.context?.status; if (status >= 500) { @@ -264,6 +334,7 @@ export async function statusCommand( } output.error('Failed to get build status', error); + output.cleanup(); exit(1); return { success: false, error }; } diff --git a/src/commands/tdd-daemon.js b/src/commands/tdd-daemon.js index d0d7904c..edda84ae 100644 --- a/src/commands/tdd-daemon.js +++ b/src/commands/tdd-daemon.js @@ -9,9 +9,376 @@ import { import { homedir } from 'node:os'; import { basename, join } from 'node:path'; import { getServerRegistry } from '../tdd/server-registry.js'; +import { withTimeout } from '../utils/async-utils.js'; import * as output from '../utils/output.js'; import { tddCommand } from './tdd.js'; +let defaultTimers = { setTimeout, clearTimeout }; + +export function getLocalDaemonFiles(directory = process.cwd()) { + let vizzlyDir = join(directory, '.vizzly'); + return { + vizzlyDir, + pidFile: join(vizzlyDir, 'server.pid'), + serverFile: join(vizzlyDir, 'server.json'), + logFile: join(vizzlyDir, 'server.log'), + }; +} + +export function removeFileIfExists(filePath, deps = {}) { + let fileExists = deps.existsSync || existsSync; + let unlinkFile = deps.unlinkSync || unlinkSync; + + if (fileExists(filePath)) { + unlinkFile(filePath); + return true; + } + + return false; +} + +export function cleanupLocalDaemonFiles(directory = process.cwd(), deps = {}) { + let { pidFile, serverFile } = getLocalDaemonFiles(directory); + return { + pidFileRemoved: removeFileIfExists(pidFile, deps), + serverFileRemoved: removeFileIfExists(serverFile, deps), + }; +} + +export function buildLegacyServerInfo({ pid, port, now = Date.now }) { + return { + pid, + port: port.toString(), + startTime: now(), + }; +} + +export function writeLegacyGlobalServerFile( + { pid, port }, + { + home = homedir, + exists = existsSync, + mkdir = mkdirSync, + writeFile = writeFileSync, + now = Date.now, + } = {} +) { + let globalVizzlyDir = join(home(), '.vizzly'); + if (!exists(globalVizzlyDir)) { + mkdir(globalVizzlyDir, { recursive: true }); + } + + let globalServerFile = join(globalVizzlyDir, 'server.json'); + let serverInfo = buildLegacyServerInfo({ pid, port, now }); + writeFile(globalServerFile, JSON.stringify(serverInfo, null, 2)); + return { path: globalServerFile, serverInfo }; +} + +export function cleanupLegacyGlobalServerFile({ + home = homedir, + exists = existsSync, + unlink = unlinkSync, +} = {}) { + let globalServerFile = join(home(), '.vizzly', 'server.json'); + return removeFileIfExists(globalServerFile, { + existsSync: exists, + unlinkSync: unlink, + }); +} + +export function unregisterDaemonServer({ + port, + directory = process.cwd(), + registry = getServerRegistry(), +}) { + registry.unregister({ port, directory }); +} + +export function cleanupDaemonState({ + port, + directory = process.cwd(), + registry = getServerRegistry(), + localFileDeps = {}, + legacyFileDeps = {}, +} = {}) { + let localFiles = cleanupLocalDaemonFiles(directory, localFileDeps); + let legacyGlobalServerFileRemoved = + cleanupLegacyGlobalServerFile(legacyFileDeps); + + try { + if (port !== undefined) { + unregisterDaemonServer({ port, directory, registry }); + } else { + registry.unregister({ directory }); + } + } catch { + // Non-fatal; stale file cleanup is still useful on its own. + } + + return { + ...localFiles, + legacyGlobalServerFileRemoved, + }; +} + +export function buildDashboardUrl(port = 47392) { + return `http://localhost:${port}`; +} + +export function buildOpenDashboardCommand(url, platform = process.platform) { + if (platform === 'darwin') { + return { command: 'open', args: [url] }; + } + + if (platform === 'win32') { + return { command: 'cmd', args: ['/c', 'start', '', url] }; + } + + return { command: 'xdg-open', args: [url] }; +} + +function wait(ms, timers = defaultTimers) { + return new Promise(resolve => { + timers.setTimeout(resolve, ms); + }); +} + +function isProcessRunning(pid) { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function parsePositiveInteger(value) { + let text = String(value).trim(); + if (!/^\d+$/.test(text)) { + return null; + } + + let number = Number(text); + return Number.isSafeInteger(number) && number > 0 ? number : null; +} + +export function readDaemonPidFile(pidFile, deps = {}) { + let fileExists = deps.existsSync || existsSync; + let readFile = deps.readFileSync || readFileSync; + + if (!fileExists(pidFile)) { + return null; + } + + try { + return parsePositiveInteger(readFile(pidFile, 'utf8')); + } catch { + return null; + } +} + +export async function findDaemonPidByPort(port, { spawnProcess = spawn } = {}) { + try { + let lsofProcess = spawnProcess('lsof', ['-ti', `:${port}`], { + stdio: 'pipe', + }); + + let lsofOutput = ''; + lsofProcess.stdout.on('data', data => { + lsofOutput += data.toString(); + }); + + return await new Promise(resolve => { + lsofProcess.on('close', code => { + if (code === 0 && lsofOutput.trim()) { + let foundPid = parsePositiveInteger(lsofOutput.trim().split('\n')[0]); + resolve(foundPid); + return; + } + + resolve(null); + }); + + lsofProcess.on('error', () => { + resolve(null); + }); + }); + } catch { + return null; + } +} + +export async function resolveDaemonPid({ + port, + pidFile = getLocalDaemonFiles().pidFile, + readPid = readDaemonPidFile, + findByPort = findDaemonPidByPort, + fileDeps = {}, +} = {}) { + let pid = readPid(pidFile, fileDeps); + if (pid) { + return pid; + } + + return await findByPort(port); +} + +export function buildDaemonChildArgs({ + entrypoint = process.argv[1], + port, + options = {}, + globalOptions = {}, +}) { + return [ + entrypoint, + 'tdd', + 'start', + '--daemon-child', + '--port', + port.toString(), + ...(options.open ? ['--open'] : []), + ...(options.baselineBuild + ? ['--baseline-build', options.baselineBuild] + : []), + ...(options.baselineComparison + ? ['--baseline-comparison', options.baselineComparison] + : []), + ...(options.environment ? ['--environment', options.environment] : []), + ...(options.threshold !== undefined + ? ['--threshold', options.threshold.toString()] + : []), + ...(options.minClusterSize !== undefined + ? ['--min-cluster-size', options.minClusterSize.toString()] + : []), + ...(options.timeout ? ['--timeout', options.timeout] : []), + ...(options.failOnDiff ? ['--fail-on-diff'] : []), + ...(options.token ? ['--token', options.token] : []), + ...(globalOptions.json ? ['--json'] : []), + ...(globalOptions.verbose ? ['--verbose'] : []), + ...(globalOptions.noColor ? ['--no-color'] : []), + ]; +} + +export function validateTddStartOptions(options = {}) { + let errors = []; + + if (options.port) { + let port = Number(options.port); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + errors.push('Port must be a valid number between 1 and 65535'); + } + } + + if (options.timeout) { + let timeout = Number(options.timeout); + if (!Number.isInteger(timeout) || timeout < 1000) { + errors.push('Timeout must be at least 1000 milliseconds'); + } + } + + if (options.threshold !== undefined) { + let threshold = Number(options.threshold); + if (!Number.isFinite(threshold) || threshold < 0) { + errors.push( + 'Threshold must be a non-negative number (CIEDE2000 Delta E)' + ); + } + } + + if (options.minClusterSize !== undefined) { + let minClusterSize = Number(options.minClusterSize); + if (!Number.isInteger(minClusterSize) || minClusterSize < 1) { + errors.push('Min cluster size must be a positive integer'); + } + } + + return errors; +} + +export async function waitForDaemonChildInit( + child, + { timeoutMs = 30000, timers = defaultTimers } = {} +) { + let cleanup = () => {}; + let initPromise = new Promise(resolve => { + let handleDisconnect = () => { + cleanup(); + resolve({ ok: true }); + }; + + let handleExit = () => { + cleanup(); + resolve({ ok: false, reason: 'exit' }); + }; + + cleanup = () => { + child.off('disconnect', handleDisconnect); + child.off('exit', handleExit); + }; + + child.on('disconnect', handleDisconnect); + child.on('exit', handleExit); + }); + + try { + return await withTimeout( + initPromise, + timeoutMs, + 'TDD server initialization timed out', + timers + ); + } catch (error) { + cleanup(); + return { ok: false, reason: 'timeout', error }; + } +} + +export async function waitForServerRunning( + port, + { + maxAttempts = 10, + delayMs = 200, + isRunning = isServerRunning, + timers = defaultTimers, + } = {} +) { + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (await isRunning(port)) { + return true; + } + + if (attempt < maxAttempts - 1) { + await wait(delayMs * (attempt + 1), timers); + } + } + + return false; +} + +export async function waitForProcessExit( + pid, + { + timeoutMs = 2000, + intervalMs = 100, + processRunning = isProcessRunning, + timers = defaultTimers, + } = {} +) { + let elapsedMs = 0; + + while (elapsedMs < timeoutMs) { + if (!processRunning(pid)) { + return true; + } + + let nextDelay = Math.min(intervalMs, timeoutMs - elapsedMs); + await wait(nextDelay, timers); + elapsedMs += nextDelay; + } + + return !processRunning(pid); +} + /** * Start TDD server in daemon mode * @param {Object} options - Command options @@ -38,7 +405,7 @@ export async function tddStartCommand(options = {}, globalOptions = {}) { status: 'already_running', port: existingServer.port, pid: existingServer.pid, - dashboardUrl: `http://localhost:${existingServer.port}`, + dashboardUrl: buildDashboardUrl(existingServer.port), }); return; } @@ -62,13 +429,7 @@ export async function tddStartCommand(options = {}, globalOptions = {}) { return; } else { // Stale entry - clean it up (registry and local files) - registry.unregister({ directory: process.cwd() }); - - let vizzlyDir = join(process.cwd(), '.vizzly'); - let pidFile = join(vizzlyDir, 'server.pid'); - let serverFile = join(vizzlyDir, 'server.json'); - if (existsSync(pidFile)) unlinkSync(pidFile); - if (existsSync(serverFile)) unlinkSync(serverFile); + cleanupDaemonState({ directory: process.cwd(), registry }); } } @@ -102,7 +463,7 @@ export async function tddStartCommand(options = {}, globalOptions = {}) { try { // Ensure .vizzly directory exists - let vizzlyDir = join(process.cwd(), '.vizzly'); + let { vizzlyDir } = getLocalDaemonFiles(); if (!existsSync(vizzlyDir)) { mkdirSync(vizzlyDir, { recursive: true }); } @@ -118,33 +479,9 @@ export async function tddStartCommand(options = {}, globalOptions = {}) { } // Spawn child process with stdio inherited during init for direct error visibility - const child = spawn( + let child = spawn( process.execPath, - [ - process.argv[1], // CLI entry point - 'tdd', - 'start', - '--daemon-child', // Special flag for child process - '--port', - port.toString(), - ...(options.open ? ['--open'] : []), - ...(options.baselineBuild - ? ['--baseline-build', options.baselineBuild] - : []), - ...(options.baselineComparison - ? ['--baseline-comparison', options.baselineComparison] - : []), - ...(options.environment ? ['--environment', options.environment] : []), - ...(options.threshold !== undefined - ? ['--threshold', options.threshold.toString()] - : []), - ...(options.timeout ? ['--timeout', options.timeout] : []), - ...(options.failOnDiff ? ['--fail-on-diff'] : []), - ...(options.token ? ['--token', options.token] : []), - ...(globalOptions.json ? ['--json'] : []), - ...(globalOptions.verbose ? ['--verbose'] : []), - ...(globalOptions.noColor ? ['--no-color'] : []), - ], + buildDaemonChildArgs({ port, options, globalOptions }), { detached: true, stdio: ['ignore', 'inherit', 'inherit', 'ipc'], @@ -152,39 +489,9 @@ export async function tddStartCommand(options = {}, globalOptions = {}) { } ); - // Wait for child to signal successful init or exit with error - let initComplete = false; - let initFailed = false; - - await new Promise(resolve => { - // Child disconnects IPC when initialization succeeds - child.on('disconnect', () => { - initComplete = true; - resolve(); - }); - - // Child exits before disconnecting = initialization failed - child.on('exit', () => { - if (!initComplete) { - initFailed = true; - resolve(); - } - }); + let initResult = await waitForDaemonChildInit(child); - // Timeout after 30 seconds to prevent indefinite wait - const timeoutId = setTimeout(() => { - if (!initComplete && !initFailed) { - initFailed = true; - resolve(); - } - }, 30000); - - // Clear timeout if we resolve early - child.on('disconnect', () => clearTimeout(timeoutId)); - child.on('exit', () => clearTimeout(timeoutId)); - }); - - if (initFailed) { + if (!initResult.ok) { if (options.baselineBuild && !globalOptions.verbose) { output.stopSpinner(); } @@ -196,14 +503,7 @@ export async function tddStartCommand(options = {}, globalOptions = {}) { child.unref(); // Verify server started with retries - const maxRetries = 10; - const retryDelay = 200; // Start with 200ms - let running = false; - - for (let i = 0; i < maxRetries && !running; i++) { - await new Promise(resolve => setTimeout(resolve, retryDelay * (i + 1))); - running = await isServerRunning(port); - } + let running = await waitForServerRunning(port); if (options.baselineBuild && !globalOptions.verbose) { output.stopSpinner(); @@ -224,14 +524,14 @@ export async function tddStartCommand(options = {}, globalOptions = {}) { registry.cleanupStale(); // Register this server with log file path for menubar to read - let serverLogFile = join(process.cwd(), '.vizzly', 'server.log'); + let { logFile } = getLocalDaemonFiles(); registry.register({ pid: child.pid, port: port, directory: process.cwd(), name: basename(process.cwd()), startedAt: new Date().toISOString(), - logFile: serverLogFile, + logFile, }); } catch { // Non-fatal @@ -239,23 +539,13 @@ export async function tddStartCommand(options = {}, globalOptions = {}) { // Also write legacy server.json for SDK discovery (backwards compatibility) try { - const globalVizzlyDir = join(homedir(), '.vizzly'); - if (!existsSync(globalVizzlyDir)) { - mkdirSync(globalVizzlyDir, { recursive: true }); - } - const globalServerFile = join(globalVizzlyDir, 'server.json'); - const serverInfo = { - pid: child.pid, - port: port.toString(), - startTime: Date.now(), - }; - writeFileSync(globalServerFile, JSON.stringify(serverInfo, null, 2)); + writeLegacyGlobalServerFile({ pid: child.pid, port }); } catch { // Non-fatal, SDK can still use health check } // JSON output for successful start - let dashboardUrl = `http://localhost:${port}`; + let dashboardUrl = buildDashboardUrl(port); if (globalOptions.json) { output.data({ status: 'started', @@ -317,11 +607,8 @@ export async function tddStartCommand(options = {}, globalOptions = {}) { * @private */ export async function runDaemonChild(options = {}, globalOptions = {}) { - const vizzlyDir = join(process.cwd(), '.vizzly'); - const port = options.port || 47392; - - // Set up log file for menubar app to read - const logFile = join(vizzlyDir, 'server.log'); + let { pidFile, serverFile, logFile } = getLocalDaemonFiles(); + let port = options.port || 47392; // Configure output to write JSON logs to file (before tddCommand configures it) output.configure({ @@ -333,7 +620,7 @@ export async function runDaemonChild(options = {}, globalOptions = {}) { try { // Use existing tddCommand but with daemon mode - const { cleanup } = await tddCommand( + let { cleanup } = await tddCommand( null, // No test command - server only { ...options, @@ -348,44 +635,21 @@ export async function runDaemonChild(options = {}, globalOptions = {}) { } // Store our PID for the stop command - const pidFile = join(vizzlyDir, 'server.pid'); writeFileSync(pidFile, process.pid.toString()); - const serverInfo = { + let serverInfo = { pid: process.pid, port: port, startTime: Date.now(), failOnDiff: options.failOnDiff || false, - logFile: logFile, + logFile, }; - writeFileSync( - join(vizzlyDir, 'server.json'), - JSON.stringify(serverInfo, null, 2) - ); + writeFileSync(serverFile, JSON.stringify(serverInfo, null, 2)); // Set up graceful shutdown - const handleShutdown = async () => { + let handleShutdown = async () => { try { - // Clean up PID files - if (existsSync(pidFile)) unlinkSync(pidFile); - const serverFile = join(vizzlyDir, 'server.json'); - if (existsSync(serverFile)) unlinkSync(serverFile); - - // Unregister from global registry (for menubar app) - try { - let registry = getServerRegistry(); - registry.unregister({ port: port, directory: process.cwd() }); - } catch { - // Non-fatal - } - - // Clean up legacy global server file - try { - const globalServerFile = join(homedir(), '.vizzly', 'server.json'); - if (existsSync(globalServerFile)) unlinkSync(globalServerFile); - } catch { - // Non-fatal - } + cleanupDaemonState({ port }); // Use the cleanup function from tddCommand await cleanup(); @@ -421,51 +685,8 @@ export async function tddStopCommand(options = {}, globalOptions = {}) { color: !globalOptions.noColor, }); - const vizzlyDir = join(process.cwd(), '.vizzly'); - const pidFile = join(vizzlyDir, 'server.pid'); - const serverFile = join(vizzlyDir, 'server.json'); - - // First try to find process by PID file - let pid = null; - if (existsSync(pidFile)) { - try { - pid = parseInt(readFileSync(pidFile, 'utf8').trim(), 10); - } catch { - // Invalid PID file - } - } - - // If no PID file or invalid, try to find by port using lsof - const port = options.port || 47392; - if (!pid) { - try { - const lsofProcess = spawn('lsof', ['-ti', `:${port}`], { stdio: 'pipe' }); - - let lsofOutput = ''; - lsofProcess.stdout.on('data', data => { - lsofOutput += data.toString(); - }); - - await new Promise(resolve => { - lsofProcess.on('close', code => { - if (code === 0 && lsofOutput.trim()) { - const foundPid = parseInt(lsofOutput.trim().split('\n')[0], 10); - if (foundPid && !Number.isNaN(foundPid)) { - pid = foundPid; - } - } - resolve(); - }); - - lsofProcess.on('error', () => { - // lsof not available, that's ok - resolve(); - }); - }); - } catch { - // lsof failed, that's ok too - } - } + let port = options.port || 47392; + let pid = await resolveDaemonPid({ port }); if (!pid) { // JSON output for not running @@ -479,45 +700,30 @@ export async function tddStopCommand(options = {}, globalOptions = {}) { } // Clean up any stale files - if (existsSync(pidFile)) unlinkSync(pidFile); - if (existsSync(serverFile)) unlinkSync(serverFile); + cleanupDaemonState({ port }); return; } try { - let _colors = output.getColors(); - // Try to kill the process gracefully process.kill(pid, 'SIGTERM'); output.startSpinner('Stopping TDD server...'); - // Give it a moment to shut down gracefully - await new Promise(resolve => setTimeout(resolve, 2000)); + let exited = await waitForProcessExit(pid); // Check if it's still running - try { - process.kill(pid, 0); // Just check if process exists - // If we get here, process is still running, force kill it + if (!exited) { process.kill(pid, 'SIGKILL'); output.stopSpinner(); output.debug('tdd', 'Force killed process'); - } catch { + } else { // Process is gone, which is what we want output.stopSpinner(); } // Clean up files - if (existsSync(pidFile)) unlinkSync(pidFile); - if (existsSync(serverFile)) unlinkSync(serverFile); - - // Unregister from global registry (for menubar app) - try { - let registry = getServerRegistry(); - registry.unregister({ port: port, directory: process.cwd() }); - } catch { - // Non-fatal - } + cleanupDaemonState({ port }); // JSON output for successful stop if (globalOptions.json) { @@ -534,16 +740,7 @@ export async function tddStopCommand(options = {}, globalOptions = {}) { if (error.code === 'ESRCH') { // Process not found - clean up stale files output.warn('TDD server was not running (cleaning up stale files)'); - if (existsSync(pidFile)) unlinkSync(pidFile); - if (existsSync(serverFile)) unlinkSync(serverFile); - - // Still unregister from registry - try { - let registry = getServerRegistry(); - registry.unregister({ port: port, directory: process.cwd() }); - } catch { - // Non-fatal - } + cleanupDaemonState({ port }); } else { output.error('Error stopping TDD server', error); } @@ -562,9 +759,7 @@ export async function tddStatusCommand(_options, globalOptions = {}) { color: !globalOptions.noColor, }); - const vizzlyDir = join(process.cwd(), '.vizzly'); - const pidFile = join(vizzlyDir, 'server.pid'); - const serverFile = join(vizzlyDir, 'server.json'); + let { pidFile, serverFile } = getLocalDaemonFiles(); if (!existsSync(pidFile)) { // JSON output for not running @@ -580,7 +775,12 @@ export async function tddStatusCommand(_options, globalOptions = {}) { } try { - const pid = parseInt(readFileSync(pidFile, 'utf8').trim(), 10); + let pid = parsePositiveInteger(readFileSync(pidFile, 'utf8')); + if (!pid) { + output.warn('TDD server pid file is invalid (cleaning up stale files)'); + cleanupDaemonState(); + return; + } // Check if process is actually running process.kill(pid, 0); // Signal 0 just checks if process exists @@ -591,7 +791,7 @@ export async function tddStatusCommand(_options, globalOptions = {}) { } // Try to check health endpoint - const health = await checkServerHealth(serverInfo.port); + let health = await checkServerHealth(serverInfo.port); if (health.running) { // Calculate uptime @@ -599,16 +799,16 @@ export async function tddStatusCommand(_options, globalOptions = {}) { let uptimeStr = ''; if (serverInfo.startTime) { uptimeMs = Date.now() - serverInfo.startTime; - const uptime = Math.floor(uptimeMs / 1000); - const hours = Math.floor(uptime / 3600); - const minutes = Math.floor((uptime % 3600) / 60); - const seconds = uptime % 60; + let uptime = Math.floor(uptimeMs / 1000); + let hours = Math.floor(uptime / 3600); + let minutes = Math.floor((uptime % 3600) / 60); + let seconds = uptime % 60; if (hours > 0) uptimeStr += `${hours}h `; if (minutes > 0 || hours > 0) uptimeStr += `${minutes}m `; uptimeStr += `${seconds}s`; } - let dashboardUrl = `http://localhost:${serverInfo.port}`; + let dashboardUrl = buildDashboardUrl(serverInfo.port); // JSON output for running status if (globalOptions.json) { @@ -653,10 +853,7 @@ export async function tddStatusCommand(_options, globalOptions = {}) { } catch (error) { if (error.code === 'ESRCH') { output.warn('TDD server process not found (cleaning up stale files)'); - unlinkSync(pidFile); - if (existsSync(serverFile)) { - unlinkSync(serverFile); - } + cleanupDaemonState(); } else { output.error('Error checking TDD server status', error); } @@ -669,7 +866,7 @@ export async function tddStatusCommand(_options, globalOptions = {}) { */ async function isServerRunning(port = 47392) { try { - const health = await checkServerHealth(port); + let health = await checkServerHealth(port); return health.running; } catch { return false; @@ -682,8 +879,8 @@ async function isServerRunning(port = 47392) { */ async function checkServerHealth(port = 47392) { try { - const response = await fetch(`http://localhost:${port}/health`); - const data = await response.json(); + let response = await fetch(`http://localhost:${port}/health`); + let data = await response.json(); return { running: response.ok, port: data.port, @@ -699,19 +896,10 @@ async function checkServerHealth(port = 47392) { * @private */ function openDashboard(port = 47392) { - const url = `http://localhost:${port}`; - - // Cross-platform open command - let openCmd; - if (process.platform === 'darwin') { - openCmd = 'open'; - } else if (process.platform === 'win32') { - openCmd = 'start'; - } else { - openCmd = 'xdg-open'; - } + let url = buildDashboardUrl(port); + let { command, args } = buildOpenDashboardCommand(url); - spawn(openCmd, [url], { + spawn(command, args, { detached: true, stdio: 'ignore', }).unref(); diff --git a/src/commands/tdd.js b/src/commands/tdd.js index 5fa3751c..525c1174 100644 --- a/src/commands/tdd.js +++ b/src/commands/tdd.js @@ -113,7 +113,7 @@ export async function tddCommand( // Show config in verbose mode output.debug( 'config', - `port=${config.server.port} threshold=${config.comparison.threshold}` + `port=${config.server.port} threshold=${config.comparison.threshold} minClusterSize=${config.comparison.minClusterSize}` ); } @@ -153,6 +153,7 @@ export async function tddCommand( commit, environment: config.build.environment, threshold: config.comparison.threshold, + minClusterSize: config.comparison.minClusterSize, allowNoToken: config.allowNoToken || false, baselineBuildId: config.baselineBuildId, baselineComparisonId: config.baselineComparisonId, @@ -310,27 +311,34 @@ export function validateTddOptions(testCommand, options) { } if (options.port) { - let port = parseInt(options.port, 10); - if (Number.isNaN(port) || port < 1 || port > 65535) { + let port = Number(options.port); + if (!Number.isInteger(port) || port < 1 || port > 65535) { errors.push('Port must be a valid number between 1 and 65535'); } } if (options.timeout) { - let timeout = parseInt(options.timeout, 10); - if (Number.isNaN(timeout) || timeout < 1000) { + let timeout = Number(options.timeout); + if (!Number.isInteger(timeout) || timeout < 1000) { errors.push('Timeout must be at least 1000 milliseconds'); } } if (options.threshold !== undefined) { - let threshold = parseFloat(options.threshold); - if (Number.isNaN(threshold) || threshold < 0) { + let threshold = Number(options.threshold); + if (!Number.isFinite(threshold) || threshold < 0) { errors.push( 'Threshold must be a non-negative number (CIEDE2000 Delta E)' ); } } + if (options.minClusterSize !== undefined) { + let minClusterSize = Number(options.minClusterSize); + if (!Number.isInteger(minClusterSize) || minClusterSize < 1) { + errors.push('Min cluster size must be a positive integer'); + } + } + return errors; } diff --git a/src/index.js b/src/index.js index 3616064f..49cfb570 100644 --- a/src/index.js +++ b/src/index.js @@ -11,13 +11,24 @@ import 'dotenv/config'; // Client exports for convenience export { configure, setEnabled, vizzlyScreenshot } from './client/index.js'; // Errors -export { UploadError } from './errors/vizzly-error.js'; +export { + AuthError, + BuildError, + ConfigError, + NetworkError, + ScreenshotError, + TimeoutError, + UploadError, + ValidationError, + VizzlyError, +} from './errors/vizzly-error.js'; +export { createPluginServices } from './plugin-api.js'; // Primary SDK export -export { createVizzly } from './sdk/index.js'; +export { createVizzly, VizzlySDK } from './sdk/index.js'; export { createServices } from './services/index.js'; +export { createTDDService } from './tdd/tdd-service.js'; // Core services (for advanced usage) export { createUploader } from './uploader/index.js'; -export { createTDDService } from './tdd/tdd-service.js'; // Configuration helper export { defineConfig } from './utils/config-helpers.js'; // Utilities diff --git a/src/sdk/index.js b/src/sdk/index.js index fee2dc7e..92b99b45 100644 --- a/src/sdk/index.js +++ b/src/sdk/index.js @@ -18,8 +18,8 @@ import { startServer as startScreenshotServer, stopServer as stopScreenshotServer, } from '../screenshot-server/index.js'; -import { createUploader } from '../uploader/index.js'; import { createTDDService } from '../tdd/tdd-service.js'; +import { createUploader } from '../uploader/index.js'; import { loadConfig } from '../utils/config-loader.js'; import { resolveImageBuffer } from '../utils/file-helpers.js'; import * as output from '../utils/output.js'; @@ -448,9 +448,9 @@ export class VizzlySDK extends EventEmitter { } } +export { createTDDService } from '../tdd/tdd-service.js'; // Export service creators for advanced usage export { createUploader } from '../uploader/index.js'; -export { createTDDService } from '../tdd/tdd-service.js'; // Re-export key utilities and errors export { loadConfig } from '../utils/config-loader.js'; export * as output from '../utils/output.js'; diff --git a/src/services/build-manager.js b/src/services/build-manager.js index 1aba253d..eb5240d2 100644 --- a/src/services/build-manager.js +++ b/src/services/build-manager.js @@ -1,131 +1,43 @@ /** - * Build Manager - Pure functions for build lifecycle management + * Build Manager - local build object creation for the test runner. */ -import crypto from 'node:crypto'; +import { randomUUID } from 'node:crypto'; /** - * Generate unique build ID for local build management only. - * Note: The API generates its own UUIDs for actual builds - this local ID - * is only used for CLI internal tracking and is not sent to the API. - * @returns {string} Build ID - */ -export function generateBuildId() { - return `build-${crypto.randomUUID()}`; -} - -/** - * Create build object + * Create a local build object for test-runner orchestration. + * + * The API creates persisted build IDs. This object is only used inside the + * CLI process to give the runner a consistent shape before API creation. + * * @param {Object} buildOptions - Build configuration + * @param {Object} [deps] + * @param {Function} [deps.randomId] + * @param {Function} [deps.now] + * @param {Function} [deps.timestamp] * @returns {Object} Build object */ -export function createBuildObject(buildOptions) { - const { +export function createBuildObject(buildOptions, deps = {}) { + let { name, branch, commit, environment = 'test', metadata = {}, } = buildOptions; + let randomId = deps.randomId || randomUUID; + let now = deps.now || (() => new Date().toISOString()); + let timestamp = deps.timestamp || Date.now; return { - id: generateBuildId(), - name: name || `build-${Date.now()}`, + id: `build-${randomId()}`, + name: name || `build-${timestamp()}`, branch, commit, environment, metadata, status: 'pending', - createdAt: new Date().toISOString(), + createdAt: now(), screenshots: [], }; } - -/** - * Update build with new status and data - * @param {Object} build - Current build - * @param {string} status - New status - * @param {Object} updates - Additional updates - * @returns {Object} Updated build - */ -export function updateBuild(build, status, updates = {}) { - return { - ...build, - status, - updatedAt: new Date().toISOString(), - ...updates, - }; -} - -/** - * Add screenshot to build - * @param {Object} build - Current build - * @param {Object} screenshot - Screenshot data - * @returns {Object} Updated build - */ -export function addScreenshotToBuild(build, screenshot) { - return { - ...build, - screenshots: [ - ...build.screenshots, - { - ...screenshot, - addedAt: new Date().toISOString(), - }, - ], - }; -} - -/** - * Finalize build with result - * @param {Object} build - Current build - * @param {Object} result - Build result - * @returns {Object} Finalized build - */ -export function finalizeBuildObject(build, result = {}) { - const finalStatus = result.success ? 'completed' : 'failed'; - - return { - ...build, - status: finalStatus, - completedAt: new Date().toISOString(), - result, - }; -} - -/** - * Create queued build item - * @param {Object} buildOptions - Build options - * @returns {Object} Queued build item - */ -export function createQueuedBuild(buildOptions) { - return { - ...buildOptions, - queuedAt: new Date().toISOString(), - }; -} - -/** - * Validate build options - * @param {Object} buildOptions - Build options to validate - * @returns {Object} Validation result - */ -export function validateBuildOptions(buildOptions) { - let errors = []; - - if (!buildOptions.name && !buildOptions.branch) { - errors.push('Either name or branch is required'); - } - - if ( - buildOptions.environment && - !['test', 'staging', 'production'].includes(buildOptions.environment) - ) { - errors.push('Environment must be one of: test, staging, production'); - } - - return { - valid: errors.length === 0, - errors, - }; -} diff --git a/src/services/index.js b/src/services/index.js index c5abbaf3..16cb2327 100644 --- a/src/services/index.js +++ b/src/services/index.js @@ -9,7 +9,7 @@ * This factory is only used by cli.js to provide services to plugins. */ -import { ServerManager } from './server-manager.js'; +import { createServerManager } from '../server-manager/index.js'; import { TestRunner } from './test-runner.js'; /** @@ -26,9 +26,7 @@ import { TestRunner } from './test-runner.js'; * @returns {Object} Services object for plugins */ export function createServices(config) { - let serverManager = new ServerManager(config, { - services: {}, - }); + let serverManager = createServerManager(config, {}); let testRunner = new TestRunner(config, serverManager); diff --git a/src/services/server-manager.js b/src/services/server-manager.js deleted file mode 100644 index da4449e1..00000000 --- a/src/services/server-manager.js +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Server Manager Service - * Manages the HTTP server with functional handlers - * - * This class is a thin wrapper around the functional operations in - * src/server-manager/. It maintains backwards compatibility while - * delegating to pure functions for testability. - */ - -import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'; -import { createApiClient } from '../api/index.js'; -import { createApiHandler } from '../server/handlers/api-handler.js'; -import { createTddHandler } from '../server/handlers/tdd-handler.js'; -import { createHttpServer } from '../server/http-server.js'; -import { - buildServerInterface, - getTddResults, - startServer, - stopServer, -} from '../server-manager/index.js'; - -export class ServerManager { - constructor(config, options = {}) { - this.config = config; - this.httpServer = null; - this.handler = null; - this.services = options.services || {}; - this.tddMode = false; - - // Dependency injection for testing - defaults to real implementations - this.deps = options.deps || { - createHttpServer, - createTddHandler, - createApiHandler, - createApiClient, - fs: { mkdirSync, writeFileSync, existsSync, unlinkSync }, - }; - } - - async start(buildId = null, tddMode = false, setBaseline = false) { - this.buildId = buildId; - this.tddMode = tddMode; - this.setBaseline = setBaseline; - - let result = await startServer({ - config: this.config, - buildId, - tddMode, - setBaseline, - projectRoot: process.cwd(), - services: this.services, - deps: this.deps, - }); - - this.httpServer = result.httpServer; - this.handler = result.handler; - } - - async stop() { - await stopServer({ - httpServer: this.httpServer, - handler: this.handler, - projectRoot: process.cwd(), - deps: this.deps, - }); - } - - // Expose server interface for compatibility - get server() { - return buildServerInterface({ - handler: this.handler, - httpServer: this.httpServer, - }); - } - - /** - * Get TDD results (comparisons, screenshot count, etc.) - * Only available in TDD mode after tests have run - */ - async getTddResults() { - return getTddResults({ - tddMode: this.tddMode, - handler: this.handler, - }); - } -} diff --git a/src/services/static-report-generator.js b/src/services/static-report-generator.js index fc22c646..013cbf04 100644 --- a/src/services/static-report-generator.js +++ b/src/services/static-report-generator.js @@ -15,7 +15,7 @@ import { writeFileSync, } from 'node:fs'; import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; let __dirname = dirname(fileURLToPath(import.meta.url)); @@ -141,6 +141,14 @@ ${css} `; } +export async function loadStaticReportRenderer( + ssrModulePath = getSSRModulePath() +) { + let moduleUrl = pathToFileURL(ssrModulePath).href; + let { renderStaticReport } = await import(moduleUrl); + return renderStaticReport; +} + /** * Generate a static report from the current TDD results * @@ -179,17 +187,13 @@ export async function generateStaticReport(workingDir, options = {}) { // Transform image URLs to relative paths let transformedData = transformImageUrls(reportData); - // Load and use the SSR module - let ssrModulePath = getSSRModulePath(); - let { renderStaticReport } = await import(ssrModulePath); + let renderStaticReport = + options.renderStaticReport || (await loadStaticReportRenderer()); let renderedContent = renderStaticReport(transformedData); - // Get CSS - let reporterDistPath = getReporterDistPath(); - let css = readFileSync( - join(reporterDistPath, 'reporter-bundle.css'), - 'utf8' - ); + let css = + options.css ?? + readFileSync(join(getReporterDistPath(), 'reporter-bundle.css'), 'utf8'); // Create output directory mkdirSync(outputDir, { recursive: true }); @@ -224,5 +228,5 @@ export async function generateStaticReport(workingDir, options = {}) { * Get the file:// URL for a report path */ export function getReportFileUrl(reportPath) { - return `file://${reportPath}`; + return pathToFileURL(reportPath).href; } diff --git a/src/services/test-runner.js b/src/services/test-runner.js index 36384e40..9f116f86 100644 --- a/src/services/test-runner.js +++ b/src/services/test-runner.js @@ -2,9 +2,8 @@ * Test Runner Service * Orchestrates the test execution flow * - * This class is a thin wrapper around the functional operations in - * src/test-runner/. It maintains backwards compatibility while - * delegating to pure functions for testability. + * This EventEmitter adapter keeps the stable plugin-facing runner contract + * while delegating execution to the functional operations in src/test-runner/. */ import { spawn } from 'node:child_process'; @@ -138,47 +137,6 @@ export class TestRunner extends EventEmitter { }); } - async executeTestCommand(testCommand, env) { - return new Promise((resolve, reject) => { - let proc = this.deps.spawn(testCommand, { - env, - stdio: 'inherit', - shell: true, - }); - - this.testProcess = proc; - - proc.on('error', error => { - reject( - this.deps.createError( - `Failed to run test command: ${error.message}`, - 'TEST_COMMAND_FAILED' - ) - ); - }); - - proc.on('exit', (code, signal) => { - if (signal === 'SIGINT') { - reject( - this.deps.createError( - 'Test command was interrupted', - 'TEST_COMMAND_INTERRUPTED' - ) - ); - } else if (code !== 0) { - reject( - this.deps.createError( - `Test command exited with code ${code}`, - 'TEST_COMMAND_FAILED' - ) - ); - } else { - resolve(); - } - }); - }); - } - async cancel() { await cancelTests({ testProcess: this.testProcess, diff --git a/src/tdd/index.js b/src/tdd/index.js index 3d18a274..a13a45c5 100644 --- a/src/tdd/index.js +++ b/src/tdd/index.js @@ -58,10 +58,7 @@ export { compareImages, isDimensionMismatchError, } from './services/comparison-service.js'; -export { - downloadHotspots, - extractScreenshotNames, -} from './services/hotspot-service.js'; +export { downloadHotspots } from './services/hotspot-service.js'; export { buildResults, calculateSummary, diff --git a/src/tdd/metadata/region-metadata.js b/src/tdd/metadata/region-metadata.js index b639e08a..085243a9 100644 --- a/src/tdd/metadata/region-metadata.js +++ b/src/tdd/metadata/region-metadata.js @@ -56,38 +56,3 @@ export function saveRegionMetadata(workingDir, regionData, summary = {}) { writeFileSync(regionsPath, JSON.stringify(content, null, 2)); } - -/** - * Get regions for a specific screenshot with caching support - * - * This is a pure function that takes a cache object as parameter - * for stateless operation. The cache is mutated if data needs to be loaded. - * - * @param {Object} cache - Cache object { data: Object|null, loaded: boolean } - * @param {string} workingDir - Working directory - * @param {string} screenshotName - Name of the screenshot - * @returns {Object|null} Region data or null if not available - */ -export function getRegionsForScreenshot(cache, workingDir, screenshotName) { - // Check cache first - if (cache.data?.[screenshotName]) { - return cache.data[screenshotName]; - } - - // Load from disk if not yet loaded - if (!cache.loaded) { - cache.data = loadRegionMetadata(workingDir); - cache.loaded = true; - } - - return cache.data?.[screenshotName] || null; -} - -/** - * Create an empty region cache object - * - * @returns {{ data: null, loaded: boolean }} - */ -export function createRegionCache() { - return { data: null, loaded: false }; -} diff --git a/src/tdd/server-registry.js b/src/tdd/server-registry.js index 4ab495e2..65a16732 100644 --- a/src/tdd/server-registry.js +++ b/src/tdd/server-registry.js @@ -1,4 +1,4 @@ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { createServer } from 'node:net'; @@ -10,9 +10,11 @@ import { join } from 'node:path'; * Enables the menubar app to discover and manage multiple concurrent servers. */ export class ServerRegistry { - constructor() { - this.vizzlyHome = process.env.VIZZLY_HOME || join(homedir(), '.vizzly'); + constructor({ vizzlyHome, logger = console } = {}) { + this.vizzlyHome = + vizzlyHome || process.env.VIZZLY_HOME || join(homedir(), '.vizzly'); this.registryPath = join(this.vizzlyHome, 'servers.json'); + this.logger = logger; } /** @@ -38,7 +40,9 @@ export class ServerRegistry { } } catch (_err) { // Corrupted file, start fresh - console.warn('Warning: Could not read server registry, starting fresh'); + this.logger.warn( + 'Warning: Could not read server registry, starting fresh' + ); } return { version: 1, servers: [] }; } @@ -60,11 +64,11 @@ export class ServerRegistry { throw new Error('Missing required fields: pid, port, directory'); } - let port = Number(serverInfo.port); - let pid = Number(serverInfo.pid); + let port = parsePositiveInteger(serverInfo.port); + let pid = parsePositiveInteger(serverInfo.pid); - if (Number.isNaN(port) || Number.isNaN(pid)) { - throw new Error('Invalid port or pid - must be numbers'); + if (port === null || pid === null) { + throw new Error('Invalid port or pid - must be positive integers'); } let registry = this.read(); @@ -178,16 +182,7 @@ export class ServerRegistry { * The menubar app listens for this in addition to file watching. */ notifyMenubar() { - if (process.platform !== 'darwin') return; - - try { - execSync('notifyutil -p dev.vizzly.serverChanged', { - stdio: 'ignore', - timeout: 500, - }); - } catch { - // Non-fatal - menubar will still see changes via file watching - } + notifyServerRegistryChanged(); } /** @@ -229,6 +224,36 @@ export class ServerRegistry { } } +function parsePositiveInteger(value) { + if (typeof value === 'number') { + return Number.isInteger(value) && value > 0 ? value : null; + } + + if (typeof value === 'string' && /^[1-9]\d*$/.test(value)) { + return Number(value); + } + + return null; +} + +export function notifyServerRegistryChanged({ + platform = process.platform, + execFile = execFileSync, +} = {}) { + if (platform !== 'darwin') return false; + + try { + execFile('notifyutil', ['-p', 'dev.vizzly.serverChanged'], { + stdio: 'ignore', + timeout: 500, + }); + return true; + } catch { + // Non-fatal - menubar will still see changes via file watching + return false; + } +} + /** * Check if a port is free (not in use by any process) * @param {number} port - Port to check diff --git a/src/tdd/services/hotspot-service.js b/src/tdd/services/hotspot-service.js index 32e69a9c..a1a0fe67 100644 --- a/src/tdd/services/hotspot-service.js +++ b/src/tdd/services/hotspot-service.js @@ -45,17 +45,3 @@ export async function downloadHotspots(options) { 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/region-service.js b/src/tdd/services/region-service.js deleted file mode 100644 index 05a6fb8d..00000000 --- a/src/tdd/services/region-service.js +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Region Service - * - * Functions for downloading and managing user-defined hotspot regions from the cloud. - * Regions are 2D bounding boxes that users have confirmed as dynamic content areas. - */ - -import { saveRegionMetadata } from '../metadata/region-metadata.js'; - -/** - * Download user-defined regions from cloud API - * - * @param {Object} options - * @param {Object} options.api - ApiService instance - * @param {string} options.workingDir - Working directory - * @param {string[]} options.screenshotNames - Names of screenshots to get regions for - * @param {boolean} options.includeCandidates - Include candidate regions (default: false) - * @returns {Promise<{ success: boolean, count: number, regionCount: number, error?: string }>} - */ -export async function downloadRegions(options) { - let { api, workingDir, screenshotNames, includeCandidates = false } = options; - - if (!screenshotNames || screenshotNames.length === 0) { - return { success: true, count: 0, regionCount: 0 }; - } - - try { - let response = await api.getRegions(screenshotNames, { includeCandidates }); - - if (!response?.regions) { - return { success: false, error: 'API returned no region data' }; - } - - // Save regions to disk - saveRegionMetadata(workingDir, response.regions, response.summary); - - // Calculate stats - let count = Object.keys(response.regions).length; - let regionCount = response.summary?.total_regions || 0; - - return { success: true, count, regionCount }; - } catch (error) { - return { success: false, error: error.message }; - } -} - -/** - * Extract screenshot names from a list of screenshots - * - * @param {Array} screenshots - Screenshots with name property - * @returns {string[]} - */ -export function extractScreenshotNames(screenshots) { - if (!screenshots || !Array.isArray(screenshots)) { - return []; - } - - return screenshots.map(s => s.name).filter(Boolean); -} diff --git a/src/tdd/tdd-service.js b/src/tdd/tdd-service.js index 0b6a0d58..beceee0e 100644 --- a/src/tdd/tdd-service.js +++ b/src/tdd/tdd-service.js @@ -256,7 +256,7 @@ export class TddService { // State this.baselineData = null; this.comparisons = []; - this.threshold = config.comparison?.threshold || 2.0; + this.threshold = config.comparison?.threshold ?? 2.0; this.minClusterSize = config.comparison?.minClusterSize ?? 2; this.signatureProperties = config.signatureProperties ?? []; @@ -1199,7 +1199,7 @@ export class TddService { } this.baselineData = metadata; - this.threshold = metadata.threshold || this.threshold; + this.threshold = metadata.threshold ?? this.threshold; this.signatureProperties = metadata.signatureProperties || this.signatureProperties; diff --git a/tests/commands/doctor.test.js b/tests/commands/doctor.test.js new file mode 100644 index 00000000..0eaaf6c6 --- /dev/null +++ b/tests/commands/doctor.test.js @@ -0,0 +1,294 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + createDoctorDiagnostics, + doctorCommand, + getApiUrlCheck, + getNodeVersionCheck, + getThresholdCheck, + validateDoctorOptions, +} from '../../src/commands/doctor.js'; + +function createMockOutput() { + let calls = []; + return { + calls, + configure: opts => calls.push({ method: 'configure', args: [opts] }), + data: value => calls.push({ method: 'data', args: [value] }), + error: (message, error) => + calls.push({ method: 'error', args: [message, error] }), + header: (command, mode) => + calls.push({ method: 'header', args: [command, mode] }), + printErr: message => calls.push({ method: 'printErr', args: [message] }), + startSpinner: message => + calls.push({ method: 'startSpinner', args: [message] }), + stopSpinner: () => calls.push({ method: 'stopSpinner', args: [] }), + warn: message => calls.push({ method: 'warn', args: [message] }), + cleanup: () => calls.push({ method: 'cleanup', args: [] }), + getColors: () => ({ + brand: { + success: value => value, + danger: value => value, + textTertiary: value => value, + }, + cyan: value => value, + dim: value => value, + gray: value => value, + green: value => value, + underline: value => value, + white: value => value, + yellow: value => value, + }), + }; +} + +let validConfig = { + apiUrl: 'https://api.example.test', + apiKey: 'token-123', + comparison: { threshold: 2 }, + server: { port: 47888 }, +}; + +describe('commands/doctor', () => { + describe('validateDoctorOptions', () => { + it('returns no errors', () => { + assert.deepStrictEqual(validateDoctorOptions({}), []); + }); + }); + + describe('diagnostic helpers', () => { + it('creates the default diagnostics shape', () => { + assert.deepStrictEqual(createDoctorDiagnostics(), { + environment: { + nodeVersion: null, + nodeVersionValid: null, + }, + configuration: { + apiUrl: null, + apiUrlValid: null, + threshold: null, + thresholdValid: null, + port: null, + }, + connectivity: { + checked: false, + ok: null, + error: null, + }, + }); + }); + + it('requires Node 22 or newer', () => { + let unsupported = getNodeVersionCheck('v21.9.0'); + let supported = getNodeVersionCheck('v22.0.0'); + let supportedWithoutPrefix = getNodeVersionCheck('22.1.0'); + + assert.strictEqual(unsupported.check.ok, false); + assert.match(unsupported.check.value, />= 22/); + assert.strictEqual(supported.check.ok, true); + assert.strictEqual(supportedWithoutPrefix.check.ok, true); + }); + + it('reports malformed Node versions as unsupported', () => { + let result = getNodeVersionCheck('v22abc'); + + assert.strictEqual(result.check.ok, false); + assert.strictEqual(result.diagnostic.nodeVersionValid, false); + assert.match(result.check.value, /unrecognized/); + }); + + it('accepts only HTTP API URLs', () => { + assert.strictEqual(getApiUrlCheck('https://api.test').apiUrlValid, true); + assert.strictEqual(getApiUrlCheck('http://api.test').apiUrlValid, true); + assert.strictEqual( + getApiUrlCheck('file:///tmp/vizzly').apiUrlValid, + false + ); + assert.strictEqual(getApiUrlCheck('not a url').apiUrlValid, false); + }); + + it('accepts non-negative CIEDE2000 thresholds', () => { + assert.deepStrictEqual(getThresholdCheck(2), { + threshold: 2, + thresholdValid: true, + check: { + name: 'Threshold', + value: '2 (CIEDE2000)', + ok: true, + }, + }); + assert.strictEqual(getThresholdCheck(-1).thresholdValid, false); + assert.strictEqual(getThresholdCheck('nope').thresholdValid, false); + }); + }); + + describe('doctorCommand', () => { + it('reports local diagnostics as JSON without API connectivity', async () => { + let output = createMockOutput(); + + await doctorCommand( + {}, + { json: true }, + { + getApiToken: () => null, + loadConfig: async () => validConfig, + nodeVersion: 'v22.2.0', + output, + } + ); + + let dataCall = output.calls.find(call => call.method === 'data'); + assert.strictEqual(dataCall.args[0].passed, true); + assert.strictEqual( + dataCall.args[0].diagnostics.environment.nodeVersionValid, + true + ); + assert.strictEqual( + dataCall.args[0].diagnostics.connectivity.checked, + false + ); + assert.ok(output.calls.some(call => call.method === 'cleanup')); + }); + + it('checks API connectivity when requested', async () => { + let output = createMockOutput(); + let capturedClientOptions = null; + let capturedBuildOptions = null; + + await doctorCommand( + { api: true }, + { json: true }, + { + createApiClient: options => { + capturedClientOptions = options; + return { kind: 'client' }; + }, + getApiToken: () => null, + getBuilds: async (_client, options) => { + capturedBuildOptions = options; + return { builds: [] }; + }, + loadConfig: async () => validConfig, + nodeVersion: 'v22.2.0', + output, + } + ); + + assert.deepStrictEqual(capturedClientOptions, { + baseUrl: 'https://api.example.test', + token: 'token-123', + command: 'doctor', + }); + assert.deepStrictEqual(capturedBuildOptions, { limit: 1 }); + + let dataCall = output.calls.find(call => call.method === 'data'); + assert.strictEqual( + dataCall.args[0].diagnostics.connectivity.checked, + true + ); + assert.strictEqual(dataCall.args[0].diagnostics.connectivity.ok, true); + }); + + it('fails when API connectivity is requested without a token', async () => { + let output = createMockOutput(); + let exitCode = null; + + await doctorCommand( + { api: true }, + { json: true }, + { + getApiToken: () => null, + loadConfig: async () => ({ ...validConfig, apiKey: null }), + nodeVersion: 'v22.2.0', + output, + exit: code => { + exitCode = code; + }, + } + ); + + assert.strictEqual(exitCode, 1); + let dataCall = output.calls.find(call => call.method === 'data'); + assert.strictEqual(dataCall.args[0].passed, false); + assert.strictEqual( + dataCall.args[0].diagnostics.connectivity.error, + 'Missing API token (VIZZLY_TOKEN)' + ); + }); + + it('prints human-readable diagnostics and context', async () => { + let output = createMockOutput(); + + await doctorCommand( + {}, + {}, + { + getApiToken: () => null, + getContext: () => [ + { type: 'success', label: 'Logged in', value: 'rob@example.com' }, + ], + loadConfig: async () => validConfig, + nodeVersion: 'v22.2.0', + output, + } + ); + + assert.ok( + output.calls.some( + call => call.method === 'header' && call.args[1] === 'local' + ) + ); + assert.ok( + output.calls.some( + call => call.method === 'printErr' && call.args[0].includes('Node.js') + ) + ); + assert.ok( + output.calls.some( + call => + call.method === 'printErr' && + call.args[0].includes('rob@example.com') + ) + ); + }); + + it('exits with status 1 when local diagnostics fail', async () => { + let output = createMockOutput(); + let exitCode = null; + + await doctorCommand( + {}, + { json: true }, + { + getApiToken: () => null, + loadConfig: async () => ({ + ...validConfig, + apiUrl: 'file:///tmp/vizzly', + comparison: { threshold: -1 }, + }), + nodeVersion: 'v21.9.0', + output, + exit: code => { + exitCode = code; + }, + } + ); + + assert.strictEqual(exitCode, 1); + let dataCall = output.calls.find(call => call.method === 'data'); + assert.strictEqual(dataCall.args[0].passed, false); + assert.strictEqual( + dataCall.args[0].diagnostics.environment.nodeVersionValid, + false + ); + assert.strictEqual( + dataCall.args[0].diagnostics.configuration.apiUrlValid, + false + ); + assert.strictEqual( + dataCall.args[0].diagnostics.configuration.thresholdValid, + false + ); + }); + }); +}); diff --git a/tests/commands/preview.test.js b/tests/commands/preview.test.js index f7450299..63e3c979 100644 --- a/tests/commands/preview.test.js +++ b/tests/commands/preview.test.js @@ -532,6 +532,37 @@ describe('previewCommand', () => { ); }); + it('treats regex syntax literally in --exclude patterns', async () => { + writeFileSync( + join(distDir, 'widget[primary]-main.js'), + 'console.log("skip")' + ); + writeFileSync(join(distDir, 'widgetp-main.js'), 'console.log("keep")'); + let output = createMockOutput(); + + let result = await previewCommand( + distDir, + { + dryRun: true, + build: 'build-123', + exclude: ['widget[primary]*.js'], + }, + {}, + { + loadConfig: async () => ({ + apiKey: null, + apiUrl: 'https://api.test', + }), + output, + exit: () => {}, + } + ); + + let filePaths = result.files.map(f => f.path); + assert.ok(!filePaths.includes('widget[primary]-main.js')); + assert.ok(filePaths.includes('widgetp-main.js')); + }); + it('excludes directories matching --exclude patterns with trailing slash', async () => { let output = createMockOutput(); @@ -842,4 +873,44 @@ describe('previewCommand', () => { assert.strictEqual(uploadCalled, true, 'Should call upload'); assert.strictEqual(result.success, true); }); + + it('uploads previews from paths with spaces', async () => { + let spacedDir = join(testDir, 'dist with spaces'); + mkdirSync(spacedDir, { recursive: true }); + writeFileSync(join(spacedDir, 'index.html'), ''); + + let output = createMockOutput(); + let uploadedBytes = 0; + + let result = await previewCommand( + spacedDir, + { build: 'build-123' }, + {}, + { + loadConfig: async () => ({ + apiKey: 'test-token', + apiUrl: 'https://api.test', + }), + createApiClient: () => ({}), + getBuild: async () => ({ + id: 'build-123', + project: { id: 'proj-1', name: 'Test Project', isPublic: true }, + }), + uploadPreviewZip: async (_client, _buildId, zipBuffer) => { + uploadedBytes = zipBuffer.length; + return { + previewUrl: 'https://preview.test', + uploaded: 1, + totalBytes: zipBuffer.length, + newBytes: zipBuffer.length, + }; + }, + output, + exit: () => {}, + } + ); + + assert.strictEqual(result.success, true); + assert.ok(uploadedBytes > 0, 'Should upload a generated ZIP'); + }); }); diff --git a/tests/commands/run.test.js b/tests/commands/run.test.js index e7f39994..69a21519 100644 --- a/tests/commands/run.test.js +++ b/tests/commands/run.test.js @@ -1,6 +1,10 @@ import assert from 'node:assert'; import { describe, it } from 'node:test'; -import { runCommand, validateRunOptions } from '../../src/commands/run.js'; +import { + resolveBuildDisplayUrl, + runCommand, + validateRunOptions, +} from '../../src/commands/run.js'; /** * Create mock output object that tracks calls @@ -56,6 +60,77 @@ function createMockConfig(overrides = {}) { } describe('commands/run', () => { + describe('resolveBuildDisplayUrl', () => { + it('uses the API result URL when one is already present', async () => { + let url = await resolveBuildDisplayUrl({ + result: { + buildId: 'build-123', + url: 'https://app.test/acme/web/builds/build-123', + }, + config: createMockConfig(), + createApiClient: () => { + throw new Error('should not fetch token context'); + }, + }); + + assert.strictEqual(url, 'https://app.test/acme/web/builds/build-123'); + }); + + it('builds an organization/project URL from token context', async () => { + let clientArgs; + let url = await resolveBuildDisplayUrl({ + result: { buildId: 'build-123' }, + config: createMockConfig({ + apiUrl: 'https://api.test/api/v1', + apiKey: 'token-123', + }), + createApiClient: args => { + clientArgs = args; + return { client: true }; + }, + getTokenContext: async client => { + assert.deepStrictEqual(client, { client: true }); + return { + organization: { slug: 'acme' }, + project: { slug: 'web' }, + }; + }, + }); + + assert.deepStrictEqual(clientArgs, { + baseUrl: 'https://api.test/api/v1', + token: 'token-123', + command: 'run', + }); + assert.strictEqual(url, 'https://api.test/acme/web/builds/build-123'); + }); + + it('falls back to the build URL when token context lookup fails', async () => { + let url = await resolveBuildDisplayUrl({ + result: { buildId: 'build-123' }, + config: createMockConfig({ + apiUrl: 'https://api.test/api/v1', + apiKey: 'token-123', + }), + createApiClient: () => ({ client: true }), + getTokenContext: async () => { + throw new Error('context unavailable'); + }, + }); + + assert.strictEqual(url, 'https://api.test/builds/build-123'); + }); + + it('returns undefined when no URL can be resolved without a token', async () => { + let url = await resolveBuildDisplayUrl({ + result: { buildId: 'build-123' }, + config: createMockConfig({ apiKey: null }), + }); + + assert.strictEqual(url, undefined); + }); + }); + describe('runCommand', () => { it('returns error when no API key and allowNoToken not set', async () => { let output = createMockOutput(); @@ -1222,6 +1297,13 @@ describe('commands/run', () => { ); }); + it('should fail with decimal port number', () => { + let errors = validateRunOptions('npm test', { port: '3000.5' }); + assert.ok( + errors.includes('Port must be a valid number between 1 and 65535') + ); + }); + it('should fail with port out of range (too low)', () => { let errors = validateRunOptions('npm test', { port: '0' }); assert.ok( @@ -1250,6 +1332,13 @@ describe('commands/run', () => { ); }); + it('should fail with decimal timeout', () => { + let errors = validateRunOptions('npm test', { timeout: '5000.5' }); + assert.ok( + errors.includes('Timeout must be at least 1000 milliseconds') + ); + }); + it('should fail with timeout too low', () => { let errors = validateRunOptions('npm test', { timeout: '500' }); assert.ok( @@ -1269,6 +1358,11 @@ describe('commands/run', () => { assert.ok(errors.includes('Batch size must be a positive integer')); }); + it('should fail with decimal batch size', () => { + let errors = validateRunOptions('npm test', { batchSize: '2.5' }); + assert.ok(errors.includes('Batch size must be a positive integer')); + }); + it('should fail with zero batch size', () => { let errors = validateRunOptions('npm test', { batchSize: '0' }); assert.ok(errors.includes('Batch size must be a positive integer')); @@ -1297,6 +1391,17 @@ describe('commands/run', () => { ); }); + it('should fail with decimal upload timeout', () => { + let errors = validateRunOptions('npm test', { + uploadTimeout: '2500.5', + }); + assert.ok( + errors.includes( + 'Upload timeout must be a positive integer (milliseconds)' + ) + ); + }); + it('should fail with zero upload timeout', () => { let errors = validateRunOptions('npm test', { uploadTimeout: '0' }); assert.ok( @@ -1307,6 +1412,61 @@ describe('commands/run', () => { }); }); + describe('comparison validation', () => { + it('should pass with exact-match threshold and min cluster size', () => { + let errors = validateRunOptions('npm test', { + threshold: '0', + minClusterSize: '1', + }); + + assert.strictEqual(errors.length, 0); + }); + + it('should fail with invalid threshold', () => { + let errors = validateRunOptions('npm test', { + threshold: 'invalid', + }); + + assert.ok( + errors.includes( + 'Threshold must be a non-negative number (CIEDE2000 Delta E)' + ) + ); + }); + + it('should fail when threshold has trailing text', () => { + let errors = validateRunOptions('npm test', { + threshold: '2abc', + }); + + assert.ok( + errors.includes( + 'Threshold must be a non-negative number (CIEDE2000 Delta E)' + ) + ); + }); + + it('should fail with non-integer min cluster size', () => { + let errors = validateRunOptions('npm test', { + minClusterSize: '2.5', + }); + + assert.ok( + errors.includes('Min cluster size must be a positive integer') + ); + }); + + it('should fail with zero min cluster size', () => { + let errors = validateRunOptions('npm test', { + minClusterSize: '0', + }); + + assert.ok( + errors.includes('Min cluster size must be a positive integer') + ); + }); + }); + describe('multiple validation errors', () => { it('should return all validation errors', () => { let errors = validateRunOptions('', { diff --git a/tests/commands/status.test.js b/tests/commands/status.test.js index 6cf26e91..a7e11602 100644 --- a/tests/commands/status.test.js +++ b/tests/commands/status.test.js @@ -1,6 +1,13 @@ -import assert from 'node:assert'; +import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { + createBuildInfo, + createBuildUrl, + createComparisonStats, + createStatusData, + getProcessingProgress, + normalizeBuildStatus, + shouldFailStatus, statusCommand, validateStatusOptions, } from '../../src/commands/status.js'; @@ -45,6 +52,66 @@ function createMockOutput() { }; } +function createBuild(overrides = {}) { + return { + id: 'build-123', + status: 'completed', + name: 'Homepage', + created_at: '2026-05-18T12:00:00.000Z', + updated_at: '2026-05-18T12:01:00.000Z', + completed_at: '2026-05-18T12:02:00.000Z', + environment: 'ci', + branch: 'main', + commit_sha: 'abcdef1234567890', + commit_message: 'Update homepage', + screenshot_count: 3, + total_comparisons: 3, + new_comparisons: 1, + changed_comparisons: 1, + identical_comparisons: 1, + approval_status: 'pending', + execution_time_ms: 4250, + is_baseline: false, + user_agent: 'vizzly-test', + project_id: 'project-123', + failed_jobs: 0, + ...overrides, + }; +} + +function createStatusHarness(build = createBuild(), previewInfo = null) { + let output = createMockOutput(); + let clientConfig = null; + let exitCode = null; + + return { + output, + get clientConfig() { + return clientConfig; + }, + get exitCode() { + return exitCode; + }, + deps: { + loadConfig: async () => ({ + apiKey: 'test-token', + apiUrl: 'https://api.test/api', + }), + createApiClient: config => { + clientConfig = config; + return { kind: 'client' }; + }, + getBuild: async () => ({ build }), + getPreviewInfo: async () => previewInfo, + getApiUrl: () => 'https://app.test/api', + output, + exit: code => { + exitCode = code; + }, + }, + }; +} + describe('commands/status', () => { describe('validateStatusOptions', () => { it('returns no errors for valid build ID', () => { @@ -63,7 +130,209 @@ describe('commands/status', () => { }); }); + describe('status helpers', () => { + it('normalizes wrapped and unwrapped build responses', () => { + let build = createBuild(); + + assert.strictEqual(normalizeBuildStatus({ build }), build); + assert.strictEqual(normalizeBuildStatus(build), build); + }); + + it('creates JSON status data with preview details', () => { + let data = createStatusData(createBuild(), { + preview_url: 'https://preview.test', + status: 'ready', + file_count: 12, + expires_at: '2026-05-19T12:00:00.000Z', + }); + + assert.deepStrictEqual(data, { + buildId: 'build-123', + status: 'completed', + name: 'Homepage', + createdAt: '2026-05-18T12:00:00.000Z', + updatedAt: '2026-05-18T12:01:00.000Z', + completedAt: '2026-05-18T12:02:00.000Z', + environment: 'ci', + branch: 'main', + commit: 'abcdef1234567890', + commitMessage: 'Update homepage', + screenshotsTotal: 3, + comparisonsTotal: 3, + newComparisons: 1, + changedComparisons: 1, + identicalComparisons: 1, + approvalStatus: 'pending', + executionTime: 4250, + isBaseline: false, + userAgent: 'vizzly-test', + preview: { + url: 'https://preview.test', + status: 'ready', + fileCount: 12, + expiresAt: '2026-05-19T12:00:00.000Z', + }, + }); + }); + + it('creates human build info and comparison stats', () => { + let build = createBuild(); + let colors = createMockOutput().getColors(); + + assert.deepStrictEqual(createBuildInfo(build), { + Name: 'Homepage', + Status: 'COMPLETED', + Environment: 'ci', + Branch: 'main', + Commit: 'abcdef12 - Update homepage', + }); + assert.strictEqual( + createComparisonStats(build, colors), + '1 new · 1 changed · 1 identical' + ); + }); + + it('creates build URLs, progress, and failure status', () => { + assert.strictEqual( + createBuildUrl('https://app.test/api', createBuild()), + 'https://app.test/projects/project-123/builds/build-123' + ); + assert.strictEqual( + createBuildUrl('https://api.test/api/v1', createBuild()), + 'https://api.test/projects/project-123/builds/build-123' + ); + assert.strictEqual(createBuildUrl(null, createBuild()), null); + assert.strictEqual( + getProcessingProgress( + createBuild({ + status: 'processing', + completed_jobs: 2, + failed_jobs: 1, + processing_screenshots: 1, + }) + ), + 75 + ); + assert.strictEqual(getProcessingProgress(createBuild()), null); + assert.strictEqual( + shouldFailStatus(createBuild({ status: 'failed' })), + true + ); + assert.strictEqual( + shouldFailStatus(createBuild({ failed_jobs: 1 })), + true + ); + assert.strictEqual(shouldFailStatus(createBuild()), false); + }); + }); + describe('statusCommand', () => { + it('fetches build and preview data for JSON output', async () => { + let harness = createStatusHarness(createBuild(), { + preview_url: 'https://preview.test', + status: 'ready', + file_count: 4, + expires_at: '2026-05-19T12:00:00.000Z', + }); + + await statusCommand('build-123', {}, { json: true }, harness.deps); + + assert.deepStrictEqual(harness.clientConfig, { + baseUrl: 'https://api.test/api', + token: 'test-token', + command: 'status', + }); + + let dataCall = harness.output.calls.find(call => call.method === 'data'); + assert.strictEqual(dataCall.args[0].buildId, 'build-123'); + assert.deepStrictEqual(dataCall.args[0].preview, { + url: 'https://preview.test', + status: 'ready', + fileCount: 4, + expiresAt: '2026-05-19T12:00:00.000Z', + }); + assert.ok(harness.output.calls.some(call => call.method === 'cleanup')); + }); + + it('prints human-readable status and exits non-zero for failed builds', async () => { + let harness = createStatusHarness( + createBuild({ status: 'failed', failed_jobs: 1 }), + { preview_url: 'https://preview.test' } + ); + + await statusCommand('build-123', {}, {}, harness.deps); + + assert.strictEqual(harness.exitCode, 1); + assert.ok( + harness.output.calls.some( + call => call.method === 'header' && call.args[1] === 'failed' + ) + ); + assert.ok( + harness.output.calls.some( + call => call.method === 'labelValue' && call.args[0] === 'Preview' + ) + ); + }); + + it('prints processing progress when jobs are active', async () => { + let harness = createStatusHarness( + createBuild({ + status: 'processing', + completed_jobs: 2, + failed_jobs: 0, + processing_screenshots: 2, + }) + ); + + await statusCommand('build-123', {}, {}, harness.deps); + + assert.ok( + harness.output.calls.some( + call => call.method === 'print' && call.args[0].includes('50%') + ) + ); + }); + + it('does not render NaN average diff in verbose output', async () => { + let harness = createStatusHarness( + createBuild({ avg_diff_percentage: undefined }) + ); + + await statusCommand('build-123', {}, { verbose: true }, harness.deps); + + let keyValueCalls = harness.output.calls.filter( + call => call.method === 'keyValue' + ); + let verboseInfo = keyValueCalls.at(-1).args[0]; + assert.equal(Object.hasOwn(verboseInfo, 'Avg Diff'), false); + }); + + it('cleans up and exits when no API token is configured', async () => { + let output = createMockOutput(); + let exitCode = null; + + let result = await statusCommand( + 'build-123', + {}, + {}, + { + loadConfig: async () => ({ apiUrl: 'https://api.test' }), + output, + exit: code => { + exitCode = code; + }, + } + ); + + assert.strictEqual(exitCode, 1); + assert.deepStrictEqual(result, { + success: false, + result: { reason: 'missing_token' }, + }); + assert.ok(output.calls.some(call => call.method === 'cleanup')); + }); + it('does not fail CI when API returns 5xx error', async () => { let output = createMockOutput(); let exitCode = null; @@ -136,6 +405,7 @@ describe('commands/status', () => { assert.strictEqual(exitCode, 1); assert.ok(output.calls.some(c => c.method === 'error')); + assert.ok(output.calls.some(c => c.method === 'cleanup')); }); }); }); diff --git a/tests/commands/tdd-daemon.test.js b/tests/commands/tdd-daemon.test.js new file mode 100644 index 00000000..84b24962 --- /dev/null +++ b/tests/commands/tdd-daemon.test.js @@ -0,0 +1,650 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { describe, it } from 'node:test'; +import { + buildDaemonChildArgs, + buildDashboardUrl, + buildLegacyServerInfo, + buildOpenDashboardCommand, + cleanupDaemonState, + cleanupLegacyGlobalServerFile, + cleanupLocalDaemonFiles, + findDaemonPidByPort, + getLocalDaemonFiles, + readDaemonPidFile, + removeFileIfExists, + resolveDaemonPid, + validateTddStartOptions, + waitForDaemonChildInit, + waitForProcessExit, + waitForServerRunning, + writeLegacyGlobalServerFile, +} from '../../src/commands/tdd-daemon.js'; + +function createManualTimers() { + let timers = new Map(); + let nextId = 1; + + return { + setTimeout(fn, ms) { + let id = nextId++; + timers.set(id, { fn, ms }); + return id; + }, + clearTimeout(id) { + timers.delete(id); + }, + trigger(id) { + let timer = timers.get(id); + timers.delete(id); + timer?.fn(); + }, + get(id) { + return timers.get(id); + }, + }; +} + +async function flushMicrotasks() { + await Promise.resolve(); +} + +async function triggerTimer(timers, id) { + for (let i = 0; i < 5 && !timers.get(id); i++) { + await flushMicrotasks(); + } + timers.trigger(id); + await flushMicrotasks(); +} + +function createLsofProcess({ output = '', closeCode = 0, emitError = false }) { + let child = new EventEmitter(); + child.stdout = new EventEmitter(); + + queueMicrotask(() => { + if (emitError) { + child.emit('error', new Error('lsof unavailable')); + return; + } + + if (output) { + child.stdout.emit('data', output); + } + child.emit('close', closeCode); + }); + + return child; +} + +describe('commands/tdd-daemon helpers', () => { + describe('daemon file helpers', () => { + it('resolves local daemon files for a workspace', () => { + assert.deepStrictEqual(getLocalDaemonFiles('/repo/app'), { + vizzlyDir: '/repo/app/.vizzly', + pidFile: '/repo/app/.vizzly/server.pid', + serverFile: '/repo/app/.vizzly/server.json', + logFile: '/repo/app/.vizzly/server.log', + }); + }); + + it('removes files only when they exist', () => { + let removed = []; + let existing = new Set(['/repo/app/.vizzly/server.pid']); + + assert.strictEqual( + removeFileIfExists('/repo/app/.vizzly/server.pid', { + existsSync: path => existing.has(path), + unlinkSync: path => { + removed.push(path); + existing.delete(path); + }, + }), + true + ); + assert.strictEqual( + removeFileIfExists('/repo/app/.vizzly/server.json', { + existsSync: path => existing.has(path), + unlinkSync: path => removed.push(path), + }), + false + ); + assert.deepStrictEqual(removed, ['/repo/app/.vizzly/server.pid']); + }); + + it('cleans local pid and server files together', () => { + let removed = []; + let existing = new Set([ + '/repo/app/.vizzly/server.pid', + '/repo/app/.vizzly/server.json', + ]); + + let result = cleanupLocalDaemonFiles('/repo/app', { + existsSync: path => existing.has(path), + unlinkSync: path => removed.push(path), + }); + + assert.deepStrictEqual(result, { + pidFileRemoved: true, + serverFileRemoved: true, + }); + assert.deepStrictEqual(removed, [ + '/repo/app/.vizzly/server.pid', + '/repo/app/.vizzly/server.json', + ]); + }); + + it('writes the legacy global server file for SDK discovery', () => { + let createdDirectories = []; + let writes = []; + + let result = writeLegacyGlobalServerFile( + { pid: 1234, port: 47400 }, + { + home: () => '/home/test', + exists: () => false, + mkdir: (path, options) => { + createdDirectories.push({ path, options }); + }, + writeFile: (path, contents) => { + writes.push({ path, contents: JSON.parse(contents) }); + }, + now: () => 987654321, + } + ); + + assert.deepStrictEqual(createdDirectories, [ + { path: '/home/test/.vizzly', options: { recursive: true } }, + ]); + assert.deepStrictEqual(writes, [ + { + path: '/home/test/.vizzly/server.json', + contents: { + pid: 1234, + port: '47400', + startTime: 987654321, + }, + }, + ]); + assert.deepStrictEqual(result, { + path: '/home/test/.vizzly/server.json', + serverInfo: buildLegacyServerInfo({ + pid: 1234, + port: 47400, + now: () => 987654321, + }), + }); + }); + + it('cleans the legacy global server file when present', () => { + let removed = []; + let didRemove = cleanupLegacyGlobalServerFile({ + home: () => '/home/test', + exists: path => path === '/home/test/.vizzly/server.json', + unlink: path => removed.push(path), + }); + + assert.strictEqual(didRemove, true); + assert.deepStrictEqual(removed, ['/home/test/.vizzly/server.json']); + }); + + it('cleans local files, legacy global state, and registry entries together', () => { + let removed = []; + let registryCalls = []; + let localFiles = new Set([ + '/repo/app/.vizzly/server.pid', + '/repo/app/.vizzly/server.json', + ]); + let legacyFiles = new Set(['/home/test/.vizzly/server.json']); + + let result = cleanupDaemonState({ + port: 47400, + directory: '/repo/app', + registry: { + unregister: args => registryCalls.push(args), + }, + localFileDeps: { + existsSync: path => localFiles.has(path), + unlinkSync: path => removed.push(path), + }, + legacyFileDeps: { + home: () => '/home/test', + exists: path => legacyFiles.has(path), + unlink: path => removed.push(path), + }, + }); + + assert.deepStrictEqual(result, { + pidFileRemoved: true, + serverFileRemoved: true, + legacyGlobalServerFileRemoved: true, + }); + assert.deepStrictEqual(removed, [ + '/repo/app/.vizzly/server.pid', + '/repo/app/.vizzly/server.json', + '/home/test/.vizzly/server.json', + ]); + assert.deepStrictEqual(registryCalls, [ + { port: 47400, directory: '/repo/app' }, + ]); + }); + + it('cleans daemon files even when registry cleanup fails', () => { + let removed = []; + + let result = cleanupDaemonState({ + directory: '/repo/app', + registry: { + unregister: () => { + throw new Error('registry unavailable'); + }, + }, + localFileDeps: { + existsSync: path => path === '/repo/app/.vizzly/server.pid', + unlinkSync: path => removed.push(path), + }, + legacyFileDeps: { + home: () => '/home/test', + exists: () => false, + unlink: path => removed.push(path), + }, + }); + + assert.deepStrictEqual(result, { + pidFileRemoved: true, + serverFileRemoved: false, + legacyGlobalServerFileRemoved: false, + }); + assert.deepStrictEqual(removed, ['/repo/app/.vizzly/server.pid']); + }); + }); + + describe('daemon pid discovery', () => { + it('reads a daemon pid from a valid pid file', () => { + let pid = readDaemonPidFile('/repo/app/.vizzly/server.pid', { + existsSync: () => true, + readFileSync: () => '1234\n', + }); + + assert.strictEqual(pid, 1234); + }); + + it('treats missing, unreadable, and invalid pid files as no process', () => { + assert.strictEqual( + readDaemonPidFile('/repo/app/.vizzly/server.pid', { + existsSync: () => false, + readFileSync: () => '1234', + }), + null + ); + assert.strictEqual( + readDaemonPidFile('/repo/app/.vizzly/server.pid', { + existsSync: () => true, + readFileSync: () => { + throw new Error('permission denied'); + }, + }), + null + ); + assert.strictEqual( + readDaemonPidFile('/repo/app/.vizzly/server.pid', { + existsSync: () => true, + readFileSync: () => 'not-a-pid', + }), + null + ); + assert.strictEqual( + readDaemonPidFile('/repo/app/.vizzly/server.pid', { + existsSync: () => true, + readFileSync: () => '1234abc', + }), + null + ); + assert.strictEqual( + readDaemonPidFile('/repo/app/.vizzly/server.pid', { + existsSync: () => true, + readFileSync: () => '0', + }), + null + ); + }); + + it('finds the first process listening on the daemon port', async () => { + let calls = []; + let pid = await findDaemonPidByPort(47400, { + spawnProcess: (command, args, options) => { + calls.push({ command, args, options }); + return createLsofProcess({ output: '4321\n9876\n' }); + }, + }); + + assert.strictEqual(pid, 4321); + assert.deepStrictEqual(calls, [ + { + command: 'lsof', + args: ['-ti', ':47400'], + options: { stdio: 'pipe' }, + }, + ]); + }); + + it('returns no pid when port lookup fails or returns invalid output', async () => { + assert.strictEqual( + await findDaemonPidByPort(47400, { + spawnProcess: () => createLsofProcess({ output: '', closeCode: 1 }), + }), + null + ); + assert.strictEqual( + await findDaemonPidByPort(47400, { + spawnProcess: () => createLsofProcess({ output: 'nope\n' }), + }), + null + ); + assert.strictEqual( + await findDaemonPidByPort(47400, { + spawnProcess: () => createLsofProcess({ output: '4321abc\n' }), + }), + null + ); + assert.strictEqual( + await findDaemonPidByPort(47400, { + spawnProcess: () => createLsofProcess({ emitError: true }), + }), + null + ); + assert.strictEqual( + await findDaemonPidByPort(47400, { + spawnProcess: () => { + throw new Error('spawn failed'); + }, + }), + null + ); + }); + + it('prefers the pid file before falling back to port discovery', async () => { + let findByPortCalls = 0; + let pid = await resolveDaemonPid({ + port: 47400, + pidFile: '/repo/app/.vizzly/server.pid', + readPid: pidFile => { + assert.strictEqual(pidFile, '/repo/app/.vizzly/server.pid'); + return 1234; + }, + findByPort: () => { + findByPortCalls++; + return 4321; + }, + }); + + assert.strictEqual(pid, 1234); + assert.strictEqual(findByPortCalls, 0); + }); + + it('falls back to port discovery when the pid file is stale', async () => { + let pid = await resolveDaemonPid({ + port: 47400, + readPid: () => null, + findByPort: port => { + assert.strictEqual(port, 47400); + return 4321; + }, + }); + + assert.strictEqual(pid, 4321); + }); + }); + + describe('buildDaemonChildArgs', () => { + it('builds daemon child args from explicit options', () => { + let args = buildDaemonChildArgs({ + entrypoint: '/repo/bin/vizzly.js', + port: 47400, + options: { + open: true, + baselineBuild: 'build-123', + baselineComparison: 'comparison-456', + environment: 'staging', + threshold: 0.05, + minClusterSize: 4, + timeout: '45000', + failOnDiff: true, + token: 'token-abc', + }, + globalOptions: { + json: true, + verbose: true, + noColor: true, + }, + }); + + assert.deepStrictEqual(args, [ + '/repo/bin/vizzly.js', + 'tdd', + 'start', + '--daemon-child', + '--port', + '47400', + '--open', + '--baseline-build', + 'build-123', + '--baseline-comparison', + 'comparison-456', + '--environment', + 'staging', + '--threshold', + '0.05', + '--min-cluster-size', + '4', + '--timeout', + '45000', + '--fail-on-diff', + '--token', + 'token-abc', + '--json', + '--verbose', + '--no-color', + ]); + }); + }); + + describe('validateTddStartOptions', () => { + it('accepts valid comparison options', () => { + assert.deepStrictEqual( + validateTddStartOptions({ threshold: '0', minClusterSize: '1' }), + [] + ); + }); + + it('rejects invalid comparison options', () => { + assert.deepStrictEqual( + validateTddStartOptions({ + threshold: '-1', + minClusterSize: '2.5', + }), + [ + 'Threshold must be a non-negative number (CIEDE2000 Delta E)', + 'Min cluster size must be a positive integer', + ] + ); + }); + }); + + describe('dashboard open helpers', () => { + it('builds the local dashboard URL for a port', () => { + assert.strictEqual(buildDashboardUrl(47400), 'http://localhost:47400'); + }); + + it('uses open on macOS', () => { + assert.deepStrictEqual( + buildOpenDashboardCommand('http://localhost:47400', 'darwin'), + { + command: 'open', + args: ['http://localhost:47400'], + } + ); + }); + + it('uses xdg-open on Linux', () => { + assert.deepStrictEqual( + buildOpenDashboardCommand('http://localhost:47400', 'linux'), + { + command: 'xdg-open', + args: ['http://localhost:47400'], + } + ); + }); + + it('uses cmd start on Windows because start is a shell built-in', () => { + assert.deepStrictEqual( + buildOpenDashboardCommand('http://localhost:47400', 'win32'), + { + command: 'cmd', + args: ['/c', 'start', '', 'http://localhost:47400'], + } + ); + }); + }); + + describe('waitForDaemonChildInit', () => { + it('resolves when the daemon child disconnects after initialization', async () => { + let timers = createManualTimers(); + let child = new EventEmitter(); + + let promise = waitForDaemonChildInit(child, { timers }); + child.emit('disconnect'); + + let result = await promise; + + assert.deepStrictEqual(result, { ok: true }); + assert.strictEqual(timers.get(1), undefined); + assert.strictEqual(child.listenerCount('disconnect'), 0); + assert.strictEqual(child.listenerCount('exit'), 0); + }); + + it('returns an exit result when the daemon child exits first', async () => { + let timers = createManualTimers(); + let child = new EventEmitter(); + + let promise = waitForDaemonChildInit(child, { timers }); + child.emit('exit'); + + let result = await promise; + + assert.deepStrictEqual(result, { ok: false, reason: 'exit' }); + assert.strictEqual(timers.get(1), undefined); + assert.strictEqual(child.listenerCount('disconnect'), 0); + assert.strictEqual(child.listenerCount('exit'), 0); + }); + + it('returns a timeout result and removes listeners', async () => { + let timers = createManualTimers(); + let child = new EventEmitter(); + + let promise = waitForDaemonChildInit(child, { timers }); + await triggerTimer(timers, 1); + + let result = await promise; + + assert.strictEqual(result.ok, false); + assert.strictEqual(result.reason, 'timeout'); + assert.match(result.error.message, /initialization timed out/); + assert.strictEqual(child.listenerCount('disconnect'), 0); + assert.strictEqual(child.listenerCount('exit'), 0); + }); + }); + + describe('waitForServerRunning', () => { + it('checks immediately and returns when the health check succeeds', async () => { + let calls = []; + + let running = await waitForServerRunning(47400, { + isRunning: async port => { + calls.push(port); + return true; + }, + }); + + assert.strictEqual(running, true); + assert.deepStrictEqual(calls, [47400]); + }); + + it('waits between concrete health checks until the server responds', async () => { + let timers = createManualTimers(); + let attempts = 0; + let promise = waitForServerRunning(47400, { + maxAttempts: 3, + delayMs: 200, + timers, + isRunning: async () => { + attempts++; + return attempts === 3; + }, + }); + + await triggerTimer(timers, 1); + await triggerTimer(timers, 2); + + assert.strictEqual(await promise, true); + assert.strictEqual(attempts, 3); + }); + + it('returns false when all health checks fail', async () => { + let timers = createManualTimers(); + let promise = waitForServerRunning(47400, { + maxAttempts: 2, + delayMs: 200, + timers, + isRunning: async () => false, + }); + + await triggerTimer(timers, 1); + + assert.strictEqual(await promise, false); + }); + }); + + describe('waitForProcessExit', () => { + it('returns true immediately when the process is already gone', async () => { + let exited = await waitForProcessExit(123, { + processRunning: () => false, + }); + + assert.strictEqual(exited, true); + }); + + it('returns true once the process exits during the grace period', async () => { + let timers = createManualTimers(); + let checks = 0; + let promise = waitForProcessExit(123, { + timeoutMs: 300, + intervalMs: 100, + timers, + processRunning: () => { + checks++; + return checks < 2; + }, + }); + + await triggerTimer(timers, 1); + + assert.strictEqual(await promise, true); + assert.strictEqual(checks, 2); + }); + + it('returns false when the process is still running after the grace period', async () => { + let timers = createManualTimers(); + let promise = waitForProcessExit(123, { + timeoutMs: 200, + intervalMs: 100, + timers, + processRunning: () => true, + }); + + await triggerTimer(timers, 1); + await triggerTimer(timers, 2); + + assert.strictEqual(await promise, false); + }); + }); +}); diff --git a/tests/commands/tdd.test.js b/tests/commands/tdd.test.js index 8a7e6655..1f592330 100644 --- a/tests/commands/tdd.test.js +++ b/tests/commands/tdd.test.js @@ -34,7 +34,7 @@ function createMockConfig(overrides = {}) { apiUrl: 'https://api.test', server: { port: 47392, timeout: 30000 }, build: { environment: 'test' }, - comparison: { threshold: 0.1 }, + comparison: { threshold: 0.1, minClusterSize: 2 }, ...overrides, }; } @@ -398,6 +398,37 @@ describe('commands/tdd', () => { assert.strictEqual(capturedRunOptions.commit, 'def456'); }); + it('passes comparison config through to the TDD run', async () => { + let output = createMockOutput(); + let capturedRunOptions = null; + + await tddCommand( + 'npm test', + {}, + {}, + { + loadConfig: async () => + createMockConfig({ + comparison: { threshold: 0, minClusterSize: 6 }, + }), + createServerManager: () => ({ + start: async () => {}, + stop: async () => {}, + }), + runTests: async ({ runOptions }) => { + capturedRunOptions = runOptions; + return { screenshotsCaptured: 0, comparisons: [] }; + }, + detectBranch: async () => 'main', + detectCommit: async () => 'abc123', + output, + } + ); + + assert.strictEqual(capturedRunOptions.threshold, 0); + assert.strictEqual(capturedRunOptions.minClusterSize, 6); + }); + it('invokes onBuildCreated callback', async () => { let output = createMockOutput(); @@ -698,6 +729,13 @@ describe('commands/tdd', () => { ); }); + it('should fail with decimal port number', () => { + let errors = validateTddOptions('npm test', { port: '3000.5' }); + assert.ok( + errors.includes('Port must be a valid number between 1 and 65535') + ); + }); + it('should fail with port out of range (too low)', () => { let errors = validateTddOptions('npm test', { port: '0' }); assert.ok( @@ -726,6 +764,13 @@ describe('commands/tdd', () => { ); }); + it('should fail with decimal timeout', () => { + let errors = validateTddOptions('npm test', { timeout: '5000.5' }); + assert.ok( + errors.includes('Timeout must be at least 1000 milliseconds') + ); + }); + it('should fail with timeout too low', () => { let errors = validateTddOptions('npm test', { timeout: '500' }); assert.ok( @@ -759,6 +804,15 @@ describe('commands/tdd', () => { ); }); + it('should fail when threshold has trailing text', () => { + let errors = validateTddOptions('npm test', { threshold: '2abc' }); + assert.ok( + errors.includes( + 'Threshold must be a non-negative number (CIEDE2000 Delta E)' + ) + ); + }); + it('should fail with threshold below 0', () => { let errors = validateTddOptions('npm test', { threshold: '-0.1' }); assert.ok( @@ -774,6 +828,27 @@ describe('commands/tdd', () => { }); }); + describe('min cluster size validation', () => { + it('should pass with a positive integer', () => { + let errors = validateTddOptions('npm test', { minClusterSize: '2' }); + assert.strictEqual(errors.length, 0); + }); + + it('should fail with zero', () => { + let errors = validateTddOptions('npm test', { minClusterSize: '0' }); + assert.ok( + errors.includes('Min cluster size must be a positive integer') + ); + }); + + it('should fail with a decimal', () => { + let errors = validateTddOptions('npm test', { minClusterSize: '2.5' }); + assert.ok( + errors.includes('Min cluster size must be a positive integer') + ); + }); + }); + describe('multiple validation errors', () => { it('should return all validation errors', () => { let errors = validateTddOptions('', { diff --git a/tests/services/build-manager.test.js b/tests/services/build-manager.test.js index 4b7ec35a..8b57ab1c 100644 --- a/tests/services/build-manager.test.js +++ b/tests/services/build-manager.test.js @@ -1,219 +1,51 @@ import assert from 'node:assert'; import { describe, it } from 'node:test'; -import { - addScreenshotToBuild, - createBuildObject, - createQueuedBuild, - finalizeBuildObject, - generateBuildId, - updateBuild, - validateBuildOptions, -} from '../../src/services/build-manager.js'; +import { createBuildObject } from '../../src/services/build-manager.js'; describe('services/build-manager', () => { - describe('generateBuildId', () => { - it('generates a unique build ID with prefix', () => { - let id = generateBuildId(); - - assert.ok(id.startsWith('build-')); - assert.ok(id.length > 10); - }); - - it('generates different IDs on each call', () => { - let id1 = generateBuildId(); - let id2 = generateBuildId(); - - assert.notStrictEqual(id1, id2); - }); - }); - describe('createBuildObject', () => { - it('creates build with required fields', () => { - let build = createBuildObject({ + it('creates the local build shape used by the runner', () => { + let build = createBuildObject( + { + name: 'Test Build', + branch: 'main', + commit: 'abc123', + metadata: { ci: true }, + }, + { + randomId: () => 'local-id', + now: () => '2026-05-18T12:00:00.000Z', + } + ); + + assert.deepStrictEqual(build, { + id: 'build-local-id', name: 'Test Build', branch: 'main', commit: 'abc123', - }); - - assert.ok(build.id.startsWith('build-')); - assert.strictEqual(build.name, 'Test Build'); - assert.strictEqual(build.branch, 'main'); - assert.strictEqual(build.commit, 'abc123'); - assert.strictEqual(build.environment, 'test'); - assert.deepStrictEqual(build.metadata, {}); - assert.strictEqual(build.status, 'pending'); - assert.ok(build.createdAt); - assert.deepStrictEqual(build.screenshots, []); - }); - - it('uses default name when not provided', () => { - let build = createBuildObject({ branch: 'main' }); - - assert.ok(build.name.startsWith('build-')); - }); - - it('uses custom environment and metadata', () => { - let build = createBuildObject({ - name: 'Build', - environment: 'production', + environment: 'test', metadata: { ci: true }, - }); - - assert.strictEqual(build.environment, 'production'); - assert.deepStrictEqual(build.metadata, { ci: true }); - }); - }); - - describe('updateBuild', () => { - it('updates build status', () => { - let build = { - id: 'build-1', status: 'pending', - name: 'Test', - }; - - let updated = updateBuild(build, 'running'); - - assert.strictEqual(updated.status, 'running'); - assert.ok(updated.updatedAt); - assert.strictEqual(updated.name, 'Test'); - }); - - it('merges additional updates', () => { - let build = { id: 'build-1', status: 'pending' }; - - let updated = updateBuild(build, 'completed', { result: 'success' }); - - assert.strictEqual(updated.status, 'completed'); - assert.strictEqual(updated.result, 'success'); - }); - - it('does not mutate original build', () => { - let build = { id: 'build-1', status: 'pending' }; - - updateBuild(build, 'running'); - - assert.strictEqual(build.status, 'pending'); - }); - }); - - describe('addScreenshotToBuild', () => { - it('adds screenshot to build', () => { - let build = { - id: 'build-1', + createdAt: '2026-05-18T12:00:00.000Z', screenshots: [], - }; - let screenshot = { name: 'homepage', path: '/img/home.png' }; - - let updated = addScreenshotToBuild(build, screenshot); - - assert.strictEqual(updated.screenshots.length, 1); - assert.strictEqual(updated.screenshots[0].name, 'homepage'); - assert.ok(updated.screenshots[0].addedAt); - }); - - it('appends to existing screenshots', () => { - let build = { - id: 'build-1', - screenshots: [{ name: 'first' }], - }; - - let updated = addScreenshotToBuild(build, { name: 'second' }); - - assert.strictEqual(updated.screenshots.length, 2); - }); - - it('does not mutate original build', () => { - let build = { id: 'build-1', screenshots: [] }; - - addScreenshotToBuild(build, { name: 'test' }); - - assert.strictEqual(build.screenshots.length, 0); - }); - }); - - describe('finalizeBuildObject', () => { - it('sets status to completed on success', () => { - let build = { id: 'build-1', status: 'running' }; - - let finalized = finalizeBuildObject(build, { success: true }); - - assert.strictEqual(finalized.status, 'completed'); - assert.ok(finalized.completedAt); - assert.deepStrictEqual(finalized.result, { success: true }); - }); - - it('sets status to failed on failure', () => { - let build = { id: 'build-1', status: 'running' }; - - let finalized = finalizeBuildObject(build, { success: false }); - - assert.strictEqual(finalized.status, 'failed'); - }); - - it('handles empty result', () => { - let build = { id: 'build-1', status: 'running' }; - - let finalized = finalizeBuildObject(build); - - assert.strictEqual(finalized.status, 'failed'); // no success = failed - assert.deepStrictEqual(finalized.result, {}); - }); - }); - - describe('createQueuedBuild', () => { - it('creates queued build with timestamp', () => { - let options = { name: 'Build', branch: 'main' }; - - let queued = createQueuedBuild(options); - - assert.strictEqual(queued.name, 'Build'); - assert.strictEqual(queued.branch, 'main'); - assert.ok(queued.queuedAt); - }); - }); - - describe('validateBuildOptions', () => { - it('returns valid when name is provided', () => { - let result = validateBuildOptions({ name: 'Build' }); - - assert.strictEqual(result.valid, true); - assert.deepStrictEqual(result.errors, []); - }); - - it('returns valid when branch is provided', () => { - let result = validateBuildOptions({ branch: 'main' }); - - assert.strictEqual(result.valid, true); - }); - - it('returns invalid when neither name nor branch provided', () => { - let result = validateBuildOptions({}); - - assert.strictEqual(result.valid, false); - assert.ok(result.errors.some(e => e.includes('name or branch'))); - }); - - it('validates environment values', () => { - let validResult = validateBuildOptions({ - name: 'Build', - environment: 'production', - }); - assert.strictEqual(validResult.valid, true); - - let invalidResult = validateBuildOptions({ - name: 'Build', - environment: 'invalid', }); - assert.strictEqual(invalidResult.valid, false); - assert.ok(invalidResult.errors.some(e => e.includes('Environment'))); }); - it('accepts all valid environments', () => { - for (let env of ['test', 'staging', 'production']) { - let result = validateBuildOptions({ name: 'Build', environment: env }); - assert.strictEqual(result.valid, true, `Should accept ${env}`); - } + it('keeps existing default behavior for callers with minimal options', () => { + let build = createBuildObject( + { branch: 'main' }, + { + randomId: () => 'minimal-id', + now: () => '2026-05-18T12:00:00.000Z', + timestamp: () => 1779120000000, + } + ); + + assert.strictEqual(build.id, 'build-minimal-id'); + assert.strictEqual(build.name, 'build-1779120000000'); + assert.strictEqual(build.branch, 'main'); + assert.strictEqual(build.environment, 'test'); + assert.deepStrictEqual(build.metadata, {}); }); }); }); diff --git a/tests/services/static-report-generator.test.js b/tests/services/static-report-generator.test.js index be994091..1cf1ae8f 100644 --- a/tests/services/static-report-generator.test.js +++ b/tests/services/static-report-generator.test.js @@ -10,6 +10,11 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { afterEach, beforeEach, describe, it } from 'node:test'; import { fileURLToPath } from 'node:url'; +import { + generateStaticReport, + getReportFileUrl, + loadStaticReportRenderer, +} from '../../src/services/static-report-generator.js'; let __dirname = dirname(fileURLToPath(import.meta.url)); @@ -96,6 +101,23 @@ function setupMockVizzlyDir(workingDir, options = {}) { return vizzlyDir; } +async function generateInjectedStaticReport(workingDir, options = {}) { + let capturedReportData; + let result = await generateStaticReport(workingDir, { + css: options.css ?? '.report { color: red; }', + outputDir: options.outputDir, + renderStaticReport: reportData => { + capturedReportData = reportData; + return ( + options.renderedContent || + `
${JSON.stringify(reportData)}
` + ); + }, + }); + + return { result, capturedReportData }; +} + describe('services/static-report-generator', () => { let tempDir; @@ -109,10 +131,6 @@ describe('services/static-report-generator', () => { describe('generateStaticReport', () => { it('should return error when report data is missing', async () => { - let { generateStaticReport } = await import( - '../../src/services/static-report-generator.js' - ); - let result = await generateStaticReport(tempDir); assert.strictEqual(result.success, false); @@ -120,20 +138,50 @@ describe('services/static-report-generator', () => { assert.ok(result.error.includes('No report data found')); }); - it('should generate HTML report with correct structure', async t => { - // Skip if SSR build artifacts don't exist (CI runs tests before build) - if (!ssrBuildExists()) { - t.skip('SSR build not available - run npm run build first'); - return; - } - + it('generates an HTML report with injected renderer and CSS', async () => { + let capturedReportData; setupMockVizzlyDir(tempDir); - let { generateStaticReport } = await import( - '../../src/services/static-report-generator.js' + let result = await generateStaticReport(tempDir, { + css: '.report { color: red; }', + renderStaticReport: reportData => { + capturedReportData = reportData; + return '
Rendered report
'; + }, + }); + + assert.strictEqual(result.success, true); + assert.ok(result.reportPath.endsWith('index.html')); + assert.ok(existsSync(result.reportPath)); + assert.strictEqual( + capturedReportData.comparisons[0].baseline, + './images/baselines/test.png' + ); + assert.strictEqual( + capturedReportData.comparisons[0].current, + './images/current/test.png' + ); + assert.strictEqual( + capturedReportData.comparisons[0].diff, + './images/diffs/test.png' ); - let result = await generateStaticReport(tempDir); + let html = readFileSync(result.reportPath, 'utf8'); + assert.ok(html.includes('