#!/usr/bin/env bun /** * E2E Test Environment Setup Script * * One-click setup for E2E testing environment. * * Usage: * bun e2e/scripts/setup.ts [options] * * Options: * --clean Clean up existing containers and processes * --skip-db Skip database setup (use existing) * --skip-migrate Skip database migration * --build Build the application before starting * --start Start the server after setup * --port Server port (default: 3006) * --help Show help message */ import { spawn, spawnSync } from 'node:child_process'; import { resolve } from 'node:path'; // ============================================================================ // Configuration // ============================================================================ const CONFIG = { containerName: 'postgres-e2e', databaseDriver: 'node', databaseUrl: 'postgresql://postgres:postgres@localhost:5433/postgres', dbPort: 5433, defaultPort: 3006, dockerImage: 'paradedb/paradedb:latest', projectRoot: resolve(__dirname, '../..'), // S3 Mock (required even if not testing file uploads) s3Mock: { accessKeyId: 'e2e-mock-access-key', bucket: 'e2e-mock-bucket', endpoint: 'https://e2e-mock-s3.localhost', secretAccessKey: 'e2e-mock-secret-key', }, // 2 minutes // Secrets (for e2e testing only) secrets: { betterAuthSecret: 'e2e-test-secret-key-for-better-auth-32chars!', keyVaultsSecret: 'LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s=', }, serverTimeout: 120_000, }; // ============================================================================ // Utilities // ============================================================================ const colors = { cyan: (s: string) => `\u001B[36m${s}\u001B[0m`, dim: (s: string) => `\u001B[2m${s}\u001B[0m`, green: (s: string) => `\u001B[32m${s}\u001B[0m`, red: (s: string) => `\u001B[31m${s}\u001B[0m`, yellow: (s: string) => `\u001B[33m${s}\u001B[0m`, }; function log(emoji: string, message: string) { console.log(`${emoji} ${message}`); } function logStep(step: number, total: number, message: string) { console.log(`\n${colors.cyan(`[${step}/${total}]`)} ${message}`); } function exec( command: string, args: string[] = [], options: { cwd?: string; silent?: boolean } = {}, ) { const { cwd = CONFIG.projectRoot, silent = false } = options; const result = spawnSync(command, args, { cwd, encoding: 'utf8', shell: true, stdio: silent ? 'pipe' : 'inherit', }); return result; } function execAsync( command: string, args: string[] = [], env: Record = {}, ): Promise { return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd: CONFIG.projectRoot, env: { ...process.env, ...env }, shell: true, stdio: 'inherit', }); child.on('close', (code) => { if (code === 0) { resolve(); } else { reject(new Error(`Command failed with code ${code}`)); } }); child.on('error', reject); }); } async function sleep(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); }); } async function waitForCondition( check: () => Promise, timeout: number, interval: number = 1000, onWait?: () => void, ): Promise { const startTime = Date.now(); while (Date.now() - startTime < timeout) { if (await check()) { return true; } onWait?.(); await sleep(interval); } return false; } // ============================================================================ // Docker Operations // ============================================================================ function isDockerRunning(): boolean { const result = exec('docker', ['info'], { silent: true }); return result.status === 0; } function isContainerRunning(name: string): boolean { const result = exec('docker', ['ps', '-q', '-f', `name=${name}`], { silent: true }); return !!result.stdout?.trim(); } function containerExists(name: string): boolean { const result = exec('docker', ['ps', '-aq', '-f', `name=${name}`], { silent: true }); return !!result.stdout?.trim(); } function stopContainer(name: string): void { if (isContainerRunning(name)) { log('๐Ÿ›‘', `Stopping container: ${name}`); exec('docker', ['stop', name], { silent: true }); } } function removeContainer(name: string): void { if (containerExists(name)) { log('๐Ÿ—‘๏ธ ', `Removing container: ${name}`); exec('docker', ['rm', name], { silent: true }); } } async function startPostgres(): Promise { // Check Docker is running if (!isDockerRunning()) { throw new Error('Docker is not running. Please start Docker Desktop first.'); } if (isContainerRunning(CONFIG.containerName)) { log('โœ…', 'PostgreSQL container is already running'); return; } // Remove existing container if exists removeContainer(CONFIG.containerName); log('๐Ÿ˜', 'Starting PostgreSQL container...'); const result = exec('docker', [ 'run', '-d', '--name', CONFIG.containerName, '-e', 'POSTGRES_PASSWORD=postgres', '-p', `${CONFIG.dbPort}:5432`, CONFIG.dockerImage, ]); if (result.status !== 0) { throw new Error('Failed to start PostgreSQL container'); } // Wait for database to be ready process.stdout.write(' Waiting for PostgreSQL to be ready'); const isReady = await waitForCondition( async () => { const result = exec('docker', ['exec', CONFIG.containerName, 'pg_isready'], { silent: true }); return result.status === 0; }, 30_000, 2000, () => process.stdout.write('.'), ); console.log(); if (!isReady) { throw new Error('PostgreSQL failed to start within 30 seconds'); } log('โœ…', 'PostgreSQL is ready'); } // ============================================================================ // Process Management // ============================================================================ function killProcessOnPort(port: number): void { const result = exec('lsof', ['-ti', `:${port}`], { silent: true }); const pids = result.stdout?.trim(); if (pids) { log('๐Ÿ”ช', `Killing processes on port ${port}`); for (const pid of pids.split('\n')) { if (pid) { exec('kill', ['-9', pid], { silent: true }); } } } } // ============================================================================ // Database Operations // ============================================================================ async function runMigration(): Promise { log('๐Ÿ”„', 'Running database migration...'); await execAsync('bun', ['run', 'db:migrate'], { DATABASE_DRIVER: CONFIG.databaseDriver, DATABASE_URL: CONFIG.databaseUrl, KEY_VAULTS_SECRET: CONFIG.secrets.keyVaultsSecret, }); log('โœ…', 'Database migration completed'); } // ============================================================================ // Build Operations // ============================================================================ async function buildApp(): Promise { log('๐Ÿ”จ', 'Building application (this may take a few minutes)...'); await execAsync('bun', ['run', 'build'], { AUTH_SECRET: CONFIG.secrets.betterAuthSecret, DATABASE_DRIVER: CONFIG.databaseDriver, DATABASE_URL: CONFIG.databaseUrl, KEY_VAULTS_SECRET: CONFIG.secrets.keyVaultsSecret, SKIP_LINT: '1', }); log('โœ…', 'Application built successfully'); } // ============================================================================ // Server Operations // ============================================================================ async function isServerRunning(port: number): Promise { try { const response = await fetch(`http://localhost:${port}/chat`, { method: 'HEAD' }); return response.ok; } catch { return false; } } function getServerEnv(port: number): Record { return { AUTH_EMAIL_VERIFICATION: '0', AUTH_SECRET: CONFIG.secrets.betterAuthSecret, DATABASE_DRIVER: CONFIG.databaseDriver, DATABASE_URL: CONFIG.databaseUrl, KEY_VAULTS_SECRET: CONFIG.secrets.keyVaultsSecret, NODE_OPTIONS: '--max-old-space-size=6144', PORT: String(port), S3_ACCESS_KEY_ID: CONFIG.s3Mock.accessKeyId, S3_BUCKET: CONFIG.s3Mock.bucket, S3_ENDPOINT: CONFIG.s3Mock.endpoint, S3_SECRET_ACCESS_KEY: CONFIG.s3Mock.secretAccessKey, }; } async function startServer(port: number): Promise { if (await isServerRunning(port)) { log('โœ…', `Server is already running on port ${port}`); return; } // Kill any process on the port first killProcessOnPort(port); log('๐Ÿš€', `Starting server on port ${port}...`); const env = getServerEnv(port); // Start server in background const child = spawn('bunx', ['next', 'start', '-p', String(port)], { cwd: CONFIG.projectRoot, detached: true, env: { ...process.env, ...env }, stdio: 'ignore', }); child.unref(); // Wait for server to be ready process.stdout.write(' Waiting for server to be ready'); const isReady = await waitForCondition( () => isServerRunning(port), CONFIG.serverTimeout, 2000, () => process.stdout.write('.'), ); console.log(); if (!isReady) { throw new Error(`Server failed to start within ${CONFIG.serverTimeout / 1000} seconds`); } log('โœ…', `Server is ready at http://localhost:${port}`); } // ============================================================================ // Cleanup // ============================================================================ function cleanup(): void { log('๐Ÿงน', 'Cleaning up environment...'); stopContainer(CONFIG.containerName); removeContainer(CONFIG.containerName); killProcessOnPort(3006); killProcessOnPort(3010); killProcessOnPort(5433); log('โœ…', 'Cleanup completed'); } // ============================================================================ // CLI // ============================================================================ function showHelp(): void { console.log(` ${colors.cyan('E2E Test Environment Setup Script')} ${colors.dim('Usage:')} bun e2e/scripts/setup.ts [options] ${colors.dim('Options:')} --clean Clean up existing containers and processes --skip-db Skip database setup (use existing) --skip-migrate Skip database migration --build Build the application before starting --start Start the server after setup --port Server port (default: ${CONFIG.defaultPort}) --help Show this help message ${colors.dim('Examples:')} ${colors.green('bun e2e/scripts/setup.ts')} # Setup DB only ${colors.green('bun e2e/scripts/setup.ts --start')} # Setup DB and start server ${colors.green('bun e2e/scripts/setup.ts --build --start')} # Full setup with build ${colors.green('bun e2e/scripts/setup.ts --clean')} # Clean up environment ${colors.dim('After setup, run tests with:')} cd e2e BASE_URL=http://localhost:3006 bun run test `); } interface Options { build: boolean; clean: boolean; help: boolean; port: number; skipDb: boolean; skipMigrate: boolean; start: boolean; } function parseArgs(): Options { const args = process.argv.slice(2); const options: Options = { build: false, clean: false, help: false, port: CONFIG.defaultPort, skipDb: false, skipMigrate: false, start: false, }; for (let i = 0; i < args.length; i++) { switch (args[i]) { case '--help': case '-h': { options.help = true; break; } case '--clean': { options.clean = true; break; } case '--skip-db': { options.skipDb = true; break; } case '--skip-migrate': { options.skipMigrate = true; break; } case '--build': { options.build = true; break; } case '--start': { options.start = true; break; } case '--port': { options.port = parseInt(args[++i], 10) || CONFIG.defaultPort; break; } } } return options; } // ============================================================================ // Main // ============================================================================ async function main(): Promise { const options = parseArgs(); if (options.help) { showHelp(); process.exit(0); } console.log(` ${colors.cyan('๐Ÿคฏ LobeHub E2E Environment Setup')} ${'โ”€'.repeat(50)} `); try { if (options.clean) { cleanup(); process.exit(0); } // Calculate total steps let totalSteps = 0; if (!options.skipDb) totalSteps++; if (!options.skipMigrate) totalSteps++; if (options.build) totalSteps++; if (options.start) totalSteps++; let currentStep = 0; // Step 1: Start database if (!options.skipDb) { logStep(++currentStep, totalSteps, 'Setting up PostgreSQL database'); await startPostgres(); } // Step 2: Run migration if (!options.skipMigrate) { logStep(++currentStep, totalSteps, 'Running database migrations'); await runMigration(); } // Step 3: Build (optional) if (options.build) { logStep(++currentStep, totalSteps, 'Building application'); await buildApp(); } // Step 4: Start server (optional) if (options.start) { logStep(++currentStep, totalSteps, 'Starting application server'); await startServer(options.port); } console.log(` ${'โ”€'.repeat(50)} ${colors.green('โœ… E2E environment setup completed!')} `); // Print next steps if (!options.start) { console.log(`${colors.dim('Next steps:')}`); console.log(` 1. Start the server (in project root): ${colors.cyan(`bun e2e/scripts/setup.ts --start`)} 2. Or start manually: ${colors.cyan(`bunx next start -p ${options.port}`)} `); } console.log(`${colors.dim('Run tests:')}`); console.log(` cd e2e ${colors.cyan(`BASE_URL=http://localhost:${options.port} bun run test`)} ${colors.dim('# Debug mode (show browser)')} ${colors.cyan(`HEADLESS=false BASE_URL=http://localhost:${options.port} bun run test`)} `); } catch (error) { console.error(`\n${colors.red('โŒ Setup failed:')}`, error); process.exit(1); } } await main();