#!/usr/bin/env bun import { existsSync } from "node:fs"; import { readdir } from "node:fs/promises"; import { basename, resolve } from "node:path"; import { loadLocalEnv } from "@server/utils/envUtils.js"; import { spawn } from "bun"; import chalk from "chalk"; import dotenv from "dotenv"; import pLimit from "p-limit"; loadLocalEnv(); // Load environment variables from server/.env dotenv.config({ path: resolve(process.cwd(), "server", ".env") }); interface TestResult { file: string; status: "pending" | "running" | "passed" | "failed"; output: string; duration: number; error?: string; lastTestName?: string; } class TestRunner { private results: Map = new Map(); private testFiles: string[] = []; private maxParallel: number = 6; private spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; private spinnerIndex = 0; private renderInterval?: Timer; private startLine = 0; private compactMode: boolean = false; private lastRenderedLines = 0; constructor(maxParallel?: number, compactMode?: boolean) { if (maxParallel) this.maxParallel = maxParallel; if (compactMode) this.compactMode = compactMode; } async collectTestFiles(directories: string[]): Promise { const testFiles: string[] = []; for (const dir of directories) { const resolvedDir = resolve(process.cwd(), dir); try { const files = await readdir(resolvedDir); for (const file of files) { if (file.endsWith(".test.ts")) { testFiles.push(resolve(resolvedDir, file)); } } } catch (error) { console.error(chalk.red(`Error reading directory ${dir}:`), error); } } return testFiles; } private extractLastTest(output: string): string | null { const lines = output.split("\n"); for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i].trim(); const testMatch = line.match(/^[✓✗]\s+(.+?)(?:\s+\[\d+\.\d+m?s\])?$/); if (testMatch) { return testMatch[1]; } const bunTestMatch = line.match(/test\s+"([^"]+)"/); if (bunTestMatch) { return bunTestMatch[1]; } } return null; } private truncateTestName(name: string, maxLength: number = 50): string { if (name.length <= maxLength) return name; return name.substring(0, maxLength - 3) + "..."; } private hideCursor() { process.stdout.write("\x1B[?25l"); } private showCursor() { process.stdout.write("\x1B[?25h"); } private moveCursor(line: number, col: number = 0) { process.stdout.write(`\x1B[${line};${col}H`); } private clearLine() { process.stdout.write("\x1B[2K"); } private getSpacesNeeded(): number { if (!this.compactMode) { return this.testFiles.length + 3; } // Compact mode: dynamically calculate based on content // Base: 10 lines for headers, stats, spacing // + 3 lines for recently completed // + failed tests * 4 (name + 2 error lines + spacing) // + running tests const failedCount = Array.from(this.results.values()).filter( (r) => r.status === "failed", ).length; const runningCount = Array.from(this.results.values()).filter( (r) => r.status === "running", ).length; return Math.min( 10 + 3 + failedCount * 4 + Math.min(runningCount, 6), 30, // Cap at 30 lines ); } private render() { this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerFrames.length; const spinner = this.spinnerFrames[this.spinnerIndex]; if (this.compactMode) { // Compact mode: show completed, failed, running tests, then stats let lineNum = this.startLine; const completed = Array.from(this.results.values()).filter( (r) => r.status === "passed" || r.status === "failed", ).length; const passed = Array.from(this.results.values()).filter( (r) => r.status === "passed", ).length; const failed = Array.from(this.results.values()).filter( (r) => r.status === "failed", ).length; // Show recently completed tests (last 3) const passedTests = Array.from(this.results.entries()) .filter(([_, result]) => result.status === "passed") .slice(-3); // Get last 3 completed if (passedTests.length > 0) { this.moveCursor(lineNum, 0); this.clearLine(); process.stdout.write( chalk.green.bold(`Recently Completed (${passed} total):\n`), ); lineNum++; for (const [file] of passedTests) { this.moveCursor(lineNum, 0); this.clearLine(); const testName = basename(file); process.stdout.write( ` ${chalk.green("✓")} ${chalk.dim(testName)}\n`, ); lineNum++; } // Add blank line this.moveCursor(lineNum, 0); this.clearLine(); process.stdout.write("\n"); lineNum++; } // Show failed tests const failedTests = Array.from(this.results.entries()).filter( ([_, result]) => result.status === "failed", ); if (failedTests.length > 0) { this.moveCursor(lineNum, 0); this.clearLine(); process.stdout.write( chalk.red.bold(`Failed (${failedTests.length}):\n`), ); lineNum++; for (const [file, result] of failedTests) { this.moveCursor(lineNum, 0); this.clearLine(); const testName = basename(file); process.stdout.write(` ${chalk.red("✗")} ${testName}\n`); lineNum++; // Show first 2 lines of error if (result.error) { const errorLines = result.error.split("\n").filter((l) => l.trim()); const displayLines = errorLines.slice(0, 2); for (const line of displayLines) { this.moveCursor(lineNum, 0); this.clearLine(); const truncated = line.length > 80 ? line.substring(0, 77) + "..." : line; process.stdout.write(` ${chalk.dim(truncated)}\n`); lineNum++; } } } // Add blank line after failed tests this.moveCursor(lineNum, 0); this.clearLine(); process.stdout.write("\n"); lineNum++; } // Show currently running tests const runningTests = Array.from(this.results.entries()).filter( ([_, result]) => result.status === "running", ); if (runningTests.length > 0) { this.moveCursor(lineNum, 0); this.clearLine(); process.stdout.write( chalk.cyan.bold(`Running (${runningTests.length}):\n`), ); lineNum++; for (const [file, result] of runningTests) { this.moveCursor(lineNum, 0); this.clearLine(); const testName = basename(file); let displayText = ` ${chalk.cyan(spinner)} ${testName}`; if (result.lastTestName) { const truncated = this.truncateTestName(result.lastTestName, 40); displayText += chalk.dim(` › ${truncated}`); } process.stdout.write(`${displayText}\n`); lineNum++; } // Add blank line after running tests this.moveCursor(lineNum, 0); this.clearLine(); process.stdout.write("\n"); lineNum++; } // Show stats line this.moveCursor(lineNum, 0); this.clearLine(); process.stdout.write( `${chalk.cyan(spinner)} Progress: ${chalk.bold(`${completed}/${this.testFiles.length}`)} | ` + `${chalk.green(`✓ ${passed}`)} | ` + `${failed > 0 ? chalk.red(`✗ ${failed}`) : chalk.dim(`✗ ${failed}`)}\n`, ); lineNum++; // Clear any remaining lines from previous renders const maxLines = this.getSpacesNeeded(); while (lineNum < maxLines) { this.moveCursor(lineNum, 0); this.clearLine(); lineNum++; } // Track how many lines we actually used this.lastRenderedLines = lineNum - this.startLine; } else { // Full mode: show all tests let lineNum = this.startLine; for (const file of this.testFiles) { const result = this.results.get(file); if (!result) continue; this.moveCursor(lineNum, 0); this.clearLine(); const testName = basename(file); let statusIcon: string; let displayText: string; switch (result.status) { case "pending": statusIcon = chalk.dim("⋯"); displayText = chalk.dim(testName); break; case "running": statusIcon = chalk.cyan(spinner); displayText = testName; if (result.lastTestName) { const truncated = this.truncateTestName(result.lastTestName); displayText += chalk.dim(` › ${truncated}`); } break; case "passed": statusIcon = chalk.green("✓"); displayText = chalk.dim(testName); break; case "failed": statusIcon = chalk.red("✗"); displayText = testName; break; } process.stdout.write(`${statusIcon} ${displayText}\n`); lineNum++; } // Summary line const completed = Array.from(this.results.values()).filter( (r) => r.status === "passed" || r.status === "failed", ).length; const failed = Array.from(this.results.values()).filter( (r) => r.status === "failed", ).length; const running = Array.from(this.results.values()).filter( (r) => r.status === "running", ).length; this.moveCursor(lineNum + 1, 0); this.clearLine(); if (running > 0) { process.stdout.write( chalk.dim( `Running: ${running} | Completed: ${completed}/${this.testFiles.length} | Failed: ${failed}`, ), ); } // Track how many lines we actually used this.lastRenderedLines = lineNum + 2 - this.startLine; } } async runTest(file: string): Promise { const startTime = performance.now(); // Initialize as running const result: TestResult = { file, status: "running", output: "", duration: 0, }; this.results.set(file, result); try { const proc = spawn(["bun", "test", "--timeout", "0", file], { stdout: "pipe", stderr: "pipe", env: { ...process.env }, }); let output = ""; const decoder = new TextDecoder(); if (proc.stdout) { for await (const chunk of proc.stdout) { const text = decoder.decode(chunk); output += text; // Update last test name const lastTest = this.extractLastTest(output); if (lastTest) { result.lastTestName = lastTest; result.output = output; this.results.set(file, result); } } } if (proc.stderr) { for await (const chunk of proc.stderr) { output += decoder.decode(chunk); } } await proc.exited; const duration = performance.now() - startTime; if (proc.exitCode === 0) { this.results.set(file, { ...result, status: "passed", output, duration, }); } else { this.results.set(file, { ...result, status: "failed", output, duration, error: this.extractError(output), }); } } catch (error) { const duration = performance.now() - startTime; this.results.set(file, { ...result, status: "failed", output: "", duration, error: String(error), }); } } private extractError(output: string): string { const lines = output.split("\n"); const errorLines: string[] = []; let inError = false; let capturedLines = 0; for (const line of lines) { if ( line.includes("error:") || line.includes("Error:") || line.includes("Expected:") || line.includes("Received:") || line.includes("AssertionError") ) { inError = true; } if (inError) { errorLines.push(line); capturedLines++; if (capturedLines > 20) break; } if (line.match(/^[\s]*✗/)) { errorLines.push(line); } } return errorLines.length > 0 ? errorLines.join("\n").trim() : output; } private cleanup() { if (this.renderInterval) { clearInterval(this.renderInterval); } this.showCursor(); } private handleInterrupt() { this.cleanup(); // Move cursor past all output (use actual rendered lines in compact mode) const linesToMove = this.compactMode ? this.lastRenderedLines : this.getSpacesNeeded(); process.stdout.write(`\x1B[${linesToMove}B`); console.log("\n"); console.log(chalk.yellow.bold("\n⚠ Tests interrupted by user (Ctrl+C)\n")); // Print summary of what we have so far const failedTests = Array.from(this.results.values()).filter( (t) => t.status === "failed", ); const completedTests = Array.from(this.results.values()).filter( (t) => t.status === "passed" || t.status === "failed", ); console.log( chalk.dim( `Completed: ${completedTests.length}/${this.testFiles.length} tests before interruption`, ), ); if (failedTests.length > 0) { console.log( chalk.red.bold( `\n${"═".repeat(70)}\n FAILED TESTS (${failedTests.length})\n${"═".repeat(70)}\n`, ), ); for (const test of failedTests) { const testName = basename(test.file); console.log(chalk.red.bold(`\n✗ ${testName}`)); console.log(chalk.dim("─".repeat(70))); if (test.error) { const errorLines = test.error.split("\n"); for (const line of errorLines) { if (line.trim()) { if (line.includes("Expected:") || line.includes("Received:")) { console.log(chalk.yellow(line)); } else if (line.includes("✗")) { console.log(chalk.red(line)); } else { console.log(chalk.dim(line)); } } } } } console.log( chalk.red.bold( `\n${"═".repeat(70)}\n ${failedTests.length} test file(s) failed\n${"═".repeat(70)}\n`, ), ); } process.exit(130); // Standard exit code for SIGINT } async run(directories: string[]): Promise { this.testFiles = await this.collectTestFiles(directories); if (this.testFiles.length === 0) { console.log(chalk.yellow("No test files found in specified directories")); return; } console.log( chalk.bold(`\nRunning ${this.testFiles.length} test file(s)...\n`), ); // Initialize all tests as pending for (const file of this.testFiles) { this.results.set(file, { file, status: "pending", output: "", duration: 0, }); } // Setup SIGINT handler const sigintHandler = () => this.handleInterrupt(); process.on("SIGINT", sigintHandler); // Hide cursor and create space for all tests this.hideCursor(); this.startLine = 1; // Start from line 1 // Create space - less space needed in compact mode const spacesNeeded = this.getSpacesNeeded(); this.lastRenderedLines = spacesNeeded; // Initialize to full space for (let i = 0; i < spacesNeeded; i++) { console.log(); } // Move cursor back up to start rendering process.stdout.write(`\x1B[${spacesNeeded}A`); // Start rendering loop this.renderInterval = setInterval(() => this.render(), 100); // Run tests with concurrency limit const limit = pLimit(this.maxParallel); const promises = this.testFiles.map((file) => limit(() => this.runTest(file)), ); await Promise.all(promises); // Remove SIGINT handler process.off("SIGINT", sigintHandler); // Final render this.cleanup(); this.render(); // Move cursor past all output (use actual rendered lines in compact mode) const linesToMove = this.compactMode ? this.lastRenderedLines : this.getSpacesNeeded(); process.stdout.write(`\x1B[${linesToMove}B`); console.log("\n"); // Print summary this.printSummary(); } private printSummary() { const failedTests = Array.from(this.results.values()).filter( (t) => t.status === "failed", ); if (failedTests.length === 0) { console.log( chalk.green.bold(`✓ All ${this.testFiles.length} test file(s) passed!`), ); process.exit(0); } console.log( chalk.red.bold( `\n${"═".repeat(70)}\n FAILED TESTS (${failedTests.length}/${this.testFiles.length})\n${"═".repeat(70)}\n`, ), ); for (const test of failedTests) { const testName = basename(test.file); console.log(chalk.red.bold(`\n✗ ${testName}`)); console.log(chalk.dim("─".repeat(70))); if (test.error) { const errorLines = test.error.split("\n"); for (const line of errorLines) { if (line.trim()) { if (line.includes("Expected:") || line.includes("Received:")) { console.log(chalk.yellow(line)); } else if (line.includes("✗")) { console.log(chalk.red(line)); } else { console.log(chalk.dim(line)); } } } } } console.log( chalk.red.bold( `\n${"═".repeat(70)}\n ${failedTests.length} test file(s) failed\n${"═".repeat(70)}\n`, ), ); process.exit(1); } } // Base paths for shorthand test paths (tried in order) const TEST_BASE_PATHS = ["server/tests/integration/billing", "server/tests"]; // Parse CLI arguments const args = process.argv.slice(2); const directories: string[] = []; let maxParallel = 6; let compactMode = false; for (const arg of args) { if (arg.startsWith("--max=")) { maxParallel = Number.parseInt(arg.split("=")[1], 10); } else if (arg === "--compact") { compactMode = true; } else if (arg.startsWith("-")) { console.error(chalk.red(`Unknown option: ${arg}`)); console.log( "Usage: bun scripts/testScripts/runTests.ts [dir2] [...] [--max=N] [--compact]", ); process.exit(1); } else { // Try to resolve the path - if it doesn't exist, try prepending base paths let resolvedPath = arg; const fullPath = resolve(process.cwd(), arg); if (!existsSync(fullPath)) { // Try each base path in order for (const basePath of TEST_BASE_PATHS) { const withBase = `${basePath}/${arg}`; const withBaseFull = resolve(process.cwd(), withBase); if (existsSync(withBaseFull)) { resolvedPath = withBase; break; } } } directories.push(resolvedPath); } } if (directories.length === 0) { console.error(chalk.red("Error: No test directories specified")); console.log( "Usage: bun scripts/testScripts/runTests.ts [dir2] [...] [--max=N] [--compact]", ); console.log("\nOptions:"); console.log(" --max=N Set maximum parallel test files (default: 6)"); console.log( " --compact Use compact mode (only show summary and failures)", ); console.log("\nExamples:"); console.log( " bun scripts/testScripts/runTests.ts server/tests/attach/upgrade", ); console.log( " bun scripts/testScripts/runTests.ts server/tests/attach/upgrade server/tests/attach/downgrade", ); console.log( " bun scripts/testScripts/runTests.ts server/tests/attach/upgrade --max=10", ); console.log( " bun scripts/testScripts/runTests.ts server/tests/attach/upgrade --compact", ); process.exit(1); } // Run tests const runner = new TestRunner(maxParallel, compactMode); await runner.run(directories);