UNPKG

@vizzly-testing/cli

Version:

Visual review platform for UI developers and designers

406 lines (383 loc) 13.9 kB
/** * Run 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, getTokenContext as defaultGetTokenContext } from '../api/index.js'; 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 '../services/uploader.js'; import { finalizeBuild as defaultFinalizeBuild, runTests as defaultRunTests } from '../test-runner/index.js'; import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js'; import { detectBranch as defaultDetectBranch, detectCommit as defaultDetectCommit, detectCommitMessage as defaultDetectCommitMessage, detectPullRequestNumber as defaultDetectPullRequestNumber, generateBuildNameWithGit as defaultGenerateBuildNameWithGit } from '../utils/git.js'; import * as defaultOutput from '../utils/output.js'; /** * Run 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 */ export async function runCommand(testCommand, options = {}, globalOptions = {}, deps = {}) { let { loadConfig = defaultLoadConfig, createApiClient = defaultCreateApiClient, createApiBuild = defaultCreateApiBuild, finalizeApiBuild = defaultFinalizeApiBuild, getBuild = defaultGetBuild, getTokenContext = defaultGetTokenContext, createServerManager = defaultCreateServerManager, createBuildObject = defaultCreateBuildObject, createUploader = defaultCreateUploader, finalizeBuild = defaultFinalizeBuild, runTests = defaultRunTests, detectBranch = defaultDetectBranch, detectCommit = defaultDetectCommit, detectCommitMessage = defaultDetectCommitMessage, detectPullRequestNumber = defaultDetectPullRequestNumber, generateBuildNameWithGit = defaultGenerateBuildNameWithGit, spawn = defaultSpawn, output = defaultOutput, exit = code => process.exit(code), processOn = (event, handler) => process.on(event, handler), processRemoveListener = (event, handler) => process.removeListener(event, handler) } = deps; output.configure({ json: globalOptions.json, verbose: globalOptions.verbose, color: !globalOptions.noColor }); let serverManager = null; let testProcess = null; let buildId = null; let startTime = null; let isTddMode = false; let config = null; // Ensure cleanup on exit let cleanup = async () => { output.cleanup(); // Kill test process if running if (testProcess && !testProcess.killed) { testProcess.kill('SIGKILL'); } // Stop server if (serverManager) { try { await serverManager.stop(); } catch { // Silent fail } } // Finalize build if we have one if (buildId && config) { try { let executionTime = Date.now() - (startTime || Date.now()); await finalizeBuild({ buildId, tdd: isTddMode, success: false, executionTime, config, deps: { serverManager, createApiClient, finalizeApiBuild, output } }); } catch { // Silent fail on cleanup } } }; let sigintHandler = async () => { await cleanup(); exit(1); }; let exitHandler = () => output.cleanup(); processOn('SIGINT', sigintHandler); processOn('exit', exitHandler); try { // Load configuration with CLI overrides let allOptions = { ...globalOptions, ...options }; output.debug('[RUN] Loading config', { hasToken: !!allOptions.token }); config = await loadConfig(globalOptions.config, allOptions); output.debug('[RUN] Config loaded', { hasApiKey: !!config.apiKey, apiKeyPrefix: config.apiKey ? `${config.apiKey.substring(0, 8)}***` : 'NONE' }); if (globalOptions.verbose) { output.info('Token check:'); output.debug('Token details', { hasApiKey: !!config.apiKey, apiKeyType: typeof config.apiKey, apiKeyPrefix: typeof config.apiKey === 'string' && config.apiKey ? `${config.apiKey.substring(0, 10)}...` : 'none', projectSlug: config.projectSlug || 'none', organizationSlug: config.organizationSlug || 'none' }); } // Validate API token (unless --allow-no-token is set) if (!config.apiKey && !config.allowNoToken) { output.error('API token required. Use --token, set VIZZLY_TOKEN environment variable, or use --allow-no-token to run without uploading'); exit(1); return { success: false, reason: 'no-api-key' }; } // Collect git metadata and build info let branch = await detectBranch(options.branch); let commit = await detectCommit(options.commit); let message = options.message || (await detectCommitMessage()); let buildName = await generateBuildNameWithGit(options.buildName); let pullRequestNumber = detectPullRequestNumber(); if (globalOptions.verbose) { output.info('Configuration loaded'); output.debug('Config details', { testCommand, port: config.server.port, timeout: config.server.timeout, branch, commit: commit?.substring(0, 7), message, buildName, environment: config.build.environment, allowNoToken: config.allowNoToken || false }); } // Create functional dependencies output.startSpinner('Initializing test runner...'); let configWithVerbose = { ...config, verbose: globalOptions.verbose, uploadAll: options.uploadAll || false }; output.debug('[RUN] Creating services', { hasApiKey: !!configWithVerbose.apiKey }); // Create server manager (functional object) // Note: Unlike TDD mode, run command doesn't need authService/projectService // because it has no interactive dashboard - it's a one-shot CI command serverManager = createServerManager(configWithVerbose, {}); // Create build manager (functional object) let buildManager = { async createBuild(buildOptions) { return createBuildObject(buildOptions); } }; // Create uploader for --wait functionality let uploader = createUploader({ ...configWithVerbose, command: 'run' }); output.stopSpinner(); // Track build URL for display let buildUrl = null; // Prepare run options let runOptions = { testCommand, port: config.server.port, timeout: config.server.timeout, buildName, branch, commit, message, environment: config.build.environment, threshold: config.comparison.threshold, minClusterSize: config.comparison.minClusterSize, eager: config.eager || false, allowNoToken: config.allowNoToken || false, wait: config.wait || options.wait || false, uploadAll: options.uploadAll || false, pullRequestNumber, parallelId: config.parallelId }; // Start test run output.info('Starting test execution...'); startTime = Date.now(); isTddMode = runOptions.tdd || false; let result; try { result = 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 => { buildUrl = data.url; buildId = data.buildId; if (globalOptions.verbose) { output.info(`Build created: ${data.buildId}`); } if (buildUrl) { output.info(`Vizzly: ${buildUrl}`); } }, onServerReady: data => { if (globalOptions.verbose) { output.info(`Screenshot server running on port ${data.port}`); } }, onFinalizeFailed: data => { output.warn(`Failed to finalize build ${data.buildId}: ${data.error}`); } } }); // Store buildId for cleanup purposes if (result.buildId) { buildId = result.buildId; } output.complete('Test run completed'); // Show Vizzly summary with link to results if (result.buildId) { output.blank(); let colors = output.getColors(); output.print(` ${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}`; } } if (displayUrl) { output.print(` ${colors.brand.textTertiary('Results')} ${colors.cyan(colors.underline(displayUrl))}`); } else { output.print(` ${colors.brand.textTertiary('Build')} ${colors.dim(result.buildId)}`); } } } catch (error) { // Test execution failed - build should already be finalized by test runner output.stopSpinner(); // Check if it's a test command failure (as opposed to setup failure) if (error.code === 'TEST_COMMAND_FAILED' || error.code === 'TEST_COMMAND_INTERRUPTED') { // 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; output.error('Test run failed'); return { success: false, exitCode }; } else { // Setup or other error - VizzlyError.getUserMessage() provides context output.error('Test run failed', error); return { success: false, exitCode: 1 }; } } // Output results if (result.buildId) { // Wait for build completion if requested if (runOptions.wait) { output.info('Waiting for build completion...'); output.startSpinner('Processing comparisons...'); let buildResult = await uploader.waitForBuild(result.buildId); output.success('Build processing completed'); // Exit with appropriate code based on comparison results if (buildResult.failedComparisons > 0) { output.error(`${buildResult.failedComparisons} visual comparisons failed`); return { success: false, exitCode: 1 }; } } } output.cleanup(); return { success: true, result }; } catch (error) { output.stopSpinner(); // Provide more context about where the error occurred let errorContext = 'Test run failed'; if (error.message?.includes('build')) { errorContext = 'Build creation failed'; } else if (error.message?.includes('screenshot')) { errorContext = 'Screenshot processing failed'; } else if (error.message?.includes('server')) { errorContext = 'Server startup failed'; } output.error(errorContext, error); exit(1); return { success: false, error }; } finally { // Remove event listeners to prevent memory leaks processRemoveListener('SIGINT', sigintHandler); processRemoveListener('exit', exitHandler); } } /** * Validate run options * @param {string} testCommand - Test command to execute * @param {Object} options - Command options */ export function validateRunOptions(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.batchSize !== undefined) { let n = parseInt(options.batchSize, 10); if (!Number.isFinite(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) { errors.push('Upload timeout must be a positive integer (milliseconds)'); } } return errors; }