UNPKG

@vizzly-testing/cli

Version:

Visual review platform for UI developers and designers

249 lines (233 loc) 8.34 kB
/** * TDD command implementation * Uses functional operations directly - no class wrappers needed */ import { spawn as defaultSpawn } from 'node:child_process'; import { createBuild as defaultCreateApiBuild, createApiClient as defaultCreateApiClient, finalizeBuild as defaultFinalizeApiBuild, getBuild as defaultGetBuild } from '../api/index.js'; import { VizzlyError } from '../errors/vizzly-error.js'; import { createServerManager as defaultCreateServerManager } from '../server-manager/index.js'; import { createAuthService as defaultCreateAuthService } from '../services/auth-service.js'; import { createBuildObject as defaultCreateBuildObject } from '../services/build-manager.js'; import { createConfigService as defaultCreateConfigService } from '../services/config-service.js'; import { createProjectService as defaultCreateProjectService } from '../services/project-service.js'; import { initializeDaemon as defaultInitializeDaemon, runTests as defaultRunTests } from '../test-runner/index.js'; import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js'; import { detectBranch as defaultDetectBranch, detectCommit as defaultDetectCommit } from '../utils/git.js'; import * as defaultOutput from '../utils/output.js'; /** * TDD command implementation * @param {string} testCommand - Test command to execute * @param {Object} options - Command options * @param {Object} globalOptions - Global CLI options * @param {Object} deps - Dependencies for testing * @returns {Promise<{result: Object, cleanup: Function}>} Result and cleanup function */ export async function tddCommand(testCommand, options = {}, globalOptions = {}, deps = {}) { let { loadConfig = defaultLoadConfig, createApiClient = defaultCreateApiClient, createApiBuild = defaultCreateApiBuild, finalizeApiBuild = defaultFinalizeApiBuild, getBuild = defaultGetBuild, createServerManager = defaultCreateServerManager, createBuildObject = defaultCreateBuildObject, createAuthService = defaultCreateAuthService, createConfigService = defaultCreateConfigService, createProjectService = defaultCreateProjectService, initializeDaemon = defaultInitializeDaemon, runTests = defaultRunTests, detectBranch = defaultDetectBranch, detectCommit = defaultDetectCommit, spawn = defaultSpawn, output = defaultOutput } = deps; output.configure({ json: globalOptions.json, verbose: globalOptions.verbose, color: !globalOptions.noColor }); let serverManager = null; let testProcess = null; let isCleanedUp = false; // Create cleanup function that can be called by the caller let cleanup = async () => { if (isCleanedUp) return; isCleanedUp = true; output.cleanup(); if (testProcess && !testProcess.killed) { testProcess.kill('SIGKILL'); } if (serverManager) { await serverManager.stop(); } }; try { // Load configuration with CLI overrides let allOptions = { ...globalOptions, ...options }; let config = await loadConfig(globalOptions.config, allOptions); // Dev mode works locally by default - only needs token for baseline download let needsToken = options.baselineBuild || options.baselineComparison; if (!config.apiKey && needsToken) { throw new Error('API token required when using --baseline-build or --baseline-comparison flags'); } // Always allow no-token mode for dev mode unless baseline flags are used config.allowNoToken = true; // Collect git metadata let branch = await detectBranch(options.branch); let commit = await detectCommit(options.commit); // Show header (skip in daemon mode) if (!options.daemon) { let mode = config.apiKey ? 'local' : 'local'; output.header('tdd', mode); // Show config in verbose mode output.debug('config', `port=${config.server.port} threshold=${config.comparison.threshold}`); } // Create functional dependencies output.startSpinner('Initializing TDD server...'); let configWithVerbose = { ...config, verbose: globalOptions.verbose }; // Create services for dashboard tabs let configService = createConfigService({ workingDir: process.cwd() }); let authService = createAuthService(); let projectService = createProjectService(); // Create server manager (functional object) serverManager = createServerManager(configWithVerbose, { configService, authService, projectService }); // Create build manager (functional object that provides the interface runTests expects) let buildManager = { async createBuild(buildOptions) { return createBuildObject(buildOptions); } }; output.stopSpinner(); let runOptions = { testCommand, port: config.server.port, timeout: config.server.timeout, tdd: true, daemon: options.daemon || false, setBaseline: options.setBaseline || false, branch, commit, environment: config.build.environment, threshold: config.comparison.threshold, allowNoToken: config.allowNoToken || false, baselineBuildId: config.baselineBuildId, baselineComparisonId: config.baselineComparisonId, wait: false }; // In daemon mode, just start the server without running tests if (options.daemon) { await initializeDaemon({ initOptions: runOptions, deps: { serverManager, createError: (msg, code) => new VizzlyError(msg, code), output, onServerReady: data => { output.debug('server', `ready on :${data.port}`); } } }); return { result: { success: true, daemon: true, port: config.server.port }, cleanup }; } // Normal dev mode - run tests output.debug('run', testCommand); let runResult = await runTests({ runOptions, config: configWithVerbose, deps: { serverManager, buildManager, spawn: (command, spawnOptions) => { let proc = spawn(command, spawnOptions); testProcess = proc; return proc; }, createApiClient, createApiBuild, getBuild, finalizeApiBuild, createError: (msg, code) => new VizzlyError(msg, code), output, onBuildCreated: data => { output.debug('build', `created ${data.buildId?.substring(0, 8)}`); }, onServerReady: data => { output.debug('server', `ready on :${data.port}`); }, onFinalizeFailed: data => { output.warn(`Failed to finalize build: ${data.error}`); } } }); // Determine success based on comparison results // (Summary is printed by printResults() in tdd-service.js, called from getTddResults) let hasFailures = runResult.failed || runResult.comparisons?.some(c => c.status === 'failed'); return { result: { success: !hasFailures, exitCode: hasFailures ? 1 : 0, ...runResult }, cleanup }; } catch (error) { output.error('Test failed', error); return { result: { success: false, exitCode: 1, error: error.message }, cleanup }; } } /** * Validate TDD options * @param {string} testCommand - Test command to execute * @param {Object} options - Command options */ export function validateTddOptions(testCommand, options) { let errors = []; if (!testCommand || testCommand.trim() === '') { errors.push('Test command is required'); } if (options.port) { let port = parseInt(options.port, 10); if (Number.isNaN(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) { errors.push('Timeout must be at least 1000 milliseconds'); } } if (options.threshold !== undefined) { let threshold = parseFloat(options.threshold); if (Number.isNaN(threshold) || threshold < 0) { errors.push('Threshold must be a non-negative number (CIEDE2000 Delta E)'); } } return errors; }