run all tests

This commit is contained in:
John Yeo
2026-01-26 17:21:54 +00:00
84 changed files with 1932 additions and 2294 deletions

View File

@@ -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);

View File

@@ -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()}/`, "");
}
}
}