UNPKG

@vizzly-testing/cli

Version:

Visual review platform for UI developers and designers

321 lines (300 loc) 11 kB
import { loadConfig } from '../utils/config-loader.js'; import { ConsoleUI } from '../utils/console-ui.js'; import { createServiceContainer } from '../container/index.js'; import { detectBranch, detectCommit, detectCommitMessage, detectPullRequestNumber, generateBuildNameWithGit } from '../utils/git.js'; /** * Run command implementation * @param {string} testCommand - Test command to execute * @param {Object} options - Command options * @param {Object} globalOptions - Global CLI options */ export async function runCommand(testCommand, options = {}, globalOptions = {}) { // Create UI handler const ui = new ConsoleUI({ json: globalOptions.json, verbose: globalOptions.verbose, color: !globalOptions.noColor }); let testRunner = null; let buildId = null; let startTime = null; let isTddMode = false; // Ensure cleanup on exit const cleanup = async () => { ui.cleanup(); // Cancel test runner (kills process and stops server) if (testRunner) { try { await testRunner.cancel(); } catch { // Silent fail } } // Finalize build if we have one if (testRunner && buildId) { try { const executionTime = Date.now() - (startTime || Date.now()); await testRunner.finalizeBuild(buildId, isTddMode, false, executionTime); } catch { // Silent fail on cleanup } } }; const sigintHandler = async () => { await cleanup(); process.exit(1); }; const exitHandler = () => ui.cleanup(); process.on('SIGINT', sigintHandler); process.on('exit', exitHandler); try { // Load configuration with CLI overrides const allOptions = { ...globalOptions, ...options }; // Debug: Check options before loadConfig if (process.env.DEBUG_CONFIG) { console.log('[RUN] allOptions.token:', allOptions.token ? allOptions.token.substring(0, 8) + '***' : 'NONE'); } const config = await loadConfig(globalOptions.config, allOptions); // Debug: Check config immediately after loadConfig if (process.env.DEBUG_CONFIG) { console.log('[RUN] Config after loadConfig:', { hasApiKey: !!config.apiKey, apiKeyPrefix: config.apiKey ? config.apiKey.substring(0, 8) + '***' : 'NONE' }); } if (globalOptions.verbose) { ui.info('Token check:', { 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) { ui.error('API token required. Use --token, set VIZZLY_TOKEN environment variable, or use --allow-no-token to run without uploading'); return; } // Collect git metadata and build info const branch = await detectBranch(options.branch); const commit = await detectCommit(options.commit); const message = options.message || (await detectCommitMessage()); const buildName = await generateBuildNameWithGit(options.buildName); const pullRequestNumber = detectPullRequestNumber(); if (globalOptions.verbose) { ui.info('Configuration loaded', { 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 service container and get test runner service ui.startSpinner('Initializing test runner...'); const configWithVerbose = { ...config, verbose: globalOptions.verbose, uploadAll: options.uploadAll || false }; // Debug: Check config before creating container if (process.env.DEBUG_CONFIG) { console.log('[RUN] Config before container:', { hasApiKey: !!configWithVerbose.apiKey, apiKeyPrefix: configWithVerbose.apiKey ? configWithVerbose.apiKey.substring(0, 8) + '***' : 'NONE' }); } const command = 'run'; const container = await createServiceContainer(configWithVerbose, command); testRunner = await container.get('testRunner'); // Assign to outer scope variable ui.stopSpinner(); // Track build URL for display let buildUrl = null; // Set up event handlers testRunner.on('progress', progressData => { const { message: progressMessage } = progressData; ui.progress(progressMessage || 'Running tests...'); }); testRunner.on('test-output', output => { // In non-JSON mode, show test output directly if (!globalOptions.json) { ui.stopSpinner(); console.log(output.data); } }); testRunner.on('server-ready', serverInfo => { if (globalOptions.verbose) { ui.info(`Screenshot server running on port ${serverInfo.port}`); ui.info('Server details', serverInfo); } }); testRunner.on('screenshot-captured', screenshotInfo => { // Use UI for consistent formatting ui.info(`Vizzly: Screenshot captured - ${screenshotInfo.name}`); }); testRunner.on('build-created', buildInfo => { buildUrl = buildInfo.url; buildId = buildInfo.buildId; // Debug: Log build creation details if (globalOptions.verbose) { ui.info(`Build created: ${buildInfo.buildId} - ${buildInfo.name}`); } // Use UI for consistent formatting if (buildUrl) { ui.info(`Vizzly: ${buildUrl}`); } }); testRunner.on('build-failed', buildError => { ui.error('Failed to create build', buildError); }); testRunner.on('error', error => { ui.stopSpinner(); // Stop spinner to ensure error is visible ui.error('Test runner error occurred', error, 0); // Don't exit immediately, let runner handle it }); testRunner.on('build-finalize-failed', errorInfo => { ui.warning(`Failed to finalize build ${errorInfo.buildId}: ${errorInfo.error}`); }); // Prepare run options const runOptions = { testCommand, port: config.server.port, timeout: config.server.timeout, buildName, branch, commit, message, environment: config.build.environment, threshold: config.comparison.threshold, 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 ui.info('Starting test execution...'); startTime = Date.now(); isTddMode = runOptions.tdd || false; let result; try { result = await testRunner.run(runOptions); // Store buildId for cleanup purposes if (result.buildId) { buildId = result.buildId; } ui.success('Test run completed successfully'); // Show Vizzly summary if (result.buildId) { console.log(`🐻 Vizzly: Captured ${result.screenshotsCaptured} screenshots in build ${result.buildId}`); if (result.url) { console.log(`🔗 Vizzly: View results at ${result.url}`); } } } catch (error) { // Test execution failed - build should already be finalized by test runner ui.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 const exitCodeMatch = error.message.match(/exited with code (\d+)/); const exitCode = exitCodeMatch ? parseInt(exitCodeMatch[1], 10) : 1; ui.error('Test run failed'); return { success: false, exitCode }; } else { // Setup or other error ui.error('Test run failed', error); return { success: false, exitCode: 1 }; } } // Output results if (result.buildId) { // Wait for build completion if requested if (runOptions.wait) { ui.info('Waiting for build completion...'); ui.startSpinner('Processing comparisons...'); const uploader = await container.get('uploader'); const buildResult = await uploader.waitForBuild(result.buildId); ui.success('Build processing completed'); // Exit with appropriate code based on comparison results if (buildResult.failedComparisons > 0) { ui.error(`${buildResult.failedComparisons} visual comparisons failed`, {}, 0); // Return error status without calling process.exit in tests return { success: false, exitCode: 1 }; } } } ui.cleanup(); } catch (error) { ui.stopSpinner(); // Ensure spinner is stopped before showing error // Provide more context about where the error occurred let errorContext = 'Test run failed'; if (error.message && error.message.includes('build')) { errorContext = 'Build creation failed'; } else if (error.message && error.message.includes('screenshot')) { errorContext = 'Screenshot processing failed'; } else if (error.message && error.message.includes('server')) { errorContext = 'Server startup failed'; } ui.error(errorContext, error); } finally { // Remove event listeners to prevent memory leaks process.removeListener('SIGINT', sigintHandler); process.removeListener('exit', exitHandler); } } /** * Validate run options * @param {string} testCommand - Test command to execute * @param {Object} options - Command options */ export function validateRunOptions(testCommand, options) { const errors = []; if (!testCommand || testCommand.trim() === '') { errors.push('Test command is required'); } if (options.port) { const port = parseInt(options.port, 10); if (isNaN(port) || port < 1 || port > 65535) { errors.push('Port must be a valid number between 1 and 65535'); } } if (options.timeout) { const timeout = parseInt(options.timeout, 10); if (isNaN(timeout) || timeout < 1000) { errors.push('Timeout must be at least 1000 milliseconds'); } } if (options.batchSize !== undefined) { const n = parseInt(options.batchSize, 10); if (!Number.isFinite(n) || n <= 0) { errors.push('Batch size must be a positive integer'); } } if (options.uploadTimeout !== undefined) { const n = parseInt(options.uploadTimeout, 10); if (!Number.isFinite(n) || n <= 0) { errors.push('Upload timeout must be a positive integer (milliseconds)'); } } return errors; }