701 lines
18 KiB
TypeScript
Executable File
701 lines
18 KiB
TypeScript
Executable File
#!/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<string, TestResult> = 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<string[]> {
|
||
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<void> {
|
||
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<void> {
|
||
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 <dir1> [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 <dir1> [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);
|