#!/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 { 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 { 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>(); // 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 (/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 { const testFiles: string[] = []; const collectRecursive = async (dirPath: string): Promise => { 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 { 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 {SPINNER_FRAMES[frame]}; } 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 ( {icon} {fileName} {retryBadge}{" "} (✓{passedCount} {failedCount > 0 && ( ✗{failedCount} )} ) ); } interface FailedTestProps { test: IndividualTest; isWarning?: boolean; } function FailedTest({ test, isWarning }: FailedTestProps) { const color = isWarning ? "yellow" : "red"; return ( {truncate(test.name, 60)} {test.error?.message && ( — {truncate(test.error.message, 70)} )} {test.error?.location && ( {toClickablePath(test.error.location)} )} ); } 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 ( {fileName} {(passedCount > 0 || failedCount > 0) && ( {" "} (✓{passedCount} {failedCount > 0 && ✗{failedCount}}) )} {result.currentTest && ( › {truncate(result.currentTest, 35)} )} ); } 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 ( {fileName} (retrying) {(passedCount > 0 || failedCount > 0) && ( {" "} (✓{passedCount} {failedCount > 0 && ✗{failedCount}}) )} {result.currentTest && ( › {truncate(result.currentTest, 35)} )} ); } 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>(new Map()); const dirtyRef = useRef(false); const [results, setResults] = useState>( new Map(), ); const [isComplete, setIsComplete] = useState(false); // Track which completed files have already been emitted to // so we only append new ones (Static items are write-once). const emittedFilesRef = useRef>(new Set()); const [staticItems, setStaticItems] = useState([]); // 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(); 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(pendingRef.current); setResults(snapshot); // Emit newly-completed files to // 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(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 ( {/* Static section: completed files + failures. Written once, never re-rendered. */} {(item: StaticItem) => { if (item.type === "retry_result") { const { result } = item; if (result.status === "passed") { return ( ↳ ✓ {basename(result.file)} passed on retry ); } return ( ↳ ✗ {basename(result.file)} still failed after retry ); } const { result, willRetry } = item; return ( {result.tests .filter((t: IndividualTest) => t.status === "failed") .map((t: IndividualTest) => ( ))} ); }} {/* Dynamic section below — only this part re-renders */} {"─".repeat(60)} {/* Running files */} {runningFiles.length > 0 && ( {runningFiles.map((r) => ( ))} )} {/* Actively retrying */} {retryingFiles.length > 0 && ( {retryingFiles.map((r) => ( ))} )} {/* Progress */} {!isComplete && } {isComplete && } {" "} {completedFiles.length}/{testFiles.length} {" "} | ✓ {passedTests.length} |{" "} 0 ? "red" : undefined}> ✗ {failedTests.length} {runningFiles.length > 0 && ( | {runningFiles.length} running )} {retryingFiles.length > 0 && ( | {retryingFiles.length} retrying )} {/* Final summary when complete */} {isComplete && ( )} ); } 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(); 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 ( {"═".repeat(60)} ✓ ALL {passedTests.length} TESTS PASSED{retryText} ( {(totalDuration / 1000).toFixed(1)}s) {"═".repeat(60)} ); } return ( {"═".repeat(60)} FAILED ({failedTests.length} test{failedTests.length !== 1 ? "s" : ""},{" "} {failedFiles.length} file{failedFiles.length !== 1 ? "s" : ""}) {"═".repeat(60)} {crashedFiles.map((result) => ( 📁 {basename(result.file)} {"─".repeat(50)} {" "} ✗ File produced 0 tests (likely import/module error or process crash) {result.crashError && ( {result.crashError} )} ))} {Array.from(failedByFile.entries()).map(([file, tests]) => ( 📁 {basename(file)} {"─".repeat(50)} {tests.map((test) => ( ✗ {test.name} {test.error?.location && ( {" "} {toClickablePath(test.error.location)} )} {test.error?.message && ( {test.error.message} )} ))} ))} {"═".repeat(60)} SUMMARY: {passedTests.length} passed |{" "} {failedTests.length} failed {crashedFiles.length > 0 && ( | {crashedFiles.length} crashed )}{" "} | {(totalDuration / 1000).toFixed(1)}s {"═".repeat(60)} ); } // ============================================================================ // 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 [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 [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( , ); } main();