run all tests
This commit is contained in:
@@ -1,724 +0,0 @@
|
||||
#!/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);
|
||||
@@ -1,15 +1,43 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { spawn } from "bun";
|
||||
import { Box, render, Text, useApp } from "ink";
|
||||
import pLimit from "p-limit";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
// Base paths for shorthand test paths (tried in order)
|
||||
const TEST_BASE_PATHS = ["server/tests/integration/billing", "server/tests"];
|
||||
// Base path for shorthand test paths
|
||||
const INTEGRATION_TEST_BASE = "server/tests";
|
||||
|
||||
/**
|
||||
* Recursively search for a folder by name within a base directory.
|
||||
* Returns the first matching folder path, or null if not found.
|
||||
*/
|
||||
async function findFolderByName(
|
||||
basePath: string,
|
||||
folderName: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const entries = await readdir(basePath);
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(basePath, entry);
|
||||
const entryStat = await stat(fullPath);
|
||||
|
||||
if (entryStat.isDirectory()) {
|
||||
if (entry === folderName) {
|
||||
return fullPath;
|
||||
}
|
||||
const found = await findFolderByName(fullPath, folderName);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Track all running processes for cleanup
|
||||
const runningProcesses = new Set<ReturnType<typeof spawn>>();
|
||||
@@ -148,6 +176,11 @@ function parseErrorFromLines(
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to the test file path if no location found in stack trace
|
||||
if (!location) {
|
||||
location = filePath;
|
||||
}
|
||||
|
||||
test.error = {
|
||||
message: errorMessage || "Test failed",
|
||||
location,
|
||||
@@ -184,18 +217,27 @@ function extractCurrentTest(output: string): string | null {
|
||||
async function collectTestFiles(directories: string[]): Promise<string[]> {
|
||||
const testFiles: string[] = [];
|
||||
|
||||
for (const dir of directories) {
|
||||
const resolvedDir = resolve(process.cwd(), dir);
|
||||
const collectRecursive = async (dirPath: string): Promise<void> => {
|
||||
try {
|
||||
const files = await readdir(resolvedDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith(".test.ts")) {
|
||||
testFiles.push(resolve(resolvedDir, file));
|
||||
const entries = await readdir(dirPath);
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dirPath, entry);
|
||||
const entryStat = await stat(fullPath);
|
||||
|
||||
if (entryStat.isDirectory()) {
|
||||
await collectRecursive(fullPath);
|
||||
} else if (entry.endsWith(".test.ts")) {
|
||||
testFiles.push(fullPath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error reading directory ${dir}:`, error);
|
||||
console.error(`Error reading directory ${dirPath}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
for (const dir of directories) {
|
||||
const resolvedDir = resolve(process.cwd(), dir);
|
||||
await collectRecursive(resolvedDir);
|
||||
}
|
||||
|
||||
return testFiles;
|
||||
@@ -307,6 +349,14 @@ function truncate(str: string, maxLength: number): string {
|
||||
return str.substring(0, maxLength - 3) + "...";
|
||||
}
|
||||
|
||||
function toRelativePath(absolutePath: string): string {
|
||||
const workspaceRoot = process.cwd();
|
||||
if (absolutePath.startsWith(workspaceRoot)) {
|
||||
return absolutePath.slice(workspaceRoot.length + 1);
|
||||
}
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
interface CompletedFileProps {
|
||||
result: TestFileResult;
|
||||
}
|
||||
@@ -352,7 +402,7 @@ function FailedTest({ test, fileName }: FailedTestProps) {
|
||||
{test.error?.location && (
|
||||
<Box marginLeft={2}>
|
||||
<Text dimColor>→ </Text>
|
||||
<Text color="cyan">{test.error.location}</Text>
|
||||
<Text color="cyan">{toRelativePath(test.error.location)}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
@@ -611,7 +661,7 @@ function FinalSummary({ results }: FinalSummaryProps) {
|
||||
<Box key={test.name} flexDirection="column" marginTop={1}>
|
||||
<Text color="red"> ✗ {test.name}</Text>
|
||||
{test.error?.location && (
|
||||
<Text color="cyan"> {test.error.location}</Text>
|
||||
<Text color="cyan"> {toRelativePath(test.error.location)}</Text>
|
||||
)}
|
||||
{test.error?.message && (
|
||||
<Text color="yellow"> {test.error.message}</Text>
|
||||
@@ -644,7 +694,9 @@ function FinalSummary({ results }: FinalSummaryProps) {
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const directories: string[] = [];
|
||||
let maxParallel = 6;
|
||||
let maxParallel = process.env.TEST_FILE_CONCURRENCY
|
||||
? Number.parseInt(process.env.TEST_FILE_CONCURRENCY, 10)
|
||||
: 6;
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg.startsWith("--max=")) {
|
||||
@@ -656,18 +708,26 @@ async function main() {
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
// Try to resolve the path - if it doesn't exist, try prepending base paths
|
||||
// Try to resolve the path in order of priority:
|
||||
// 1. Exact path from cwd
|
||||
// 2. Path under INTEGRATION_TEST_BASE
|
||||
// 3. Search for folder name within INTEGRATION_TEST_BASE
|
||||
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;
|
||||
const withBase = `${INTEGRATION_TEST_BASE}/${arg}`;
|
||||
const withBaseFull = resolve(process.cwd(), withBase);
|
||||
if (existsSync(withBaseFull)) {
|
||||
resolvedPath = withBase;
|
||||
} else {
|
||||
// Search for the folder by name within the base directory
|
||||
const folderName = basename(arg);
|
||||
const baseFullPath = resolve(process.cwd(), INTEGRATION_TEST_BASE);
|
||||
const found = await findFolderByName(baseFullPath, folderName);
|
||||
if (found) {
|
||||
// Convert back to relative path
|
||||
resolvedPath = found.replace(`${process.cwd()}/`, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user