UNPKG

citty-test-utils

Version:

Unified testing framework for CLI applications with auto-detecting local/cleanroom execution, vitest config integration, and simplified scenario DSL.

509 lines (461 loc) 15 kB
#!/usr/bin/env node /** * @fileoverview Unified Runner for Citty Testing (v1.0.0) * @description Single entry point for running CLI tests with automatic mode detection * * Features: * - Auto-detect mode: cleanroom vs local based on config * - Config hierarchy: vitest.config > options > defaults * - Fluent assertions on results * - Fail-fast with clear error messages * * @example * // Simple usage - auto-detects mode from config * const result = await runCitty(['--help']) * result.expectSuccess().expectOutput('Usage:') * * @example * // Override config with options * const result = await runCitty(['test'], { * cleanroom: { enabled: false }, // Force local mode * timeout: 5000 * }) * * @example * // Explicit cleanroom config * const result = await runCitty(['deploy'], { * cleanroom: { * enabled: true, * nodeImage: 'node:20-alpine', * memoryLimit: '1g' * } * }) */ import { existsSync } from 'node:fs' import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' import { z } from 'zod' import { execSync } from 'node:child_process' // Import existing runners import { runLocalCitty as executeLocal, runLocalCittySafe } from './local-runner.js' import { setupCleanroom, runCitty as executeCleanroom, teardownCleanroom } from './cleanroom-runner.js' import { wrapExpectation } from '../assertions/assertions.js' /** * Zod schema for cleanroom configuration */ const CleanroomConfigSchema = z.object({ enabled: z.boolean().default(false), nodeImage: z.string().optional().default('node:20-alpine'), memoryLimit: z.string().optional().default('512m'), cpuLimit: z.string().optional().default('1.0'), timeout: z.number().positive().optional().default(60000), rootDir: z.string().optional().default('.'), }).optional() /** * Zod schema for unified runner options * Note: Using z.any() for env due to Zod v4 record API limitations */ const UnifiedRunnerOptionsSchema = z.object({ // CLI execution options cliPath: z.string().optional(), cwd: z.string().optional(), env: z.any().optional(), // Record<string, string> - using any() for Zod v4 compatibility timeout: z.number().positive().optional().default(30000), // Cleanroom options cleanroom: CleanroomConfigSchema, // Output options json: z.boolean().optional().default(false), // Mode override (bypasses auto-detection) mode: z.enum(['local', 'cleanroom', 'auto']).optional().default('auto'), }).passthrough() /** * Vitest config schema (citty section) */ const VitestCittyConfigSchema = z.object({ cliPath: z.string().optional(), cwd: z.string().optional(), cleanroom: CleanroomConfigSchema.optional(), timeout: z.number().positive().optional(), env: z.any().optional(), // Record<string, string> }).optional() /** * Load vitest config and extract citty settings * * @param {string} [configPath] - Optional path to vitest config * @returns {Promise<Object>} Parsed citty config section or empty object */ async function loadVitestConfig(configPath) { try { // Try to find vitest config in current directory const cwd = process.cwd() const configFiles = [ configPath, resolve(cwd, 'vitest.config.js'), resolve(cwd, 'vitest.config.mjs'), resolve(cwd, 'vitest.config.ts'), ].filter(Boolean) for (const file of configFiles) { if (existsSync(file)) { // Dynamically import the config const config = await import(file) const vitestConfig = config.default || config // Extract citty-specific settings from test.env or top-level citty key const cittyConfig = { cliPath: vitestConfig.test?.env?.TEST_CLI_PATH, cwd: vitestConfig.test?.env?.TEST_CWD, timeout: vitestConfig.test?.testTimeout, // Check for citty section ...(vitestConfig.citty || {}), } // Validate and return return VitestCittyConfigSchema.parse(cittyConfig) } } // No config found, return empty object return {} } catch (error) { // Config loading failed, return empty object (fail gracefully) console.warn(`Warning: Failed to load vitest config: ${error.message}`) return {} } } /** * Merge configuration with proper precedence * Priority: options > vitest config > environment > defaults * * @param {Object} vitestConfig - Config from vitest.config * @param {Object} options - User-provided options * @param {Object} defaults - Default values * @returns {Object} Merged configuration */ function mergeConfig(vitestConfig = {}, options = {}, defaults = {}) { return { // CLI path: options > vitest > env > default cliPath: options.cliPath || vitestConfig.cliPath || process.env.TEST_CLI_PATH || defaults.cliPath || './src/cli.mjs', // Working directory: options > vitest > env > default cwd: options.cwd || vitestConfig.cwd || process.env.TEST_CWD || defaults.cwd || process.cwd(), // Environment variables: merge all sources env: options.env !== undefined ? options.env : (vitestConfig.env || defaults.env || {}), // Timeout: options > vitest > default timeout: options.timeout || vitestConfig.timeout || defaults.timeout || 30000, // Cleanroom config: deep merge cleanroom: { enabled: options.cleanroom?.enabled ?? vitestConfig.cleanroom?.enabled ?? defaults.cleanroom?.enabled ?? false, nodeImage: options.cleanroom?.nodeImage || vitestConfig.cleanroom?.nodeImage || defaults.cleanroom?.nodeImage || 'node:20-alpine', memoryLimit: options.cleanroom?.memoryLimit || vitestConfig.cleanroom?.memoryLimit || defaults.cleanroom?.memoryLimit || '512m', cpuLimit: options.cleanroom?.cpuLimit || vitestConfig.cleanroom?.cpuLimit || defaults.cleanroom?.cpuLimit || '1.0', timeout: options.cleanroom?.timeout || vitestConfig.cleanroom?.timeout || defaults.cleanroom?.timeout || 60000, rootDir: options.cleanroom?.rootDir || vitestConfig.cleanroom?.rootDir || defaults.cleanroom?.rootDir || '.', }, // JSON output flag json: options.json ?? defaults.json ?? false, // Mode override mode: options.mode || defaults.mode || 'auto', } } /** * Detect execution mode based on configuration * * @param {Object} config - Merged configuration * @returns {string} Detected mode: 'cleanroom' or 'local' */ function detectMode(config) { // If mode is explicitly set and not 'auto', use it if (config.mode !== 'auto') { return config.mode } // Auto-detect: check if cleanroom is enabled if (config.cleanroom?.enabled === true) { return 'cleanroom' } // Default to local mode return 'local' } /** * Execute CLI in local mode * * @param {string[]} args - CLI arguments * @param {Object} config - Merged configuration * @returns {Promise<Object>} Execution result */ async function executeLocalMode(args, config) { const { cliPath, cwd, env, timeout } = config // Validate CLI path exists const resolvedCliPath = resolve(cwd, cliPath) if (!existsSync(resolvedCliPath)) { throw new Error( `CLI file not found: ${resolvedCliPath}\n` + `Expected path: ${cliPath}\n` + `Working directory: ${cwd}\n` + `Resolved to: ${resolvedCliPath}\n\n` + `Possible fixes:\n` + ` 1. Check the cliPath is correct\n` + ` 2. Ensure the file exists at the specified location\n` + ` 3. Use an absolute path: cliPath: '/absolute/path/to/cli.js'\n` + ` 4. Check your working directory (cwd) is correct\n` + ` 5. Configure in vitest.config: test.env.TEST_CLI_PATH` ) } // Execute using local runner const result = executeLocal({ cliPath, cwd, env, timeout, args, }) return result } /** * Check if Docker is available and running * * @throws {Error} If Docker is not available or not running */ function checkDockerAvailability() { try { // Try to run docker ps to check if Docker is running execSync('docker ps', { stdio: 'pipe' }) } catch (error) { throw new Error( `Docker is not available or not running.\n\n` + `Cleanroom testing requires Docker to create isolated test environments.\n\n` + `To fix this:\n` + ` 1. Install Docker Desktop: https://www.docker.com/products/docker-desktop\n` + ` 2. Start Docker Desktop and wait for it to be ready\n` + ` 3. Verify Docker is running: docker ps\n\n` + `Alternatively, disable cleanroom mode in your vitest.config.js:\n` + ` test: {\n` + ` citty: {\n` + ` cleanroom: { enabled: false }\n` + ` }\n` + ` }\n\n` + `Original error: ${error.message}` ) } } /** * Execute CLI in cleanroom mode * * @param {string[]} args - CLI arguments * @param {Object} config - Merged configuration * @returns {Promise<Object>} Execution result */ async function executeCleanroomMode(args, config) { const { cleanroom, cwd, env, timeout, json } = config // Check Docker availability before attempting cleanroom setup checkDockerAvailability() // Setup cleanroom if not already initialized await setupCleanroom({ rootDir: cleanroom.rootDir || cwd, nodeImage: cleanroom.nodeImage, memoryLimit: cleanroom.memoryLimit, cpuLimit: cleanroom.cpuLimit, timeout: cleanroom.timeout, }) // Execute in cleanroom const result = await executeCleanroom(args, { json, cwd: '/app', // Container working directory timeout, env, }) // The cleanroom runner already wraps with expectations return result } /** * Unified CLI runner with automatic mode detection * * Single function signature for all CLI testing needs. * Automatically detects whether to use local or cleanroom mode based on config. * * @param {string[]} args - CLI arguments to execute * @param {Object} [options={}] - Execution options (optional) * @param {string} [options.cliPath] - Path to CLI file (overrides config) * @param {string} [options.cwd] - Working directory (overrides config) * @param {Object} [options.env] - Environment variables (merged with config) * @param {number} [options.timeout=30000] - Execution timeout in ms * @param {Object} [options.cleanroom] - Cleanroom configuration * @param {boolean} [options.cleanroom.enabled] - Enable cleanroom mode * @param {string} [options.cleanroom.nodeImage] - Docker image * @param {string} [options.cleanroom.memoryLimit] - Memory limit * @param {string} [options.cleanroom.cpuLimit] - CPU limit * @param {boolean} [options.json=false] - Parse stdout as JSON * @param {string} [options.mode='auto'] - Force mode: 'local', 'cleanroom', or 'auto' * @returns {Promise<Object>} Result with fluent assertions * * @throws {Error} If CLI file not found (local mode) * @throws {Error} If Docker not available (cleanroom mode) * @throws {Error} If validation fails (invalid options) * * @example * // Auto-detect mode from config * const result = await runCitty(['--help']) * result.expectSuccess().expectOutput('Usage:') * * @example * // Force local mode * const result = await runCitty(['test'], { mode: 'local' }) * * @example * // Force cleanroom mode * const result = await runCitty(['deploy'], { * cleanroom: { enabled: true } * }) * * @example * // Override config from vitest.config * const result = await runCitty(['build'], { * cliPath: './custom-cli.js', * timeout: 60000 * }) */ export async function runCitty(args, options = {}) { // Validate arguments if (!Array.isArray(args)) { throw new Error( `Invalid arguments: expected array, got ${typeof args}\n` + `Usage: runCitty(['--help'], options)\n` + `Example: runCitty(['test', '--verbose'])` ) } // Validate options with Zod const validatedOptions = UnifiedRunnerOptionsSchema.parse(options) // Load vitest config const vitestConfig = await loadVitestConfig(validatedOptions.configPath) // Merge configuration with proper precedence const config = mergeConfig(vitestConfig, validatedOptions, { cliPath: './src/cli.mjs', cwd: process.cwd(), env: {}, timeout: 30000, cleanroom: { enabled: false, nodeImage: 'node:20-alpine', memoryLimit: '512m', cpuLimit: '1.0', timeout: 60000, rootDir: '.', }, json: false, mode: 'auto', }) // Detect mode const mode = detectMode(config) // Execute based on mode let result if (mode === 'cleanroom') { result = await executeCleanroomMode(args, config) } else { result = await executeLocalMode(args, config) } // Wrap with assertions if not already wrapped if (typeof result.expectSuccess !== 'function') { result = wrapExpectation(result) } // Add metadata about execution result.mode = mode result.config = config return result } /** * Safe version of runCitty that catches errors and returns result object * Useful for testing error cases without throwing * * @param {string[]} args - CLI arguments * @param {Object} [options={}] - Execution options * @returns {Promise<Object>} Result object (never throws) * * @example * // Test error handling * const result = await runCittySafe(['invalid-command']) * result.expectFailure().expectStderr('Unknown command') */ export async function runCittySafe(args, options = {}) { try { return await runCitty(args, options) } catch (error) { // Return error as result object return wrapExpectation({ success: false, exitCode: 1, stdout: '', stderr: error.message || String(error), args, cwd: options.cwd || process.cwd(), durationMs: 0, command: `runCitty(${JSON.stringify(args)})`, error, }) } } /** * Get current configuration (useful for debugging) * * @param {Object} [options={}] - Options to merge * @returns {Promise<Object>} Resolved configuration * * @example * const config = await getCittyConfig() * console.log('Mode:', config.mode) * console.log('CLI Path:', config.cliPath) * console.log('Cleanroom:', config.cleanroom) */ export async function getCittyConfig(options = {}) { const validatedOptions = UnifiedRunnerOptionsSchema.parse(options) const vitestConfig = await loadVitestConfig(validatedOptions.configPath) const config = mergeConfig(vitestConfig, validatedOptions, { cliPath: './src/cli.mjs', cwd: process.cwd(), env: {}, timeout: 30000, cleanroom: { enabled: false, nodeImage: 'node:20-alpine', memoryLimit: '512m', cpuLimit: '1.0', timeout: 60000, rootDir: '.', }, json: false, mode: 'auto', }) return { ...config, detectedMode: detectMode(config), } } /** * Re-export teardown for convenience */ export { teardownCleanroom } // Default export export default runCitty