Files
cfw-autumn/scripts/testScripts/runTestsV2.ts
2026-01-21 12:50:19 +00:00

725 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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") });
// Base path for shorthand test paths
const INTEGRATION_TEST_BASE = "server/tests/integration/billing";
interface IndividualTest {
name: string;
status: "pending" | "running" | "passed" | "failed";
duration?: number;
error?: {
message: string;
location?: string; // file:line for cmd+click
details?: string;
};
}
interface TestFileResult {
file: string;
status: "pending" | "running" | "passed" | "failed";
tests: IndividualTest[];
currentTest?: string;
output: string;
duration: number;
}
class TestRunnerV2 {
private results: Map<string, TestFileResult> = new Map();
private testFiles: string[] = [];
private maxParallel: number = 6;
private spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
private spinnerIndex = 0;
private renderInterval?: Timer;
private startLine = 0;
private lastRenderedLines = 0;
constructor(maxParallel?: number) {
if (maxParallel) this.maxParallel = maxParallel;
}
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 parseTestOutput(output: string, filePath: string): IndividualTest[] {
const tests: IndividualTest[] = [];
const lines = output.split("\n");
// Track where each test result appears
// Error output appears BEFORE the (fail) line in bun test output
let lastTestEndIndex = -1;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Match (pass) or (fail) test results
const passMatch = line.match(
/^\(pass\)\s+(.+?)\s+\[(\d+(?:\.\d+)?m?s)\]/,
);
const failMatch = line.match(
/^\(fail\)\s+(.+?)\s+\[(\d+(?:\.\d+)?m?s)\]/,
);
if (passMatch) {
const [, name, duration] = passMatch;
tests.push({
name: name.trim(),
status: "passed",
duration: this.parseDuration(duration),
});
lastTestEndIndex = i;
} else if (failMatch) {
const [, name, duration] = failMatch;
// Look BACKWARDS from this line to find the error output
// Error appears between the last test result and this (fail) line
const errorStartIndex = lastTestEndIndex + 1;
const errorLines = lines.slice(errorStartIndex, i);
const test: IndividualTest = {
name: name.trim(),
status: "failed",
duration: this.parseDuration(duration),
};
// Parse the error from the lines before this (fail)
this.parseErrorFromLines(test, errorLines, filePath);
tests.push(test);
lastTestEndIndex = i;
}
}
return tests;
}
private parseErrorFromLines(
test: IndividualTest,
errorLines: string[],
filePath: string,
): void {
const errorText = errorLines.join("\n");
// Find error message - look for "error:" line
let errorMessage = "";
for (const line of errorLines) {
const errorMatch = line.match(/^error:\s*(.+)/i);
if (errorMatch) {
errorMessage = errorMatch[1].trim();
break;
}
}
// Find Expected/Received for assertion errors
const expectedMatch = errorText.match(/Expected:\s*(.+)/);
const receivedMatch = errorText.match(/Received:\s*(.+)/);
if (expectedMatch && receivedMatch) {
errorMessage = `Expected: ${expectedMatch[1]}, Received: ${receivedMatch[1]}`;
}
// Check for timeout
if (errorText.includes("this test timed out")) {
errorMessage = "Test timed out";
}
// Find location - prioritize the test file itself in stack trace
let location: string | undefined;
const testFileName = basename(filePath);
for (const line of errorLines) {
// Match stack trace lines like:
// at async <anonymous> (/path/to/file.test.ts:38:29)
// at functionName (/path/to/file.ts:123:45)
const stackMatch = line.match(/at\s+.*?\(([^)]+\.ts):(\d+):\d+\)/);
if (stackMatch) {
const matchedFile = stackMatch[1];
const lineNum = stackMatch[2];
// Prefer .test.ts files
if (matchedFile.endsWith(".test.ts")) {
location = `${matchedFile}:${lineNum}`;
break;
}
// Otherwise take first server file if we don't have one yet
if (!location && matchedFile.includes("/server/")) {
location = `${matchedFile}:${lineNum}`;
}
}
}
test.error = {
message: errorMessage || "Test failed",
location,
details: errorText.slice(0, 500),
};
}
private parseDuration(duration: string): number {
// Parse "123.45ms" or "1.23s" to milliseconds
if (duration.endsWith("ms")) {
return Number.parseFloat(duration);
}
if (duration.endsWith("s")) {
return Number.parseFloat(duration) * 1000;
}
return Number.parseFloat(duration);
}
private extractCurrentTest(output: string): string | null {
// Look for the last test that started (before pass/fail)
const lines = output.split("\n");
// Find last pass/fail to know what's completed
let lastCompletedIndex = -1;
for (let i = lines.length - 1; i >= 0; i--) {
if (lines[i].match(/^\(pass\)/) || lines[i].match(/^\(fail\)/)) {
lastCompletedIndex = i;
break;
}
}
// The "current" test would be indicated by the test that's running
// Bun doesn't explicitly say which test is running, so we show the last completed
if (lastCompletedIndex >= 0) {
const match = lines[lastCompletedIndex].match(
/^\((?:pass|fail)\)\s+(.+?)\s+\[/,
);
if (match) {
return match[1].trim();
}
}
return null;
}
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 clearToEndOfScreen() {
process.stdout.write("\x1B[J");
}
private truncate(str: string, maxLength: number): string {
if (str.length <= maxLength) return str;
return str.substring(0, maxLength - 3) + "...";
}
private render() {
this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerFrames.length;
const spinner = this.spinnerFrames[this.spinnerIndex];
let lineNum = this.startLine;
// Calculate stats - only count tests from COMPLETED files for accurate progress
const runningFiles = Array.from(this.results.entries()).filter(
([_, r]) => r.status === "running",
);
const completedFiles = Array.from(this.results.entries()).filter(
([_, r]) => r.status === "passed" || r.status === "failed",
);
const pendingFiles = Array.from(this.results.entries()).filter(
([_, r]) => r.status === "pending",
);
// Only count tests from completed files for stable progress
const completedTests = completedFiles.flatMap(([_, r]) => r.tests);
const passedTests = completedTests.filter(
(t) => t.status === "passed",
).length;
const failedTests = completedTests.filter(
(t) => t.status === "failed",
).length;
// Header
this.moveCursor(lineNum, 0);
this.clearLine();
process.stdout.write(
chalk.cyan.bold(`Running ${this.testFiles.length} test files...\n`),
);
lineNum++;
// Blank line
this.moveCursor(lineNum, 0);
this.clearLine();
process.stdout.write("\n");
lineNum++;
// Show running files with their current test
if (runningFiles.length > 0) {
this.moveCursor(lineNum, 0);
this.clearLine();
process.stdout.write(
chalk.yellow.bold(`Running (${runningFiles.length}):\n`),
);
lineNum++;
for (const [file, result] of runningFiles) {
const fileName = basename(file);
this.moveCursor(lineNum, 0);
this.clearLine();
// Show file with spinner
let fileDisplay = ` ${chalk.cyan(spinner)} ${fileName}`;
// Show completed tests count for this file
const filePassedCount = result.tests.filter(
(t) => t.status === "passed",
).length;
const fileFailedCount = result.tests.filter(
(t) => t.status === "failed",
).length;
if (filePassedCount > 0 || fileFailedCount > 0) {
fileDisplay += chalk.dim(
` (${chalk.green(`${filePassedCount}`)}${fileFailedCount > 0 ? chalk.red(`${fileFailedCount}`) : ""})`,
);
}
// Show current/last test
const currentTest = this.extractCurrentTest(result.output);
if (currentTest) {
fileDisplay += chalk.dim(` ${this.truncate(currentTest, 40)}`);
}
process.stdout.write(`${fileDisplay}\n`);
lineNum++;
}
// Blank line after running
this.moveCursor(lineNum, 0);
this.clearLine();
process.stdout.write("\n");
lineNum++;
}
// Show recently completed files (last 3)
if (completedFiles.length > 0) {
const recentCompleted = completedFiles.slice(-3);
this.moveCursor(lineNum, 0);
this.clearLine();
process.stdout.write(
chalk.dim(
`Completed (${completedFiles.length}/${this.testFiles.length} files):\n`,
),
);
lineNum++;
for (const [file, result] of recentCompleted) {
const fileName = basename(file);
this.moveCursor(lineNum, 0);
this.clearLine();
const filePassedCount = result.tests.filter(
(t) => t.status === "passed",
).length;
const fileFailedCount = result.tests.filter(
(t) => t.status === "failed",
).length;
const icon =
result.status === "passed" ? chalk.green("✓") : chalk.red("✗");
const nameColor = result.status === "passed" ? chalk.dim : chalk.white;
process.stdout.write(
` ${icon} ${nameColor(fileName)} ${chalk.dim(`(${chalk.green(`${filePassedCount}`)}${fileFailedCount > 0 ? chalk.red(`${fileFailedCount}`) : ""})`)}\n`,
);
lineNum++;
}
// Blank line
this.moveCursor(lineNum, 0);
this.clearLine();
process.stdout.write("\n");
lineNum++;
}
// Show inline errors from recently completed files (compact view)
const recentFailedTests = completedFiles
.flatMap(([file, result]) =>
result.tests
.filter((t) => t.status === "failed")
.map((t) => ({ ...t, file })),
)
.slice(-2); // Show last 2 failures
if (recentFailedTests.length > 0) {
this.moveCursor(lineNum, 0);
this.clearLine();
process.stdout.write(chalk.red.bold(`Recent Failures:\n`));
lineNum++;
for (const test of recentFailedTests) {
this.moveCursor(lineNum, 0);
this.clearLine();
process.stdout.write(
` ${chalk.red("✗")} ${this.truncate(test.name, 50)}\n`,
);
lineNum++;
if (test.error?.message) {
this.moveCursor(lineNum, 0);
this.clearLine();
process.stdout.write(
` ${chalk.dim("→")} ${chalk.yellow(this.truncate(test.error.message, 60))}\n`,
);
lineNum++;
}
if (test.error?.location) {
this.moveCursor(lineNum, 0);
this.clearLine();
process.stdout.write(
` ${chalk.dim("→")} ${chalk.cyan(test.error.location)}\n`,
);
lineNum++;
}
}
// Blank line
this.moveCursor(lineNum, 0);
this.clearLine();
process.stdout.write("\n");
lineNum++;
}
// Progress bar
this.moveCursor(lineNum, 0);
this.clearLine();
process.stdout.write(chalk.dim("─".repeat(60) + "\n"));
lineNum++;
this.moveCursor(lineNum, 0);
this.clearLine();
process.stdout.write(
`${chalk.cyan(spinner)} Progress: ${chalk.bold(`${completedFiles.length}/${this.testFiles.length} files`)} | ` +
`${chalk.green(`${passedTests}`)} | ` +
`${failedTests > 0 ? chalk.red(`${failedTests}`) : chalk.dim(`${failedTests}`)} | ` +
`${chalk.dim(`${runningFiles.length} running`)}\n`,
);
lineNum++;
// Clear remaining lines
this.moveCursor(lineNum, 0);
this.clearToEndOfScreen();
this.lastRenderedLines = lineNum - this.startLine;
}
async runTest(file: string): Promise<void> {
const startTime = performance.now();
// Initialize as running
const result: TestFileResult = {
file,
status: "running",
tests: [],
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;
result.output = output;
// Parse tests as they complete
result.tests = this.parseTestOutput(output, file);
this.results.set(file, result);
}
}
if (proc.stderr) {
for await (const chunk of proc.stderr) {
output += decoder.decode(chunk);
result.output = output;
}
}
await proc.exited;
const duration = performance.now() - startTime;
// Final parse
const tests = this.parseTestOutput(output, file);
const hasFailures = tests.some((t) => t.status === "failed");
this.results.set(file, {
...result,
status: hasFailures ? "failed" : "passed",
tests,
output,
duration,
});
} catch (error) {
const duration = performance.now() - startTime;
this.results.set(file, {
...result,
status: "failed",
output: String(error),
duration,
});
}
}
private cleanup() {
if (this.renderInterval) {
clearInterval(this.renderInterval);
}
this.showCursor();
}
private handleInterrupt() {
this.cleanup();
console.log(
chalk.yellow.bold("\n\n⚠ Tests interrupted by user (Ctrl+C)\n"),
);
this.printSummary();
process.exit(130);
}
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;
}
// Initialize all tests as pending
for (const file of this.testFiles) {
this.results.set(file, {
file,
status: "pending",
tests: [],
output: "",
duration: 0,
});
}
// Setup SIGINT handler
const sigintHandler = () => this.handleInterrupt();
process.on("SIGINT", sigintHandler);
// Hide cursor and create initial space
this.hideCursor();
this.startLine = 1;
// Create some initial space
for (let i = 0; i < 20; i++) {
console.log();
}
process.stdout.write("\x1B[20A");
// 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 and cleanup
this.cleanup();
this.render();
// Move past the rendered output
process.stdout.write(`\x1B[${this.lastRenderedLines + 2}B`);
// Print summary
this.printSummary();
}
private printSummary() {
const allTests = Array.from(this.results.values()).flatMap((r) => r.tests);
const failedTests = allTests.filter((t) => t.status === "failed");
const passedTests = allTests.filter((t) => t.status === "passed");
const totalDuration = Array.from(this.results.values()).reduce(
(sum, r) => sum + r.duration,
0,
);
console.log("\n");
if (failedTests.length === 0) {
console.log(
chalk.green.bold(
`${"═".repeat(68)}\n` +
` ✓ ALL ${passedTests.length} TESTS PASSED (${(totalDuration / 1000).toFixed(1)}s)\n` +
`${"═".repeat(68)}\n`,
),
);
process.exit(0);
}
// Failed tests summary
console.log(
chalk.red.bold(
`${"═".repeat(68)}\n` +
` FAILED TESTS (${failedTests.length})\n` +
`${"═".repeat(68)}`,
),
);
// Group failed tests by file
const failedByFile = new Map<string, IndividualTest[]>();
for (const [file, result] of this.results.entries()) {
const fileFailed = result.tests.filter((t) => t.status === "failed");
if (fileFailed.length > 0) {
failedByFile.set(file, fileFailed);
}
}
for (const [file, tests] of failedByFile) {
console.log(chalk.red.bold(`\n📁 ${basename(file)}`));
console.log(chalk.dim("─".repeat(60)));
for (const test of tests) {
console.log(chalk.red(`\n ✗ ${test.name}`));
if (test.error?.location) {
console.log(chalk.cyan(` ${test.error.location}`));
}
if (test.error?.message) {
console.log(chalk.yellow(`\n ${test.error.message}`));
}
if (test.error?.details) {
// Show a few lines of error details
const detailLines = test.error.details
.split("\n")
.filter((l) => l.trim())
.slice(0, 8);
for (const line of detailLines) {
console.log(chalk.dim(` ${this.truncate(line.trim(), 70)}`));
}
}
}
}
console.log(
chalk.red.bold(
`\n═${"═".repeat(68)}\n` +
` SUMMARY: ${chalk.green(`${passedTests.length} passed`)} | ${chalk.red(`${failedTests.length} failed`)} | ${(totalDuration / 1000).toFixed(1)}s\n` +
`${"═".repeat(68)}\n`,
),
);
process.exit(1);
}
}
// Parse CLI arguments
const args = process.argv.slice(2);
const directories: string[] = [];
let maxParallel = 6;
for (const arg of args) {
if (arg.startsWith("--max=")) {
maxParallel = Number.parseInt(arg.split("=")[1], 10);
} else if (arg.startsWith("-")) {
console.error(chalk.red(`Unknown option: ${arg}`));
console.log(
"Usage: bun scripts/testScripts/runTestsV2.ts <dir1> [dir2] [...] [--max=N]",
);
process.exit(1);
} else {
// Try to resolve the path - if it doesn't exist, prepend the base path
let resolvedPath = arg;
const fullPath = resolve(process.cwd(), arg);
if (!existsSync(fullPath)) {
const withBase = `${INTEGRATION_TEST_BASE}/${arg}`;
const withBaseFull = resolve(process.cwd(), withBase);
if (existsSync(withBaseFull)) {
resolvedPath = withBase;
}
}
directories.push(resolvedPath);
}
}
if (directories.length === 0) {
console.error(chalk.red("Error: No test directories specified"));
console.log(
"Usage: bun scripts/testScripts/runTestsV2.ts <dir1> [dir2] [...] [--max=N]",
);
console.log("\nOptions:");
console.log(" --max=N Set maximum parallel test files (default: 6)");
console.log("\nExamples:");
console.log(
" bun scripts/testScripts/runTestsV2.ts update-subscription/custom-plan",
);
console.log(
" bun scripts/testScripts/runTestsV2.ts update-subscription/custom-plan update-subscription/errors --max=4",
);
process.exit(1);
}
// Run tests
const runner = new TestRunnerV2(maxParallel);
await runner.run(directories);