diff --git a/package.json b/package.json index 7851c6acc..b4fb09a90 100644 --- a/package.json +++ b/package.json @@ -105,9 +105,9 @@ "claude:trace": "bun scripts/claude-trace.ts", "build": "bun run --workspaces --if-present build", "build:native": "bun --cwd=packages/natives run build", - "test": "bun run --parallel test:ts test:rs", - "test:ts": "GITHUB_ACTIONS= bun run --workspaces --if-present test -- --only-failures && bun run test:scripts", - "test:scripts": "bun test scripts/ci-concurrency.test.ts scripts/ci-release-notes.test.ts", + "test": "bun scripts/ci-test-ts.ts local", + "test:ts": "bun scripts/ci-test-ts.ts local-ts", + "test:scripts": "bun test scripts/ci-concurrency.test.ts scripts/ci-release-notes.test.ts scripts/ci-test-ts.test.ts", "test:rs": "bun scripts/run-rs-task.ts test:rs", "check": "bun run --parallel check:ts check:rs", "check:ts": "bun run check:tools && bun run --workspaces --if-present check", diff --git a/scripts/ci-test-ts.ts b/scripts/ci-test-ts.ts index 533f57583..04d36524d 100755 --- a/scripts/ci-test-ts.ts +++ b/scripts/ci-test-ts.ts @@ -6,6 +6,8 @@ import * as path from "node:path"; type Mode = | "all" + | "local" + | "local-ts" | "workspace" | "native" | "coding-agent-singleton" @@ -28,24 +30,33 @@ const repoRoot = path.join(import.meta.dir, ".."); const args = process.argv.slice(2); const isDryRun = args.includes("--dry-run"); const requestedMode = args.find(arg => !arg.startsWith("--")) ?? "all"; -// `--only-failures` is Bun's output filter — it hides passing tests, keeping the -// log terse, and is the default here (CI and the root `test:ts` aggregate append -// it). It does NOT skip tests or share any cross-process cache, so chunks are -// safe to run concurrently. The package-level `test` script passes `--full` for -// verbose output (every test line); an explicit `--only-failures` still wins. +// `--only-failures` is Bun's output filter — it hides passing tests within each +// chunk, keeping the log terse, and is the default here (CI and the root +// `test:ts` aggregate append it). It does NOT skip tests or share any +// cross-process cache, so chunks are safe to run concurrently. The package-level +// `test` script passes `--full` for verbose output (every test line); an explicit +// `--only-failures` still wins. const onlyFailures = args.includes("--only-failures") || !args.includes("--full"); const onlyFailuresArgs = onlyFailures ? ["--only-failures"] : []; +// Quiet mode (the default) collapses each parallel chunk to a one-line pass/fail +// progress entry and replays full stdout/stderr only for chunks that failed, so +// the failure is never buried under thousands of passing-chunk lines. `--full` +// opts back into inline replay of every chunk. Tied to `onlyFailures` so the +// quiet path is whatever the verbose filter is not. +const quiet = onlyFailures; -const validModes = new Set([ - "all", - "workspace", - "native", - "coding-agent-singleton", - "coding-agent-ui", - "coding-agent-runtime", - "coding-agent-native", - "coding-agent-heavy", -]); +const validModes: Record = { + all: true, + local: true, + "local-ts": true, + workspace: true, + native: true, + "coding-agent-singleton": true, + "coding-agent-ui": true, + "coding-agent-runtime": true, + "coding-agent-native": true, + "coding-agent-heavy": true, +}; // `chunkSize` splits a bucket's file list into that-many-file groups, each run as a // separate `bun --smol test` child process. A fresh process per chunk resets Bun's @@ -86,6 +97,22 @@ const nativeAndIntegrationPackages = [ "packages/typescript-edit-benchmark", ]; +// Packages the CI buckets deliberately skip but a local full run should still +// cover. mnemopi's embedding suites need a ~270MB fastembed model absent from CI +// runners (so it flakes/times out there); robomp-web lives under python/robomp +// and is outside every CI TS bucket. Both run with `--smol` to bound RSS when +// fanned out alongside everything else. +const localOnlyWorkspacePackages = ["packages/mnemopi", "python/robomp/web"]; + +// Repo-level script tests. CI's `workspace` bucket only runs the concurrency +// regression (it's the GHA-config guard that must gate merges); a local full run +// also exercises the release-notes and runner-output tests. +const repoScriptTests = [ + "scripts/ci-concurrency.test.ts", + "scripts/ci-release-notes.test.ts", + "scripts/ci-test-ts.test.ts", +]; + const codingAgentNativePathPatterns = [ /(^|\/)[^/]*(bash|native|browser|cmux|mnemopi|hindsight|memory)[^/]*\.test\.ts$/i, /^test\/[^/]*(ask|gh|irc|task|eval|search|read|write|edit|ast|resolve|sqlite|web-search|fetch|image|ssh|tool)[^/]*\.test\.ts$/, @@ -184,11 +211,28 @@ function shellQuote(value: string): string { return `'${value.replaceAll("'", `'\\''`)}'`; } -function workspaceTestCommand(pkg: string, parallel: number, smol = false): TestCommand { +function workspaceTestCommand( + pkg: string, + parallel: number, + options: { smol?: boolean; extraArgs?: string[] } = {}, +): TestCommand { + const { smol = false, extraArgs = [] } = options; return { label: pkg, cwd: pkg, - command: ["bun", ...(smol ? ["--smol"] : []), "test", `--parallel=${parallel}`], + command: ["bun", ...(smol ? ["--smol"] : []), "test", `--parallel=${parallel}`, ...extraArgs], + }; +} + +// The Rust suite as one pooled command, so root `bun run test` reports TS and +// Rust under the same progress stream / failure report. Delegates to +// run-rs-task.ts, which self-skips when no Rust-affecting files changed locally +// (printing a one-line notice) and resolves the cargo/nextest invocation. +function rustTestCommand(): TestCommand { + return { + label: "rust (cargo nextest; skipped if no Rust changes)", + cwd: ".", + command: ["bun", "scripts/run-rs-task.ts", "test:rs"], }; } @@ -309,7 +353,7 @@ async function commandsForMode(mode: Mode): Promise { }, ]; case "native": - return nativeAndIntegrationPackages.map(pkg => workspaceTestCommand(pkg, 4, true)); + return nativeAndIntegrationPackages.map(pkg => workspaceTestCommand(pkg, 4, { smol: true })); case "coding-agent-singleton": return await codingAgentTestCommands("singleton"); case "coding-agent-ui": @@ -331,6 +375,32 @@ async function commandsForMode(mode: Mode): Promise { ...(await commandsForMode("native")), ...(await commandsForMode("coding-agent-heavy")), ]; + // `local-ts` is the full local TypeScript run that root `bun run test:ts` + // drives: every package the old `--workspaces` fan-out covered (the CI + // `all` set PLUS mnemopi and robomp-web, which CI omits) and every repo + // script test, routed through this one quiet runner so the whole suite + // shares one progress stream and one failure report. + case "local-ts": + return [ + ...fastWorkspacePackages.map(pkg => workspaceTestCommand(pkg, 8, { extraArgs: onlyFailuresArgs })), + ...nativeAndIntegrationPackages.map(pkg => + workspaceTestCommand(pkg, 4, { smol: true, extraArgs: onlyFailuresArgs }), + ), + ...localOnlyWorkspacePackages.map(pkg => + workspaceTestCommand(pkg, 4, { smol: true, extraArgs: onlyFailuresArgs }), + ), + ...(await commandsForMode("coding-agent-heavy")), + { + label: "scripts", + cwd: ".", + command: ["bun", "test", "--parallel=4", ...onlyFailuresArgs, ...repoScriptTests], + }, + ]; + // `local` is what root `bun run test` drives: the full TS suite plus the + // Rust task, so a single invocation reports TS and Rust together. The Rust + // command self-skips when no Rust-affecting files changed (see run-rs-task). + case "local": + return [...(await commandsForMode("local-ts")), rustTestCommand()]; } } @@ -426,15 +496,66 @@ function testConcurrency(total: number): number { return Math.min(Math.max(1, os.availableParallelism()), total); } +// ANSI styling for interactive runs only; disabled when stdout is not a TTY or +// NO_COLOR is set, so CI logs and piped/aggregated output stay plain text. +const useColor = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR; +const paint = (code: string, value: string): string => (useColor ? `\x1b[${code}m${value}\x1b[0m` : value); +const style = { + green: (s: string) => paint("32", s), + red: (s: string) => paint("31", s), + bold: (s: string) => paint("1", s), + dim: (s: string) => paint("2", s), +}; + +// Outcome of one finished chunk. `output` is the chunk's combined stdout+stderr, +// buffered so it can be withheld during a quiet run and replayed only on failure. +interface ChunkOutcome { + label: string; + command: string; + exitCode: number; + seconds: number; + output: string; +} + +// One-line live progress entry, e.g. `[12/86] ok 3.2s