@vizzly-testing/cli
Version:
Visual review platform for UI developers and designers
321 lines (300 loc) • 11 kB
JavaScript
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;
}