diff --git a/AGENTS.md b/AGENTS.md index 8b0fcf01e3..cc9851178c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -163,17 +163,25 @@ it binds you regardless of which mechanism is within reach. ```bash bun install bun run typecheck # bun x tsc --noEmit (strict) -bun run test # full tests/ suite +bun run test:changed # import-graph tests against the resolved `dev` merge base +bun run test # full tests/ suite (PR-ready / explicit ask only) bun run lint:gui # GUI eslint bun run privacy:scan # credential/privacy scan used by CI bun run build:gui # Vite GUI build ``` During implementation, use the smallest focused checks that directly cover the -changed subsystem. Do not run repository-wide `bun run typecheck` or -`bun run test` for a scoped change unless the change affects shared runtime, -routing, config, server behavior, a focused result is failed or ambiguous, or -the user explicitly asks for full validation. +changed subsystem. Prefer `bun test tests/.test.ts` for a known file, or +`bun run test:changed` when the touch set is broader than one file. Do **not** +run repository-wide `bun run test` or a bare `bun test` with no file arguments +for a scoped change by default. `bun run test:changed` follows Bun's parsed module graph: it +selects test files that import changed modules, but it cannot see dependencies +expressed through subprocesses, source files read as data, or golden/derived +files. Run the relevant focused tests explicitly for those paths; if no reliable +focused set covers them, the full suite is required even for a scoped change. +That indirect-dependency case is the explicit exception to the scoped-change +default. The full suite is ~850 files, so otherwise reserve it for a failed or +ambiguous focused result, an explicit user request, or the PR-ready gate below. Before creating or updating a non-trivial PR as review-ready, or before approving such a PR, run `bun run typecheck` and `bun run test`. CI runs these @@ -274,8 +282,9 @@ reviewers (Codex, CodeRabbit). assumptions about a compile step, or code paths that break `bun run typecheck` / `bun run test`. - **Tests:** behavior changes in `src/` need a focused regression test near - the existing tests for that subsystem. Shared routing, adapter, config, or - server changes need the full suite green. + the existing tests for that subsystem. During implementation, run the relevant + focused files and use `bun run test:changed` for import-connected coverage as + described above; the full suite is the PR-ready gate. - **Docs sync:** user-facing behavior changes should update `docs-site/` (and keep translated locales from contradicting the English source). - **Privacy:** `bun run privacy:scan` must stay green; never introduce logging diff --git a/docs-site/src/content/docs/contributing.md b/docs-site/src/content/docs/contributing.md index 00d164dbbc..f4e1c84d0e 100644 --- a/docs-site/src/content/docs/contributing.md +++ b/docs-site/src/content/docs/contributing.md @@ -15,7 +15,9 @@ bun install bun run dev:proxy # proxy API in dev mode bun run dev:gui # dashboard dev server (another terminal) bun run typecheck # bun x tsc --noEmit -bun run test # bun test ./tests/ +bun run test:changed # routine import-graph test selection +bun test tests/router.test.ts # routine focused test +bun run test # complete suite (PR-ready / explicit ask) ``` `bun run dev` remains an alias for `bun run dev:proxy`. The dashboard dev server is `bun run dev:gui`; @@ -28,13 +30,18 @@ scripts so local commands match CI: ```bash bun run typecheck # strict TypeScript check -bun run test # complete tests/ suite +bun run test:changed # import-graph tests against the resolved dev merge base +bun run test # complete tests/ suite (PR-ready / explicit ask) bun test tests/router.test.ts # focused test file bun run build:gui # Vite GUI build + package preparation bun run privacy:scan # credential/privacy scan used by CI bun run prepare:package # refresh package launchers/assets ``` +`test:changed` selects the first comparison ref that exists, in order: `upstream/dev`, +`origin/dev`, then local `dev`. It reports that ref and the exact `git merge-base HEAD ` +commit, then passes the merge-base SHA to Bun. + Most tests are flat `tests/*.test.ts` Bun tests. `tests/helpers/` contains shared fixtures and `tests/e2e-style/` contains broader native-parity scenarios. Keep a focused regression near the existing tests for the subsystem you change; run the full suite for shared routing, adapters, config, diff --git a/docs-site/src/content/docs/fr/contributing.md b/docs-site/src/content/docs/fr/contributing.md index 408191cdbf..facce7337b 100644 --- a/docs-site/src/content/docs/fr/contributing.md +++ b/docs-site/src/content/docs/fr/contributing.md @@ -15,7 +15,9 @@ bun install bun run dev:proxy # proxy API in dev mode bun run dev:gui # dashboard dev server (another terminal) bun run typecheck # bun x tsc --noEmit -bun run test # bun test ./tests/ +bun run test:changed # routine import-graph test selection +bun test tests/router.test.ts # routine focused test +bun run test # complete suite (PR-ready / explicit ask) ``` `bun run dev` reste un alias pour `bun run dev:proxy`. Le serveur de développement du tableau de bord est `bun run dev:gui` ; @@ -28,7 +30,8 @@ distincte. Utilisez les scripts enregistrés afin que les commandes locales corr ```bash bun run typecheck # strict TypeScript check -bun run test # complete tests/ suite +bun run test:changed # import-graph tests against the resolved dev merge base +bun run test # complete tests/ suite (PR-ready / explicit ask) bun test tests/router.test.ts # focused test file bun run build:gui # Vite GUI build + package preparation bun run privacy:scan # credential/privacy scan used by CI diff --git a/docs-site/src/content/docs/ja/contributing.md b/docs-site/src/content/docs/ja/contributing.md index b7246c06b9..26a1de8c42 100644 --- a/docs-site/src/content/docs/ja/contributing.md +++ b/docs-site/src/content/docs/ja/contributing.md @@ -12,7 +12,9 @@ bun install bun run dev:proxy # 開発モードのプロキシ API bun run dev:gui # ダッシュボード dev サーバー(別ターミナル) bun run typecheck # bun x tsc --noEmit -bun run test # bun test ./tests/ +bun run test:changed # routine import-graph test selection +bun test tests/router.test.ts # routine focused test +bun run test # complete suite (PR-ready / explicit ask) ``` `bun run dev` は引き続き `bun run dev:proxy` のエイリアスとして動作します。ダッシュボード dev サーバーは diff --git a/docs-site/src/content/docs/ko/contributing.md b/docs-site/src/content/docs/ko/contributing.md index aea5a33ff7..21892e69cf 100644 --- a/docs-site/src/content/docs/ko/contributing.md +++ b/docs-site/src/content/docs/ko/contributing.md @@ -12,7 +12,9 @@ bun install bun run dev:proxy # 개발 모드 프록시 API bun run dev:gui # 대시보드 dev 서버(다른 터미널) bun run typecheck # bun x tsc --noEmit -bun run test # bun test ./tests/ +bun run test:changed # routine import-graph test selection +bun test tests/router.test.ts # routine focused test +bun run test # complete suite (PR-ready / explicit ask) ``` `bun run dev`는 계속 `bun run dev:proxy`의 별칭으로 동작합니다. 대시보드 dev 서버는 diff --git a/docs-site/src/content/docs/ru/contributing.md b/docs-site/src/content/docs/ru/contributing.md index 7f63296516..7ac718ae2d 100644 --- a/docs-site/src/content/docs/ru/contributing.md +++ b/docs-site/src/content/docs/ru/contributing.md @@ -12,7 +12,9 @@ bun install bun run dev:proxy # прокси-API в режиме разработки bun run dev:gui # dev-сервер дашборда (другой терминал) bun run typecheck # bun x tsc --noEmit -bun run test # bun test ./tests/ +bun run test:changed # routine import-graph test selection +bun test tests/router.test.ts # routine focused test +bun run test # complete suite (PR-ready / explicit ask) ``` `bun run dev` остаётся псевдонимом для `bun run dev:proxy`. Dev-сервер дашборда — `bun run dev:gui`; diff --git a/docs-site/src/content/docs/tr/contributing.md b/docs-site/src/content/docs/tr/contributing.md index a6691f02b3..9fd8c71a20 100644 --- a/docs-site/src/content/docs/tr/contributing.md +++ b/docs-site/src/content/docs/tr/contributing.md @@ -17,7 +17,9 @@ bun install bun run dev:proxy # geliştirme modunda proxy API bun run dev:gui # kontrol paneli geliştirme sunucusu (başka bir terminalde) bun run typecheck # bun x tsc --noEmit -bun run test # bun test ./tests/ +bun run test:changed # routine import-graph test selection +bun test tests/router.test.ts # routine focused test +bun run test # complete suite (PR-ready / explicit ask) ``` `bun run dev`, `bun run dev:proxy` komutunun bir takma adıdır. Kontrol paneli diff --git a/docs-site/src/content/docs/zh-cn/contributing.md b/docs-site/src/content/docs/zh-cn/contributing.md index 5708abb3f9..5f102c587f 100644 --- a/docs-site/src/content/docs/zh-cn/contributing.md +++ b/docs-site/src/content/docs/zh-cn/contributing.md @@ -12,7 +12,9 @@ bun install bun run dev:proxy # 开发模式代理 API bun run dev:gui # 仪表盘 dev 服务器(另一个终端) bun run typecheck # bun x tsc --noEmit -bun run test # bun test ./tests/ +bun run test:changed # routine import-graph test selection +bun test tests/router.test.ts # routine focused test +bun run test # complete suite (PR-ready / explicit ask) ``` `bun run dev` 继续作为 `bun run dev:proxy` 的别名。仪表盘 dev 服务器使用 `bun run dev:gui`; diff --git a/docs-site/src/content/docs/zh-tw/contributing.md b/docs-site/src/content/docs/zh-tw/contributing.md index 66e04e0c3b..e39c0d1f66 100644 --- a/docs-site/src/content/docs/zh-tw/contributing.md +++ b/docs-site/src/content/docs/zh-tw/contributing.md @@ -12,7 +12,9 @@ bun install bun run dev:proxy # 開發模式代理 API bun run dev:gui # 儀表板 dev 伺服器(另一個終端) bun run typecheck # bun x tsc --noEmit -bun run test # bun test ./tests/ +bun run test:changed # routine import-graph test selection +bun test tests/router.test.ts # routine focused test +bun run test # complete suite (PR-ready / explicit ask) ``` `bun run dev` 繼續作為 `bun run dev:proxy` 的別名。儀表板 dev 伺服器使用 `bun run dev:gui`; diff --git a/package.json b/package.json index 7b3d03972e..1277fa50b3 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "dev:gui": "cd gui && bun run dev", "start": "bun run src/cli/index.ts start", "test": "bun scripts/test.ts", + "test:changed": "bun scripts/test.ts --changed=dev", "typecheck": "bun x tsc --noEmit", "audit:high": "bun audit --audit-level=high && cd gui && bun audit --audit-level=high", "privacy:scan": "bun scripts/privacy-scan.ts", diff --git a/scripts/test.ts b/scripts/test.ts index 29357a681a..832a537191 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -144,12 +144,136 @@ const BUN_TEST_OPTIONS_REQUIRING_VALUES = new Set([ "--config", ]); +export interface ChangedRunPreflight { + comparisonRef: string; + comparisonCommit: string; + changedFiles: string[]; +} + +const changedComparisonRefs = ["upstream/dev", "origin/dev", "dev"] as const; + +/** Choose the highest-priority conventional dev ref without assuming which remote is canonical. */ +export function selectChangedComparisonRef(refExists: (ref: string) => boolean): string | null { + return changedComparisonRefs.find(refExists) ?? null; +} + +function decodeOutput(output: Uint8Array | undefined): string { + return output ? new TextDecoder().decode(output) : ""; +} + +function changedComparisonRef(requested: string[]): string | null { + const delimiterIndex = requested.indexOf("--"); + const wrapperArgs = delimiterIndex === -1 ? requested : requested.slice(0, delimiterIndex); + const changedArg = wrapperArgs.find(arg => arg === "--changed" || arg.startsWith("--changed=")); + if (!changedArg) return null; + if (changedArg === "--changed" || changedArg === "--changed=") { + throw new Error( + "[test] changed mode requires an explicit comparison ref; use --changed= so the selection can be validated.", + ); + } + return changedArg.slice("--changed=".length); +} + +function gitRefExists( + ref: string, + cwd: string, + env: Record, +): boolean { + const result = Bun.spawnSync(["git", "rev-parse", "--verify", "--quiet", `${ref}^{commit}`], { + cwd, + env, + stdout: "ignore", + stderr: "ignore", + }); + return result.exitCode === 0; +} + +function gitOutput( + args: string[], + cwd: string, + env: Record, +): string { + const result = Bun.spawnSync(["git", ...args], { + cwd, + env, + stdout: "pipe", + stderr: "pipe", + }); + if (result.exitCode !== 0) { + const detail = decodeOutput(result.stderr).trim() || `exit ${result.exitCode ?? "unknown"}`; + throw new Error(`[test] git ${args[0]} failed while validating changed mode: ${detail}`); + } + return decodeOutput(result.stdout); +} + +/** Resolve changed mode and inventory the diff against that commit before invoking Bun. */ +export function inspectChangedRun( + requested: string[], + cwd: string = process.cwd(), + env: Record = process.env, +): ChangedRunPreflight | null { + const requestedComparisonRef = changedComparisonRef(requested); + if (!requestedComparisonRef) return null; + if (requestedComparisonRef.startsWith("-")) { + throw new Error( + `[test] --changed comparison ref ${JSON.stringify(requestedComparisonRef)} is invalid.`, + ); + } + + const comparisonRef = requestedComparisonRef === "dev" + ? selectChangedComparisonRef(ref => gitRefExists(ref, cwd, env)) + : requestedComparisonRef; + if (!comparisonRef) { + throw new Error( + `[test] --changed=dev could not resolve a comparison ref; none of ${changedComparisonRefs.join(", ")} exists.`, + ); + } + + if (!gitRefExists(comparisonRef, cwd, env)) { + throw new Error( + `[test] --changed comparison ref ${JSON.stringify(comparisonRef)} does not resolve to a commit.`, + ); + } + + const comparisonCommit = gitOutput(["merge-base", "HEAD", comparisonRef], cwd, env).trim(); + if (!comparisonCommit) { + throw new Error( + `[test] --changed comparison ref ${JSON.stringify(comparisonRef)} has no merge base with HEAD.`, + ); + } + + const diff = gitOutput(["diff", "--name-only", comparisonCommit, "--"], cwd, env); + const changedFiles = [...new Set(diff.split("\n").filter(Boolean))]; + return { comparisonRef, comparisonCommit, changedFiles }; +} + +/** Refuse a successful changed-mode run when Bun silently selected no tests for a real diff. */ +export function changedSelectionFailure( + preflight: ChangedRunPreflight, + output: string, +): string | null { + if (preflight.changedFiles.length === 0) return null; + const summary = output + .replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, "") + .match(/Ran\s+(\d+)\s+tests?\s+across\s+(\d+)\s+files?\b/i); + if (!summary) { + return `[test] could not validate --changed=${preflight.comparisonCommit} (${preflight.comparisonRef} merge base): Bun did not emit a recognizable selection summary for a diff containing ${preflight.changedFiles.length} changed file(s).`; + } + if (Number(summary[1]) !== 0 || Number(summary[2]) !== 0) return null; + return `[test] --changed=${preflight.comparisonCommit} (${preflight.comparisonRef} merge base) selected 0 tests across 0 files, but the diff contains ${preflight.changedFiles.length} changed file(s). Bun follows only the parsed module graph; run the relevant focused tests for subprocess, read-as-data, or golden-file dependencies, or run the full suite.`; +} + +/** + * True for a filter-less `bun run test`: no file arguments and no `--changed`. + * `--timeout` / `--dots` / `--parallel=N` still count as full. + */ /** True for a filter-less `bun run test`. `--timeout` / `--dots` / `--parallel=N` still count. */ function isFullSuiteRun(requested: string[]): boolean { const delimiterIndex = requested.indexOf("--"); const wrapperArgs = delimiterIndex === -1 ? requested : requested.slice(0, delimiterIndex); const passedThrough = delimiterIndex === -1 ? [] : requested.slice(delimiterIndex + 1); if (passedThrough.length > 0) return false; + if (hasCliFlag(requested, "--changed")) return false; for (let index = 0; index < wrapperArgs.length; index++) { const arg = wrapperArgs[index]; @@ -170,13 +294,25 @@ function isFullSuiteRun(requested: string[]): boolean { * workers made deadline-sensitive tests fail under load, so the repository default is deterministic. * A caller-supplied `--parallel` or `--parallel=N` is left alone. */ -export function resolveBunTestArgs(requested: string[]): string[] { +export function resolveBunTestArgs( + requested: string[], + comparisonCommit?: string, +): string[] { + const delimiterIndex = requested.indexOf("--"); + const effectiveRequested = comparisonCommit + ? requested.map((arg, index) => ( + (delimiterIndex === -1 || index < delimiterIndex) + && (arg === "--changed" || arg.startsWith("--changed=")) + ? "--changed=" + comparisonCommit + : arg + )) + : requested; const args = ["--isolate"]; - if (!hasCliFlag(requested, "--parallel")) { + if (!hasCliFlag(effectiveRequested, "--parallel")) { args.push(`--parallel=${DEFAULT_TEST_PARALLELISM}`); } - args.push(...requested); - if (isFullSuiteRun(requested)) args.push("./tests/"); + args.push(...effectiveRequested); + if (isFullSuiteRun(effectiveRequested)) args.push("./tests/"); return args; } @@ -211,12 +347,12 @@ function canUseSerialLanes(requested: string[]): boolean { } /** Build the default full-suite plan: one bounded main lane plus isolated risky files. */ -export function resolveBunTestPlan(requested: string[]): BunTestLane[] { +export function resolveBunTestPlan(requested: string[], comparisonCommit?: string): BunTestLane[] { if (!canUseSerialLanes(requested)) { - return [{ label: "suite", args: resolveBunTestArgs(requested), timeoutMs: 15 * 60 * 1000 }]; + return [{ label: "suite", args: resolveBunTestArgs(requested, comparisonCommit), timeoutMs: 15 * 60 * 1000 }]; } - const mainArgs = resolveBunTestArgs(requested); + const mainArgs = resolveBunTestArgs(requested, comparisonCommit); const rootIndex = mainArgs.lastIndexOf("./tests/"); const ignores = SERIAL_FULL_SUITE_FILES.flatMap(file => ["--path-ignore-patterns", `**/${file}`]); mainArgs.splice(rootIndex === -1 ? mainArgs.length : rootIndex, 0, ...ignores); @@ -247,16 +383,18 @@ function waitWithTimeout(promise: Promise, timeoutMs: number): Promise { +async function runTestLane(lane: BunTestLane, runId: string, capture = false): Promise<{ exitCode: number; output: string }> { const isolated = createIsolatedTestEnvironment({ ...process.env, [TEST_RUN_ID_ENV]: runId }); const startedAt = Date.now(); let interrupted: NodeJS.Signals | null = null; const child = Bun.spawn([process.execPath, "test", ...lane.args], { env: isolated.env, stdin: "inherit", - stdout: "inherit", - stderr: "inherit", + stdout: capture ? "pipe" : "inherit", + stderr: capture ? "pipe" : "inherit", }); + const stdoutP = capture ? new Response(child.stdout).text() : Promise.resolve(""); + const stderrP = capture ? new Response(child.stderr).text() : Promise.resolve(""); const forward = (signal: NodeJS.Signals) => { interrupted = signal; try { child.kill(signal); } catch { /* child already exited */ } @@ -277,13 +415,17 @@ async function runTestLane(lane: BunTestLane, runId: string): Promise { try { child.kill("SIGKILL"); } catch { /* child already exited */ } await waitWithTimeout(exited, 2_000); } - return 124; + return { exitCode: 124, output: "" }; } - if (interrupted === "SIGINT") return 130; - if (interrupted === "SIGTERM") return 143; + const [stdout, stderr] = await Promise.all([stdoutP, stderrP]); + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); + const output = stdout + "\n" + stderr; + if (interrupted === "SIGINT") return { exitCode: 130, output }; + if (interrupted === "SIGTERM") return { exitCode: 143, output }; const seconds = ((Date.now() - startedAt) / 1000).toFixed(1); console.warn(`[test] ${lane.label} finished in ${seconds}s (exit ${exitCode}).`); - return exitCode; + return { exitCode, output }; } finally { process.off("SIGINT", onInterrupt); process.off("SIGTERM", onTerminate); @@ -293,32 +435,55 @@ async function runTestLane(lane: BunTestLane, runId: string): Promise { if (import.meta.main) { const requestedTests = process.argv.slice(2); - const runId = randomUUID(); - const lock = await acquireTestRunLock({ - runId, - onWait: owner => console.warn( - `[test] another Bun test run${owner ? ` (pid ${owner.pid})` : ""} holds the machine lock; waiting. ` - + "Set OCX_TEST_NO_QUEUE=1 only for intentional overlap.", - ), - onAcquiredAfterWait: elapsedMs => console.warn(`[test] acquired the machine lock after ${Math.round(elapsedMs / 1000)}s.`), - }); - const startedAt = Date.now(); + let changedRun: ReturnType = null; try { - let exitCode = 0; - for (const lane of resolveBunTestPlan(requestedTests)) { - const laneExitCode = await runTestLane(lane, runId); - if (laneExitCode !== 0 && exitCode === 0) exitCode = laneExitCode; - if ([124, 130, 143].includes(laneExitCode)) break; - } - const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000); - if (isFullSuiteRun(requestedTests) && elapsedSeconds > 600) { + changedRun = inspectChangedRun(requestedTests); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } + if (process.exitCode !== 1) { + if (changedRun) { console.warn( - `[test] the suite took ${elapsedSeconds}s; with --parallel=${DEFAULT_TEST_PARALLELISM} it should finish in a few minutes on an idle machine. ` - + "Check for another test runner, a busy CPU, or a test that started polling something real.", + `[test] changed mode comparison ref: ${changedRun.comparisonRef}; merge base: ${changedRun.comparisonCommit}`, ); } - process.exitCode = exitCode; - } finally { - lock.release(); + const runId = randomUUID(); + const lock = await acquireTestRunLock({ + runId, + onWait: owner => console.warn( + `[test] another Bun test run${owner ? ` (pid ${owner.pid})` : ""} holds the machine lock; waiting. ` + + "Set OCX_TEST_NO_QUEUE=1 only for intentional overlap.", + ), + onAcquiredAfterWait: elapsedMs => console.warn(`[test] acquired the machine lock after ${Math.round(elapsedMs / 1000)}s.`), + }); + const startedAt = Date.now(); + try { + let exitCode = 0; + let captured = ""; + for (const lane of resolveBunTestPlan(requestedTests, changedRun?.comparisonCommit)) { + const result = await runTestLane(lane, runId, Boolean(changedRun)); + captured += result.output; + if (result.exitCode !== 0 && exitCode === 0) exitCode = result.exitCode; + if ([124, 130, 143].includes(result.exitCode)) break; + } + if (exitCode === 0 && changedRun) { + const selectionFailure = changedSelectionFailure(changedRun, captured); + if (selectionFailure) { + console.error(selectionFailure); + exitCode = 1; + } + } + const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000); + if (isFullSuiteRun(requestedTests) && elapsedSeconds > 600) { + console.warn( + `[test] the suite took ${elapsedSeconds}s; with --parallel=${DEFAULT_TEST_PARALLELISM} it should finish in a few minutes on an idle machine. ` + + "Check for another test runner, a busy CPU, or a test that started polling something real.", + ); + } + process.exitCode = exitCode; + } finally { + lock.release(); + } } } diff --git a/src/AGENTS.md b/src/AGENTS.md index ab7a5fef46..9347a655fe 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -23,6 +23,7 @@ This file applies to `src/` and inherits the repository-wide rules in `/AGENTS.m - Place focused regression coverage near the existing tests for the affected subsystem. - For focused behavior, run the relevant `bun test tests/.test.ts` and `bun run typecheck`. -- For shared routing, adapters, config, OAuth, or server behavior, also run `bun run test`. +- If the change set is broader than one file, run `bun run test:changed` instead of the full suite. +- Run `bun run test` only before marking a PR review-ready, or when the user explicitly asks for the full suite. - For logging, requests, credentials, account data, or fixtures, also run `bun run privacy:scan`. - Update `docs-site/` when the change affects user-visible behavior or configuration. diff --git a/tests/test-runner.test.ts b/tests/test-runner.test.ts index 9589bc59d8..cac4f9bfd8 100644 --- a/tests/test-runner.test.ts +++ b/tests/test-runner.test.ts @@ -3,9 +3,12 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { isAbsolute, join } from "node:path"; import { + changedSelectionFailure, createIsolatedTestEnvironment, + inspectChangedRun, resolveBunTestArgs, resolveBunTestPlan, + selectChangedComparisonRef, SERIAL_FULL_SUITE_FILES, } from "../scripts/test"; import { @@ -19,6 +22,38 @@ import { windowsIdentityPowerShellSpawnOptionsForTests, } from "../src/codex/user-identity"; + +function runGit(cwd: string, ...args: string[]): string { + const result = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" }); + if (result.exitCode !== 0) { + throw new Error(new TextDecoder().decode(result.stderr)); + } + return new TextDecoder().decode(result.stdout).trim(); +} + +function commitFixture(cwd: string, path: string, contents: string, message: string): string { + writeFileSync(join(cwd, path), contents); + runGit(cwd, "add", path); + runGit( + cwd, + "-c", + "user.name=OpenCodex Test", + "-c", + "user.email=test@opencodex.invalid", + "commit", + "-m", + message, + ); + return runGit(cwd, "rev-parse", "HEAD"); +} + +function initChangedRunFixture(): { cwd: string; base: string } { + const cwd = mkdtempSync(join(tmpdir(), "opencodex-changed-ref-")); + runGit(cwd, "init", "--quiet"); + const base = commitFixture(cwd, "base.txt", "base\n", "base"); + return { cwd, base }; +} + describe("test runner isolation", () => { test("redirects user homes to a disposable root", () => { const isolated = createIsolatedTestEnvironment({ PATH: "/test/bin", HOME: "/real/home" }); @@ -175,6 +210,118 @@ describe("bun test argv", () => { test("arguments after the delimiter are passed through instead of parsed as wrapper flags", () => { expect(resolveBunTestArgs(["--", "--parallel=2"])) .toEqual(["--isolate", "--parallel=4", "--", "--parallel=2"]); + const mergeBase = "0123456789abcdef0123456789abcdef01234567"; + expect(resolveBunTestArgs(["--", "--changed=fixture"], mergeBase)) + .toEqual(["--isolate", "--parallel=4", "--", "--changed=fixture"]); + expect(inspectChangedRun(["--", "--changed=fixture"])).toBeNull(); + }); + + test("changed-mode stays explicitly filtered without redundant arguments", () => { + expect(resolveBunTestArgs(["--changed=dev"])) + .toEqual(["--isolate", "--parallel=4", "--changed=dev"]); + const mergeBase = "0123456789abcdef0123456789abcdef01234567"; + expect(resolveBunTestArgs(["--changed=dev"], mergeBase)) + .toEqual(["--isolate", "--parallel=4", "--changed=" + mergeBase]); + expect(resolveBunTestPlan(["--changed=dev"])).toHaveLength(1); + }); + + test("changed-mode prefers the first existing conventional dev ref", () => { + const selectFrom = (...existing: string[]) => { + const probed: string[] = []; + const selected = selectChangedComparisonRef(ref => { + probed.push(ref); + return existing.includes(ref); + }); + return { selected, probed }; + }; + + expect(selectFrom("upstream/dev", "origin/dev", "dev")).toEqual({ + selected: "upstream/dev", + probed: ["upstream/dev"], + }); + expect(selectFrom("origin/dev", "dev")).toEqual({ + selected: "origin/dev", + probed: ["upstream/dev", "origin/dev"], + }); + expect(selectFrom("dev")).toEqual({ + selected: "dev", + probed: ["upstream/dev", "origin/dev", "dev"], + }); + expect(selectFrom()).toEqual({ + selected: null, + probed: ["upstream/dev", "origin/dev", "dev"], + }); + }); + + test("changed-mode requires an explicit, resolvable comparison ref", () => { + expect(() => inspectChangedRun(["--changed"])).toThrow("requires an explicit comparison ref"); + expect(() => inspectChangedRun(["--changed=refs/heads/definitely-missing-test-ref"])) + .toThrow("does not resolve to a commit"); + const inspected = inspectChangedRun(["--changed=HEAD"]); + expect(inspected?.comparisonRef).toBe("HEAD"); + expect(inspected?.comparisonCommit).toBe(runGit(process.cwd(), "rev-parse", "HEAD")); + }); + + test("changed-mode uses the shared merge base for behind, ahead, and diverged refs", () => { + const fixtures: string[] = []; + try { + const behind = initChangedRunFixture(); + fixtures.push(behind.cwd); + runGit(behind.cwd, "branch", "candidate", behind.base); + commitFixture(behind.cwd, "head.txt", "head\n", "head ahead of candidate"); + expect(inspectChangedRun(["--changed=candidate"], behind.cwd)).toMatchObject({ + comparisonRef: "candidate", + comparisonCommit: behind.base, + changedFiles: ["head.txt"], + }); + + const ahead = initChangedRunFixture(); + fixtures.push(ahead.cwd); + const candidateTip = commitFixture(ahead.cwd, "candidate.txt", "candidate\n", "candidate ahead"); + runGit(ahead.cwd, "branch", "candidate", candidateTip); + runGit(ahead.cwd, "checkout", "--quiet", "--detach", ahead.base); + expect(inspectChangedRun(["--changed=candidate"], ahead.cwd)).toMatchObject({ + comparisonRef: "candidate", + comparisonCommit: ahead.base, + changedFiles: [], + }); + + const diverged = initChangedRunFixture(); + fixtures.push(diverged.cwd); + runGit(diverged.cwd, "checkout", "--quiet", "-b", "candidate"); + commitFixture(diverged.cwd, "candidate.txt", "candidate\n", "candidate side"); + runGit(diverged.cwd, "checkout", "--quiet", "--detach", diverged.base); + commitFixture(diverged.cwd, "head.txt", "head\n", "head side"); + expect(inspectChangedRun(["--changed=candidate"], diverged.cwd)).toMatchObject({ + comparisonRef: "candidate", + comparisonCommit: diverged.base, + changedFiles: ["head.txt"], + }); + } finally { + for (const fixture of fixtures) rmSync(fixture, { recursive: true, force: true }); + } + }); + + test("rejects an empty changed selection when the diff is non-empty", () => { + expect(changedSelectionFailure( + { comparisonRef: "upstream/dev", comparisonCommit: "base-sha", changedFiles: ["src/router.ts"] }, + "Ran 0 tests across 0 files.", + )).toContain("--changed=base-sha (upstream/dev merge base) selected 0 tests across 0 files"); + expect(changedSelectionFailure( + { comparisonRef: "dev", comparisonCommit: "base-sha", changedFiles: ["src/router.ts"] }, + "Ran 9 tests across 1 file.", + )).toBeNull(); + expect(changedSelectionFailure( + { comparisonRef: "HEAD", comparisonCommit: "head-sha", changedFiles: [] }, + "Ran 0 tests across 0 files.", + )).toBeNull(); + }); + + test("rejects an unrecognized changed-mode summary for a non-empty diff", () => { + expect(changedSelectionFailure( + { comparisonRef: "dev", comparisonCommit: "base-sha", changedFiles: ["src/router.ts"] }, + "0 pass\n0 fail", + )).toContain("did not emit a recognizable selection summary"); }); test("the wrapper passes parallel execution through to bun", () => {