import { execFileSync, spawnSync } from 'child_process'; import Redis from 'ioredis'; import { beforeAll, afterAll, beforeEach } from 'vitest'; /** * Global E2E test setup and teardown. * Manages Docker container lifecycle and Redis connection cleanup. * * Security: Uses execFileSync instead of execSync to prevent command injection. */ const REDIS_HOST = 'localhost'; const REDIS_PORT = 6380; const DOCKER_COMPOSE_FILE = 'e2e/docker-compose.yml'; const HEALTH_CHECK_TIMEOUT = 30000; const HEALTH_CHECK_INTERVAL = 500; let redisClient: Redis | null = null; /** * Safe wrapper for executing Docker commands using execFileSync. * No user input is involved - all commands are hardcoded. */ function execDocker(args: string[]): string { try { return execFileSync('docker', args, { encoding: 'utf8', stdio: 'pipe', }); } catch (error: any) { // Docker command failed, return empty string for ps checks return ''; } } /** * Safe wrapper for executing Docker Compose commands. * Uses spawnSync with array arguments to prevent shell injection. */ function execDockerCompose(args: string[]): void { const result = spawnSync('docker-compose', ['-f', DOCKER_COMPOSE_FILE, ...args], { encoding: 'utf8', stdio: 'pipe', }); if (result.error) { throw result.error; } if (result.status !== 0) { throw new Error(`docker-compose failed: ${result.stderr}`); } } /** * Start Redis container and wait for it to be ready */ async function startRedis(): Promise { console.log('Starting Redis container for E2E tests...'); try { // Check if container is already running const psOutput = execDocker(['ps', '--filter', 'name=queue-test-redis', '--format', '{{.Names}}']); const isRunning = psOutput.includes('queue-test-redis'); if (isRunning) { console.log('Redis container already running, stopping it first...'); execDockerCompose(['down']); } // Start container execDockerCompose(['up', '-d']); // Wait for Redis to be ready await waitForRedis(); console.log('Redis container ready'); } catch (error) { console.error('Failed to start Redis container:', error); throw error; } } /** * Stop Redis container */ async function stopRedis(): Promise { console.log('Stopping Redis container...'); try { execDockerCompose(['down']); console.log('Redis container stopped'); } catch (error) { console.error('Failed to stop Redis container:', error); throw error; } } /** * Wait for Redis to accept connections */ async function waitForRedis(): Promise { const startTime = Date.now(); const maxWaitTime = HEALTH_CHECK_TIMEOUT; while (Date.now() - startTime < maxWaitTime) { try { const testClient = new Redis({ host: REDIS_HOST, port: REDIS_PORT, maxRetriesPerRequest: 1, retryStrategy: () => null, }); await testClient.ping(); await testClient.quit(); return; } catch (error) { // Connection failed, wait and retry await new Promise((resolve) => setTimeout(resolve, HEALTH_CHECK_INTERVAL)); } } throw new Error(`Redis failed to start within ${maxWaitTime}ms`); } /** * Get or create Redis client for test utilities */ export function getRedisClient(): Redis { if (!redisClient) { redisClient = new Redis({ host: REDIS_HOST, port: REDIS_PORT, maxRetriesPerRequest: null, }); } return redisClient; } /** * Clean all Redis data between tests */ export async function cleanRedis(): Promise { const client = getRedisClient(); await client.flushdb(); } /** * Get Redis connection options for tests */ export function getRedisConnection() { return { host: REDIS_HOST, port: REDIS_PORT, }; } /** * Close all Redis connections */ async function closeRedis(): Promise { if (redisClient) { await redisClient.quit(); redisClient = null; } } /** * Wait for a condition to be true */ export async function waitFor( condition: () => boolean | Promise, options: { timeout?: number; interval?: number } = {}, ): Promise { const timeout = options.timeout ?? 5000; const interval = options.interval ?? 100; const startTime = Date.now(); while (Date.now() - startTime < timeout) { const result = await condition(); if (result) { return; } await new Promise((resolve) => setTimeout(resolve, interval)); } throw new Error(`Condition not met within ${timeout}ms`); } /** * Wait for a specific number of jobs to reach a state */ export async function waitForJobs( queue: any, state: 'completed' | 'failed' | 'active' | 'waiting', count: number, timeout = 5000, ): Promise { await waitFor( async () => { const counts = await queue.getJobCounts(); return counts[state] >= count; }, { timeout }, ); } // Global setup beforeAll(async () => { await startRedis(); }, 60000); // Allow up to 60s for container startup // Global teardown afterAll(async () => { await closeRedis(); await stopRedis(); }, 30000); // Clean Redis between each test beforeEach(async () => { await cleanRedis(); });