Files
lobehub/e2e/scripts/setup.ts
2026-01-23 23:57:08 +08:00

537 lines
14 KiB
TypeScript

#!/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 <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<string, string> = {},
): Promise<void> {
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<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function waitForCondition(
check: () => Promise<boolean>,
timeout: number,
interval: number = 1000,
onWait?: () => void,
): Promise<boolean> {
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<void> {
// 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<void> {
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<void> {
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<boolean> {
try {
const response = await fetch(`http://localhost:${port}/chat`, { method: 'HEAD' });
return response.ok;
} catch {
return false;
}
}
function getServerEnv(port: number): Record<string, string> {
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<void> {
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 <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<void> {
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();