Files
cfw-autumn/scripts/testScripts/runTestsV2.tsx
2026-04-23 11:50:26 +01:00

1140 lines
31 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, stat } from "node:fs/promises";
import { basename, join, resolve } from "node:path";
import { spawn } from "bun";
import { Box, render, Static, Text, useApp } from "ink";
import pLimit from "p-limit";
import { useCallback, useEffect, useRef, useState } from "react";
import { testRunConfig } from "./testRunConfig";
// Base path for shorthand test paths
const INTEGRATION_TEST_BASE = testRunConfig.testsBaseDir;
/** Recursively search for a folder whose path ends with the given suffix. */
async function findFolderByPath(
basePath: string,
pathSuffix: string,
): Promise<string | null> {
const normalizedSuffix = `/${pathSuffix}`;
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 (fullPath.endsWith(normalizedSuffix)) {
return fullPath;
}
const found = await findFolderByPath(fullPath, pathSuffix);
if (found) return found;
}
}
} catch {
// Ignore errors
}
return null;
}
/** Recursively search for a file whose path ends with the given suffix. */
async function findFileByPath(
basePath: string,
pathSuffix: string,
): Promise<string | null> {
const normalizedSuffix = `/${pathSuffix}`;
try {
const entries = await readdir(basePath);
for (const entry of entries) {
const fullPath = join(basePath, entry);
const entryStat = await stat(fullPath);
if (entryStat.isDirectory()) {
const found = await findFileByPath(fullPath, pathSuffix);
if (found) return found;
} else if (fullPath.endsWith(normalizedSuffix)) {
return fullPath;
}
}
} catch {
// Ignore errors
}
return null;
}
// Track all running processes for cleanup
const runningProcesses = new Set<ReturnType<typeof spawn>>();
// Ultra-kill on Ctrl+C
process.on("SIGINT", () => {
// Kill all running test processes immediately
for (const proc of runningProcesses) {
try {
proc.kill(9); // SIGKILL
} catch {
// Process might already be dead
}
}
runningProcesses.clear();
console.log("\n\n⚠ Tests interrupted by user (Ctrl+C)\n");
process.exit(130);
});
interface IndividualTest {
name: string;
status: "passed" | "failed";
duration?: number;
error?: {
message: string;
location?: string;
};
}
interface TestFileResult {
file: string;
status:
| "pending"
| "running"
| "passed"
| "failed"
| "retry_queued"
| "retrying";
tests: IndividualTest[];
/** Failed tests from the first attempt (preserved during retry for visibility) */
firstAttemptFailures?: IndividualTest[];
currentTest?: string;
duration: number;
attempt: number;
passedOnRetry: boolean;
/** Captured stderr when a file crashes with 0 tests */
crashError?: string;
}
// ============================================================================
// Test Output Parsing
// ============================================================================
function parseTestOutput(output: string, filePath: string): IndividualTest[] {
const tests: IndividualTest[] = [];
const lines = output.split("\n");
let lastTestEndIndex = -1;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line) continue;
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: string = passMatch[1] ?? "";
const duration: string = passMatch[2] ?? "0ms";
tests.push({
name: name.trim(),
status: "passed",
duration: parseDuration(duration),
});
lastTestEndIndex = i;
} else if (failMatch) {
const name: string = failMatch[1] ?? "";
const duration: string = failMatch[2] ?? "0ms";
// Look BACKWARDS from this line to find the error output
const errorStartIndex = lastTestEndIndex + 1;
const errorLines = lines.slice(errorStartIndex, i);
const test: IndividualTest = {
name: name.trim(),
status: "failed",
duration: parseDuration(duration),
};
parseErrorFromLines(test, errorLines, filePath);
tests.push(test);
lastTestEndIndex = i;
}
}
return tests;
}
function 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?.[1]) {
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?.[1] && receivedMatch?.[1]) {
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;
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+)\)/);
const matchedFile = stackMatch?.[1];
const lineNum = stackMatch?.[2];
const colNum = stackMatch?.[3];
if (matchedFile && lineNum && colNum) {
// Prefer .test.ts files
if (matchedFile.endsWith(".test.ts")) {
location = `${matchedFile}:${lineNum}:${colNum}`;
break;
}
// Otherwise take first server file if we don't have one yet
if (!location && matchedFile.includes("/server/")) {
location = `${matchedFile}:${lineNum}:${colNum}`;
}
}
}
// 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,
};
}
function parseDuration(duration: string): number {
if (duration.endsWith("ms")) {
return Number.parseFloat(duration);
}
if (duration.endsWith("s")) {
return Number.parseFloat(duration) * 1000;
}
return Number.parseFloat(duration);
}
function extractCurrentTest(output: string): string | null {
const lines = output.split("\n");
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i];
if (!line) continue;
const match = line.match(/^\((?:pass|fail)\)\s+(.+?)\s+\[/);
if (match?.[1]) {
return match[1].trim();
}
}
return null;
}
// ============================================================================
// Test Runner Logic
// ============================================================================
async function collectTestFiles(paths: string[]): Promise<string[]> {
const testFiles: string[] = [];
const collectRecursive = async (dirPath: string): Promise<void> => {
try {
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 ${dirPath}:`, error);
}
};
for (const p of paths) {
const resolved = resolve(process.cwd(), p);
// If it's a direct .test.ts file, add it directly
if (p.endsWith(".test.ts") && existsSync(resolved)) {
testFiles.push(resolved);
continue;
}
await collectRecursive(resolved);
}
// Deduplicate (same file may appear via multiple paths)
return [...new Set(testFiles)];
}
async function runTestFile({
file,
onUpdate,
attempt = 1,
failedTestNames,
verbose = false,
}: {
file: string;
onUpdate: (result: TestFileResult) => void;
attempt?: number;
failedTestNames?: string[];
verbose?: boolean;
}): Promise<TestFileResult> {
const startTime = performance.now();
const result: TestFileResult = {
file,
status: "running",
tests: [],
duration: 0,
attempt,
passedOnRetry: false,
};
onUpdate(result);
try {
const command = ["bun", "test", "--timeout", "0"];
if (failedTestNames && failedTestNames.length > 0) {
const pattern = failedTestNames
.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.join("|");
command.push("--test-name-pattern", pattern);
}
command.push(file);
const proc = spawn(command, {
stdout: "pipe",
stderr: "pipe",
env: { ...process.env },
});
// Track process for cleanup on SIGINT
runningProcesses.add(proc);
let output = "";
let stderrOutput = "";
const decoder = new TextDecoder();
const stderrDecoder = new TextDecoder();
const fileLabel = basename(file);
if (proc.stdout) {
for await (const chunk of proc.stdout) {
const text = decoder.decode(chunk);
output += text;
if (verbose) {
process.stderr.write(`[${fileLabel}:stdout] ${text}`);
}
// Update with parsed tests
const tests = parseTestOutput(output, file);
const currentTest = extractCurrentTest(output);
onUpdate({
...result,
tests,
currentTest: currentTest || undefined,
});
}
}
if (proc.stderr) {
for await (const chunk of proc.stderr) {
const text = stderrDecoder.decode(chunk);
output += text;
stderrOutput += text;
if (verbose) {
process.stderr.write(`[${fileLabel}:stderr] ${text}`);
}
}
}
const exitCode = await proc.exited;
// Remove from tracking
runningProcesses.delete(proc);
const duration = performance.now() - startTime;
const tests = parseTestOutput(output, file);
const hasFailures = tests.some((t) => t.status === "failed");
const hasNoTests = tests.length === 0;
const processExitedNonZero = exitCode !== 0;
// A file is considered failed if:
// 1. Any individual test failed, OR
// 2. The process exited non-zero (e.g. module import error), OR
// 3. Zero tests were found AND process exited non-zero (likely a silent import failure)
// Note: Empty files that run successfully (exit 0) are treated as passed/skipped
const isFailed = hasFailures || processExitedNonZero;
const finalResult: TestFileResult = {
file,
status: isFailed ? "failed" : "passed",
tests,
duration,
attempt,
passedOnRetry: false,
crashError:
hasNoTests && stderrOutput.trim()
? stderrOutput.trim().slice(0, 1000)
: undefined,
};
onUpdate(finalResult);
return finalResult;
} catch {
const duration = performance.now() - startTime;
const finalResult: TestFileResult = {
file,
status: "failed",
tests: [],
duration,
attempt,
passedOnRetry: false,
};
onUpdate(finalResult);
return finalResult;
}
}
// ============================================================================
// Ink Components
// ============================================================================
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
function Spinner() {
const [frame, setFrame] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setFrame((prev: number) => (prev + 1) % SPINNER_FRAMES.length);
}, 80);
return () => clearInterval(timer);
}, []);
return <Text color="cyan">{SPINNER_FRAMES[frame]}</Text>;
}
function truncate(str: string, maxLength: number): string {
if (str.length <= maxLength) return str;
return `${str.substring(0, maxLength - 3)}...`;
}
function toClickablePath(absolutePath: string): string {
const workspaceRoot = process.cwd();
if (absolutePath.startsWith(workspaceRoot)) {
return `./${absolutePath.slice(workspaceRoot.length + 1)}`;
}
return absolutePath;
}
interface CompletedFileProps {
result: TestFileResult;
willRetry?: boolean;
}
function CompletedFile({ result, willRetry }: CompletedFileProps) {
const fileName = basename(result.file);
const passedCount = result.tests.filter((t) => t.status === "passed").length;
const failedCount = result.tests.filter((t) => t.status === "failed").length;
const icon = result.status === "passed" ? "✓" : "✗";
// Yellow for first failures (will retry), red for final failures
const iconColor =
result.status === "passed" ? "green" : willRetry ? "yellow" : "red";
const retryBadge = willRetry ? " (will retry)" : "";
return (
<Box>
<Text color={iconColor}>{icon} </Text>
<Text dimColor={result.status === "passed"}>
{fileName}
{retryBadge}{" "}
</Text>
<Text dimColor>
(<Text color="green">{passedCount}</Text>
{failedCount > 0 && (
<Text color={willRetry ? "yellow" : "red"}> {failedCount}</Text>
)}
)
</Text>
</Box>
);
}
interface FailedTestProps {
test: IndividualTest;
isWarning?: boolean;
}
function FailedTest({ test, isWarning }: FailedTestProps) {
const color = isWarning ? "yellow" : "red";
return (
<Box flexDirection="column" marginLeft={2}>
<Box>
<Text color={color}> </Text>
<Text>{truncate(test.name, 60)}</Text>
{test.error?.message && (
<Text color="yellow"> {truncate(test.error.message, 70)}</Text>
)}
</Box>
{test.error?.location && (
<Box marginLeft={4}>
<Text color="cyan">{toClickablePath(test.error.location)}</Text>
</Box>
)}
</Box>
);
}
interface RunningFileProps {
result: TestFileResult;
}
function RunningFile({ result }: RunningFileProps) {
const fileName = basename(result.file);
const passedCount = result.tests.filter((t) => t.status === "passed").length;
const failedCount = result.tests.filter((t) => t.status === "failed").length;
return (
<Box>
<Text> </Text>
<Spinner />
<Text> {fileName}</Text>
{(passedCount > 0 || failedCount > 0) && (
<Text dimColor>
{" "}
(<Text color="green">{passedCount}</Text>
{failedCount > 0 && <Text color="red"> {failedCount}</Text>})
</Text>
)}
{result.currentTest && (
<Text dimColor> {truncate(result.currentTest, 35)}</Text>
)}
</Box>
);
}
interface RetryingFileProps {
result: TestFileResult;
}
function RetryingFile({ result }: RetryingFileProps) {
const fileName = basename(result.file);
const passedCount = result.tests.filter((t) => t.status === "passed").length;
const failedCount = result.tests.filter((t) => t.status === "failed").length;
return (
<Box>
<Text> </Text>
<Spinner />
<Text color="yellow"> {fileName} </Text>
<Text color="yellow">(retrying)</Text>
{(passedCount > 0 || failedCount > 0) && (
<Text dimColor>
{" "}
(<Text color="green">{passedCount}</Text>
{failedCount > 0 && <Text color="red"> {failedCount}</Text>})
</Text>
)}
{result.currentTest && (
<Text dimColor> {truncate(result.currentTest, 35)}</Text>
)}
</Box>
);
}
interface StaticItem {
key: string;
type: "result" | "retry_result";
result: TestFileResult;
willRetry?: boolean;
}
interface TestRunnerAppProps {
testFiles: string[];
maxParallel: number;
verbose: boolean;
}
function TestRunnerApp({
testFiles,
maxParallel,
verbose,
}: TestRunnerAppProps) {
const { exit } = useApp();
// Mutable ref accumulates updates from stdout chunks without triggering re-renders.
// A fixed-interval timer flushes the ref into React state at ~10fps.
const pendingRef = useRef<Map<string, TestFileResult>>(new Map());
const dirtyRef = useRef(false);
const [results, setResults] = useState<Map<string, TestFileResult>>(
new Map(),
);
const [isComplete, setIsComplete] = useState(false);
// Track which completed files have already been emitted to <Static>
// so we only append new ones (Static items are write-once).
const emittedFilesRef = useRef<Set<string>>(new Set());
const [staticItems, setStaticItems] = useState<StaticItem[]>([]);
// Track whether retry phase is complete (to defer static emission of failed files)
const retryPhaseCompleteRef = useRef(false);
// Initialize all files as pending
useEffect(() => {
const initial = new Map<string, TestFileResult>();
for (const file of testFiles) {
initial.set(file, {
file,
status: "pending",
tests: [],
duration: 0,
attempt: 1,
passedOnRetry: false,
});
}
pendingRef.current = initial;
setResults(initial);
}, [testFiles]);
// Flush pending ref into state on a fixed interval (~10fps)
useEffect(() => {
const interval = setInterval(() => {
if (!dirtyRef.current) return;
dirtyRef.current = false;
const snapshot = new Map<string, TestFileResult>(pendingRef.current);
setResults(snapshot);
// Emit newly-completed files to <Static>
// Emit passed files immediately, and failed files on first attempt (before retry)
const newStatic: StaticItem[] = [];
for (const [file, result] of snapshot.entries()) {
// Emit passed files
if (result.status === "passed" && !emittedFilesRef.current.has(file)) {
emittedFilesRef.current.add(file);
newStatic.push({ key: file, type: "result", result });
}
// Emit failed files on first attempt (will retry)
if (
result.status === "failed" &&
result.attempt === 1 &&
!emittedFilesRef.current.has(file)
) {
emittedFilesRef.current.add(file);
newStatic.push({
key: file,
type: "result",
result,
willRetry: true,
});
}
// Emit retry results (either passed or still failed after retry)
const retryKey = `${file}:retry`;
if (
result.attempt === 2 &&
(result.status === "passed" || result.status === "failed") &&
!emittedFilesRef.current.has(retryKey)
) {
emittedFilesRef.current.add(retryKey);
newStatic.push({ key: retryKey, type: "retry_result", result });
}
}
if (newStatic.length > 0) {
setStaticItems((prev: StaticItem[]) => [...prev, ...newStatic]);
}
}, 100);
return () => clearInterval(interval);
}, []);
// The callback given to each test process — writes to the mutable ref only
const updateResult = useCallback((result: TestFileResult) => {
pendingRef.current.set(result.file, result);
dirtyRef.current = true;
}, []);
// Run tests
useEffect(() => {
const runAllTests = async () => {
const limit = pLimit(maxParallel);
// Phase 1: Initial run
const promises = testFiles.map((file) =>
limit(() =>
runTestFile({ file, onUpdate: updateResult, attempt: 1, verbose }),
),
);
await Promise.all(promises);
// Phase 2: Retry failed files with same concurrency as initial run
const allPendingResults: TestFileResult[] = Array.from(
pendingRef.current.values(),
);
const failedFiles = allPendingResults.filter(
(r) => r.status === "failed" && r.attempt === 1,
);
if (failedFiles.length > 0) {
// Mark all failed files as queued for retry, preserving first-attempt failures
for (const result of failedFiles) {
const firstAttemptFailures = result.tests.filter(
(t) => t.status === "failed",
);
const queuedResult: TestFileResult = {
...result,
status: "retry_queued",
firstAttemptFailures,
};
pendingRef.current.set(result.file, queuedResult);
}
dirtyRef.current = true;
// Run retries concurrently with same limit as initial run
const retryLimit = pLimit(maxParallel);
const retryPromises = failedFiles.map((result) =>
retryLimit(async () => {
const firstAttemptFailures = result.tests.filter(
(t) => t.status === "failed",
);
const hasUnnamedFailure = firstAttemptFailures.some((test) =>
test.name.includes("(unnamed)"),
);
const failedTestNames = hasUnnamedFailure
? []
: firstAttemptFailures.map((test) => test.name);
// Mark this specific file as actively retrying
const retryingResult: TestFileResult = {
...result,
status: "retrying",
firstAttemptFailures,
};
pendingRef.current.set(result.file, retryingResult);
dirtyRef.current = true;
const retryResult = await runTestFile({
file: result.file,
onUpdate: updateResult,
attempt: 2,
failedTestNames,
verbose,
});
if (retryResult.status === "passed") {
retryResult.passedOnRetry = true;
}
retryResult.firstAttemptFailures = firstAttemptFailures;
pendingRef.current.set(result.file, retryResult);
dirtyRef.current = true;
return retryResult;
}),
);
await Promise.all(retryPromises);
}
// Mark retry phase as complete so failed files can be emitted to static
retryPhaseCompleteRef.current = true;
// Final flush to ensure every completed result is captured
const finalSnapshot = new Map<string, TestFileResult>(pendingRef.current);
setResults(finalSnapshot);
const newStatic: StaticItem[] = [];
for (const [file, result] of finalSnapshot.entries()) {
if (
(result.status === "passed" || result.status === "failed") &&
!emittedFilesRef.current.has(file)
) {
emittedFilesRef.current.add(file);
newStatic.push({ key: file, type: "result", result });
}
// Also emit any retry results that weren't captured
const retryKey = `${file}:retry`;
if (
result.attempt === 2 &&
(result.status === "passed" || result.status === "failed") &&
!emittedFilesRef.current.has(retryKey)
) {
emittedFilesRef.current.add(retryKey);
newStatic.push({ key: retryKey, type: "retry_result", result });
}
}
if (newStatic.length > 0) {
setStaticItems((prev: StaticItem[]) => [...prev, ...newStatic]);
}
setIsComplete(true);
};
if (testFiles.length > 0) {
runAllTests();
}
}, [testFiles, maxParallel, updateResult, verbose]);
// Exit when complete
useEffect(() => {
if (!isComplete) return;
const exitResults: TestFileResult[] = Array.from(results.values());
const exitFailedFiles = exitResults.some((r) => r.status === "failed");
// Small delay to ensure final render
setTimeout(() => {
exit();
process.exit(exitFailedFiles ? 1 : 0);
}, 100);
}, [isComplete, results, exit]);
const allResults: TestFileResult[] = Array.from(results.values());
const runningFiles = allResults.filter((r) => r.status === "running");
const retryingFiles = allResults.filter((r) => r.status === "retrying");
const completedFiles = allResults.filter(
(r) => r.status === "passed" || r.status === "failed",
);
const completedTests = completedFiles.flatMap((r) => r.tests);
const passedTests = completedTests.filter((t) => t.status === "passed");
const failedTests = completedTests.filter((t) => t.status === "failed");
return (
<Box flexDirection="column">
{/* Static section: completed files + failures. Written once, never re-rendered. */}
<Static items={staticItems}>
{(item: StaticItem) => {
if (item.type === "retry_result") {
const { result } = item;
if (result.status === "passed") {
return (
<Box key={item.key}>
<Text color="green"> </Text>
<Text color="green">
{basename(result.file)} passed on retry
</Text>
</Box>
);
}
return (
<Box key={item.key}>
<Text color="red"> </Text>
<Text color="red">
{basename(result.file)} still failed after retry
</Text>
</Box>
);
}
const { result, willRetry } = item;
return (
<Box key={item.key} flexDirection="column">
<CompletedFile result={result} willRetry={willRetry} />
{result.tests
.filter((t: IndividualTest) => t.status === "failed")
.map((t: IndividualTest) => (
<FailedTest
key={`${result.file}-${t.name}`}
test={t}
isWarning={willRetry}
/>
))}
</Box>
);
}}
</Static>
{/* Dynamic section below — only this part re-renders */}
<Text dimColor>{"─".repeat(60)}</Text>
{/* Running files */}
{runningFiles.length > 0 && (
<Box flexDirection="column">
{runningFiles.map((r) => (
<RunningFile key={r.file} result={r} />
))}
</Box>
)}
{/* Actively retrying */}
{retryingFiles.length > 0 && (
<Box flexDirection="column">
{retryingFiles.map((r) => (
<RetryingFile key={r.file} result={r} />
))}
</Box>
)}
{/* Progress */}
<Box>
{!isComplete && <Spinner />}
{isComplete && <Text color="green"></Text>}
<Text>
{" "}
<Text bold>
{completedFiles.length}/{testFiles.length}
</Text>{" "}
| <Text color="green"> {passedTests.length}</Text> |{" "}
<Text color={failedTests.length > 0 ? "red" : undefined}>
{failedTests.length}
</Text>
{runningFiles.length > 0 && (
<Text dimColor> | {runningFiles.length} running</Text>
)}
{retryingFiles.length > 0 && (
<Text color="yellow"> | {retryingFiles.length} retrying</Text>
)}
</Text>
</Box>
{/* Final summary when complete */}
{isComplete && (
<Box flexDirection="column" marginTop={1}>
<FinalSummary results={allResults} />
</Box>
)}
</Box>
);
}
interface FinalSummaryProps {
results: TestFileResult[];
}
function FinalSummary({ results }: FinalSummaryProps) {
const allTests = results.flatMap((r) => r.tests);
const passedTests = allTests.filter((t) => t.status === "passed");
const failedTests = allTests.filter((t) => t.status === "failed");
const failedFiles = results.filter((r) => r.status === "failed");
const passedOnRetryFiles = results.filter((r) => r.passedOnRetry);
const totalDuration = results.reduce((sum, r) => sum + r.duration, 0);
// Files that failed with 0 tests (e.g. import errors, process crashes)
const crashedFiles = failedFiles.filter((r) => r.tests.length === 0);
const failedByFile = new Map<string, IndividualTest[]>();
for (const result of results) {
const fileFailed = result.tests.filter((t) => t.status === "failed");
if (fileFailed.length > 0) {
failedByFile.set(result.file, fileFailed);
}
}
const hasAnyFailure = failedFiles.length > 0;
if (!hasAnyFailure) {
const retryText =
passedOnRetryFiles.length > 0
? ` (${passedOnRetryFiles.length} on retry)`
: "";
return (
<Box flexDirection="column">
<Text color="green" bold>
{"═".repeat(60)}
</Text>
<Text color="green" bold>
ALL {passedTests.length} TESTS PASSED{retryText} (
{(totalDuration / 1000).toFixed(1)}s)
</Text>
<Text color="green" bold>
{"═".repeat(60)}
</Text>
</Box>
);
}
return (
<Box flexDirection="column">
<Text color="red" bold>
{"═".repeat(60)}
</Text>
<Text color="red" bold>
FAILED ({failedTests.length} test{failedTests.length !== 1 ? "s" : ""},{" "}
{failedFiles.length} file{failedFiles.length !== 1 ? "s" : ""})
</Text>
<Text color="red" bold>
{"═".repeat(60)}
</Text>
{crashedFiles.map((result) => (
<Box key={result.file} flexDirection="column" marginTop={1}>
<Text color="red" bold>
📁 {basename(result.file)}
</Text>
<Text dimColor>{"─".repeat(50)}</Text>
<Box marginLeft={2} flexDirection="column">
<Text color="red">
{" "}
File produced 0 tests (likely import/module error or process
crash)
</Text>
{result.crashError && (
<Text color="yellow"> {result.crashError}</Text>
)}
</Box>
</Box>
))}
{Array.from(failedByFile.entries()).map(([file, tests]) => (
<Box key={file} flexDirection="column" marginTop={1}>
<Text color="red" bold>
📁 {basename(file)}
</Text>
<Text dimColor>{"─".repeat(50)}</Text>
{tests.map((test) => (
<Box key={test.name} flexDirection="column" marginTop={1}>
<Text color="red"> {test.name}</Text>
{test.error?.location && (
<Text color="cyan">
{" "}
{toClickablePath(test.error.location)}
</Text>
)}
{test.error?.message && (
<Text color="yellow"> {test.error.message}</Text>
)}
</Box>
))}
</Box>
))}
<Text> </Text>
<Text color="red" bold>
{"═".repeat(60)}
</Text>
<Text bold>
SUMMARY: <Text color="green">{passedTests.length} passed</Text> |{" "}
<Text color="red">{failedTests.length} failed</Text>
{crashedFiles.length > 0 && (
<Text color="red"> | {crashedFiles.length} crashed</Text>
)}{" "}
| {(totalDuration / 1000).toFixed(1)}s
</Text>
<Text color="red" bold>
{"═".repeat(60)}
</Text>
</Box>
);
}
// ============================================================================
// CLI Entry Point
// ============================================================================
async function main() {
const args = process.argv.slice(2);
const directories: string[] = [];
let maxParallel = process.env.TEST_FILE_CONCURRENCY
? Number.parseInt(process.env.TEST_FILE_CONCURRENCY, 10)
: testRunConfig.legacyConcurrency;
let verbose = process.env.TEST_VERBOSE === "1";
for (const arg of args) {
if (arg.startsWith("--max=")) {
const maxValue = arg.split("=")[1];
maxParallel = maxValue
? Number.parseInt(maxValue, 10)
: testRunConfig.legacyConcurrency;
} else if (arg === "--verbose" || arg === "-v") {
verbose = true;
} else if (arg.startsWith("-")) {
console.error(`Unknown option: ${arg}`);
console.log(
"Usage: bun scripts/testScripts/runTestsV2.tsx <path1> [path2] [...] [--max=N] [--verbose]",
);
process.exit(1);
} else {
// Try to resolve the path in order of priority:
// 1. Exact path from cwd (file or directory)
// 2. Path under INTEGRATION_TEST_BASE
// 3. Search for folder/file name within INTEGRATION_TEST_BASE
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;
} else {
// Search for a matching folder or file under INTEGRATION_TEST_BASE
const baseFullPath = resolve(process.cwd(), INTEGRATION_TEST_BASE);
if (arg.endsWith(".test.ts")) {
const found = await findFileByPath(baseFullPath, arg);
if (found) {
resolvedPath = found.replace(`${process.cwd()}/`, "");
}
} else {
const found = await findFolderByPath(baseFullPath, arg);
if (found) {
resolvedPath = found.replace(`${process.cwd()}/`, "");
}
}
}
}
directories.push(resolvedPath);
}
}
if (directories.length === 0) {
console.error("Error: No test paths specified");
console.log(
"Usage: bun scripts/testScripts/runTestsV2.tsx <path1> [path2] [...] [--max=N]",
);
process.exit(1);
}
const testFiles = await collectTestFiles(directories);
if (testFiles.length === 0) {
console.log("No test files found in specified directories");
return;
}
render(
<TestRunnerApp
testFiles={testFiles}
maxParallel={maxParallel}
verbose={verbose}
/>,
);
}
main();