diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b94d49c368..51333f8f8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -878,57 +878,13 @@ jobs: run: |- bun test --preload ./test-setup/augment-bun-vi.ts --preload ./scripts/tests/test-setup.ts scripts/tests/test-orchestrator.test.ts - # - # Bun-native manifest discovery gate (issues #2475, #2847) - # - # Resolves every root declared in scripts/bun-test-manifest.ts without - # executing anything: globs are expanded, and every selected file, preload, - # tsconfig override and global-setup module must exist. That is what proves - # no test file was dropped. - # - # It deliberately does NOT run the suite. `test_shard` already executes every - # root exactly once by invoking each workspace's own `test` script, so a - # second full run would double the CI bill for no extra signal. The one-owner - # invariant is enforced by scripts/tests/bun-manifest-root-ownership.bun.test.ts, - # which fails if a root gains a second executor or loses its only one. - bun_native_test_parity: - name: 'Bun Native Test Compatibility' - runs-on: 'ubuntu-latest' - timeout-minutes: 10 - needs: - - 'doc_change_filter' - - 'skip_check' - # Skip on docs-only PRs (issue #342): a doc edit cannot change test - # manifest root resolution or file existence. - if: ${{ needs.doc_change_filter.outputs.docs_only != 'true' && needs.skip_check.outputs.should_skip != 'true' }} - permissions: - contents: 'read' - steps: - - name: 'Checkout' - uses: 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' # ratchet:actions/checkout@v7 - with: - persist-credentials: false - - - name: 'Setup Bun' - uses: 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6' # ratchet:oven-sh/setup-bun@v2 - with: - bun-version-file: '.bun-version' - - - name: 'Cache Bun dependencies' - uses: 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' # ratchet:actions/cache@v6 - with: - path: | - ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} - - - name: 'Install dependencies' - run: |- - bun install - git checkout -- bun.lock - - - name: 'Resolve every Bun-native root (no execution)' + - name: 'Verify every test file is covered by some executor' + # Fails when a test file exists on disk that no executor runs (uncovered) + # or when two executors both run the same file (doubly-executed). This + # runs on every non-docs-only PR regardless of which shard it touches, + # so a package-only change cannot introduce an uncovered file. run: |- - bun scripts/run_bun_tests.ts --dry-run + bun scripts/check-test-file-coverage.ts # # Test: Node (Linux only — issue #2876) diff --git a/dev-docs/bun.md b/dev-docs/bun.md index 41e8d20103..1c7668e3b3 100644 --- a/dev-docs/bun.md +++ b/dev-docs/bun.md @@ -503,7 +503,7 @@ This script is a Bun-backed orchestrator that mirrors `npm run test 4. **Runs the script harness tests** (`scripts/tests/`) after workspace tests, matching the root `test:scripts` script. These run natively under - Bun via the `scripts-tests` and `scripts-tests-slow` manifest roots. + Bun via the `scripts-tests` root. ### CLI flags @@ -656,36 +656,38 @@ still executed by both `npm run test` and the `bun scripts/test.ts` orchestrator # All workspaces bun scripts/run_bun_tests.ts -# Single workspace (exact manifest workspace name) +# Single workspace (exact root name) bun scripts/run_bun_tests.ts --workspace a2a-server # Or through a migrated workspace's package script npm run test:bun --workspace @vybestack/llxprt-code-a2a-server ``` -### Test roots (`scripts/bun-test-manifest.ts`) +### Test roots (`scripts/bun-test-roots.ts`) -Every file the native runner executes belongs to a **root** declared in -`scripts/bun-test-manifest.ts`. A root selects its files in one of two ways: +Every file the native runner executes is **discovered** from a **root** declared +in `scripts/bun-test-roots.ts`. A root declares the directories to scan and the +execution settings; there is no allowlist, file list, or exclude pattern. A newly +added test file is picked up automatically and can never be silently dropped. -- **`include` / `exclude` globs** — used by fully migrated roots. This is the - Bun-native equivalent of a Vitest config's `include`, and it is what makes - "no test file can be silently dropped" mechanically true: a newly added - test file runs without any manifest edit. -- **`files`** — an explicit list, used while a workspace is only partly - migrated and naming alone cannot tell a Bun-ready file from one still owned - by Vitest. +The default test-file pattern is `/\.(test|spec|bun)\.(ts|tsx|js)$/` (excluding +`.d.ts`). Directories named `node_modules`, `dist`, `coverage`, `tmp`, `bundle`, +`__snapshots__`, and any directory starting with `.` are skipped during the walk. A root may also declare: -| Field | Purpose | -| -------------- | ------------------------------------------------------------------------------ | -| `preload` | One or more Bun `--preload` scripts (the equivalent of Vitest `setupFiles`) | -| `tsconfig` | A test-only `--tsconfig-override`, e.g. to stub the editor-injected `vscode` | -| `timeout` | Per-test timeout, mirroring Vitest `testTimeout` | -| `retries` | Per-file retry budget, mirroring Vitest `retry` | -| `globalSetup` | `setup()` / `teardown()` run once in the runner process around the whole root | -| `credentialed` | Marks a root that calls a real provider; excluded unless requested by `--root` | +| Field | Purpose | +| ------------------ | ------------------------------------------------------------------------------------------------- | +| `cwd` | Working directory override; defaults to `packages/` | +| `directories` | Subdirectories of `cwd` to scan; defaults to `cwd` itself | +| `pattern` | Custom test-file pattern, e.g. `\.eval\.ts$` | +| `preload` | One or more Bun `--preload` scripts (the equivalent of Vitest `setupFiles`) | +| `tsconfig` | A test-only `--tsconfig-override`, e.g. to stub the editor-injected `vscode` | +| `timeout` | Per-test timeout, mirroring Vitest `testTimeout` | +| `retries` | Per-file retry budget, mirroring Vitest `retry` | +| `globalSetup` | `setup()` / `teardown()` run once in the runner process around the whole root | +| `credentialed` | Marks a root that calls a real provider; excluded unless requested by `--root` | +| `timeoutOverrides` | Per-file timeout budgets keyed by an absolute-path pattern; changes budget only, never membership | `--root ` is an alias of `--workspace `. @@ -703,10 +705,21 @@ Every root therefore runs **exactly once**. `test_shard` covers each workspace root; the scripts shard covers the roots that belong to no workspace, listed in `SCRIPTS_SHARD_ROOTS` in `scripts/test.ts`. A root with a second executor, or none at all, fails -`scripts/tests/bun-manifest-root-ownership.bun.test.ts`. - -The `bun_native_test_parity` job does **not** execute tests. It resolves the -manifest (`--dry-run`): globs expand and every selected file, preload, tsconfig -override and global-setup module must exist. That is what proves no test file -was dropped — re-running the whole suite a second time would double the CI bill -for no extra signal. +`scripts/tests/bun-test-root-ownership.bun.test.ts`. + +The `bun_native_test_parity` job (which only ran `--dry-run` against the +manifest) has been removed: the manifest is gone, and its completeness check is +subsumed by the coverage guard. `scripts/check-test-file-coverage.ts` derives +the covered set from each executor's own discovery code — the shared runner +and the bespoke `run-bun-tests.ts` runners — and fails if any repository test +file is uncovered or claimed by more than one executor. + +The guard runs as a step in the always-run `bun_test_orchestrator_smoke` CI job +(gated only on docs-only / skip), so it executes on every PR that could change +test inventory, not just PRs that touch the `scripts` shard. What the guard +proves: every test file on disk is claimed by at least one executor, and no +file is claimed by more than one. What it does not prove: that those executors +pass (that is the test suite's job) or that the executor table itself is +complete (that is proven by the ownership test's bespoke-runner wiring +assertions). Without re-running the suite, it catches the silent-omission and +duplicate-execution classes of regression. diff --git a/dev-docs/test-runner-inventory.md b/dev-docs/test-runner-inventory.md index 876639f30a..92dd8c3f7f 100644 --- a/dev-docs/test-runner-inventory.md +++ b/dev-docs/test-runner-inventory.md @@ -6,44 +6,67 @@ Bun execution command (or explains why it still requires Vitest). ## Summary -Counts are the manifest's own resolution — regenerate with -`bun scripts/run_bun_tests.ts --root --dry-run` rather than editing them -by hand. - -| Root | Bun-native files | Primary runner | -| ----------------------------- | ---------------- | ---------------------------- | -| packages/a2a-server | 21 | Bun (manifest) | -| packages/agents | all | Bun (`run-bun-tests.ts`) | -| packages/auth | all | Bun (`run-bun-tests.ts`) | -| packages/cli | 24 | **Vitest** (#2578) | -| packages/core | 1 | Bun (`run-bun-tests.ts`) | -| packages/ide-integration | 10 | Bun (manifest) | -| packages/lsp | all | Bun (`bun test`) | -| packages/mcp | 43 | Bun (manifest) | -| packages/policy | 12 | Bun (manifest) | -| packages/providers | 492 | Bun (manifest) | -| packages/settings | 15 | Bun (manifest) | -| packages/storage | 32 | Bun (manifest) | -| packages/telemetry | 13 | Bun (manifest) | -| packages/test-utils | 11 | Bun (manifest) | -| packages/tools | 73 | Bun (manifest) | -| packages/vscode-ide-companion | 7 | Bun (manifest) | -| scripts/tests | 212 (+1 slow) | Bun (manifest) | -| test-setup | 2 | Bun (manifest) | -| evals | 1 | Bun (manifest, credentialed) | -| integration-tests | 31 | Bun (manifest, credentialed) | - -`core` carries a small manifest entry alongside a different primary runner. -Those files are excluded from the primary selection, so nothing runs twice -within a workspace. +There is no manifest. Roots are discovery-based: each root in +`scripts/bun-test-roots.ts` declares the directories to scan and the execution +settings (preload, tsconfig, timeout, retries, globalSetup), and the runner +walks the filesystem to resolve test files. A newly added test file is picked +up automatically and can never be silently dropped. Regenerate the per-root +counts with `bun scripts/run_bun_tests.ts --root --dry-run` rather than +editing them by hand. + +`scripts/check-test-file-coverage.ts` is the mechanism that keeps this +inventory honest. It walks the whole repository for test files and fails when +one exists on disk that no executor runs (AC8) or when two executors both run +the same file (AC7). Its covered set is derived from each executor's own +discovery code — the shared root resolver and the bespoke workspace runners — +rather than restated, so the guard does not duplicate the selection logic it +checks. What it proves: every test file on disk is claimed by at least one +executor, and no file is claimed by more than one. What it does not prove: +that those executors pass (that is the test suite's job) or that the executor +table itself is complete (that is proven by the ownership test's bespoke-runner +wiring assertions). + +| Root | Bun-native files | Primary runner | +| ----------------------------- | ---------------- | --------------------------------- | +| packages/a2a-server | 21 | Bun (shared runner) | +| packages/agents (src) | 340 | Bun (`run-bun-tests.ts`) | +| packages/agents (test-bun) | 6 | Bun (shared runner) | +| packages/auth | 42 | Bun (`run-bun-tests.ts`) | +| packages/cli | 670 | Bun (`run-bun-tests.ts`) | +| packages/core | 352 | Bun (`run-bun-tests.ts`) | +| packages/ide-integration | 10 | Bun (shared runner) | +| packages/lsp | 13 | Bun (shared runner) | +| packages/mcp | 43 | Bun (shared runner) | +| packages/policy | 12 | Bun (shared runner) | +| packages/providers | 544 | Bun (shared runner) | +| packages/settings | 15 | Bun (shared runner) | +| packages/storage | 38 | Bun (shared runner) | +| packages/telemetry | 13 | Bun (shared runner) | +| packages/test-utils | 11 | Bun (shared runner) | +| packages/tools | 88 | Bun (shared runner) | +| packages/vscode-ide-companion | 7 | Bun (shared runner) | +| scripts/tests | 225 | Bun (shared runner) | +| test-setup | 3 | Bun (shared runner) | +| evals | 1 | Bun (shared runner, credentialed) | +| integration-tests | 32 | Bun (shared runner, credentialed) | + +`cli` and `core` no longer have shared roots: their bespoke discovery runners +(`packages/{cli,core}/run-bun-tests.ts`) discover every test file, so a shared +root would be strictly redundant. The `agents` shared root now covers only +`test-bun`; its `src` tests run through the bespoke runner, so nothing in +`agents/src` runs twice. + +`telemetry` and `cli` are redundant-with-a-reason: `telemetry` migrated under +#2836 and its shared root is already glob/discovery-driven; `cli` migrated +under #2843 and its bespoke runner discovers everything with no allowlist and +no exclusion list. ### Where Vitest still executes -| Path | Invoked by | -| ------------------------------------------------------------------------ | ----------------------------------------------- | -| `packages/cli` `test` / `test:ci` (+ integration, covered, fast, legacy) | the `cli` shard via `scripts/test.ts` | -| `packages/storage` `test:vitest` | `secure_store_backend` in `ci.yml`, and nightly | -| `packages/test-utils/src/quota-guard-vitest-integration.test.ts` | itself — it is the test _of_ Vitest integration | +| Path | Invoked by | +| ---------------------------------------------------------------- | ----------------------------------------------- | +| `packages/storage` `test:vitest` | `secure_store_backend` in `ci.yml`, and nightly | +| `packages/test-utils/src/quota-guard-vitest-integration.test.ts` | itself — it is the test _of_ Vitest integration | Everything else that mentions `vitest` is either an unused `test:vitest` escape hatch (`auth`, `lsp`, `mcp`, `providers`, `storage`, `tools`) or the @@ -53,10 +76,11 @@ handler. ## Fully migrated workspaces (Bun-native as primary `test` script) These workspaces run Bun as their `test`/`test:ci` scripts. Most now delegate -to the shared manifest runner +to the shared runner (`bun ../../scripts/run_bun_tests.ts --workspace --junit junit.xml`), -which gives one isolated process per file; a few predate it and call -`bun test` directly. +which resolves files by discovery and gives one isolated process per file; a +few predate it and call `bun test` directly, and `cli`/`core`/`agents`/`auth` +run their own bespoke `run-bun-tests.ts`. ### packages/a2a-server @@ -70,7 +94,7 @@ shared Vitest-compatibility shim. **Command:** `bun run-bun-tests.ts` -All 331 test files are Bun-native and the workspace `test`/`test:ci` scripts run +All 340 test files are Bun-native and the workspace `test`/`test:ci` scripts run Bun. **Vitest is gone from this workspace entirely** — no `test:vitest` fallback, no `vitest.config.ts`, no `vitest` devDependency, and no test file imports it. The test API comes from `src/testApi.ts`, which re-exports `bun:test` with the @@ -105,13 +129,13 @@ CI job. ### packages/core -**Command:** `bun test --path-ignore-patterns dist --reporter=junit --reporter-outfile=junit.xml` +**Command:** `bun run-bun-tests.ts` -All 322 core test files are Bun-native (310 original files + 1 new split file -`SessionLockManager.property.test.ts`). The workspace `test`/`test:ci` scripts -use `bun test` directly. A `bunfig.toml` preloads the `augment-bun-vi.ts` compat -shim and a workspace-specific `bun-preload.ts` that replicates the vitest -setupFiles (storage isolation, provider runtime bootstrap). +All 352 core test files are Bun-native. The workspace `test`/`test:ci` scripts +use `bun run-bun-tests.ts`, which discovers every `*.{test,spec}.{ts,tsx}` +file under `src` and `test`. A `bunfig.toml` preloads the `augment-bun-vi.ts` +compat shim and a workspace-specific `bun-preload.ts` that replicates the +vitest setupFiles (storage isolation, provider runtime bootstrap). Migration changes: @@ -132,10 +156,11 @@ Migration changes: ### packages/auth -**Command:** `bun test --path-ignore-patterns dist --reporter=junit --reporter-outfile=junit.xml` +**Command:** `bun run-bun-tests.ts` -All 37 auth test files are Bun-native. The workspace `test`/`test:ci` scripts -use `bun test` directly. A `bunfig.toml` preloads the compat shim and a +All 42 auth test files are Bun-native. The workspace `test`/`test:ci` scripts +use `bun run-bun-tests.ts`, which discovers every `*.{test,spec}.{ts,tsx}` +file under `src`. A `bunfig.toml` preloads the compat shim and a workspace-specific `bun-preload.ts` for storage isolation. Migration changes: @@ -149,16 +174,20 @@ Migration changes: ### packages/lsp -**Command:** `bun test` +**Command:** `bun ../../scripts/run_bun_tests.ts --workspace lsp --junit junit.xml` -All test files are Bun-native. No Vitest imports remain. +All 13 test files (under `test/`) are Bun-native and discovered by the shared +`lsp` root. No Vitest imports remain. The workspace previously used a bare +`bun test`; it now uses the shared runner, which gives one isolated process per +file, matching the form used by sibling workspaces. ### packages/policy -**Command:** `bun test --path-ignore-patterns research` +**Command:** `bun ../../scripts/run_bun_tests.ts --workspace policy --junit junit.xml` -All 6 test files are Bun-native. The `research/` directory is excluded via -`--path-ignore-patterns` because it contains non-test source. +All 12 test files are Bun-native and discovered by the shared `policy` root. +There is no `src/research` directory and no exclusion list — discovery walks +`packages/policy` and runs every matching file. ### packages/test-utils (partially migrated in this PR) @@ -167,7 +196,7 @@ All 6 test files are Bun-native. The `research/` directory is excluded via - `src/quota-guard.test.ts` (44 tests) - `src/util.test.ts` (7 tests) -**Manifest entry:** `bun scripts/run_bun_tests.ts --workspace test-utils` +**Root entry:** `bun scripts/run_bun_tests.ts --workspace test-utils` **Vitest-retained file (1):** @@ -186,66 +215,45 @@ All 6 test files are Bun-native. The `research/` directory is excluded via PTY-based testing. Has timing-sensitive behavior under Bun's event loop. Deferred for the same runtime reasons as process-run. -## Manifest-based Bun-native test files - -These files run under Bun via `scripts/run_bun_tests.ts` separately from their -workspace's primary selection — either because that workspace's primary `test` -script still uses Vitest for the bulk of its files, or because the files are -Bun-only fixtures the primary selection does not match. - -### packages/agents (3 files) - -The workspace runs all of its `*.test.ts` / `*.spec.ts` files through -`bun run-bun-tests.ts` (see above). These entries stay in the manifest so the -`Bun Native Test Compatibility` job also covers them, and because the two -`test-bun/*.bun.ts` files are Bun-only fixtures that the workspace runner's -`*.test.*` / `*.spec.*` discovery does not match. - -- `src/core/CompressionProfileResolver.proxyKeyStorage.test.ts` -- `test-bun/generatingModelStamp.issue2511.bun.ts` -- `test-bun/subagentAnthropicTextSettings.issue1738.bun.ts` - -### packages/cli (12 files) +## Shared-runner test files (discovery-based) -- `src/__tests__/cliSessionDispatch.characterization.test.tsx` -- `src/utils/sandbox-containers.test.ts` -- `src/zed-integration/zed-session-lifecycle.test.ts` -- `test-utils/augment-bun-vi-cleanup.bun.ts` +These workspaces run their Bun-native tests through the shared runner +(`scripts/run_bun_tests.ts`), which resolves files by walking each root's +declared directories in `scripts/bun-test-roots.ts`. There is no per-file list: +every `*.{test,spec,bun}.{ts,tsx,js}` file under a root's scanned directories +runs. The `Bun Native Test Compatibility` job that once re-checked the manifest +no longer exists — its resolution role is subsumed by the coverage guard above, +which additionally proves resolution is _complete_. -The JSP/1 observation producer suite (issue #2779) is Bun-native from the start -rather than migrated. These eight files are excluded from the Vitest selection -in `packages/cli/vitest.test-groups.ts`, so they run under `bun test` only and -do not change `SELECTED_FILE_COUNT`: +### packages/agents (`test-bun`, 6 files) -- `src/observation/jspBounds.test.ts` -- `src/observation/jspProducer.test.ts` -- `src/observation/jspProducerState.test.ts` -- `src/observation/jspRedaction.test.ts` -- `src/observation/jspSchema.test.ts` -- `src/observation/jspTransport.test.ts` -- `src/observation/jspWiring.test.ts` -- `src/observation/observationTap.test.ts` +The workspace runs all of its `src/**/*.{test,spec}.{ts,tsx}` files through +`bun run-bun-tests.ts` (see above). The shared `agents` root now scans only +`test-bun`, so the `test-bun/*.bun.ts` fixtures — Bun-only suites the bespoke +runner's `*.test.*` / `*.spec.*` discovery does not match — run through the +shared runner instead. Nothing in `agents/src` runs twice. -The sandbox SSH agent preflight suite (issue #1699) follows the same pattern — -Bun-native from the start and excluded from the Vitest selection: +### packages/cli — bespoke runner (670 files) -- `src/utils/sandbox-ssh-agent-preflight.test.ts` - -It partially mocks `node:child_process` through an async `importOriginal` -factory rather than a bare `vi.mock` automock: automocking walks every export -and throws on `ChildProcess`'s private `#stdin` getter under Bun's native -runner. +`cli` migrated under #2843. Its `test` / `test:ci` scripts run +`packages/cli/run-bun-tests.ts`, which discovers every +`*.{test,spec,bun}.{ts,tsx}` file under `src`, `test`, `test-bun` and +`test-utils` with no allowlist and no exclusion list. The JSP/1 observation +producer suite (issue #2779) and the sandbox SSH agent preflight suite +(issue #1699) — both Bun-native from the start — are discovered alongside +every other file rather than maintained as a separate list. `cli` has no +shared root: it would be strictly redundant with the bespoke runner. ### packages/core — fully migrated (see above) -### packages/providers (manifest-driven, ~474 files) +### packages/providers (discovery-driven, 544 files) -The providers workspace primary `test` script is fully manifest-driven -(`bun ../../scripts/run_bun_tests.ts --workspace providers`). All listed -manifest files run under Bun in isolated processes. The manifest has grown -well beyond the single file listed at issue #2578 time; see -`scripts/bun-test-manifest.ts` for the authoritative file list. Notable -#2946 additions: +The providers workspace primary `test` script is fully discovery-driven +(`bun ../../scripts/run_bun_tests.ts --workspace providers`). The shared root +walks `packages/providers` and runs every `*.{test,spec,bun}.{ts,tsx,js}` file +in its own isolated process. The root has grown well beyond the single file +listed at issue #2578 time; see `scripts/bun-test-roots.ts` for the +authoritative root table. Notable #2946 additions: - `src/__tests__/BaseProvider.proxyKeyStorage.test.ts` - `src/gemini/GeminiProvider.auth.test.ts` @@ -254,11 +262,11 @@ well beyond the single file listed at issue #2578 time; see Seven storage secure-store test files are genuinely Bun-native: they live under `test-bun/` with the `.bun.ts` suffix and import from `bun:test`, following the -same convention as `packages/tools/test-bun`. They run via -`scripts/run_bun_tests.ts --workspace storage` (isolated process per file) and -use the `test-setup-storage-isolation.ts` preload (the same setup file the -Vitest config uses) so `isolateStorageRoots()` runs before any test module -imports the `Storage` singleton. +same convention as `packages/tools/test-bun`. They are discovered by the shared +`storage` root (`scripts/run_bun_tests.ts --workspace storage`, isolated process +per file) and use the `test-setup-storage-isolation.ts` preload (the same setup +file the Vitest config uses) so `isolateStorageRoots()` runs before any test +module imports the `Storage` singleton. Because the `.bun.ts` suffix does not match Vitest's default `*.{test,spec}.*` include pattern, these files are invisible to `vitest run` — @@ -282,10 +290,9 @@ invokes — the mock was dead weight and was removed rather than reproduced. ### packages/cli — extension settings storage -`src/config/extensions/settingsStorage.test.ts` is Bun-native and registered in -the manifest, following the CLI's existing convention of keeping such files -under `src/` and excluding them from the Vitest selection (see `baseExclude` in -`vitest.test-groups.ts`). +`src/config/extensions/settingsStorage.test.ts` is Bun-native and discovered by +the cli bespoke runner alongside every other cli test file, following the CLI's +convention of keeping such files under `src/`. It previously replaced the entire storage module with a stand-in `SecureStore` via `vi.mock`. Rather than reproduce that in bun:test, the production class now @@ -296,17 +303,13 @@ substituted. Consequently CONFLICT, TIMEOUT, and error classification are now exercised through SecureStore's actual code paths instead of hand-thrown error-shaped objects. -### packages/telemetry (11 files) - -All 11 telemetry test files are verified Bun-native and run via -`scripts/run_bun_tests.ts --workspace telemetry` (isolated process per file). +### packages/telemetry (13 files) -The workspace primary `test` script still uses Vitest because -`@opentelemetry/core`'s CJS `require("@opentelemetry/api")` does not resolve -`createContextKey` correctly when all telemetry files run in a single Bun -process on Linux CI. Running each file in its own process (the manifest -approach) avoids this interop issue. Once the upstream Bun CJS/ESM interop -issue is resolved, the workspace `test` script can switch to `bun test`. +All 13 telemetry test files are verified Bun-native and discovered by the +shared `telemetry` root (`scripts/run_bun_tests.ts --workspace telemetry`, +isolated process per file). The root is glob/discovery-driven, so telemetry is +redundant-with-a-reason: it migrated under #2836 and no Vitest selection runs +it. - `src/debug/ConfigurationManager.test.ts` - `src/debug/DebugLogger.test.ts` @@ -320,38 +323,34 @@ issue is resolved, the workspace `test` script can switch to `bun test`. - `src/telemetry/tool-call-decision.test.ts` - `src/telemetry/types.test.ts` -### test-setup (2 files at repo root) +### test-setup (3 files at repo root) - `test-setup/augment-bun-vi.test.ts` - `test-setup/stub-helpers.bun.test.ts` +- `test-setup/vitest-parity.test.ts` ## Remaining workspaces (future migration slices) -Two workspaces still execute their full suite under Vitest, tracked by #2578: - -1. **packages/cli** (~659 files) - -Every other root listed in the summary is Bun-native. `settings`, -`ide-integration`, `vscode-ide-companion`, `a2a-server`, `policy`, +Every workspace now runs its full suite under Bun (see the bespoke runners for +`cli`, `core`, `agents`, `auth` and the shared runner for the rest, including +`lsp`). +`settings`, `ide-integration`, `vscode-ide-companion`, `a2a-server`, `policy`, `telemetry`, `test-utils`, `scripts/tests`, `evals` and `integration-tests` were migrated by #2847, which also deleted their `vitest.config.ts` files. ## Enumerated Vitest retention (acceptance criterion #8) -Two categories exist. The first still executes and is scoped to #2578; the -second does not execute at all. +Two categories exist. The first still executes; the second does not execute at +all. **Still executes:** -1. **`packages/cli`** — primary `test`/`test:ci` scripts, run by its shard - through `scripts/test.ts`. - -2. **`packages/storage` `test:vitest`** — the `secure_store_backend` job (and +1. **`packages/storage` `test:vitest`** — the `secure_store_backend` job (and its nightly twin) needs the two backend-specific configs (`vitest.config.native-keyring.ts`, `vitest.config.fallback-behavior.ts`) to force a keyring backend per leg. -3. **`packages/test-utils/src/quota-guard-vitest-integration.test.ts`** — +2. **`packages/test-utils/src/quota-guard-vitest-integration.test.ts`** — spawns `vitest run` subprocesses to test Vitest's own runtime semantics. A meta-test of the runner, not an application test. Note that the production quota hook it once mirrored now lives in @@ -360,27 +359,31 @@ second does not execute at all. **Does not execute:** -4. **Per-workspace `test:vitest` scripts** — `auth`, `lsp`, `mcp`, +3. **Per-workspace `test:vitest` scripts** — `auth`, `lsp`, `mcp`, `providers`, `storage` and `tools` keep one as an escape hatch. No workflow and no `test` script invokes them. `packages/core` has none: its - script was removed with the Bun exclusion list (issue #2968). + script was removed with the Bun exclusion list (issue #2968). `packages/cli` + migrated fully to Bun under #2843 and has no Vitest selection at all. -5. **The `vitest` import specifier** — migrated test files still import +4. **The `vitest` import specifier** — migrated test files still import `describe`/`it`/`expect` from `'vitest'`, which Bun resolves through its own injected handler. `vitest` therefore stays in `devDependencies`. ## Canonical Bun-native test command ```bash -# All native Bun test files (manifest-based): +# All native Bun test files (discovery-based, all non-credentialed roots): bun scripts/run_bun_tests.ts # A specific workspace: bun scripts/run_bun_tests.ts --workspace telemetry + +# List what would run without executing (--dry-run): +bun scripts/run_bun_tests.ts --dry-run ``` -For the full repository test suite (including vitest-only workspaces during -the migration transition): +For the full repository test suite (including the storage Vitest leg and the +quota-guard Vitest-integration meta-test): ```bash npm run test diff --git a/package.json b/package.json index 6c33177f7f..e9c79ab48b 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "lint:agents-api-surface": "bun scripts/check-agents-api-surface.ts", "lint:test-shards": "bun scripts/check-test-shards.ts", "lint:affected-shards": "bun scripts/check-affected-test-shards.ts", + "lint:test-file-coverage": "bun scripts/check-test-file-coverage.ts", "affected-shards": "node scripts/affected-test-shards.ts", "affected-shards:replay": "node scripts/affected-test-shards.ts --replay 120", "lint:all": "./scripts/lint-all.sh", diff --git a/packages/agents/run-bun-tests.ts b/packages/agents/run-bun-tests.ts index 2b39049c2d..30159d8d2e 100644 --- a/packages/agents/run-bun-tests.ts +++ b/packages/agents/run-bun-tests.ts @@ -38,9 +38,17 @@ import { statSync, writeFileSync, } from 'node:fs'; -import { join } from 'node:path'; +import { join, relative } from 'node:path'; import { availableParallelism, tmpdir } from 'node:os'; +/** + * Every path this runner touches — discovery, the child's working directory + * and the JUnit report — is anchored here rather than at `process.cwd()`, so + * the runner behaves identically no matter where it is invoked from. + */ +const WORKSPACE_ROOT = import.meta.dir; +const JUNIT_PATH = join(WORKSPACE_ROOT, 'junit.xml'); + const TEST_ROOTS = ['src'] as const; /** Upper bound on file concurrency, regardless of how many cores are present. */ @@ -147,6 +155,21 @@ function findTestFiles(dir: string): string[] { return results; } +/** + * Returns the absolute paths of every test file this runner would execute for + * the given absolute workspace `root`. The script entry point calls this same + * function (see `main`), so the two can never diverge. + * + * Root scanned: `src`. Files match the `TEST_FILE_SUFFIXES` conventions and + * the `PRUNED_DIRECTORIES` entries (build/dependency output) plus dot-prefixed + * directories are skipped. + */ +export function discoverTestFiles(root: string): string[] { + return TEST_ROOTS.flatMap((testRoot) => + findTestFiles(join(root, testRoot)), + ).sort(); +} + interface TestResult { readonly file: string; readonly passed: boolean; @@ -178,7 +201,7 @@ function runTestFile(file: string, reportPath: string): Promise { file, ], { - cwd: process.cwd(), + cwd: WORKSPACE_ROOT, stdio: 'inherit', env: process.env, }, @@ -345,7 +368,9 @@ function generateJUnit( } async function main(): Promise { - const testFiles = TEST_ROOTS.flatMap((root) => findTestFiles(root)).sort(); + const testFiles = discoverTestFiles(WORKSPACE_ROOT).map((file) => + relative(WORKSPACE_ROOT, file), + ); if (testFiles.length === 0) { console.error('No test files found under: ' + TEST_ROOTS.join(', ')); process.exit(1); @@ -407,9 +432,11 @@ async function main(): Promise { (failed.length > 0 ? ` (${failed.length} failed)` : ''), ); - writeFileSync('junit.xml', generateJUnit(results, reportPathFor)); + writeFileSync(JUNIT_PATH, generateJUnit(results, reportPathFor)); rmSync(reportDir, { recursive: true, force: true }); process.exit(failed.length > 0 ? 1 : 0); } -await main(); +if (import.meta.main) { + await main(); +} diff --git a/packages/auth/run-bun-tests.ts b/packages/auth/run-bun-tests.ts index 254467589f..b2e2902c2b 100644 --- a/packages/auth/run-bun-tests.ts +++ b/packages/auth/run-bun-tests.ts @@ -13,13 +13,23 @@ import { spawn } from 'node:child_process'; import { readdirSync, statSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, relative } from 'node:path'; import { availableParallelism } from 'node:os'; -const PRELOAD = './bun-preload.ts'; +/** + * Every path this runner touches — discovery, the child's working directory, + * the preload and the JUnit report — is anchored here rather than at + * `process.cwd()`, so the runner behaves identically no matter where it is + * invoked from. + */ +const WORKSPACE_ROOT = import.meta.dir; +const PRELOAD = join(WORKSPACE_ROOT, 'bun-preload.ts'); +const JUNIT_PATH = join(WORKSPACE_ROOT, 'junit.xml'); const CONCURRENCY = Math.min(8, availableParallelism()); const PER_FILE_TIMEOUT_MS = 60_000; +const TEST_ROOTS = ['src'] as const; + function findTestFiles(dir: string): string[] { const results: string[] = []; for (const entry of readdirSync(dir)) { @@ -36,7 +46,10 @@ function findTestFiles(dir: string): string[] { if (stat.isDirectory()) { results.push(...findTestFiles(fullPath)); } else if ( - (entry.endsWith('.test.ts') || entry.endsWith('.test.tsx')) && + (entry.endsWith('.test.ts') || + entry.endsWith('.test.tsx') || + entry.endsWith('.spec.ts') || + entry.endsWith('.spec.tsx')) && !entry.endsWith('.d.ts') ) { results.push(fullPath); @@ -45,6 +58,23 @@ function findTestFiles(dir: string): string[] { return results.sort(); } +/** + * Returns the absolute paths of every test file this runner would execute for + * the given absolute workspace `root`. The script entry point calls this same + * function (see `main`), so the two can never diverge. + * + * Root scanned: `src`. Files match `*.test.ts` / `*.test.tsx` / `*.spec.ts` / + * `*.spec.tsx` (`.d.ts` excluded); `dist`, `node_modules`, `coverage` and + * dot-prefixed entries are skipped. + */ +export function discoverTestFiles(root: string): string[] { + const results: string[] = []; + for (const testRoot of TEST_ROOTS) { + results.push(...findTestFiles(join(root, testRoot))); + } + return results; +} + interface TestResult { file: string; passed: boolean; @@ -59,7 +89,7 @@ function runTestFile(file: string): Promise { process.execPath, ['test', '--preload', PRELOAD, file], { - cwd: process.cwd(), + cwd: WORKSPACE_ROOT, stdio: 'inherit', env: process.env, }, @@ -106,7 +136,7 @@ function generateJUnit( const testCases = results .map((r) => { const className = escapeXml( - r.file.replace(/^src\//, '').replace(/\.test\.tsx?$/, ''), + r.file.replace(/^src\//, '').replace(/\.(test|spec)\.tsx?$/, ''), ); const exitCode = r.exitCode ?? -1; const failureXml = r.passed @@ -128,7 +158,9 @@ function generateJUnit( } async function main(): Promise { - const testFiles = findTestFiles('src'); + const testFiles = discoverTestFiles(WORKSPACE_ROOT).map((file) => + relative(WORKSPACE_ROOT, file), + ); if (testFiles.length === 0) { console.error('No test files found'); process.exit(1); @@ -162,11 +194,13 @@ async function main(): Promise { ); writeFileSync( - 'junit.xml', + JUNIT_PATH, generateJUnit(results, testFiles.length, failed.length), ); process.exit(failed.length > 0 ? 1 : 0); } -main(); +if (import.meta.main) { + await main(); +} diff --git a/packages/cli/bunfig.toml b/packages/cli/bunfig.toml index be227cba90..c68515d959 100644 --- a/packages/cli/bunfig.toml +++ b/packages/cli/bunfig.toml @@ -2,7 +2,15 @@ linker = "hoisted" [test] -preload = ["../../test-setup/augment-bun-vi.ts", "./bun-test-setup.ts"] +# Storage isolation must be preloaded, not imported by a test: it has to call +# isolateStorageRoots() before any test module imports the Storage singleton. +# It applies to every file here because discovery runs the whole workspace, so +# a suite that drives the real store cannot opt in per file (issue #2979). +preload = [ + "../../test-setup/augment-bun-vi.ts", + "./test-setup-storage-isolation.ts", + "./bun-test-setup.ts", +] # Matches the testTimeout/hookTimeout that the removed vitest.config.ts set. # Bun defaults to 5s, which is not enough for the tests that spawn the real CLI # as a subprocess when the suite runs with concurrency. diff --git a/packages/cli/src/ui/components/AuthDialog.test.tsx b/packages/cli/src/ui/components/AuthDialog.test.tsx index b268b8c192..6c10967c27 100644 --- a/packages/cli/src/ui/components/AuthDialog.test.tsx +++ b/packages/cli/src/ui/components/AuthDialog.test.tsx @@ -390,8 +390,10 @@ describe('AuthDialog', () => { await wait(); stdin.write('3'); - await wait(); - expect(onSelect).toHaveBeenCalledWith(undefined, SettingScope.User); + // Polled rather than slept on, for the same reason as above. + await waitFor(() => { + expect(onSelect).toHaveBeenCalledWith(undefined, SettingScope.User); + }); unmount(); }); }); diff --git a/packages/core/run-bun-tests.ts b/packages/core/run-bun-tests.ts index 08877b05f7..39393e1c08 100644 --- a/packages/core/run-bun-tests.ts +++ b/packages/core/run-bun-tests.ts @@ -22,13 +22,23 @@ import { spawn } from 'node:child_process'; import { readdirSync, statSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, relative } from 'node:path'; import { availableParallelism } from 'node:os'; -const PRELOAD = './bun-preload.ts'; +/** + * Every path this runner touches — discovery, the child's working directory, + * the preload and the JUnit report — is anchored here rather than at + * `process.cwd()`, so the runner behaves identically no matter where it is + * invoked from. + */ +const WORKSPACE_ROOT = import.meta.dir; +const PRELOAD = join(WORKSPACE_ROOT, 'bun-preload.ts'); +const JUNIT_PATH = join(WORKSPACE_ROOT, 'junit.xml'); const CONCURRENCY = Math.min(8, availableParallelism()); const PER_FILE_TIMEOUT_MS = 60_000; +const TEST_ROOTS = ['src', 'test'] as const; + function findTestFiles(dir: string): string[] { const results: string[] = []; for (const entry of readdirSync(dir)) { @@ -45,7 +55,10 @@ function findTestFiles(dir: string): string[] { if (stat.isDirectory()) { results.push(...findTestFiles(fullPath)); } else if ( - (entry.endsWith('.test.ts') || entry.endsWith('.test.tsx')) && + (entry.endsWith('.test.ts') || + entry.endsWith('.test.tsx') || + entry.endsWith('.spec.ts') || + entry.endsWith('.spec.tsx')) && !entry.endsWith('.d.ts') ) { results.push(fullPath); @@ -54,6 +67,23 @@ function findTestFiles(dir: string): string[] { return results.sort(); } +/** + * Returns the absolute paths of every test file this runner would execute for + * the given absolute workspace `root`. The script entry point calls this same + * function (see `main`), so the two can never diverge. + * + * Roots scanned: `src` and `test`. Files match `*.test.ts` / `*.test.tsx` / + * `*.spec.ts` / `*.spec.tsx` (`.d.ts` excluded); `dist`, `node_modules`, + * `coverage` and dot-prefixed entries are skipped. + */ +export function discoverTestFiles(root: string): string[] { + const results: string[] = []; + for (const testRoot of TEST_ROOTS) { + results.push(...findTestFiles(join(root, testRoot))); + } + return results; +} + interface TestResult { file: string; passed: boolean; @@ -68,7 +98,7 @@ function runTestFile(file: string): Promise { process.execPath, ['test', '--preload', PRELOAD, file], { - cwd: process.cwd(), + cwd: WORKSPACE_ROOT, stdio: 'inherit', env: process.env, }, @@ -115,7 +145,7 @@ function generateJUnit( const testCases = results .map((r) => { const className = escapeXml( - r.file.replace(/^src\//, '').replace(/\.test\.tsx?$/, ''), + r.file.replace(/^src\//, '').replace(/\.(test|spec)\.tsx?$/, ''), ); const exitCode = r.exitCode ?? -1; const failureXml = r.passed @@ -139,7 +169,9 @@ function generateJUnit( } async function main(): Promise { - const testFiles = [...findTestFiles('src'), ...findTestFiles('test')]; + const testFiles = discoverTestFiles(WORKSPACE_ROOT).map((file) => + relative(WORKSPACE_ROOT, file), + ); if (testFiles.length === 0) { console.error('No test files found'); process.exit(1); @@ -178,11 +210,13 @@ async function main(): Promise { ); writeFileSync( - 'junit.xml', + JUNIT_PATH, generateJUnit(results, testFiles.length, failed.length), ); process.exit(failed.length > 0 ? 1 : 0); } -main(); +if (import.meta.main) { + await main(); +} diff --git a/packages/lsp/package.json b/packages/lsp/package.json index 7ce4fe8e97..7a956cd690 100644 --- a/packages/lsp/package.json +++ b/packages/lsp/package.json @@ -14,8 +14,8 @@ "build": "tsc -p tsconfig.json", "lint": "eslint .", "typecheck": "tsc --noEmit", - "test": "bun test", - "test:ci": "bun test", + "test": "bun ../../scripts/run_bun_tests.ts --workspace lsp --junit junit.xml", + "test:ci": "bun ../../scripts/run_bun_tests.ts --workspace lsp --junit junit.xml", "test:vitest": "vitest run --config ./vitest.config.ts" }, "files": [ diff --git a/packages/providers/src/openai/OpenAIRequestPreparation.issue2853.test.ts b/packages/providers/src/openai/OpenAIRequestPreparation.issue2853.test.ts index c02aeccc42..ba7142a12d 100644 --- a/packages/providers/src/openai/OpenAIRequestPreparation.issue2853.test.ts +++ b/packages/providers/src/openai/OpenAIRequestPreparation.issue2853.test.ts @@ -24,9 +24,12 @@ vi.mock('@vybestack/llxprt-code-core/core/prompts.js', () => ({ getCoreSystemPromptAsync: vi.fn().mockResolvedValue('test system prompt'), })); -vi.mock('../../prompt-config/subagent-delegation.js', () => ({ - shouldIncludeSubagentDelegation: vi.fn().mockResolvedValue(false), -})); +vi.mock( + '@vybestack/llxprt-code-core/prompt-config/subagent-delegation.js', + () => ({ + shouldIncludeSubagentDelegation: vi.fn().mockResolvedValue(false), + }), +); vi.mock('../utils/userMemory.js', () => ({ resolveUserMemory: vi.fn().mockResolvedValue(''), diff --git a/packages/providers/src/runtime/promptEnvelopeProjections.test.ts b/packages/providers/src/runtime/promptEnvelopeProjections.test.ts index eb310caf41..1a4ea2911e 100644 --- a/packages/providers/src/runtime/promptEnvelopeProjections.test.ts +++ b/packages/providers/src/runtime/promptEnvelopeProjections.test.ts @@ -39,12 +39,15 @@ describe('projectAnthropicPromptEnvelope (issue #2817)', () => { expect(projection.method).toBe('messages/v1'); expect(projection.model).toBe('claude-3-5-sonnet-20241022'); expect(projection.projectionRevision).toBe(3); + // Assert immutability before toMatchObject: Bun's expect mutates the + // received object's properties when resolving asymmetric matchers, which + // would otherwise unfreeze finalizedProjection before this check runs. + expect(Object.isFrozen(projection.finalizedProjection)).toBe(true); expect(projection.finalizedProjection).toMatchObject({ kind: 'llxprt-provider-prompt-v3', protocol: 'anthropic-messages', promptText: expect.any(String), }); - expect(Object.isFrozen(projection.finalizedProjection)).toBe(true); }); it('counts more tokens for a larger prompt (messages+system+tools), not the full HTTP body', async () => { diff --git a/packages/tools/src/tools/check-async-tasks-shell-formatter.test.ts b/packages/tools/src/tools/check-async-tasks-shell-formatter.test.ts index 4a654a917a..0ae252fc6b 100644 --- a/packages/tools/src/tools/check-async-tasks-shell-formatter.test.ts +++ b/packages/tools/src/tools/check-async-tasks-shell-formatter.test.ts @@ -71,7 +71,7 @@ describe('check-async-tasks-shell-formatter — Windows pid exposure', () => { expect(details.terminate).toBeUndefined(); expect(details.status).toBe('completed'); expect(details.exitCode).toBe(0); - expect(details.completedAt).toBe('2023-11-14T22:14:10.000Z'); + expect(details.completedAt).toBe('2023-11-14T22:13:25.000Z'); }); it('does not emit termination line for a failed job', () => { diff --git a/project-plans/issue2979/plan.md b/project-plans/issue2979/plan.md new file mode 100644 index 0000000000..c8775308a3 --- /dev/null +++ b/project-plans/issue2979/plan.md @@ -0,0 +1,276 @@ +# Issue #2979 — Delete the Bun compatibility job and manifest allowlist; run every test by discovery + +## 1. Measured starting state (this branch, `main` @ 42ca2a989) + +The issue text was written against an earlier tree. The following numbers were +measured on the current checkout and supersede the ones in the issue body. + +`bun scripts/run_bun_tests.ts --dry-run` resolves **1030** files across 19 +manifest roots. + +`bun_native_test_parity` (`.github/workflows/ci.yml:882-919`) no longer executes +tests — it runs `bun scripts/run_bun_tests.ts --dry-run` only. Problems 1 and 2 +from the issue body (duplicate execution, bypassing affected-package selection) +have therefore already been fixed by intervening work. **Problem 3 — the +manifest silently omits real tests — is live and is what this change fixes.** + +### Measured drift (files on disk vs. files any job executes) + +Only workspaces whose primary runner is the shared manifest runner can drift. +Measured with a repo walk over `*.{test,spec,bun}.{ts,tsx,js}` minus +`node_modules`/`dist`/`coverage`/hidden dirs, diffed against `--dry-run`: + +| Root | On disk | Executed | Never executed | +| ------------------- | ------: | -------: | -------------: | +| `packages/providers` | 544 | 503 | **41** | +| `packages/tools` | 88 | 87 | **1** | +| `packages/storage` | 38 | 37 | **1** | +| all other manifest roots | — | — | 0 | + +`packages/{cli,core,agents,auth,lsp}` have their own discovery-based runners and +are not manifest-gated for their primary selection, so their apparent "drift" is +not drift. + +### Result of executing the 43 never-executed files + +Each was run with its workspace's real preloads. **40 pass. 3 fail:** + +| File | Failure | +| ---- | ------- | +| `packages/providers/src/runtime/promptEnvelopeProjections.test.ts` | 1 assertion fails (`projectAnthropicPromptEnvelope` protocol/method identification) | +| `packages/providers/src/openai/OpenAIRequestPreparation.issue2853.test.ts` | `Cannot find module '../../prompt-config/subagent-delegation.js'` | +| `packages/tools/src/tools/check-async-tasks-shell-formatter.test.ts` | 1 of 14 cases fails | + +These are the concrete instances of the silent-omission class the issue is +about, and fixing them is in scope ("If any fail, fix them or fix the code they +cover"). + +### Dead / redundant manifest roots found + +- **`cli`** (2 entries, 30 files) — `packages/cli/package.json` `test` runs only + `bun run-bun-tests.ts`, which discovers `src`, `test`, `test-bun`, + `test-utils` with pattern `\.(test|spec|bun)\.(ts|tsx)$`. Nothing invokes + `--workspace cli`. Redundant; already asserted by + `scripts/tests/bun-manifest-root-ownership.bun.test.ts` + (`COVERED_BY_BESPOKE_RUNNER`). +- **`core`** (4 entries) — same situation via `packages/core/run-bun-tests.ts`. +- **`agents`** (10 entries) — `packages/agents/package.json` runs *both* the + bespoke runner *and* `--workspace agents`. The bespoke runner scans `src`, so + the 4 `src/**` entries in the manifest **execute twice per CI run today**. + Only the 6 `test-bun/*.bun.ts` entries need the shared root. +- **`policy`'s `exclude: ['src/research/**']`** — `packages/policy/src/research` + does not exist. Dead exclusion. + +### `telemetry` and `cli` per the issue's acceptance criteria + +The issue asked that telemetry's and cli's manifest coverage "either be folded +into their migration issues or documented as redundant with a reason". Both are +now resolved by merged work and are documented here as redundant: + +- **telemetry** migrated under #2836. `packages/telemetry/package.json` `test` + is `bun ../../scripts/run_bun_tests.ts --workspace telemetry`; the root is + already glob-driven and covers all 13 files. Vitest no longer runs it. +- **cli** migrated under #2843. `packages/cli/run-bun-tests.ts` discovers all + 670 files with no allowlist and no exclusion list. The shared `cli` root is + strictly redundant and is deleted. + +## 2. Accepted behavior (acceptance criteria) + +**AC1 — No allowlist.** `scripts/run_bun_tests.ts` selects the files it executes +by walking the filesystem. No file list, and no exclusion pattern that removes a +discovered test file from execution, exists anywhere in the selection path. + +**AC2 — Every discovered test runs.** For every shared root, every file under +the root's scanned directories matching the root's test-file pattern is +executed. Adding a new test file to a shared-root workspace makes it run with no +configuration edit. + +**AC3 — Per-root execution settings are preserved.** A root may still declare +`cwd`, `preload` (one or many), `tsconfig`, `timeout`, `retries`, +`globalSetup`, and `credentialed`, with behavior identical to today. These +control *how* a discovered file runs, never *whether* it runs. + +**AC4 — Per-file timeout overrides, not exclusions.** The slow release-install +smoke keeps its larger budget without a separate curated root. A root may +declare timeout overrides keyed by a filename pattern; an override changes only +the budget, never membership. + +**AC5 — Non-package roots survive.** `test-setup`, `scripts-tests`, `evals` and +`integration-tests` keep executing under discovery with explicit scanned +directories. `evals` keeps its `*.eval.ts` pattern, `globalSetup`, 300 s +timeout, and `credentialed` gating. `integration-tests` keeps `globalSetup`, +300 s timeout, `retries: 2`, and `credentialed` gating. Credentialed roots stay +out of an unfiltered run. + +**AC6 — Previously-omitted files execute and pass.** All 41 providers, 1 tools +and 1 storage files listed above run under their workspace's primary `test` +script. The 3 failing files are fixed (test or product code, whichever is +wrong). None is skipped, excluded, or deleted. + +**AC7 — No duplicate execution.** No test file is executed by two different +executors in one CI run. Specifically the 4 `agents/src/**` files stop running +twice, and the redundant `cli`/`core` roots are removed. + +**AC8 — Repo-wide coverage guard.** A guard fails when a test file exists on +disk and no executor runs it. Its covered set is derived from the executors' +own discovery code, not from a restatement of it. It runs in CI as part of the +scripts shard, and it currently reports zero uncovered files. + +**AC9 — The compatibility job is gone.** `bun_native_test_parity` is removed +from `.github/workflows/ci.yml`. The resolution check it performed is subsumed +by AC8's guard, which additionally validates that resolution is *complete*. + +**AC10 — Manifest module deleted.** `scripts/bun-test-manifest.ts`, its four +`bun-test-manifest-data-*.ts` files, `bun-test-manifest-validation.ts`, and +`scripts/tests/bun-test-manifest.bun.test.ts` no longer exist. No replacement +file contains a per-file list. + +**AC11 — Full verification passes:** `npm run test`, `lint`, `typecheck`, +`format`, `build`, plus the CLI smoke. + +### Explicitly out of scope + +Migrating remaining workspaces (#2843/#2845/#2846/#2847), removing Vitest +(#2970), rewriting the `vitest` specifier (#2969), re-recording CI timings in +#2702 (a measurement to be taken after merge, not a code change), and any +change to workflow structure beyond deleting the one job. + +## 3. Boundary cases the tests must pin + +1. A root whose scanned directory contains no matching file → fail loudly + (today's `include` matched-nothing behavior), never silently run zero files. +2. A declared `preload` / `tsconfig` / `globalSetup` path that does not exist → + fail loudly. (Discovery removes the need to validate *test file* existence, + but these config paths are still hand-written.) +3. An unknown `--root`/`--workspace` name → non-zero exit with a clear message. +4. `cwd: '.'` resolves to the repo root; `cwd: undefined` resolves to + `packages/`; a relative `cwd` joins under the repo root. +5. Credentialed roots are excluded from an unfiltered run and included when + named explicitly. +6. Symlink cycles under a scanned directory must not cause unbounded recursion. +7. Discovery must skip `node_modules`, `dist`, `coverage`, `tmp`, `bundle`, + `__snapshots__` and dotted directories. +8. `--exclude`, positional path filters, `--testNamePattern`, `--dry-run`, + `--junit`, `--json-report` keep working unchanged (these are *invocation* + filters, not configuration allowlists, and the e2e workflow depends on them). +9. Timeout overrides: a file matching an override gets the override's per-test + timeout and the correspondingly scaled process timeout; a non-matching file + in the same root keeps the root/CLI timeout. +10. Coverage guard: a test file added under a scanned directory is reported as + covered; a test file added where no executor scans is reported as uncovered. + +## 4. Design + +### 4.1 `scripts/bun-test-roots.ts` (new; replaces the manifest modules) + +```ts +export interface BunTestRoot { + readonly root: string; // --root / --workspace token + readonly cwd?: string; // repo-relative; default packages/ + readonly directories?: readonly string[]; // scanned dirs under cwd; default: cwd itself + readonly pattern?: RegExp; // default DEFAULT_TEST_FILE_PATTERN + readonly preload?: string | readonly string[]; + readonly tsconfig?: string; + readonly timeout?: number; + readonly retries?: number; + readonly globalSetup?: string; + readonly credentialed?: boolean; + readonly timeoutOverrides?: readonly { readonly pattern: RegExp; readonly timeout: number }[]; +} +``` + +There is deliberately **no** `files`, `include`, or `exclude` member. The +default pattern is `/\.(test|spec|bun)\.(ts|tsx|js)$/` (the union of the +conventions in use, matching `packages/cli/run-bun-tests.ts`). + +`resolveBunTestFiles(repoRoot, rootFilter?, deps?)` returns the same +`BunTestFile[]` shape the runner consumes today (`file`, `cwd`, `preloads`, +`tsconfig`, `timeout`, `retries`, `globalSetup`) so the runner's downstream +code is untouched. Directory walking is behind an injectable dependency so the +resolver stays testable against a temp fixture rather than the real tree. + +Root table (19 → 17 roots): `a2a-server`, `agents` (directories: `['test-bun']`), +`providers`, `tools`, `mcp`, `telemetry`, `storage`, `test-utils`, `settings`, +`ide-integration`, `vscode-ide-companion`, `policy`, `test-setup` +(`cwd: '.'`, directories `['test-setup']`), `scripts-tests` (`cwd: '.'`, +directories `['scripts/tests']`, timeout override for +`issue-2603-release-install.test.ts` → 300 s), `evals`, `integration-tests`. +`cli`, `core` and `scripts-tests-slow` are deleted. + +### 4.2 `scripts/run_bun_tests.ts` + +Swap the `resolveFiles` dependency to the new resolver; update the "Roots must +be declared in …" diagnostic; update the module docblock. Invocation-time +`--exclude` / positional filters / `--testNamePattern` are unchanged. + +### 4.3 Bespoke-runner exports (small, required for a truthful guard) + +`packages/{core,agents,auth}/run-bun-tests.ts` currently call `main()` at module +scope. Add an `import.meta.main` guard (matching `packages/cli/run-bun-tests.ts`) +and export a `discoverTestFiles(root: string): string[]` that returns what the +runner already computes. No behavior change when executed as a script. + +### 4.4 `scripts/check-test-file-coverage.ts` (new) + its test + +Exports a table of executors, each contributing absolute paths from its own +discovery code: + +- the shared runner, via `resolveBunTestFiles(repoRoot)` over **all** roots + including credentialed ones; +- `packages/cli`, `packages/core`, `packages/agents`, `packages/auth`, via each + runner's exported `discoverTestFiles`; +- `packages/lsp`, whose `test` script is a bare `bun test`, modelled with Bun's + default discovery over the workspace. + +`findUncoveredTestFiles(repoRoot)` walks the repo for test files and returns +those no executor claims. `scripts/tests/test-file-coverage.bun.test.ts` +asserts the real repository returns `[]`, and exercises the boundary cases in +§3.10 against temp fixtures. It runs in the `scripts-tests` root, i.e. in the +scripts CI shard. + +### 4.5 Call sites to update + +`scripts/tests/bun-manifest-root-ownership.bun.test.ts` (imports the manifest), +`scripts/test.ts` (`SCRIPTS_SHARD_ROOTS` drops `scripts-tests-slow`), +`scripts/check-affected-test-shards.ts` (stale comment), +`packages/agents/package.json` (keep both commands; the shared one now covers +only `test-bun`), `tsconfig.scripts.json` (file list), +`dev-docs/test-runner-inventory.md` (the #2578 inventory documents the +manifest), `.github/workflows/ci.yml` (delete the job). + +## 5. Test-first plan (behavioral, per `dev-docs/RULES.md`) + +New/changed suites, all `bun:test`: + +1. `scripts/tests/bun-test-roots.bun.test.ts` — replaces the manifest suite. + Behavioral against temp fixtures + the real tree: + - a file dropped into a scanned directory is resolved without config edits + (AC2); + - a file in a skipped directory (`dist`, `node_modules`, dotted) is not; + - `cwd` resolution for `undefined` / `'.'` / relative (§3.4); + - credentialed selection semantics (§3.5); + - empty scan result fails loudly (§3.1); + - missing `preload`/`tsconfig`/`globalSetup` fails loudly (§3.2); + - unknown root produces the runner's non-zero exit (§3.3); + - symlink cycle terminates (§3.6); + - timeout override applies to the matching file only (§3.9); + - the real `providers` root resolves the previously-omitted files (AC6) and + the real root table exposes no `files`/`include`/`exclude` member (AC1). +2. `scripts/tests/test-file-coverage.bun.test.ts` — AC8, including + `findUncoveredTestFiles(repoRoot)` returning `[]` for the real repository. +3. `scripts/tests/bun-manifest-root-ownership.bun.test.ts` — retargeted at the + new root table; extended with an assertion that no file is claimed by two + executors (AC7). +4. `scripts/tests/run_bun_tests*.test.ts` — updated for the new resolver + dependency; existing invocation-filter coverage retained (§3.8). +5. The 3 failing previously-omitted files are fixed and must pass unchanged in + intent (AC6). + +## 6. Risks + +- Providers grows 503 → 544 executed files (+8 %) in its shard. The deleted + parity job frees far more than that. +- The 3 fixes touch product-adjacent code; each must be justified by what the + test asserts, not by making the test pass. +- Removing the `cli`/`core` roots relies on the bespoke runners' discovery; + AC8's guard is what proves that claim mechanically rather than by assertion. diff --git a/scripts/bun-test-manifest-data-mcp.ts b/scripts/bun-test-manifest-data-mcp.ts deleted file mode 100644 index e8430cf354..0000000000 --- a/scripts/bun-test-manifest-data-mcp.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * @license - * Copyright 2026 Vybestack LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { BunTestWorkspaceEntry } from './bun-test-manifest.ts'; - -export const MCP_MANIFEST_ENTRY: BunTestWorkspaceEntry = { - workspace: 'mcp', - preload: 'test-setup-storage-isolation.ts', - files: [ - 'src/auth/file-token-store.test.ts', - 'src/auth/oauth-provider.authenticate.test.ts', - 'src/auth/oauth-provider.token.test.ts', - 'src/auth/oauth-status.behavior.test.ts', - 'src/auth/oauth-utils.test.ts', - 'src/auth/oauth-provider-utils.test.ts', - 'src/auth/sa-impersonation-provider.test.ts', - 'src/auth/google-auth-provider.test.ts', - 'src/auth/oauth-token-storage.test.ts', - 'src/auth/token-storage/file-token-storage.test.ts', - 'src/auth/token-storage/keychain-token-storage.test.ts', - 'src/auth/token-storage/file-token-storage.behavior.test.ts', - 'src/auth/token-storage/keychain-token-storage.missing-keytar.test.ts', - 'src/auth/token-storage/base-token-storage.test.ts', - 'src/auth/token-storage/hybrid-token-storage.test.ts', - 'src/auth/token-store.test.ts', - 'src/__tests__/no-eslint-directives.test.ts', - 'src/fake/fakeMcpDiscovery.authorization.test.ts', - 'src/client/mcp-client-manager.fake-discovery.test.ts', - 'src/client/mcp-client.lifecycle.test.ts', - 'src/client/retryable-client-disconnections.test.ts', - 'src/client/mcp-client.discover-rollback.test.ts', - 'src/client/mcp-client.transport.test.ts', - 'src/client/mcp-public-api.test.ts', - 'src/client/trust-revocation-errors.test.ts', - 'src/client/mcp-tool.confirm.test.ts', - 'src/client/mcp-client.disconnect-cleanup.test.ts', - 'src/client/mcp-client.oauth.test.ts', - 'src/client/mcp-client.stale-error.test.ts', - 'src/client/mcp-client-manager.status-failure.test.ts', - 'src/client/mcp-client.discovery.test.ts', - 'src/client/mcp-client.publication-authorization.test.ts', - 'src/client/mcp-client.tools.test.ts', - 'src/client/mcp-oauth-helpers.test.ts', - 'src/client/mcp-tool.execute.test.ts', - 'src/client/mcp-client-manager-helpers.test.ts', - 'src/client/mcp-client-manager.test.ts', - 'src/client/mcp-client.resource-refresh.test.ts', - 'src/client/mcp-discovery.authorization.test.ts', - 'src/client/neutral-types.test.ts', - 'src/client/mcp-client-manager.trust.test.ts', - 'src/client/mcp-client-manager.partial-failure.test.ts', - 'src/client/mcp-client-manager.restart.test.ts', - ], -}; diff --git a/scripts/bun-test-manifest-data-providers.ts b/scripts/bun-test-manifest-data-providers.ts deleted file mode 100644 index bb53fdc1d7..0000000000 --- a/scripts/bun-test-manifest-data-providers.ts +++ /dev/null @@ -1,535 +0,0 @@ -/** - * @license - * Copyright 2026 Vybestack LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { BunTestWorkspaceEntry } from './bun-test-manifest.ts'; - -export const PROVIDERS_MANIFEST_ENTRY: BunTestWorkspaceEntry = { - workspace: 'providers', - files: [ - 'src/__tests__/attemptLifecycle.behavior.test.ts', - 'src/__tests__/attemptLifecycle.exact.test.ts', - 'src/__tests__/attemptLifecycle.exactCounts.test.ts', - 'src/__tests__/attemptLifecycle.helpers.test.ts', - 'src/__tests__/auth-migration-p16.integration.test.ts', - 'src/__tests__/BaseProvider.guard.test.ts', - 'src/__tests__/BaseProvider.proxyKeyStorage.test.ts', - 'src/__tests__/baseProvider.stateless.test.ts', - 'src/__tests__/BaseProviderNormalization.ephemeralPropagation.test.ts', - 'src/__tests__/BaseProviderNormalization.invocation.test.ts', - 'src/__tests__/errors.test.ts', - 'src/__tests__/extracted-helpers.behavior.test.ts', - 'src/__tests__/headless-provider.test.ts', - 'src/__tests__/LoadBalancingProvider.activeModel.test.ts', - 'src/__tests__/LoadBalancingProvider.circuitbreaker.test.ts', - 'src/__tests__/LoadBalancingProvider.compressionAccounting.test.ts', - 'src/__tests__/LoadBalancingProvider.delegation.test.ts', - 'src/__tests__/LoadBalancingProvider.delegation2.test.ts', - 'src/__tests__/LoadBalancingProvider.failover.errors.test.ts', - 'src/__tests__/LoadBalancingProvider.failover.retryable.test.ts', - 'src/__tests__/LoadBalancingProvider.failover.selection.test.ts', - 'src/__tests__/LoadBalancingProvider.failover.settings.test.ts', - 'src/__tests__/LoadBalancingProvider.failover.stickyIndex.test.ts', - 'src/__tests__/LoadBalancingProvider.failover.streaming.test.ts', - 'src/__tests__/LoadBalancingProvider.getContextLimit.test.ts', - 'src/__tests__/LoadBalancingProvider.getCurrentModel.test.ts', - 'src/__tests__/LoadBalancingProvider.interface.test.ts', - 'src/__tests__/LoadBalancingProvider.lifecycle.noPhantom.test.ts', - 'src/__tests__/LoadBalancingProvider.liveness.test.ts', - 'src/__tests__/LoadBalancingProvider.metrics.test.ts', - 'src/__tests__/LoadBalancingProvider.realpath.repro.test.ts', - 'src/__tests__/LoadBalancingProvider.retryBoundary.integration.test.ts', - 'src/__tests__/LoadBalancingProvider.roundrobin.test.ts', - 'src/__tests__/LoadBalancingProvider.selectionEvent.test.ts', - 'src/__tests__/LoadBalancingProvider.settings-merge.test.ts', - 'src/__tests__/LoadBalancingProvider.stats.test.ts', - 'src/__tests__/LoadBalancingProvider.stats2.test.ts', - 'src/__tests__/LoadBalancingProvider.timeout.test.ts', - 'src/__tests__/LoadBalancingProvider.tokenAccounting.test.ts', - 'src/__tests__/LoadBalancingProvider.tpm.test.ts', - 'src/__tests__/LoadBalancingProvider.types.test.ts', - 'src/__tests__/LoggingProviderWrapper.apiTelemetry.test.ts', - 'src/__tests__/LoggingProviderWrapper.enhancedMetrics.test.ts', - 'src/__tests__/LoggingProviderWrapper.getContextLimit.test.ts', - 'src/__tests__/LoggingProviderWrapper.stateless.test.ts', - 'src/__tests__/LoggingProviderWrapper.tpm.test.ts', - 'src/__tests__/ProviderManager.guard.test.ts', - 'src/__tests__/ProviderManager.sandboxBaseUrl.test.ts', - 'src/__tests__/ProviderManager.settingsSeparation.test.ts', - 'src/__tests__/retryInfrastructure.behavior.test.ts', - 'src/__tests__/RetryOrchestrator.basic.test.ts', - 'src/__tests__/RetryOrchestrator.failover-budget.test.ts', - 'src/__tests__/RetryOrchestrator.failover.test.ts', - 'src/__tests__/RetryOrchestrator.forbidden.test.ts', - 'src/__tests__/RetryOrchestrator.forbidden-composed.test.ts', - 'src/__tests__/RetryOrchestrator.getContextLimit.test.ts', - 'src/__tests__/RetryOrchestrator.integration.test.ts', - 'src/__tests__/RetryOrchestrator.invocation.test.ts', - 'src/__tests__/RetryOrchestrator.onAuthError.test.ts', - 'src/__tests__/RetryOrchestrator.timeoutCleanup.test.ts', - 'src/__tests__/safeDefaultModel.regression.test.ts', - 'src/__tests__/settings-integration/provider-settings.integration.test.ts', - 'src/__tests__/tools-formatting.test.ts', - 'src/anthropic/AnthropicApiExecution.dumpContext.test.ts', - 'src/anthropic/AnthropicApiExecution.separateDump.test.ts', - 'src/anthropic/AnthropicMessageNormalizer.crossModelThinking.test.ts', - 'src/anthropic/AnthropicMessageNormalizer.toolFailure.test.ts', - 'src/anthropic/AnthropicMessageValidator.stripEmptyTextBlocks.test.ts', - 'src/anthropic/AnthropicModelData.test.ts', - 'src/anthropic/AnthropicProvider.caching-metrics.test.ts', - 'src/anthropic/AnthropicProvider.caching.test.ts', - 'src/anthropic/AnthropicProvider.chat.test.ts', - 'src/anthropic/AnthropicProvider.dumpContext.test.ts', - 'src/anthropic/AnthropicProvider.fable5.thinking.test.ts', - 'src/anthropic/AnthropicProvider.getModels.test.ts', - 'src/anthropic/AnthropicProvider.issue1150-repro.test.ts', - 'src/anthropic/AnthropicProvider.issue1150.redacted.test.ts', - 'src/anthropic/AnthropicProvider.issue1150.shape.test.ts', - 'src/anthropic/AnthropicProvider.issue1150.streaming.test.ts', - 'src/anthropic/AnthropicProvider.issue1150.test.ts', - 'src/anthropic/AnthropicProvider.issue1150.toolresult.adjacency.test.ts', - 'src/anthropic/AnthropicProvider.issue1150.toolresult.edgecases.test.ts', - 'src/anthropic/AnthropicProvider.issue1494.test.ts', - 'src/anthropic/AnthropicProvider.issue2329.test.ts', - 'src/anthropic/AnthropicProvider.issue2411.test.ts', - 'src/anthropic/AnthropicProvider.issue276.test.ts', - 'src/anthropic/AnthropicProvider.mediaBlock.test.ts', - 'src/anthropic/AnthropicProvider.multiBlock.test.ts', - 'src/anthropic/AnthropicProvider.messaging.test.ts', - 'src/anthropic/AnthropicProvider.modelParams.test.ts', - 'src/anthropic/AnthropicProvider.oauth.test.ts', - 'src/anthropic/AnthropicProvider.ratelimits.test.ts', - 'src/anthropic/AnthropicProvider.stateless.test.ts', - 'src/anthropic/AnthropicProvider.thinking.config.test.ts', - 'src/anthropic/AnthropicProvider.thinking.context.test.ts', - 'src/anthropic/AnthropicProvider.thinking.display.test.ts', - 'src/anthropic/AnthropicProvider.thinking.multiturn.test.ts', - 'src/anthropic/AnthropicProvider.thinking.streaming.test.ts', - 'src/anthropic/AnthropicProvider.throttling.test.ts', - 'src/anthropic/AnthropicProvider.toolFormatDetection.test.ts', - 'src/anthropic/AnthropicProvider.tools.test.ts', - 'src/anthropic/AnthropicRateLimitHandler.test.ts', - 'test-bun/AnthropicRequestBuilder.issue1738.bun.ts', - 'test-bun/token-access-coordinator.bun.ts', - 'src/anthropic/AnthropicRequestBuilder.modelParams.test.ts', - 'src/anthropic/AnthropicResponseParser.issue1844.test.ts', - 'src/anthropic/AnthropicStreamProcessor.retryOwnership.test.ts', - 'src/anthropic/usageInfo.test.ts', - 'src/apiKeyQuotaResolver.test.ts', - 'src/auth/__tests__/anthropic-oauth-provider.browser-profile.spec.ts', - 'src/auth/__tests__/anthropic-oauth-provider.fallback.spec.ts', - 'src/auth/__tests__/auth-flow-orchestrator.spec.ts', - 'src/auth/__tests__/auth-import-isolation.test.ts', - 'src/auth/__tests__/auth-status-service.spec.ts', - // Excluded: Bun fake-timer incompatibility on Linux CI (issue #2842 shim). - // These pass on macOS and under vitest. Re-add when Bun runtime is fixed. - // 'src/auth/__tests__/behavioral/error-edge-cases.behavioral.spec.ts', - 'src/auth/__tests__/behavioral/multi-bucket.behavioral.spec.ts', - // Excluded: proactive-renewal tests timeout on Linux CI under Bun. - // 'src/auth/__tests__/behavioral/proactive-renewal.behavioral.spec.ts', - // Excluded: Bun fake-timer incompatibility on Linux CI. - // 'src/auth/__tests__/behavioral/single-bucket.behavioral.spec.ts', - 'src/auth/__tests__/behavioral/subagent-isolation.behavioral.spec.ts', - // Excluded: Bun fake-timer incompatibility on Linux CI. - // 'src/auth/__tests__/behavioral/user-entry-points.behavioral.spec.ts', - 'src/auth/__tests__/browser-profile-association-store.spec.ts', - 'src/auth/__tests__/BucketFailoverHandlerImpl.invalidateAuthCache.test.ts', - 'src/auth/__tests__/codex-oauth-provider.fallback.spec.ts', - 'src/auth/__tests__/codex-oauth-provider.test.ts', - 'src/auth/__tests__/forceRefreshToken.bucketResolution.test.ts', - 'src/auth/__tests__/forceRefreshToken.cacheInvalidation.test.ts', - 'src/auth/__tests__/forceRefreshToken.test.ts', - 'src/auth/__tests__/issue2891-claudecode-stale-oauth.test.ts', - 'src/auth/__tests__/issue2891-oauth-manager-identity.test.ts', - 'src/auth/__tests__/multi-bucket-auth.spec.ts', - 'src/auth/__tests__/oauth-manager-interface-contract.test.ts', - 'src/auth/__tests__/oauth-manager.getToken-bucket-peek.spec.ts', - 'src/auth/__tests__/oauth-manager.issue913.spec.ts', - 'src/auth/__tests__/oauth-manager.user-declined.spec.ts', - 'src/auth/__tests__/oauth-provider-base.spec.ts', - 'src/auth/__tests__/OAuthBucketManager.spec.ts', - // Excluded: Bun fake-timer incompatibility on Linux CI. - // 'src/auth/__tests__/oauthManager.proactive-renewal.test.ts', - 'src/auth/__tests__/oauthManager.safety.test.ts', - // Excluded: Bun fake-timer incompatibility on Linux CI. - // 'src/auth/__tests__/proactive-renewal-cross-process.spec.ts', - // Excluded: Bun fake-timer incompatibility on Linux CI. - // 'src/auth/__tests__/proactive-renewal-manager.spec.ts', - 'src/auth/__tests__/provider-registry.spec.ts', - 'src/auth/__tests__/provider-usage-info.spec.ts', - 'src/auth/anthropic-oauth-provider.local-flow.spec.ts', - 'src/auth/anthropic-oauth-provider.no-refresh-on-gettoken.spec.ts', - 'src/auth/anthropic-oauth-provider.refresh.spec.ts', - 'src/auth/anthropic-oauth-provider.test.ts', - 'src/auth/BucketFailoverHandlerImpl.case-01.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-02.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-03.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-04.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-05.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-06.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-07.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-08.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-09.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-10.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-11.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-12.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-13.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-14.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-15.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-16.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-17.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-18.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-19.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-20.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-21.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-22.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-23.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-24.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-25.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-26.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-27.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-28.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-29.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-30.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-31.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-32.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-33.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-34.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-35.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-36.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-37.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-38.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-39.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-40.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-41.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-42.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-43.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-44.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-45.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-46.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-47.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-48.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-49.spec.ts', - 'src/auth/BucketFailoverHandlerImpl.case-50.spec.ts', - // Excluded: Bun fake-timer incompatibility on Linux CI. - // 'src/auth/codex-oauth-provider.spec.ts', - 'src/auth/file-oauth-settings.test.ts', - 'src/auth/local-oauth-callback.spec.ts', - 'src/auth/oauth-manager-initialization.spec.ts', - 'src/auth/oauth-manager.auth-lock.spec.ts', - 'src/auth/oauth-manager.concurrency.spec.ts', - 'src/auth/oauth-manager.failover-wiring.spec.ts', - 'src/auth/oauth-manager.issue1317.spec.ts', - 'src/auth/oauth-manager.issue1468.case-01.spec.ts', - 'src/auth/oauth-manager.issue1468.case-02.spec.ts', - 'src/auth/oauth-manager.issue1468.case-03.spec.ts', - 'src/auth/oauth-manager.issue1468.case-04.spec.ts', - 'src/auth/oauth-manager.issue1468.case-05.spec.ts', - 'src/auth/oauth-manager.issue1468.case-06.spec.ts', - 'src/auth/oauth-manager.issue1468.case-07.spec.ts', - 'src/auth/oauth-manager.issue1468.case-08.spec.ts', - 'src/auth/oauth-manager.issue1468.case-09.spec.ts', - 'src/auth/oauth-manager.issue1468.case-10.spec.ts', - 'src/auth/oauth-manager.issue1468.case-11.spec.ts', - 'src/auth/oauth-manager.issue1468.case-12.spec.ts', - 'src/auth/oauth-manager.issue1468.case-13.spec.ts', - 'src/auth/oauth-manager.issue1468.case-14.spec.ts', - 'src/auth/oauth-manager.issue1468.case-15.spec.ts', - 'src/auth/oauth-manager.issue1468.case-16.spec.ts', - 'src/auth/oauth-manager.issue1468.case-17.spec.ts', - 'src/auth/oauth-manager.issue1468.case-18.spec.ts', - 'src/auth/oauth-manager.logout.spec.ts', - 'src/auth/oauth-manager.refresh-race.spec.ts', - 'src/auth/oauth-manager.runtime-messagebus.spec.ts', - 'src/auth/oauth-manager.spec.ts', - 'src/auth/oauth-manager.token-reuse.spec.ts', - 'src/auth/oauth-manager.wiring.spec.ts', - 'src/auth/proxy/__tests__/concurrent-dispatch.test.ts', - 'src/auth/proxy/__tests__/credential-proxy-server.test.ts', - 'src/auth/proxy/__tests__/frame-and-cancel.test.ts', - 'src/auth/proxy/__tests__/deprecation-guard.test.ts', - 'src/auth/proxy/__tests__/e2e-credential-flow.test.ts', - 'src/auth/proxy/__tests__/factory-detection-wiring.test.ts', - 'src/auth/proxy/__tests__/github-broker-catalog-drift.test.ts', - 'src/auth/proxy/__tests__/github-broker-envelope.test.ts', - 'src/auth/proxy/__tests__/github-broker-multistep.test.ts', - 'src/auth/proxy/__tests__/github-broker-p10.test.ts', - 'src/auth/proxy/__tests__/github-broker-p10b.test.ts', - 'src/auth/proxy/__tests__/github-broker-security.test.ts', - 'src/auth/proxy/__tests__/github-broker-unknown-param.bun.test.ts', - 'src/auth/proxy/__tests__/github-broker-watch.test.ts', - 'src/auth/proxy/__tests__/github-broker-write-ops.test.ts', - 'src/auth/proxy/__tests__/github-broker.test.ts', - 'src/auth/proxy/__tests__/integration.test.ts', - 'src/auth/proxy/__tests__/migration-completeness.test.ts', - 'src/auth/proxy/__tests__/oauth-exchange.spec.ts', - 'src/auth/proxy/__tests__/oauth-initiate.spec.ts', - 'src/auth/proxy/__tests__/oauth-poll.spec.ts', - 'src/auth/proxy/__tests__/oauth-session-manager.test.ts', - 'src/auth/proxy/__tests__/platform-matrix.test.ts', - 'src/auth/proxy/__tests__/platform-uds-probe.test.ts', - // Excluded: Bun fake-timer incompatibility on Linux CI. - // 'src/auth/proxy/__tests__/proactive-scheduler.test.ts', - 'src/auth/proxy/__tests__/proxy-oauth-adapter.test.ts', - 'src/auth/proxy/__tests__/refresh-coordinator.test.ts', - 'src/auth/proxy/__tests__/refresh-flow.spec.ts', - 'src/auth/runtime-accessor-bridge.spec.ts', - 'src/BaseProvider.test.ts', - 'src/chutes/usageInfo.test.ts', - 'src/composition/credentialPrecedence.test.ts', - 'src/composition/oauth-provider-registration.test.ts', - 'src/composition/__tests__/issue2891-oauth-provider-registration.test.ts', - 'src/composition/provider-gemini-switching.test.ts', - 'src/composition/provider-switching.integration.test.ts', - 'src/composition/providerAliases.builtin-qwen.test.ts', - 'src/composition/providerAliases.claudecode.factory.test.ts', - 'src/composition/providerAliases.codex.factory.test.ts', - 'src/composition/providerAliases.codex.reasoningSummary.test.ts', - 'src/composition/providerAliases.codex.test.ts', - 'src/composition/providerAliases.defaultModels.test.ts', - 'src/composition/providerAliases.kimi.test.ts', - 'src/composition/providerAliases.litellm.test.ts', - 'src/composition/providerAliases.mediaSupport.test.ts', - 'src/composition/providerAliases.modelDefaults.test.ts', - 'src/composition/providerAliases.staticModels.test.ts', - 'src/composition/providerAliases.unallowedParameters.test.ts', - 'src/composition/providerManagerInstance.oauthRegistration.test.ts', - 'src/composition/providerManagerInstance.schemaDefaults.test.ts', - 'src/composition/providerManagerInstance.staticModels.test.ts', - 'src/composition/providerManagerInstance.test.ts', - 'src/composition/providerManagerUnconfigured.test.ts', - 'src/error-reauth.spec.ts', - 'src/errors.spec.ts', - 'src/fake/FakeProvider.test.ts', - 'src/gemini/__tests__/gemini.stateless.test.ts', - 'src/gemini/__tests__/gemini.thinkingLevel.test.ts', - 'src/gemini/__tests__/gemini.thoughtSignature.test.ts', - 'src/gemini/__tests__/gemini.userMemory.test.ts', - 'src/gemini/GeminiMessageConverter.test.ts', - 'src/gemini/GeminiMessageConverter.toolFailure.test.ts', - 'src/gemini/GeminiProvider.auth.test.ts', - 'src/gemini/GeminiProvider.e2e.test.ts', - 'src/gemini/GeminiProvider.mediaBlock.test.ts', - 'src/gemini/GeminiProvider.separateDump.test.ts', - 'src/gemini/GeminiProvider.test.ts', - 'src/gemini/geminiResponseMapper.test.ts', - 'src/gemini/geminiSchemaHelpers.cycles.test.ts', - 'src/gemini/neutralConverters.property.test.ts', - 'src/gemini/neutralConverters.test.ts', - 'src/import-boundary-expectations.test.ts', - 'src/integration/multi-provider.integration.test.ts', - 'src/kimi/kimiFileUpload.test.ts', - 'src/kimi/kimiMediaProcessing.test.ts', - 'src/kimi/usageInfo.test.ts', - 'src/loadBalancing/failoverState.test.ts', - 'src/loadBalancing/loadBalancerTokenEstimator.imageTokens.test.ts', - 'src/logging/conversationResponseLogger.test.ts', - 'src/logging/ProviderPerformanceTracker.test.ts', - 'src/logging/serverToolLogger.test.ts', - 'src/LoggingProviderWrapper.test.ts', - 'src/move-map-validation.test.ts', - 'src/openai-responses/__tests__/openaiResponses.stateless.test.ts', - 'src/openai-responses/__tests__/OpenAIResponsesInputBuilder.pdf.test.ts', - 'src/openai-responses/__tests__/OpenAIResponsesInputBuilder.stateful.test.ts', - 'src/openai-responses/__tests__/OpenAIResponsesInputBuilder.toolPairing.test.ts', - 'src/openai-responses/__tests__/OpenAIResponsesProvider.codex.malformedCallId.test.ts', - 'src/openai-responses/__tests__/OpenAIResponsesProvider.codex.stateless.test.ts', - 'src/openai-responses/__tests__/OpenAIResponsesProvider.ephemerals.toolOutput.test.ts', - 'src/openai-responses/__tests__/OpenAIResponsesProvider.models.test.ts', - 'src/openai-responses/__tests__/OpenAIResponsesProvider.pdf.test.ts', - 'src/openai-responses/__tests__/OpenAIResponsesProvider.promptCacheKey.test.ts', - 'src/openai-responses/__tests__/OpenAIResponsesProvider.reasoningEffort.test.ts', - 'src/openai-responses/__tests__/OpenAIResponsesProvider.reasoningInclude.test.ts', - 'src/openai-responses/__tests__/OpenAIResponsesProvider.reasoningSummary.test.ts', - 'src/openai-responses/__tests__/OpenAIResponsesProvider.stateful.test.ts', - 'src/openai-responses/__tests__/OpenAIResponsesProvider.textVerbosity.test.ts', - 'src/openai-responses/__tests__/OpenAIResponsesProvider.toolIdNormalization.test.ts', - 'src/openai-responses/__tests__/sanitizePromptCacheKey.test.ts', - 'src/openai-responses/buildResponsesInputFromContent.mediaBlock.test.ts', - 'src/openai-responses/openAIResponsesExecutor.abort.test.ts', - 'src/openai-responses/openAIResponsesExecutor.liveness.test.ts', - 'src/openai-responses/openAIResponsesExecutor.streamIntegrity.test.ts', - 'src/openai-responses/openAIResponsesExecutor.websocket.test.ts', - 'src/openai-responses/OpenAIResponsesProvider.emptyModelFallback.test.ts', - 'src/openai-responses/OpenAIResponsesProvider.headers.test.ts', - 'src/openai-responses/OpenAIResponsesProvider.parity.test.ts', - 'src/openai-responses/OpenAIResponsesProvider.websocketFallback.test.ts', - 'src/openai-responses/OpenAIResponsesProviderCore.fetchRetry.test.ts', - 'src/openai-responses/openAIResponsesWebSocketTransport.closeDispatch.test.ts', - 'src/openai-responses/openAIResponsesWebSocketTransport.test.ts', - 'src/openai-shared/__tests__/schemaConverter.test.ts', - 'src/openai-vercel/__tests__/schemaConverter.parameterFallback.test.ts', - 'src/openai-vercel/__tests__/vercelReasoningCapture.fieldName.test.ts', - 'src/openai-vercel/errorHandling.test.ts', - 'src/openai-vercel/messageConversion.test.ts', - 'src/openai-vercel/messageConversion.toolFailure.test.ts', - 'src/openai-vercel/modelListing.test.ts', - 'src/openai-vercel/nonStreaming.config.test.ts', - 'src/openai-vercel/nonStreaming.test.ts', - 'src/openai-vercel/OpenAIVercelProvider.caching.test.ts', - 'src/openai-vercel/OpenAIVercelProvider.issue1943.test.ts', - 'src/openai-vercel/OpenAIVercelProvider.localAuth.test.ts', - 'src/openai-vercel/OpenAIVercelProvider.reasoning.test.ts', - 'src/openai-vercel/OpenAIVercelProvider.shouldRetry.test.ts', - 'src/openai-vercel/OpenAIVercelProvider.test.ts', - 'src/openai-vercel/providerRegistry.test.ts', - 'src/openai-vercel/schemaConverter.issue1844.test.ts', - 'src/openai-vercel/streaming.test.ts', - 'src/openai-vercel/vercelModelClient.localAuth.test.ts', - 'src/openai-vercel/vercelModelClient.test.ts', - 'src/openai/__tests__/formatArrayResponse.test.ts', - 'src/openai/__tests__/openai.localEndpoint.test.ts', - 'src/openai/__tests__/openai.requiresAuth.test.ts', - 'src/openai/__tests__/openai.stateless.test.ts', - 'src/openai/__tests__/OpenAIProvider.e2e.test.ts', - 'src/openai/__tests__/OpenAIProvider.thinkTags.test.ts', - 'src/openai/__tests__/schemaConverter.parameterFallback.test.ts', - 'src/openai/__tests__/ToolNameValidator.test.ts', - 'src/openai/buildResponsesRequest.stripToolCalls.test.ts', - 'src/openai/buildResponsesRequest.test.ts', - 'src/openai/buildResponsesRequest.toolIdNormalization.test.ts', - 'src/openai/buildResponsesRequest.undefined.test.ts', - 'src/openai/codexRateLimitReset.test.ts', - 'src/openai/codexUsageInfo.test.ts', - 'src/openai/ConversationCache.accumTokens.test.ts', - 'src/openai/estimateRemoteTokens.test.ts', - 'src/openai/getOpenAIProviderInfo.context.test.ts', - 'src/openai/openai-oauth.spec.ts', - 'src/openai/OpenAIApiExecution.separateDump.test.ts', - 'src/openai/OpenAIClientFactory.test.ts', - 'src/openai/openaiModelPolicy.test.ts', - 'src/openai/OpenAIProvider.caching.test.ts', - 'src/openai/OpenAIProvider.concurrentRouting.test.ts', - 'src/openai/OpenAIProvider.deepseekReasoning.test.ts', - 'src/openai/OpenAIProvider.emptyResponseRetry.conditions.test.ts', - 'src/openai/OpenAIProvider.emptyResponseRetry.test.ts', - 'src/openai/OpenAIProvider.integration.test.ts', - 'src/openai/OpenAIProvider.issue1943.test.ts', - 'src/openai/OpenAIProvider.kimiMedia.test.ts', - 'src/openai/OpenAIProvider.mediaBlock.test.ts', - 'src/openai/OpenAIProvider.mistralPayload.test.ts', - 'src/openai/OpenAIProvider.modelParamsAndHeaders.test.ts', - 'src/openai/OpenAIProvider.models.test.ts', - 'src/openai/OpenAIProvider.reasoning.test.ts', - 'src/openai/OpenAIProvider.setModel.test.ts', - 'src/openai/OpenAIProvider.shouldRetry.test.ts', - 'src/openai/OpenAIProvider.toolFormatDetection.test.ts', - 'src/openai/OpenAIProvider.toolNameErrors.test.ts', - 'src/openai/OpenAIProvider.transportRouting.test.ts', - 'src/openai/OpenAIProviders.fieldName.test.ts', - 'src/openai/OpenAIProviders.issue1844.test.ts', - 'src/openai/OpenAIRequestBuilder.test.ts', - 'src/openai/OpenAIRequestBuilder.toolFailure.test.ts', - 'src/openai/openaiRequestParams.test.ts', - 'src/openai/OpenAIRequestPreparation.issue1943.test.ts', - 'src/openai/OpenAIResponseParser.fieldName.test.ts', - 'src/openai/OpenAIResponseParser.test.ts', - 'src/openai/parseResponsesStream.issue1844.test.ts', - 'src/openai/parseResponsesStream.liveness.test.ts', - 'src/openai/parseResponsesStream.reasoning.test.ts', - 'src/openai/parseResponsesStream.responseId.test.ts', - 'src/openai/parseResponsesStream.responsesToolCalls.test.ts', - 'src/openai/parseResponsesStream.test.ts', - 'src/openai/schemaConverter.issue1844.test.ts', - 'src/openai/ToolCallCollector.test.ts', - 'src/openai/ToolCallNormalizer.test.ts', - 'src/openai/ToolCallPipeline.integration.test.ts', - 'src/openai/ToolCallPipeline.test.ts', - 'src/openai/ToolCallPipeline.toolCallId.test.ts', - 'src/openai/toolNameUtils.test.ts', - 'src/package-boundary.test.ts', - 'src/provider-content-generator-behavior.test.ts', - 'src/provider-manager-behavior.test.ts', - 'src/provider-public-api.behavior.test.ts', - 'src/ProviderContentGenerator.test.ts', - 'src/providerErrorObservation.test.ts', - 'src/providerInterface.contract.test.ts', - 'src/providerManager.context.test.ts', - 'src/ProviderManager.gemini-switch.test.ts', - 'src/ProviderManager.test.ts', - 'src/reasoning/reasoningUtils.test.ts', - 'src/retryAuthTokenResolver.test.ts', - 'src/retryConfigHandlers.test.ts', - 'src/runtime/__tests__/issue2891-lazy-oauth-gating.test.ts', - 'src/runtime/__tests__/profileApplication.authclear.test.ts', - 'src/runtime/__tests__/profileApplication.authtiming.test.ts', - 'src/runtime/__tests__/profileApplication.basics.test.ts', - 'src/runtime/__tests__/profileApplication.bucket-failover.spec.ts', - 'src/runtime/__tests__/profileApplication.failover.test.ts', - 'src/runtime/__tests__/profileApplication.issue2916.bun.test.ts', - 'src/runtime/__tests__/profileApplication.lb.authkey.test.ts', - 'src/runtime/__tests__/profileApplication.lb.detection.test.ts', - 'src/runtime/__tests__/profileApplication.unavailableProvider.test.ts', - 'src/runtime/__tests__/profileApplication.workflow.test.ts', - 'src/runtime/__tests__/profileSnapshot.loadBalancerSave.test.ts', - 'src/runtime/__tests__/provider-context-preservation.spec.ts', - 'src/runtime/__tests__/providerManagerAdoption.behavior.test.ts', - 'src/runtime/anthropic-oauth-defaults.test.ts', - 'src/runtime/assembleCliProviderRuntime.identity.test.ts', - 'src/runtime/assembleCliProviderRuntime.test.ts', - 'src/runtime/bucketFailover.test.ts', - 'src/runtime/cliEphemeralSettings.test.ts', - 'src/runtime/ephemeralSettings.mediaPdf.test.ts', - 'src/runtime/ephemeralSettings.reasoningSummary.test.ts', - 'src/runtime/ephemeralSettings.textVerbosity.test.ts', - 'src/runtime/explicitRuntimeId.behavior.test.ts', - 'src/runtime/isolatedRuntimeDefaultPointer.behavior.test.ts', - 'src/runtime/modelParamParser.test.ts', - 'src/runtime/profile-application/profileAccessors.spec.ts', - 'src/runtime/profileApplication.spec.ts', - 'src/runtime/profileSnapshot.test.ts', - 'src/runtime/provider-alias-defaults.modeldefaults.test.ts', - 'src/runtime/provider-alias-defaults.propagation.test.ts', - 'src/runtime/provider-alias-defaults.switch.test.ts', - 'src/runtime/providerConfigUtils.test.ts', - 'src/runtime/providerManagerInstance.messagebus.test.ts', - 'src/runtime/providerManagerRuntimeFactories.test.ts', - 'src/runtime/providerMutations.issue1943.test.ts', - 'src/runtime/providerMutations.spec.ts', - 'src/runtime/providerSwitch.spec.ts', - 'src/runtime/runtime-oauth-messagebus.test.ts', - 'src/runtime/runtimeAccessors.spec.ts', - 'src/runtime/runtimeContextFactory.messageBus.test.ts', - 'src/runtime/runtimeContextFactory.setRuntimeContext.test.ts', - 'src/runtime/runtimeIdentityResolution.behavior.test.ts', - 'src/runtime/runtimeLifecycle.spec.ts', - 'src/runtime/runtimeRegistry.spec.ts', - 'src/runtime/runtimeSettings.proactive-wiring.lb.spec.ts', - 'src/runtime/runtimeSettings.proactive-wiring.spec.ts', - 'src/runtime/runtimeSettings.reasoningSummary.test.ts', - 'src/runtime/runtimeSettings.spec.ts', - 'src/runtime/statelessHardening.spec.ts', - 'src/synthetic/usageInfo.test.ts', - 'src/tokenizer-behavior.test.ts', - 'src/tokenizers/Gpt56O200kPromptEstimator.test.ts', - 'src/tokenizers/Gpt56ProviderUsageParity.test.ts', - 'src/tokenizers/claude/claudeCalibration.test.ts', - 'src/tokenizers/claude/claudeCalibrationGate.test.ts', - 'src/tokenizers/claude/claudeContentFeatures.test.ts', - 'src/tokenizers/claude/claudeModelIdentity.test.ts', - 'src/tokenizers/claude/claudePromptEstimator.test.ts', - 'src/tokenizers/official/assetLoader.test.ts', - 'src/tokenizers/official/officialTokenizers.test.ts', - 'src/tokenizers/official/offlineAssets.test.ts', - 'src/tokenizers/official/providerFramingSeparation.test.ts', - 'src/utils/cacheMetricsExtractor.test.ts', - 'src/utils/containerSandbox.test.ts', - 'src/utils/contentPreview.test.ts', - 'src/utils/dumpContext.separateFiles.test.ts', - 'src/utils/dumpContext.test.ts', - 'src/utils/dumpSDKContext.test.ts', - 'src/utils/mediaUtils.test.ts', - 'src/utils/qwenEndpoint.test.ts', - 'src/utils/retryStrategy.test.ts', - 'src/utils/textSanitizer.test.ts', - 'src/utils/thinkingExtraction.test.ts', - 'src/utils/toolFormatDetection.issue1943.test.ts', - 'src/utils/toolFormatDetection.test.ts', - 'src/utils/toolNameNormalization.test.ts', - 'src/utils/toolResponsePayload.test.ts', - 'src/utils/toolResponsePayload.toolFailure.test.ts', - 'src/zai/usageInfo.test.ts', - ], -}; diff --git a/scripts/bun-test-manifest-data-storage.ts b/scripts/bun-test-manifest-data-storage.ts deleted file mode 100644 index 08c64f0753..0000000000 --- a/scripts/bun-test-manifest-data-storage.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * @license - * Copyright 2026 Vybestack LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { BunTestWorkspaceEntry } from './bun-test-manifest.ts'; - -export const STORAGE_MANIFEST_ENTRY: BunTestWorkspaceEntry = { - workspace: 'storage', - // Both preloads must be listed here: run_bun_tests.ts passes these as - // explicit --preload args and does NOT read packages/storage/bunfig.toml, so - // a preload declared only there would be silently dropped in manifest-driven - // runs (which is what `npm test` uses) and the process-wide keyring latch - // would leak between test files. - preload: [ - 'test-setup-storage-isolation.ts', - 'test-setup-bun-session-reset.ts', - ], - files: [ - 'test-bun/credential-write-lock.bun.ts', - 'test-bun/keyring-delete-verification.bun.ts', - 'test-bun/keychain-grant-persistence.bun.ts', - 'test-bun/keyring-opt-out.bun.ts', - 'test-bun/keyring-write-verification.bun.ts', - 'test-bun/machine-secret.bun.ts', - 'test-bun/machine-secret.concurrent-write.bun.ts', - 'test-bun/secure-store.bun.ts', - 'test-bun/secure-store.fallback-hardening.bun.ts', - 'test-bun/secure-store.concurrent-write.bun.ts', - 'test-bun/secure-store.runtime-replaced.bun.ts', - 'test-bun/secure-store.keyring-session.bun.ts', - 'test-bun/storage.bun.ts', - 'src/secure-store/provider-key-storage.test.ts', - 'src/secure-store/secure-store-integration.test.ts', - 'src/secure-store/secure-store.fallback-v2.test.ts', - 'src/secure-store/secure-store.fallback2.test.ts', - 'src/secure-store/secure-store-errors.test.ts', - 'src/secure-store/secure-store.basic.test.ts', - 'src/secure-store/secure-store.fallback.test.ts', - 'src/secure-store/secure-store.fallback.xdg-paths.test.ts', - 'src/secure-store/secure-store.dual-mode.test.ts', - 'src/secure-store/secure-store.native-keyring.test.ts', - 'src/secure-store/envelope-codec.test.ts', - 'src/secure-store/secure-store.fallback-behavior.test.ts', - 'src/secure-store/provider-key-storage.fallback.test.ts', - 'src/secure-store/envelope.test.ts', - 'src/secure-store/runtime-identity.test.ts', - 'src/secure-store/secure-store.migration.test.ts', - 'src/config/path-resolver.test.ts', - 'src/config/storage.agentsSecurity.test.ts', - 'src/utils/gitIgnoreParser.test.ts', - 'src/testing/isolateStorageRoots.test.ts', - 'src/services/fileDiscoveryService.test.ts', - 'src/services/fileSystemService.test.ts', - 'src/conversation/ConversationFileWriter.test.ts', - 'src/session/sessionTypes.test.ts', - ], -}; diff --git a/scripts/bun-test-manifest-data-tools.ts b/scripts/bun-test-manifest-data-tools.ts deleted file mode 100644 index c2b5658751..0000000000 --- a/scripts/bun-test-manifest-data-tools.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * @license - * Copyright 2026 Vybestack LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { BunTestWorkspaceEntry } from './bun-test-manifest.ts'; - -export const TOOLS_MANIFEST_ENTRY: BunTestWorkspaceEntry = { - workspace: 'tools', - preload: 'test-setup-storage-isolation.ts', - files: [ - 'test-bun/imageDimensions.bun.ts', - 'test-bun/imageTokenEstimation.bun.ts', - 'test-bun/language-analysis.followup.bun.ts', - 'test-bun/shell-wrapper.bun.ts', - 'test-bun/shell-tool-signal-format.bun.ts', - 'src/tools/activate-skill.test.ts', - 'src/tools/ast-edit.ide.test.ts', - 'src/tools/edit-utils.test.ts', - 'src/tools/exa-web-search.test.ts', - 'src/tools/direct-web-fetch.test.ts', - 'src/tools/github.test.ts', - 'src/tools/github-ops.test.ts', - 'src/tools/github-display.test.ts', - 'src/tools/github-unknown-param.bun.test.ts', - 'src/tools/line-range-tools-issue3036.bun.test.ts', - 'src/tools/check-async-tasks.test.ts', - 'src/tools/list-subagents.test.ts', - 'src/tools/codesearch.test.ts', - 'src/tools/codesearch-endpoint.bun.test.ts', - 'src/tools/structural-analysis/structural-analysis-modes.bun.test.ts', - 'src/tools/memoryTool.test.ts', - 'src/tools/write-file.test.ts', - 'src/tools/tools.test.ts', - 'src/tools/todo-store-injection.test.ts', - 'src/tools/todo-store-single-resolve.test.ts', - 'src/tools/generate-image/GenerateImageTool.test.ts', - 'src/tools/generate-image/GenerateImageTool.surface.test.ts', - 'src/tools/ast-edit/__tests__/ast-edit-issue-1756.test.ts', - 'src/tools/ast-edit/__tests__/ast-edit-issue-3035.test.ts', - 'src/tools/ast-edit/__tests__/ast-edit-issue-3035-review.test.ts', - 'src/tools/ast-edit/__tests__/ast-edit-summary-counts.test.ts', - 'src/tools/ast-edit/__tests__/ast-edit-rust-validation.test.ts', - 'src/tools/ast-edit/__tests__/validation-categorizer.test.ts', - 'src/tools/ast-edit/__tests__/ast-edit-ast-validation.test.ts', - 'src/tools/ast-edit/__tests__/ast-edit-preview.test.ts', - 'src/tools/ast-edit/__tests__/ast-edit-concurrency.test.ts', - 'src/tools/ast-edit/__tests__/ast-edit-edge-cases.test.ts', - 'src/tools/ast-edit/__tests__/ast-edit-preview-gaps.test.ts', - 'src/tools/ast-edit/__tests__/ast-edit-ambiguous-match.test.ts', - 'src/tools/ast-edit/__tests__/ast-edit-force-flag.test.ts', - 'src/tools/ast-edit/__tests__/ast-edit-crlf.test.ts', - 'src/tools/ast-edit/__tests__/ast-edit-c-validation.test.ts', - 'src/tools/ast-edit/__tests__/language-analysis.test.ts', - 'src/formatters/ToolFormatter.test.ts', - 'src/formatters/toolGovernanceUtils.test.ts', - 'src/formatters/doubleEscapeUtils.test.ts', - 'src/utils/ast-grep-utils.lazy.import-effects.test.ts', - 'src/utils/ast-grep-utils.lazy.registration.test.ts', - 'src/utils/ast-grep-utils.lazy.parsesource-registration.test.ts', - 'src/utils/ast-grep-utils.lazy.degradation.test.ts', - 'src/utils/ast-grep-utils.lazy.available-before-registration.test.ts', - 'src/utils/ast-grep-utils.lazy.import-throws.test.ts', - 'src/utils/imageResize.test.ts', - 'src/utils/timeoutResolution.test.ts', - 'src/utils/fileUtils.test.ts', - 'src/utils/textDelta.test.ts', - 'src/__tests__/shell-tool.test.ts', - 'src/__tests__/shell-timeout-bounds.test.ts', - 'src/__tests__/edit-ast-tools.test.ts', - 'src/__tests__/public-surface.task-tool.test.ts', - 'src/__tests__/removed-google-tools.test.ts', - 'src/__tests__/interface-contracts.test.ts', - 'src/__tests__/read-many-files-filtering-behavior.test.ts', - 'src/__tests__/tool-registry-mcp-lazy.test.ts', - 'src/__tests__/registry-contract.test.ts', - 'src/__tests__/forbidden-dependencies.test.ts', - 'src/__tests__/package-boundary.test.ts', - 'src/__tests__/shell-helpers-schema.test.ts', - 'src/__tests__/tool-key-storage.test.ts', - 'src/__tests__/glob-filtering-behavior.test.ts', - 'src/__tests__/todo-tools.test.ts', - 'src/__tests__/export-surface-helpers.test.ts', - 'src/__tests__/apply-patch.test.ts', - 'src/__tests__/apply-patch-ax.bun.test.ts', - 'src/__tests__/glob-filtering.test.ts', - 'src/__tests__/ls-filtering-behavior.test.ts', - 'src/__tests__/neutral-types.test.ts', - 'src/__tests__/package-metadata.test.ts', - 'src/__tests__/filesystem-tools.test.ts', - // Issue #3063: ReadFileTool.execute() must keep both halves of its error. - 'src/tools/__tests__/read-file-direct-api.test.ts', - 'src/__tests__/boundary-scan.test.ts', - 'src/__tests__/ripGrep-args.test.ts', - 'src/__tests__/wire-types.test.ts', - 'src/__tests__/forbidden-imports.test.ts', - 'src/__tests__/memory-tool.test.ts', - 'src/__tests__/todo-emoji-filter.test.ts', - 'src/__tests__/subagent-tools.test.ts', - ], -}; diff --git a/scripts/bun-test-manifest-validation.ts b/scripts/bun-test-manifest-validation.ts deleted file mode 100644 index 87cf3c4106..0000000000 --- a/scripts/bun-test-manifest-validation.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * @license - * Copyright 2026 Vybestack LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * File-existence and preload-path validation extracted from - * `bun-test-manifest.ts` so the manifest module stays under the ESLint - * `max-lines` budget (800 lines after blank/comment removal). - * - * All functions are pure with respect to an injectable `stat` dependency. - */ - -import type { - BunManifestDependencies, - BunTestFile, -} from './bun-test-manifest.js'; -import { BunManifestStatError, getErrorCode } from './bun-test-manifest.js'; - -/** - * Validates that every resolved test file exists on disk and is a regular - * file. Collects missing (ENOENT) and non-file paths, then validates all - * declared preload/tsconfig/globalSetup paths. Throws a single aggregated - * error when any path is missing or not a file. - */ -export function validateResolvedFiles( - files: readonly BunTestFile[], - dependencies: BunManifestDependencies, -): void { - const missingFiles: string[] = []; - const nonFiles: string[] = []; - for (const { file } of files) { - checkFileExists(dependencies, file, missingFiles, nonFiles); - } - validatePreloadPaths(files, dependencies); - rejectMissingOrNonFiles(missingFiles, nonFiles); -} - -function checkFileExists( - dependencies: BunManifestDependencies, - file: string, - missingFiles: string[], - nonFiles: string[], -): void { - try { - if (!dependencies.stat(file).isFile()) { - nonFiles.push(file); - } - } catch (error: unknown) { - const code = getErrorCode(error); - if (code === 'ENOENT') { - missingFiles.push(file); - } else { - throw new BunManifestStatError(file, code, error); - } - } -} - -function validatePreloadPaths( - files: readonly BunTestFile[], - dependencies: BunManifestDependencies, -): void { - const preloadPaths = collectPreloadPaths(files); - for (const preload of preloadPaths) { - validatePreloadExists(preload, dependencies); - } -} - -function collectPreloadPaths(files: readonly BunTestFile[]): Set { - const preloadPaths = new Set(); - for (const { preloads, tsconfig, globalSetup } of files) { - for (const preload of preloads) { - preloadPaths.add(preload); - } - if (tsconfig !== undefined) { - preloadPaths.add(tsconfig); - } - if (globalSetup !== undefined) { - preloadPaths.add(globalSetup); - } - } - return preloadPaths; -} - -function validatePreloadExists( - preload: string, - dependencies: BunManifestDependencies, -): void { - try { - if (!dependencies.stat(preload).isFile()) { - throw new BunManifestStatError( - preload, - undefined, - new Error('not a file'), - ); - } - } catch (error: unknown) { - if (error instanceof BunManifestStatError) { - throw error; - } - const code = getErrorCode(error); - if (code === 'ENOENT') { - throw new Error( - `Bun native test manifest declares a missing preload: ${preload}`, - ); - } - throw new BunManifestStatError(preload, code, error); - } -} - -function rejectMissingOrNonFiles( - missingFiles: string[], - nonFiles: string[], -): void { - if (missingFiles.length > 0) { - throw new Error( - `Bun native test manifest contains missing files:\n${missingFiles - .map((file) => ` - ${file}`) - .join('\n')}`, - ); - } - if (nonFiles.length > 0) { - throw new Error( - `Bun native test manifest contains non-files:\n${nonFiles - .map((file) => ` - ${file}`) - .join('\n')}`, - ); - } -} diff --git a/scripts/bun-test-manifest.ts b/scripts/bun-test-manifest.ts deleted file mode 100644 index 300785667b..0000000000 --- a/scripts/bun-test-manifest.ts +++ /dev/null @@ -1,500 +0,0 @@ -/** - * @license - * Copyright 2025 Vybestack LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { statSync } from 'node:fs'; -import { join } from 'node:path'; -import { validateResolvedFiles } from './bun-test-manifest-validation.js'; -import { PROVIDERS_MANIFEST_ENTRY } from './bun-test-manifest-data-providers.ts'; -import { TOOLS_MANIFEST_ENTRY } from './bun-test-manifest-data-tools.ts'; -import { MCP_MANIFEST_ENTRY } from './bun-test-manifest-data-mcp.ts'; -import { STORAGE_MANIFEST_ENTRY } from './bun-test-manifest-data-storage.ts'; - -export interface BunTestWorkspaceEntry { - readonly workspace: string; - /** - * Explicit list of test files, relative to the resolved cwd. Used by - * workspaces that are only partially migrated, where naming alone cannot - * distinguish a Bun-ready file from one still owned by Vitest. - * - * Mutually exclusive with `include`: an entry declares exactly one of the - * two so it is always obvious whether its file set is curated or derived. - */ - readonly files?: readonly string[]; - /** - * Glob patterns (relative to the resolved cwd) that select every test file - * for a fully migrated root. This is the Bun-native equivalent of a Vitest - * config's `include`, and it is what makes "no test file can be silently - * dropped" mechanically true: a newly added test file is picked up without - * any manifest edit. - */ - readonly include?: readonly string[]; - /** Glob patterns removed from the `include` result. */ - readonly exclude?: readonly string[]; - /** - * Optional explicit working directory override. When omitted, the workspace - * name is resolved under `packages/` (e.g. `packages/core`). When set, this - * path is used as the cwd and file resolution root. - */ - readonly cwd?: string; - /** - * Optional Bun `--preload` script path(s) (relative to the workspace cwd) - * run before any test module is imported. Used by workspaces whose tests - * must isolate global state (e.g. Storage roots) before test modules import - * the singleton — `bun test` does not run Vitest `setupFiles`, so a preload - * is the only way to guarantee ordering under Bun. - */ - readonly preload?: string | readonly string[]; - /** - * Optional tsconfig (relative to the workspace cwd) passed to Bun as - * `--tsconfig-override`. Used where test-only module resolution differs from - * the build configuration (e.g. stubbing the editor-injected `vscode` - * module), so the production tsconfig stays honest. - */ - readonly tsconfig?: string; - /** - * Per-test timeout in milliseconds for this root, overriding the runner's - * global `--timeout`. Mirrors a Vitest config's `testTimeout`. - */ - readonly timeout?: number; - /** - * Number of times a failing file is re-run before it is reported as failed. - * Mirrors a Vitest config's `retry`, which real-provider E2E suites rely on. - */ - readonly retries?: number; - /** - * Module (relative to the workspace cwd) exporting `setup()` and/or - * `teardown()`, executed once in the runner process around the whole root. - * Mirrors a Vitest config's `globalSetup`: mutations it makes to - * `process.env` are inherited by every spawned test process. - */ - readonly globalSetup?: string; - /** - * Marks a root that calls a real provider and therefore needs credentials - * and quota. Such roots are excluded from an unfiltered run and must be - * selected explicitly with `--root`, so the ordinary PR gate never burns - * quota; their dedicated workflows request them by name. - */ - readonly credentialed?: boolean; -} - -export interface BunTestFile { - readonly file: string; - readonly cwd: string; - /** - * Resolved absolute preload paths for this file's workspace (empty when the - * workspace declares none). Passed to `bun test --preload`. - */ - readonly preloads: readonly string[]; - /** Resolved absolute `--tsconfig-override` path, when the entry declares one. */ - readonly tsconfig?: string; - /** Per-test timeout override in milliseconds, when the entry declares one. */ - readonly timeout?: number; - /** Retry budget for this file, when the entry declares one. */ - readonly retries?: number; - /** Resolved absolute global setup module path, when the entry declares one. */ - readonly globalSetup?: string; -} - -export interface BunManifestDependencies { - stat(path: string): { isFile(): boolean }; - /** - * Expands a glob pattern to file paths relative to `cwd`. Injected so the - * resolver stays testable without touching the real filesystem. - */ - glob(pattern: string, cwd: string): readonly string[]; -} - -export class BunManifestStatError extends Error { - readonly path: string; - readonly code: string | undefined; - - constructor(path: string, code: string | undefined, cause: unknown) { - super( - `Unable to inspect Bun native test manifest path: ${path}${ - code ? ` (${code})` : '' - }`, - { cause }, - ); - this.name = 'BunManifestStatError'; - this.path = path; - this.code = code; - } -} - -const defaultManifestDependencies: BunManifestDependencies = { - stat: statSync, - glob: (pattern, cwd) => - Array.from(new Bun.Glob(pattern).scanSync({ cwd, onlyFiles: true })).sort(), -}; - -export function getErrorCode(error: unknown): string | undefined { - if (typeof error !== 'object' || error === null || !('code' in error)) { - return undefined; - } - const code = Reflect.get(error, 'code'); - return typeof code === 'string' ? code : undefined; -} - -/** - * The release-install smoke, kept in its own root because it packs and - * installs a CLI tarball and therefore needs a much larger time budget than - * the rest of the script harness. - */ -export const SLOW_SCRIPTS_TEST = 'issue-2603-release-install.test.ts'; - -/** Every test root executed by Bun's native test runner. */ -export const BUN_NATIVE_TEST_MANIFEST: readonly BunTestWorkspaceEntry[] = [ - { - workspace: 'a2a-server', - preload: [ - '../../test-setup/augment-bun-vi.ts', - 'bun-preload-storage-isolation.ts', - ], - include: ['src/**/*.test.ts'], - }, - { - workspace: 'agents', - files: [ - 'src/core/CompressionProfileResolver.proxyKeyStorage.test.ts', - // Issue #3063: explicit failure producers must mark the top-level error - // marker (AC17) and cancellation must be marked at its real source (AC18). - 'src/core/subagentToolProcessing.toolFailure.test.ts', - 'src/scheduler/status-transitions.toolFailure.test.ts', - // Issue #3063: compression must keep both the marker and the remedy (AC14). - 'src/compression/utils.toolFailureFidelity.test.ts', - 'test-bun/generatingModelStamp.issue2511.bun.ts', - 'test-bun/subagentAnthropicTextSettings.issue1738.bun.ts', - 'test-bun/taskTimeoutBounds.issue3031.bun.ts', - 'test-bun/taskTimeoutDescription.issue3031.bun.ts', - 'test-bun/taskAsyncTimeout.issue3031.bun.ts', - 'test-bun/taskTimeoutResultAgentId.cr3031.bun.ts', - ], - }, - { - workspace: 'cli', - files: [ - 'src/__tests__/cliSessionDispatch.characterization.test.tsx', - // Extension settings storage drives the REAL SecureStore against an - // in-memory keyring, so it needs no module mocking and is Bun-native. - 'test-bun/settingsStorage.bun.ts', - // JSP/1 observation producer (issue #2779). Bun-native from the start: - // these are excluded from the Vitest selection so they run only here. - 'src/observation/jspBounds.test.ts', - 'src/observation/jspProducer.test.ts', - 'src/observation/jspProducerState.test.ts', - 'src/observation/jspRedaction.test.ts', - 'src/observation/jspSchema.test.ts', - 'src/observation/jspTransport.test.ts', - 'src/observation/jspWiring.test.ts', - 'src/observation/observationTap.test.ts', - 'src/utils/sandbox-containers.test.ts', - // Issue #3081: legacy→canonical migration categorization for config - // entries that were previously routed to the data directory. - 'test-bun/pathMigration.issue3081.bun.ts', - // Issue #3081: sandbox env helpers (current-user detection, container - // home resolution, Windows path translation). - 'test-bun/sandbox-env.bun.ts', - // Sandbox SSH agent preflight (issue #1699). Bun-native from the start - // and likewise excluded from the Vitest selection. - 'src/utils/sandbox-ssh-agent-preflight.test.ts', - // Process memory hardening (issue #3028). Imports the real `bun:test` - // API rather than the Vitest shim, so it runs only here and is excluded - // from the Vitest selection. - 'src/launcher/process-memory-hardening.test.ts', - 'src/zed-integration/zed-session-lifecycle.test.ts', - // Issue #3063: Zed replay must display the model-facing remedy for a - // failed tool call, not the terse marker (AC16). - 'src/zed-integration/zed-session-replay.toolFailure.test.ts', - // Issue #2980: Zed terminal command correlation. Migrated to bun:test - // and excluded from the Vitest selection below; the strict wrapper - // matcher is exercised here while keeping the lifecycle guard intact. - 'src/zed-integration/zedIntegration.terminal.test.ts', - 'test-bun/iContentToHistoryItems.issue2511.bun.ts', - 'src/ui/commands/authCommand.loginWithBucket.issue2891.test.ts', - 'test-utils/augment-bun-vi-cleanup.bun.ts', - // Issue #2951: Windows Ctrl+Enter steering. Each file pins - // process.platform at the very top before the key-matcher module graph - // loads, so win32 and darwin must run in separate processes. - 'test-bun/steerKey.win32.bun.ts', - 'test-bun/steerKey.darwin.bun.ts', - 'test-bun/resolveKeyBindings.bun.ts', - 'test-bun/keypressLineFeed.bun.ts', - 'test-bun/profileAuthKeyNameIssue2916.bun.ts', - ], - }, - { - workspace: 'cli', - preload: 'bun-test-setup.ts', - files: [ - 'src/ui/hooks/agentStream/__tests__/useAgentEventStream.bun.tsx', - 'src/ui/hooks/agentStream/__tests__/useAgentStreamOrchestration.terminal.bun.tsx', - 'src/ui/hooks/agentStream/__tests__/useSubmitQuery.doublecancel.bun.tsx', - 'src/ui/hooks/agentStream/__tests__/useSubmitQuery.terminalError.bun.tsx', - ], - }, - { - // Issue #3052: TodoProvider must publish slash-command mutations on the - // todoEvents observation channel. Uses the real provider + real - // createTodoObservationSubscription seam (no mocks on that seam). Drives - // the REAL TodoStore against disk, so it isolates storage roots via the - // shared preload in addition to the React/Ink setup. - workspace: 'cli', - preload: ['test-setup-storage-isolation.ts', 'bun-test-setup.ts'], - files: ['src/ui/contexts/__tests__/todoProvider.observation.bun.tsx'], - }, - { - workspace: 'core', - files: [ - 'src/utils/errors.test.ts', - // Issue #1985: ToolKeyStorage.deleteKey() must still remove its own - // encrypted .key file when SecureStore.delete() surfaces a keyring - // failure. - 'src/tools/tool-key-storage.test.ts', - 'src/tools-adapters/CoreSubagentServiceAdapter.timeout.test.ts', - 'src/tools-adapters/CoreSubagentServiceAdapter.cancellation.cr3031.test.ts', - ], - }, - PROVIDERS_MANIFEST_ENTRY, - TOOLS_MANIFEST_ENTRY, - MCP_MANIFEST_ENTRY, - { - workspace: 'telemetry', - preload: [ - '../../test-setup/augment-bun-vi.ts', - 'test-setup-storage-isolation.ts', - ], - include: ['src/**/*.test.ts'], - }, - STORAGE_MANIFEST_ENTRY, - { - workspace: 'test-utils', - preload: ['../../test-setup/augment-bun-vi.ts'], - include: ['src/**/*.test.ts'], - }, - { - workspace: 'settings', - preload: [ - '../../test-setup/augment-bun-vi.ts', - 'test-setup-storage-isolation.ts', - ], - include: ['src/**/*.test.ts'], - }, - { - workspace: 'ide-integration', - preload: [ - '../../test-setup/augment-bun-vi.ts', - 'test-setup-storage-isolation.ts', - 'test-setup.ts', - ], - include: ['src/**/*.test.ts'], - }, - { - // `vscode` is injected by the editor host and cannot be resolved outside - // it, so a test-only tsconfig maps the specifier at a stub the per-file - // `vi.mock('vscode', …)` factories then replace. - workspace: 'vscode-ide-companion', - preload: [ - '../../test-setup/augment-bun-vi.ts', - 'test-setup-storage-isolation.ts', - ], - tsconfig: 'tsconfig.bun-test.json', - include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'], - }, - { - workspace: 'policy', - preload: ['../../test-setup/augment-bun-vi.ts'], - include: ['src/**/*.test.ts'], - exclude: ['src/research/**'], - }, - { - workspace: 'test-setup', - cwd: '.', - files: [ - 'test-setup/augment-bun-vi.test.ts', - 'test-setup/stub-helpers.bun.test.ts', - 'test-setup/vitest-parity.test.ts', - ], - }, - { - // The whole script harness. Previously split into several curated roots - // (acplint, scripts-pr-review, scripts-ocr-review, issue-planner-*) while - // the rest of the directory still belonged to Vitest; now that Vitest no - // longer runs this tree, one glob root covers every file — including the - // `*.bun.test.ts` files that were always Bun-only. - workspace: 'scripts-tests', - cwd: '.', - preload: ['test-setup/augment-bun-vi.ts', 'scripts/tests/test-setup.ts'], - include: ['scripts/tests/**/*.test.ts', 'scripts/tests/**/*.test.js'], - exclude: [`scripts/tests/${SLOW_SCRIPTS_TEST}`], - }, - { - // The release-install smoke packs a CLI tarball and runs three npm - // installs, so it needs a far larger budget than the rest of the harness. - // It is a separate root so the ordinary script tests keep a tight timeout - // that still catches genuine hangs. - workspace: 'scripts-tests-slow', - cwd: '.', - preload: ['test-setup/augment-bun-vi.ts', 'scripts/tests/test-setup.ts'], - files: [`scripts/tests/${SLOW_SCRIPTS_TEST}`], - timeout: 300_000, - }, - { - workspace: 'evals', - cwd: 'evals', - preload: ['../test-setup/augment-bun-vi.ts'], - include: ['**/*.eval.ts'], - globalSetup: 'globalSetup.ts', - timeout: 300_000, - credentialed: true, - }, - { - // End-to-end tests against a real provider: long per-test budget, a - // global setup that isolates storage roots for every spawned CLI, and a - // retry budget mirroring the Vitest config these replaced. - workspace: 'integration-tests', - cwd: 'integration-tests', - preload: ['../test-setup/augment-bun-vi.ts', 'setup-quota-guard.ts'], - include: ['**/*.test.ts'], - globalSetup: 'globalSetup.ts', - timeout: 300_000, - retries: 2, - credentialed: true, - }, -]; - -/** - * Resolves the working directory for a workspace entry. - * - * - When `cwd` is `undefined`, the workspace name is resolved under - * `packages/` (e.g. `packages/core`). - * - When `cwd` is an empty string, the repo root itself is used. - * - When `cwd` is a non-empty string, it is joined under the repo root. - * - * Using `cwd !== undefined` (not truthiness) ensures an empty string - * correctly means the repo root rather than falling through to the - * `packages/` default. - */ -export function resolveWorkspaceCwd( - repoRoot: string, - workspace: string, - cwd: string | undefined, -): string { - if (cwd === undefined) { - return join(repoRoot, 'packages', workspace); - } - return join(repoRoot, cwd); -} - -/** - * Expands one manifest entry into its relative test-file list. - * - * `files` is returned verbatim (curated set). `include` is expanded through - * the injected glob and then filtered by `exclude`, mirroring how a Vitest - * config's include/exclude pair selects files. Declaring both, or neither, is - * a manifest authoring error and fails loudly rather than silently running a - * partial set. - */ -export function resolveEntryFileNames( - entry: BunTestWorkspaceEntry, - resolvedCwd: string, - dependencies: BunManifestDependencies, -): readonly string[] { - const { workspace, files, include, exclude } = entry; - if (files !== undefined && include !== undefined) { - throw new Error( - `Bun native test manifest entry "${workspace}" declares both "files" and "include"; choose one.`, - ); - } - if (files !== undefined) { - return files; - } - if (include === undefined) { - throw new Error( - `Bun native test manifest entry "${workspace}" declares neither "files" nor "include".`, - ); - } - const excluded = new Set( - (exclude ?? []).flatMap((pattern) => - dependencies.glob(pattern, resolvedCwd), - ), - ); - const selected = new Set( - include.flatMap((pattern) => dependencies.glob(pattern, resolvedCwd)), - ); - const remaining = [...selected].filter((file) => !excluded.has(file)).sort(); - if (remaining.length === 0) { - throw new Error( - `Bun native test manifest entry "${workspace}" matched no test files under ${resolvedCwd}.`, - ); - } - return remaining; -} - -function toPreloadList( - preload: string | readonly string[] | undefined, -): readonly string[] { - if (preload === undefined) { - return []; - } - return typeof preload === 'string' ? [preload] : preload; -} - -/** - * Decides whether a root participates in this run. - * - * A named filter selects exactly that root, credentialed or not. An - * unfiltered run covers every root that does not require provider - * credentials, so the ordinary gate stays complete without burning quota. - */ -export function selectsEntry( - entry: BunTestWorkspaceEntry, - workspaceFilter: string | undefined, -): boolean { - if (workspaceFilter !== undefined) { - return entry.workspace === workspaceFilter; - } - return entry.credentialed !== true; -} - -export function resolveBunNativeTestFiles( - repoRoot: string, - workspaceFilter?: string, - dependencies: BunManifestDependencies = defaultManifestDependencies, -): BunTestFile[] { - const files = BUN_NATIVE_TEST_MANIFEST.filter((entry) => - selectsEntry(entry, workspaceFilter), - ).flatMap((entry) => resolveManifestEntry(entry, repoRoot, dependencies)); - validateResolvedFiles(files, dependencies); - return files.sort((left, right) => left.file.localeCompare(right.file)); -} - -function resolveManifestEntry( - entry: BunTestWorkspaceEntry, - repoRoot: string, - dependencies: BunManifestDependencies, -): BunTestFile[] { - const resolvedCwd = resolveWorkspaceCwd(repoRoot, entry.workspace, entry.cwd); - const resolvedPreloads = toPreloadList(entry.preload).map((preload) => - join(resolvedCwd, preload), - ); - return resolveEntryFileNames(entry, resolvedCwd, dependencies).map( - (file) => ({ - cwd: resolvedCwd, - file: join(resolvedCwd, file), - preloads: resolvedPreloads, - tsconfig: - entry.tsconfig !== undefined - ? join(resolvedCwd, entry.tsconfig) - : undefined, - timeout: entry.timeout, - retries: entry.retries, - globalSetup: - entry.globalSetup !== undefined - ? join(resolvedCwd, entry.globalSetup) - : undefined, - }), - ); -} diff --git a/scripts/bun-test-roots.ts b/scripts/bun-test-roots.ts new file mode 100644 index 0000000000..9c72cae92d --- /dev/null +++ b/scripts/bun-test-roots.ts @@ -0,0 +1,555 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Filesystem-discovery-based test-root table for the shared Bun test runner. + * + * Replaces the former manifest allowlist (`scripts/bun-test-manifest.ts`). + * Each root declares the directories to scan and the execution settings + * (preload, tsconfig, timeout, retries, globalSetup, credentialed). There is + * deliberately **no** `files`, `include`, or `exclude` member: a root selects + * its test files by walking the filesystem, so a newly added test file is + * picked up automatically and can never be silently dropped. + */ + +import { readdirSync, realpathSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** Per-file timeout override keyed by a path pattern. */ +export interface BunTestTimeoutOverride { + /** + * Matched against the resolved ABSOLUTE path of a discovered file, not its + * basename, so a pattern may scope itself to a directory. Anchor the end + * (`/name\.test\.ts$/`) rather than the start when targeting one file. + */ + readonly pattern: RegExp; + readonly timeout: number; +} + +export interface BunTestRoot { + /** The `--root` / `--workspace` token. */ + readonly root: string; + /** Repo-relative cwd; default `packages/`. */ + readonly cwd?: string; + /** Scanned directories under cwd; default: cwd itself. */ + readonly directories?: readonly string[]; + /** Test-file pattern; default DEFAULT_TEST_FILE_PATTERN. */ + readonly pattern?: RegExp; + /** Bun `--preload` script path(s), relative to cwd. */ + readonly preload?: string | readonly string[]; + /** Tsconfig (relative to cwd) passed as `--tsconfig-override`. */ + readonly tsconfig?: string; + /** Per-test timeout in milliseconds, overriding the runner's `--timeout`. */ + readonly timeout?: number; + /** Number of retries for a failing file before reporting it as failed. */ + readonly retries?: number; + /** Module (relative to cwd) with `setup()` / `teardown()`, run once per root. */ + readonly globalSetup?: string; + /** Marks a root that needs real credentials; excluded from unfiltered runs. */ + readonly credentialed?: boolean; + /** + * Per-file timeout overrides keyed by an absolute-path pattern. An override + * changes only the budget for the matching file, never whether it is + * executed. The first matching entry wins. + */ + readonly timeoutOverrides?: readonly BunTestTimeoutOverride[]; +} + +export interface BunTestFile { + readonly file: string; + readonly cwd: string; + /** Resolved absolute preload paths for this root (empty when none declared). */ + readonly preloads: readonly string[]; + /** Resolved absolute tsconfig path, when the root declares one. */ + readonly tsconfig?: string; + /** Per-test timeout in milliseconds, when the root declares one. */ + readonly timeout?: number; + /** Retry budget, when the root declares one. */ + readonly retries?: number; + /** Resolved absolute global-setup module path, when declared. */ + readonly globalSetup?: string; +} + +/** + * Injectable filesystem operations so the resolver and walker stay testable + * against temp fixtures rather than the live repository tree. + */ +export interface BunTestRootDependencies { + readonly stat: (path: string) => { + isFile(): boolean; + isDirectory(): boolean; + }; + readonly readDirectory: (path: string) => readonly string[]; + readonly realpath: (path: string) => string; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** + * Union of the naming conventions in use across the repository: `*.test.*`, + * `*.spec.*`, and `*.bun.*` for suites importing `bun:test` directly. + */ +export const DEFAULT_TEST_FILE_PATTERN = /\.(test|spec|bun)\.(ts|tsx|js)$/; + +const DECLARATION_FILE_PATTERN = /\.d\.ts$/; + +const SKIPPED_DIRECTORY_NAMES: ReadonlySet = new Set([ + 'node_modules', + 'dist', + 'coverage', + 'tmp', + 'bundle', + '__snapshots__', +]); + +// --------------------------------------------------------------------------- +// Error helpers +// --------------------------------------------------------------------------- + +export class BunTestRootStatError extends Error { + readonly path: string; + readonly code: string | undefined; + + constructor(path: string, code: string | undefined, cause: unknown) { + super( + `Unable to inspect Bun test root path: ${path}${ + code ? ` (${code})` : '' + }`, + { cause }, + ); + this.name = 'BunTestRootStatError'; + this.path = path; + this.code = code; + } +} + +export function getErrorCode(error: unknown): string | undefined { + if (typeof error !== 'object' || error === null || !('code' in error)) { + return undefined; + } + const code = Reflect.get(error, 'code'); + return typeof code === 'string' ? code : undefined; +} + +// --------------------------------------------------------------------------- +// Root table +// --------------------------------------------------------------------------- + +export const BUN_TEST_ROOTS: readonly BunTestRoot[] = [ + { + root: 'a2a-server', + preload: [ + '../../test-setup/augment-bun-vi.ts', + 'bun-preload-storage-isolation.ts', + ], + }, + { + root: 'agents', + directories: ['test-bun'], + }, + { + root: 'providers', + }, + { + root: 'tools', + preload: 'test-setup-storage-isolation.ts', + }, + { + root: 'mcp', + preload: 'test-setup-storage-isolation.ts', + }, + { + root: 'telemetry', + preload: [ + '../../test-setup/augment-bun-vi.ts', + 'test-setup-storage-isolation.ts', + ], + }, + { + // Both preloads are explicit because run_bun_tests.ts passes them as + // --preload args and does NOT read packages/storage/bunfig.toml, so a + // preload declared only there would be silently dropped and the + // process-wide keyring latch would leak between test files. + root: 'storage', + preload: [ + 'test-setup-storage-isolation.ts', + 'test-setup-bun-session-reset.ts', + ], + }, + { + root: 'test-utils', + preload: ['../../test-setup/augment-bun-vi.ts'], + }, + { + root: 'settings', + preload: [ + '../../test-setup/augment-bun-vi.ts', + 'test-setup-storage-isolation.ts', + ], + }, + { + root: 'ide-integration', + preload: [ + '../../test-setup/augment-bun-vi.ts', + 'test-setup-storage-isolation.ts', + 'test-setup.ts', + ], + }, + { + root: 'vscode-ide-companion', + preload: [ + '../../test-setup/augment-bun-vi.ts', + 'test-setup-storage-isolation.ts', + ], + tsconfig: 'tsconfig.bun-test.json', + }, + { + root: 'policy', + preload: ['../../test-setup/augment-bun-vi.ts'], + }, + { + root: 'lsp', + }, + { + root: 'test-setup', + cwd: '.', + directories: ['test-setup'], + }, + { + root: 'scripts-tests', + cwd: '.', + directories: ['scripts/tests'], + preload: ['test-setup/augment-bun-vi.ts', 'scripts/tests/test-setup.ts'], + timeoutOverrides: [ + { pattern: /issue-2603-release-install\.test\.ts$/, timeout: 300_000 }, + ], + }, + { + root: 'evals', + cwd: 'evals', + preload: ['../test-setup/augment-bun-vi.ts'], + pattern: /\.eval\.ts$/, + globalSetup: 'globalSetup.ts', + timeout: 300_000, + credentialed: true, + }, + { + root: 'integration-tests', + cwd: 'integration-tests', + preload: ['../test-setup/augment-bun-vi.ts', 'setup-quota-guard.ts'], + globalSetup: 'globalSetup.ts', + timeout: 300_000, + retries: 2, + credentialed: true, + }, +]; + +// --------------------------------------------------------------------------- +// Default dependencies (real filesystem) +// --------------------------------------------------------------------------- + +const defaultDependencies: BunTestRootDependencies = { + stat: (path: string) => statSync(path), + readDirectory: (path: string) => readdirSync(path), + realpath: (path: string) => realpathSync(path), +}; + +// --------------------------------------------------------------------------- +// Root selection and cwd resolution +// --------------------------------------------------------------------------- + +export function resolveRootCwd(repoRoot: string, root: BunTestRoot): string { + if (root.cwd === undefined) { + return join(repoRoot, 'packages', root.root); + } + return join(repoRoot, root.cwd); +} + +/** + * Decides whether a root participates in this run. + * + * A named filter selects exactly that root, credentialed or not. An + * unfiltered run covers every root that does not require provider credentials. + */ +export function selectsRoot(root: BunTestRoot, rootFilter?: string): boolean { + if (rootFilter !== undefined) { + return root.root === rootFilter; + } + return root.credentialed !== true; +} + +// --------------------------------------------------------------------------- +// File discovery (walker) +// --------------------------------------------------------------------------- + +export function isTestFileName( + name: string, + pattern: RegExp = DEFAULT_TEST_FILE_PATTERN, +): boolean { + return pattern.test(name) && !DECLARATION_FILE_PATTERN.test(name); +} + +/** + * Canonicalizes a path, converting a failure into the module's contextual + * error so every filesystem fault in the walker is reported the same way. + */ +function resolveRealPath(path: string, deps: BunTestRootDependencies): string { + try { + return deps.realpath(path); + } catch (error: unknown) { + throw new BunTestRootStatError(path, getErrorCode(error), error); + } +} + +/** + * Walks a directory recursively, collecting absolute paths of test files. + * + * Skips `node_modules`, `dist`, `coverage`, `tmp`, `bundle`, `__snapshots__` + * and any directory starting with `.`. Files are selected purely by the + * test-file pattern, so a dot-prefixed file that matches (e.g. + * `.hidden.test.ts`) IS included. Follows directories by real path and visits + * each real path only once so a symlink cycle terminates. + * + * Filesystem errors (unreadable directory, unstattable entry, broken + * realpath) propagate as `BunTestRootStatError` so a dropped test file is + * always loud, never silent. + */ +function walkDirectory( + dir: string, + pattern: RegExp, + deps: BunTestRootDependencies, + results: string[], + visited: Set, + seenFiles: Set, +): void { + let entries: readonly string[]; + try { + entries = deps.readDirectory(dir); + } catch (error: unknown) { + throw new BunTestRootStatError(dir, getErrorCode(error), error); + } + for (const entry of entries) { + processDirectoryEntry( + dir, + entry, + pattern, + deps, + results, + visited, + seenFiles, + ); + } +} + +function processDirectoryEntry( + dir: string, + entry: string, + pattern: RegExp, + deps: BunTestRootDependencies, + results: string[], + visited: Set, + seenFiles: Set, +): void { + const fullPath = join(dir, entry); + let stats: { isFile(): boolean; isDirectory(): boolean }; + try { + stats = deps.stat(fullPath); + } catch (error: unknown) { + throw new BunTestRootStatError(fullPath, getErrorCode(error), error); + } + if (stats.isDirectory()) { + if (entry.startsWith('.') || SKIPPED_DIRECTORY_NAMES.has(entry)) { + return; + } + const realPath = resolveRealPath(fullPath, deps); + if (visited.has(realPath)) { + return; + } + visited.add(realPath); + walkDirectory(fullPath, pattern, deps, results, visited, seenFiles); + } else if (isTestFileName(entry, pattern)) { + const realFile = resolveRealPath(fullPath, deps); + if (!seenFiles.has(realFile)) { + seenFiles.add(realFile); + results.push(fullPath); + } + } +} + +/** + * Discovers test files under a single directory using the given pattern. + * Exported so tests can exercise the walker against temp fixtures. + */ +export function discoverTestFilesInDirectory( + directory: string, + pattern: RegExp, + deps: BunTestRootDependencies, +): readonly string[] { + const results: string[] = []; + const visited = new Set(); + const seenFiles = new Set(); + visited.add(resolveRealPath(directory, deps)); + walkDirectory(directory, pattern, deps, results, visited, seenFiles); + return results; +} + +// --------------------------------------------------------------------------- +// Root resolution +// --------------------------------------------------------------------------- + +function toPreloadList( + preload: string | readonly string[] | undefined, +): readonly string[] { + if (preload === undefined) { + return []; + } + return typeof preload === 'string' ? [preload] : preload; +} + +function resolveTimeoutForFile( + root: BunTestRoot, + file: string, +): number | undefined { + if (root.timeoutOverrides !== undefined) { + for (const override of root.timeoutOverrides) { + if (override.pattern.test(file)) { + return override.timeout; + } + } + } + return root.timeout; +} + +function resolveRootConfigPaths( + root: BunTestRoot, + resolvedCwd: string, +): readonly string[] { + const paths: string[] = []; + for (const preload of toPreloadList(root.preload)) { + paths.push(join(resolvedCwd, preload)); + } + if (root.tsconfig !== undefined) { + paths.push(join(resolvedCwd, root.tsconfig)); + } + if (root.globalSetup !== undefined) { + paths.push(join(resolvedCwd, root.globalSetup)); + } + return paths; +} + +/** + * Resolves a single root into its `BunTestFile` entries, discovering test + * files by walking the filesystem and validating that every declared preload, + * tsconfig, and globalSetup path exists. + * + * Exported so tests exercise root resolution against temp fixtures. + */ +export function resolveRoot( + root: BunTestRoot, + repoRoot: string, + deps: BunTestRootDependencies = defaultDependencies, +): BunTestFile[] { + const resolvedCwd = resolveRootCwd(repoRoot, root); + + for (const configPath of resolveRootConfigPaths(root, resolvedCwd)) { + validateConfigPathExists(configPath, deps); + } + + const resolvedPreloads = toPreloadList(root.preload).map((preload) => + join(resolvedCwd, preload), + ); + const resolvedTsconfig = + root.tsconfig !== undefined ? join(resolvedCwd, root.tsconfig) : undefined; + const resolvedGlobalSetup = + root.globalSetup !== undefined + ? join(resolvedCwd, root.globalSetup) + : undefined; + const pattern = root.pattern ?? DEFAULT_TEST_FILE_PATTERN; + + const scanDirectories = + root.directories !== undefined + ? root.directories.map((dir) => join(resolvedCwd, dir)) + : [resolvedCwd]; + + const discovered: string[] = []; + const visited = new Set(); + const seenFiles = new Set(); + for (const dir of scanDirectories) { + visited.add(resolveRealPath(dir, deps)); + walkDirectory(dir, pattern, deps, discovered, visited, seenFiles); + } + + if (discovered.length === 0) { + throw new Error( + `Bun test root "${root.root}" discovered no test files under ${scanDirectories.join(', ')}.`, + ); + } + + return discovered.map((file) => ({ + cwd: resolvedCwd, + file, + preloads: resolvedPreloads, + tsconfig: resolvedTsconfig, + timeout: resolveTimeoutForFile(root, file), + retries: root.retries, + globalSetup: resolvedGlobalSetup, + })); +} + +// --------------------------------------------------------------------------- +// Config-path validation (preload / tsconfig / globalSetup) +// --------------------------------------------------------------------------- + +function validateConfigPathExists( + path: string, + deps: BunTestRootDependencies, +): void { + try { + if (!deps.stat(path).isFile()) { + throw new BunTestRootStatError(path, undefined, new Error('not a file')); + } + } catch (error: unknown) { + if (error instanceof BunTestRootStatError) { + throw error; + } + const code = getErrorCode(error); + if (code === 'ENOENT') { + throw new Error( + `Bun test root declares a missing preload/config path: ${path}`, + ); + } + throw new BunTestRootStatError(path, code, error); + } +} + +// --------------------------------------------------------------------------- +// Public resolver +// --------------------------------------------------------------------------- + +/** + * Resolves the set of `BunTestFile` entries for the given repository root. + * + * When `rootFilter` is omitted, every non-credentialed root is resolved. + * When provided, only the matching root is resolved (credentialed or not). + * Directory walking and file-stats use `deps` (defaulting to the real + * filesystem) so tests exercise the resolver against temp fixtures. + */ +export function resolveBunTestFiles( + repoRoot: string, + rootFilter?: string, + deps: BunTestRootDependencies = defaultDependencies, +): BunTestFile[] { + const files = BUN_TEST_ROOTS.filter((root) => + selectsRoot(root, rootFilter), + ).flatMap((root) => resolveRoot(root, repoRoot, deps)); + return [...files].sort((left, right) => left.file.localeCompare(right.file)); +} diff --git a/scripts/check-affected-test-shards.ts b/scripts/check-affected-test-shards.ts index 4a50c96039..ddea1d79ba 100644 --- a/scripts/check-affected-test-shards.ts +++ b/scripts/check-affected-test-shards.ts @@ -40,7 +40,7 @@ const DEFAULT_REPO_ROOT = resolve(__dirname, '..'); const DEFAULT_DATA_PATH = join(__dirname, 'affected-test-shards.data.json'); const PACKAGE_PREFIX = '@vybestack/llxprt-code-'; -// `test-bun/` holds Bun-native suites registered in scripts/bun-test-manifest.ts. +// `test-bun/` holds Bun-native suites discovered by scripts/bun-test-roots.ts. // They are tests by construction but live outside `src/`, so without this they // would be read as production code and their imports misclassified. const TEST_PATH_RE = diff --git a/scripts/check-test-file-coverage.ts b/scripts/check-test-file-coverage.ts new file mode 100644 index 0000000000..81c726ac39 --- /dev/null +++ b/scripts/check-test-file-coverage.ts @@ -0,0 +1,307 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Repository-wide test-file coverage guard (issue #2979, AC7 + AC8). + * + * Every CI executor that runs tests discovers its files by walking the + * filesystem. This module derives the set of files each executor actually + * runs from the executors' OWN discovery code — the shared root resolver + * (`resolveBunTestFiles`) and the bespoke workspace runners — then reports: + * + * - **uncovered** files: a test file on disk that no executor runs (AC8); + * - **doubly-executed** files: a file two executors both run (AC7). + * + * There is deliberately no allowlist, ignore list, or "expected uncovered" + * set anywhere. If the real-repository assertions fail, the offending files + * are a genuine finding and the root cause must be fixed (broaden a runner's + * pattern, add a missing scanned directory, or remove a real duplicate). + */ + +import { readdirSync, realpathSync, statSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + BUN_TEST_ROOTS, + type BunTestRootDependencies, + DEFAULT_TEST_FILE_PATTERN, + discoverTestFilesInDirectory, + resolveBunTestFiles, +} from './bun-test-roots.js'; +import { discoverTestFiles as discoverCliTestFiles } from '../packages/cli/run-bun-tests.js'; +import { discoverTestFiles as discoverCoreTestFiles } from '../packages/core/run-bun-tests.js'; +import { discoverTestFiles as discoverAgentsTestFiles } from '../packages/agents/run-bun-tests.js'; +import { discoverTestFiles as discoverAuthTestFiles } from '../packages/auth/run-bun-tests.js'; + +// --------------------------------------------------------------------------- +// Dependencies (real filesystem, injectable for tests) +// --------------------------------------------------------------------------- + +const defaultDependencies: BunTestRootDependencies = { + stat: (path: string) => statSync(path), + readDirectory: (path: string) => readdirSync(path), + realpath: (path: string) => realpathSync(path), +}; + +// --------------------------------------------------------------------------- +// Patterns +// --------------------------------------------------------------------------- + +/** + * Every test file the repository considers a test, for the purpose of the + * coverage guard. This is the shared runner's union pattern (`*.test`, + * `*.spec`, `*.bun` across `ts`/`tsx`/`js`) plus the `*.eval.ts` suites the + * credentialed `evals` root runs under its own pattern. + */ +const REPOSITORY_TEST_FILE_PATTERN = new RegExp( + `${DEFAULT_TEST_FILE_PATTERN.source}|\\.eval\\.ts$`, +); + +// --------------------------------------------------------------------------- +// Executor model +// --------------------------------------------------------------------------- + +/** + * One CI test executor and the absolute paths it would run for a given + * repository root. Each `discover` function delegates to the executor's real + * discovery code so the guard can never drift out of sync with what CI runs. + */ +export interface TestExecutor { + readonly name: string; + readonly discover: (repoRoot: string) => readonly string[]; +} + +/** + * The shared Bun test runner resolves every root in the root table. Credentialed + * roots (`evals`, `integration-tests`) execute too — in dedicated workflows — + * so they count as covered and are resolved here by name. + */ +function discoverSharedRunnerFiles(repoRoot: string): readonly string[] { + return BUN_TEST_ROOTS.flatMap((root) => + resolveBunTestFiles(repoRoot, root.root), + ).map((entry) => entry.file); +} + +/** + * The complete table of executors. The covered set is the union of what every + * entry here discovers. + */ +export const TEST_EXECUTORS: readonly TestExecutor[] = [ + { + name: 'shared Bun test runner (scripts/run_bun_tests.ts)', + discover: discoverSharedRunnerFiles, + }, + { + name: 'packages/cli test script (run-bun-tests.ts)', + // The CLI runner (the reference bespoke runner) returns paths relative to + // its workspace root; resolve them to absolute so they line up with the + // repository walker and the other executors. + discover: (repoRoot: string): readonly string[] => { + const workspace = join(repoRoot, 'packages', 'cli'); + return discoverCliTestFiles(workspace).map((file) => + resolve(workspace, file), + ); + }, + }, + { + name: 'packages/core test script (run-bun-tests.ts)', + discover: (repoRoot: string): readonly string[] => + discoverCoreTestFiles(join(repoRoot, 'packages', 'core')), + }, + { + name: 'packages/agents test script (run-bun-tests.ts)', + discover: (repoRoot: string): readonly string[] => + discoverAgentsTestFiles(join(repoRoot, 'packages', 'agents')), + }, + { + name: 'packages/auth test script (run-bun-tests.ts)', + discover: (repoRoot: string): readonly string[] => + discoverAuthTestFiles(join(repoRoot, 'packages', 'auth')), + }, +]; + +// --------------------------------------------------------------------------- +// Repository walk +// --------------------------------------------------------------------------- + +/** + * Walks the whole repository for test files, skipping build output and + * artifact directories (`node_modules`, `dist`, `coverage`, `bundle`, `tmp`, + * `__snapshots__` and any dot-prefixed directory — this also excludes the + * `.integration-tests/` recording directory). Reuses the shared walker so + * there is a single definition of "skip these directories". + * + * Paths are canonicalized so one real file has exactly one coverage identity, + * matching how executor claims are recorded. + */ +export function discoverRepositoryTestFiles( + repoRoot: string, + deps: BunTestRootDependencies = defaultDependencies, +): readonly string[] { + const files = discoverTestFilesInDirectory( + repoRoot, + REPOSITORY_TEST_FILE_PATTERN, + deps, + ); + return [...files].map((file) => deps.realpath(file)).sort(); +} + +/** + * Collects the union of absolute paths every executor claims, preserving each + * file's claimants so duplicate execution can be reported. + * + * Paths are canonicalized so one real file has exactly one coverage identity: + * without it, a file reached through a symlink alias would look uncovered on + * one side of the comparison and a duplicate would go unnoticed on the other. + * `discoverRepositoryTestFiles` canonicalizes the same way. + */ +function collectExecutorClaims( + repoRoot: string, + executors: readonly TestExecutor[], + deps: BunTestRootDependencies, +): { readonly files: Set; readonly counts: Map } { + const files = new Set(); + const counts = new Map(); + for (const executor of executors) { + for (const discovered of executor.discover(repoRoot)) { + const file = deps.realpath(discovered); + files.add(file); + const claimants = counts.get(file); + if (claimants === undefined) { + counts.set(file, [executor.name]); + } else { + claimants.push(executor.name); + } + } + } + return { files, counts }; +} + +/** + * Returns, sorted, every repository test file that no executor claims. + */ +export function findUncoveredTestFiles( + repoRoot: string, + deps: BunTestRootDependencies = defaultDependencies, + executors: readonly TestExecutor[] = TEST_EXECUTORS, +): readonly string[] { + const onDisk = discoverRepositoryTestFiles(repoRoot, deps); + const { files: covered } = collectExecutorClaims(repoRoot, executors, deps); + return [...onDisk].filter((file) => !covered.has(file)).sort(); +} + +/** A test file claimed by more than one executor. */ +export interface DoublyExecutedFile { + readonly file: string; + readonly executors: readonly string[]; +} + +/** + * Returns, sorted by path, every file claimed by more than one executor, each + * with the sorted list of executors that claim it. + * + * This reads only the executors' own discovery, never the repository walk, but + * still canonicalizes their paths so two aliases of one real file are reported + * as the duplicate they are. + */ +export function findDoublyExecutedTestFiles( + repoRoot: string, + executors: readonly TestExecutor[] = TEST_EXECUTORS, + deps: BunTestRootDependencies = defaultDependencies, +): readonly DoublyExecutedFile[] { + return toDoublyExecuted( + collectExecutorClaims(repoRoot, executors, deps).counts, + ); +} + +function toDoublyExecuted( + counts: Map, +): readonly DoublyExecutedFile[] { + const duplicates: DoublyExecutedFile[] = []; + for (const [file, claimants] of counts) { + if (claimants.length > 1) { + duplicates.push({ + file, + executors: [...claimants].sort(), + }); + } + } + return duplicates.sort((left, right) => left.file.localeCompare(right.file)); +} + +/** Both findings, computed from a single pass over every executor. */ +export interface TestFileCoverageReport { + readonly uncovered: readonly string[]; + readonly doublyExecuted: readonly DoublyExecutedFile[]; +} + +/** + * Computes both findings in one pass. Each executor's discovery walks the + * filesystem, so running it once rather than once per finding matters. + */ +export function analyzeTestFileCoverage( + repoRoot: string, + deps: BunTestRootDependencies = defaultDependencies, + executors: readonly TestExecutor[] = TEST_EXECUTORS, +): TestFileCoverageReport { + const onDisk = discoverRepositoryTestFiles(repoRoot, deps); + const { files: covered, counts } = collectExecutorClaims( + repoRoot, + executors, + deps, + ); + return { + uncovered: [...onDisk].filter((file) => !covered.has(file)).sort(), + doublyExecuted: toDoublyExecuted(counts), + }; +} + +// --------------------------------------------------------------------------- +// Executable guard +// --------------------------------------------------------------------------- + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const DEFAULT_REPO_ROOT = resolve(SCRIPT_DIR, '..'); + +/** + * Guard entry point: fails (non-zero exit) when any test file on disk is + * uncovered or claimed by more than one executor, prints every finding, and + * prints a concise success line otherwise. + */ +function main(): void { + const repoRoot = DEFAULT_REPO_ROOT; + + const { uncovered, doublyExecuted } = analyzeTestFileCoverage(repoRoot); + + if (uncovered.length > 0) { + console.error('test-file coverage guard FAILED — uncovered files:'); + for (const file of uncovered) { + console.error(` ${file}`); + } + } + + if (doublyExecuted.length > 0) { + console.error('test-file coverage guard FAILED — doubly-executed files:'); + for (const entry of doublyExecuted) { + console.error(` ${entry.file} ← ${entry.executors.join(', ')}`); + } + } + + if (uncovered.length > 0 || doublyExecuted.length > 0) { + process.exit(1); + } + + console.log( + 'test-file coverage guard PASSED: zero uncovered, zero doubly-executed.', + ); +} + +if ( + process.argv[1] !== undefined && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + main(); +} diff --git a/scripts/run_bun_tests.ts b/scripts/run_bun_tests.ts index 44af2e8d71..4cff9dd584 100644 --- a/scripts/run_bun_tests.ts +++ b/scripts/run_bun_tests.ts @@ -11,12 +11,11 @@ * A fresh process per file preserves the isolation expected by the existing * workspace suites while still executing every test with Bun's native runner. * - * **Important**: every executed file comes from a root declared in - * `scripts/bun-test-manifest.ts`. A root either curates an explicit `files` - * list (used while a workspace is only partly migrated, where Bun's - * module-lifecycle differences still block some files) or declares `include` - * globs (used once a root is fully migrated, so a newly added test file runs - * automatically and cannot be silently dropped). + * **Important**: every executed file is discovered by walking the filesystem + * from roots declared in `scripts/bun-test-roots.ts`. A root declares the + * directories to scan and the execution settings (preload, tsconfig, timeout, + * retries, globalSetup). There is no allowlist: a newly added test file is + * picked up automatically and can never be silently dropped. * * Usage: * bun scripts/run_bun_tests.ts [options] @@ -41,10 +40,7 @@ import { } from 'node:fs'; import { dirname, resolve, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { - resolveBunNativeTestFiles, - type BunTestFile, -} from './bun-test-manifest.js'; +import { resolveBunTestFiles, type BunTestFile } from './bun-test-roots.js'; import { buildVitestJsonReport, parseJUnitXml, @@ -418,7 +414,7 @@ export interface BunTestSpawnOptions { } /** - * Shape of a manifest `globalSetup` module. Both hooks are optional so a root + * Shape of a root `globalSetup` module. Both hooks are optional so a root * can declare setup-only or teardown-only behaviour. */ export interface BunGlobalSetupModule { @@ -449,7 +445,7 @@ export interface BunTestRunnerDependencies { } /** - * Builds the full spawn args for a single Bun test file. The manifest entry + * Builds the full spawn args for a single Bun test file. The root entry * may override the tsconfig and the per-test timeout, and may declare any * number of preload scripts (the Bun-native equivalent of Vitest's * `setupFiles`). @@ -578,7 +574,7 @@ function runSingleTestFile( /** * Collects the distinct global-setup modules declared by the selected files, - * preserving manifest order so setup runs in a deterministic sequence. + * preserving root order so setup runs in a deterministic sequence. */ export function collectGlobalSetups( files: readonly BunTestFile[], @@ -647,9 +643,7 @@ export async function runBunTests( ? `workspace "${options.workspace}"` : 'any workspace'; dependencies.stderr(`No native Bun test files found for ${scope}.`); - dependencies.stderr( - 'Roots must be declared in scripts/bun-test-manifest.ts.', - ); + dependencies.stderr('Roots must be declared in scripts/bun-test-roots.ts.'); return 1; } @@ -991,7 +985,7 @@ async function main(): Promise { invocationDirectory: process.cwd(), executable: process.execPath, environment: process.env, - resolveFiles: resolveBunNativeTestFiles, + resolveFiles: resolveBunTestFiles, resolveTsconfig: resolveTsconfigOverride, loadGlobalSetup: async (path) => (await import(pathToFileURL(path).href)) as BunGlobalSetupModule, diff --git a/scripts/test.ts b/scripts/test.ts index 39b23d4e49..a15627437d 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -247,21 +247,21 @@ function createRunnerWithPATH(rootDir: string): CommandRunner { // Orchestration // --------------------------------------------------------------------------- -// The release-install smoke test (issue #2603) takes ~175–195s because it -// packs a CLI tarball and runs three npm installs, far beyond the budget the -// rest of the script harness needs. It therefore lives in its own Bun-native -// root with a much larger timeout (issue #2780). +// The release-install smoke (issue #2603) takes ~175–195s because it packs a +// CLI tarball and runs three npm installs, far beyond the budget the rest of +// the script harness needs. It is no longer a separate root: it is discovered +// alongside every other scripts/tests file and receives its larger timeout via +// a per-file timeout override on the scripts-tests root (issue #2780). /** * Bun-native roots owned by the scripts shard, in execution order. * * These belong to no workspace, so nothing else would run them. Exported as - * the single source of truth: `scripts/tests/bun-manifest-root-ownership.bun.test.ts` - * reads this list to prove every manifest root has exactly one executor, and - * the root `test:scripts` script delegates here rather than restating it. + * the single source of truth: `scripts/tests/bun-test-root-ownership.bun.test.ts` + * reads this list to prove every root has exactly one executor, and the root + * `test:scripts` script delegates here rather than restating it. */ export const SCRIPTS_SHARD_ROOTS: readonly string[] = [ 'scripts-tests', - 'scripts-tests-slow', 'test-setup', ]; @@ -476,9 +476,9 @@ function runScriptTests( if (!existsSync(join(rootDir, SCRIPTS_TEST_DIRECTORY))) { return; } - // Each root runs as its own invocation so a root with a much larger budget - // (the release-install smoke, issue #2780) cannot weaken the timeout that - // catches hangs in the rest of the harness. Fail-fast between roots. + // Each root runs as its own invocation so a root declaring a much larger + // budget cannot weaken the timeout that catches hangs in another root. + // Fail-fast between roots. for (const root of SCRIPTS_SHARD_ROOTS) { const result = runPhase( 'scripts', diff --git a/scripts/tests/bun-test-manifest.bun.test.ts b/scripts/tests/bun-test-manifest.bun.test.ts deleted file mode 100644 index cc073b3b50..0000000000 --- a/scripts/tests/bun-test-manifest.bun.test.ts +++ /dev/null @@ -1,338 +0,0 @@ -/** - * @license - * Copyright 2026 Vybestack LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { afterEach, describe, expect, it } from 'bun:test'; -import { mkdirSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; -import { - BUN_NATIVE_TEST_MANIFEST, - BunManifestStatError, - resolveBunNativeTestFiles, - resolveEntryFileNames, - resolveWorkspaceCwd, - selectsEntry, -} from '../bun-test-manifest.js'; - -const repoRoot = resolve(__dirname, '..', '..'); -const temporaryRoots: string[] = []; - -/** Stat/glob stubs for tests that must never touch the real filesystem. */ -const throwingStat = (error: unknown) => ({ - stat: (): never => { - throw error; - }, - glob: (): readonly string[] => [], -}); - -afterEach(() => { - for (const root of temporaryRoots.splice(0)) { - rmSync(root, { recursive: true, force: true }); - } -}); - -describe('Bun native test manifest', () => { - it('gates the exact test-setup native suite', () => { - expect( - BUN_NATIVE_TEST_MANIFEST.find( - ({ workspace }) => workspace === 'test-setup', - ), - ).toEqual({ - workspace: 'test-setup', - cwd: '.', - files: [ - 'test-setup/augment-bun-vi.test.ts', - 'test-setup/stub-helpers.bun.test.ts', - // Vitest-parity guarantees relied on by every migrated workspace - // (issue #2843). - 'test-setup/vitest-parity.test.ts', - ], - }); - }); - - it('resolves every advertised workspace to verified files', () => { - for (const entry of BUN_NATIVE_TEST_MANIFEST) { - const workspace = entry.workspace; - const files = resolveBunNativeTestFiles(repoRoot, workspace); - expect(files.length, workspace).toBeGreaterThan(0); - const expectedCwd = resolveWorkspaceCwd(repoRoot, workspace, entry.cwd); - expect( - files.every(({ cwd }) => cwd === expectedCwd), - workspace, - ).toBe(true); - } - }); - - it('retains the core CI characterization sample', () => { - const files = resolveBunNativeTestFiles(repoRoot, 'core'); - expect(files.map(({ file }) => file)).toContain( - resolve(repoRoot, 'packages/core/src/utils/errors.test.ts'), - ); - }); - - it('keeps known unsupported CLI tests outside the supported set', () => { - const files = resolveBunNativeTestFiles(repoRoot, 'cli').map( - ({ file }) => file, - ); - expect(files.some((file) => file.endsWith('coreToolToggle.test.ts'))).toBe( - false, - ); - expect(files.some((file) => file.includes('useToolScheduler'))).toBe(false); - }); - - it('contains only nonempty workspace entries and existing files', () => { - for (const entry of BUN_NATIVE_TEST_MANIFEST) { - expect( - resolveBunNativeTestFiles(repoRoot, entry.workspace).length, - entry.workspace, - ).toBeGreaterThan(0); - } - - // A workspace may be declared by more than one entry, and an entry may - // derive its files from globs rather than a curated list. Only the - // curated entries have a count that can be predicted here. - for (const workspace of new Set( - BUN_NATIVE_TEST_MANIFEST.map(({ workspace }) => workspace), - )) { - const entries = BUN_NATIVE_TEST_MANIFEST.filter( - (entry) => entry.workspace === workspace, - ); - if (entries.some((entry) => entry.files === undefined)) { - continue; - } - const expectedFileCount = entries.reduce( - (total, entry) => total + (entry.files?.length ?? 0), - 0, - ); - expect(resolveBunNativeTestFiles(repoRoot, workspace)).toHaveLength( - expectedFileCount, - ); - } - }); - - it('declares exactly one of files or include for every entry', () => { - for (const entry of BUN_NATIVE_TEST_MANIFEST) { - expect( - (entry.files === undefined) !== (entry.include === undefined), - entry.workspace, - ).toBe(true); - } - }); - - it('returns an empty set for an unknown workspace', () => { - expect(resolveBunNativeTestFiles(repoRoot, 'unknown')).toEqual([]); - }); - - it('fails when a selected manifest file is missing', () => { - const missingRepoRoot = resolve(repoRoot, 'definitely-missing-repository'); - - expect(() => resolveBunNativeTestFiles(missingRepoRoot, 'core')).toThrow( - 'Bun native test manifest contains missing files', - ); - }); - - it('classifies only ENOENT as a missing manifest path', () => { - const cause = Object.assign(new Error('missing'), { - code: 'ENOENT', - path: '/cause/path', - }); - - expect(() => - resolveBunNativeTestFiles('/fixture', 'core', throwingStat(cause)), - ).toThrow('Bun native test manifest contains missing files'); - }); - - it('preserves path, code, and cause for non-ENOENT stat failures', () => { - const cause = Object.assign(new Error('permission denied'), { - code: 'EACCES', - path: '/cause/path', - }); - let thrown: unknown; - - try { - resolveBunNativeTestFiles('/fixture', 'core', throwingStat(cause)); - } catch (error: unknown) { - thrown = error; - } - - expect(thrown).toBeInstanceOf(BunManifestStatError); - if (!(thrown instanceof BunManifestStatError)) { - throw new Error('Expected BunManifestStatError'); - } - // Built with `join` so the expectation uses native separators; the - // resolver joins the same way, so a literal POSIX path only matches - // on POSIX platforms. - expect(thrown.path).toBe( - join('/fixture', 'packages/core/src/utils/errors.test.ts'), - ); - expect(thrown.code).toBe('EACCES'); - expect(thrown.cause).toBe(cause); - }); - - it('rejects a manifest path that exists but is not a regular file', () => { - const fixtureRoot = join( - tmpdir(), - `bun-test-manifest-directory-${process.pid}-${Date.now()}`, - ); - temporaryRoots.push(fixtureRoot); - // Every declared 'core' path must exist as a directory, otherwise the - // missing-file check fires before the non-file check under assertion. - for (const entry of BUN_NATIVE_TEST_MANIFEST.filter( - (candidate) => candidate.workspace === 'core', - )) { - // Glob roots declare `include` instead of `files`; `core` is curated, so - // this is defensive against the shared type rather than a live case. - for (const file of entry.files ?? []) { - mkdirSync(join(fixtureRoot, 'packages/core', file), { - recursive: true, - }); - } - } - - expect(() => resolveBunNativeTestFiles(fixtureRoot, 'core')).toThrow( - 'Bun native test manifest contains non-files', - ); - }); -}); - -describe('selectsEntry', () => { - it('includes an ordinary root in an unfiltered run', () => { - expect( - selectsEntry({ workspace: 'core', files: ['a.test.ts'] }, undefined), - ).toBe(true); - }); - - it('excludes a credentialed root from an unfiltered run', () => { - expect( - selectsEntry( - { workspace: 'evals', files: ['a.eval.ts'], credentialed: true }, - undefined, - ), - ).toBe(false); - }); - - it('includes a credentialed root when it is requested by name', () => { - expect( - selectsEntry( - { workspace: 'evals', files: ['a.eval.ts'], credentialed: true }, - 'evals', - ), - ).toBe(true); - }); - - it('excludes any root that is not the named one', () => { - expect( - selectsEntry({ workspace: 'core', files: ['a.test.ts'] }, 'cli'), - ).toBe(false); - }); -}); - -describe('resolveEntryFileNames', () => { - const globDependencies = (matches: Record) => ({ - stat: () => ({ isFile: () => true }), - glob: (pattern: string): readonly string[] => matches[pattern] ?? [], - }); - - it('returns a curated file list verbatim', () => { - expect( - resolveEntryFileNames( - { workspace: 'w', files: ['b.test.ts', 'a.test.ts'] }, - '/root', - globDependencies({}), - ), - ).toEqual(['b.test.ts', 'a.test.ts']); - }); - - it('expands include globs and sorts the result', () => { - expect( - resolveEntryFileNames( - { workspace: 'w', include: ['**/*.test.ts'] }, - '/root', - globDependencies({ '**/*.test.ts': ['b.test.ts', 'a.test.ts'] }), - ), - ).toEqual(['a.test.ts', 'b.test.ts']); - }); - - it('removes exclude matches from the include result', () => { - expect( - resolveEntryFileNames( - { - workspace: 'w', - include: ['**/*.test.ts'], - exclude: ['**/*.bun.test.ts'], - }, - '/root', - globDependencies({ - '**/*.test.ts': ['a.test.ts', 'b.bun.test.ts'], - '**/*.bun.test.ts': ['b.bun.test.ts'], - }), - ), - ).toEqual(['a.test.ts']); - }); - - it('deduplicates files matched by more than one include pattern', () => { - expect( - resolveEntryFileNames( - { workspace: 'w', include: ['a*.ts', '*.test.ts'] }, - '/root', - globDependencies({ - 'a*.ts': ['a.test.ts'], - '*.test.ts': ['a.test.ts', 'b.test.ts'], - }), - ), - ).toEqual(['a.test.ts', 'b.test.ts']); - }); - - it('rejects an entry declaring both files and include', () => { - expect(() => - resolveEntryFileNames( - { workspace: 'w', files: ['a.test.ts'], include: ['*.test.ts'] }, - '/root', - globDependencies({}), - ), - ).toThrow('declares both "files" and "include"'); - }); - - it('rejects an entry declaring neither files nor include', () => { - expect(() => - resolveEntryFileNames({ workspace: 'w' }, '/root', globDependencies({})), - ).toThrow('declares neither "files" nor "include"'); - }); - - it('fails loudly when include globs match nothing', () => { - expect(() => - resolveEntryFileNames( - { workspace: 'w', include: ['**/*.test.ts'] }, - '/root', - globDependencies({}), - ), - ).toThrow('matched no test files'); - }); -}); - -describe('resolveWorkspaceCwd', () => { - it('resolves undefined cwd to packages/', () => { - expect(resolveWorkspaceCwd(repoRoot, 'core', undefined)).toBe( - join(repoRoot, 'packages', 'core'), - ); - }); - - it('resolves empty string cwd to the repo root', () => { - expect(resolveWorkspaceCwd(repoRoot, 'core', '')).toBe(repoRoot); - }); - - it("resolves '.' cwd to the repo root via join", () => { - expect(resolveWorkspaceCwd(repoRoot, 'core', '.')).toBe( - join(repoRoot, '.'), - ); - }); - - it('resolves a relative cwd by joining under repo root', () => { - expect(resolveWorkspaceCwd(repoRoot, 'core', 'test-setup')).toBe( - join(repoRoot, 'test-setup'), - ); - }); -}); diff --git a/scripts/tests/bun-manifest-root-ownership.bun.test.ts b/scripts/tests/bun-test-root-ownership.bun.test.ts similarity index 53% rename from scripts/tests/bun-manifest-root-ownership.bun.test.ts rename to scripts/tests/bun-test-root-ownership.bun.test.ts index 87bbcea9d6..79a8fca266 100644 --- a/scripts/tests/bun-manifest-root-ownership.bun.test.ts +++ b/scripts/tests/bun-test-root-ownership.bun.test.ts @@ -10,17 +10,14 @@ * The sharded matrix runs each workspace's own `test` script plus the root * `test:scripts`; that is the only thing that executes tests. A root nobody * runs would silently never execute, and a root two scripts run would burn CI - * twice for no signal. This is the guarantee that lets the parity job stop - * re-running the whole manifest. + * twice for no signal. */ import { describe, expect, it } from 'bun:test'; import { readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; -import { - BUN_NATIVE_TEST_MANIFEST, - type BunTestWorkspaceEntry, -} from '../bun-test-manifest.ts'; +import { BUN_TEST_ROOTS, type BunTestRoot } from '../bun-test-roots.js'; +import { TEST_EXECUTORS } from '../check-test-file-coverage.js'; import { SCRIPTS_SHARD_ROOTS, scriptsRootCommand } from '../test.ts'; const repoRoot = resolve(import.meta.dir, '..', '..'); @@ -36,21 +33,6 @@ function readPackageJson(path: string): PackageJson { const rootPackage = readPackageJson(join(repoRoot, 'package.json')); -/** - * Roots whose files are executed by a workspace's own bespoke Bun runner - * rather than the shared manifest runner, so no `--workspace` token appears. - * Each entry names the script that covers it. - */ -const COVERED_BY_BESPOKE_RUNNER: Readonly> = { - // packages/core/run-bun-tests.ts scans src/ and test/, which includes the - // single file the manifest lists for `core`. - core: 'packages/core/run-bun-tests.ts', - // packages/cli/run-bun-tests.ts discovers every test file in the workspace - // (issue #2843), which subsumes the files the manifest used to list for - // `cli`. - cli: 'packages/cli/run-bun-tests.ts', -}; - /** * Every command the sharded CI matrix actually executes. * @@ -79,38 +61,77 @@ function scriptsRunning(root: string): readonly string[] { ); } -const offlineRoots: readonly BunTestWorkspaceEntry[] = - BUN_NATIVE_TEST_MANIFEST.filter((entry) => entry.credentialed !== true); +const offlineRoots: readonly BunTestRoot[] = BUN_TEST_ROOTS.filter( + (root) => root.credentialed !== true, +); -describe('Bun-native manifest root ownership', () => { +describe('Bun-native test root ownership', () => { it('has at least one root to check', () => { expect(offlineRoots.length).toBeGreaterThan(0); }); for (const entry of offlineRoots) { - const bespoke = COVERED_BY_BESPOKE_RUNNER[entry.workspace]; - - it(`runs the "${entry.workspace}" root exactly once`, () => { - const runners = scriptsRunning(entry.workspace); - if (bespoke !== undefined) { - // A bespoke runner already covers these files; the shared runner must - // not also run them, or they would execute twice in the same shard. - expect(runners).toEqual([]); - return; - } + it(`runs the "${entry.root}" root exactly once`, () => { + const runners = scriptsRunning(entry.root); expect(runners).toHaveLength(1); }); } it('runs every credentialed root only on explicit request', () => { - const credentialed = BUN_NATIVE_TEST_MANIFEST.filter( + const credentialed = BUN_TEST_ROOTS.filter( (entry) => entry.credentialed === true, ); expect(credentialed.length).toBeGreaterThan(0); for (const entry of credentialed) { // Credentialed roots call a real provider, so no workspace `test` script // may pull them into the offline gate. - expect(scriptsRunning(entry.workspace)).toEqual([]); + expect(scriptsRunning(entry.root)).toEqual([]); } }); }); + +// --------------------------------------------------------------------------- +// Bespoke-runner executors must still be wired in their workspace test scripts +// --------------------------------------------------------------------------- + +/** + * Extracts the workspace name from a bespoke-runner executor name of the form + * `packages/ test script (run-bun-tests.ts)`. Returns undefined for + * executors that do not name a bespoke runner. + */ +function workspaceFromBespokeExecutor(name: string): string | undefined { + const match = + /^packages\/([a-z0-9-]+) test script \(run-bun-tests\.ts\)$/.exec(name); + return match?.[1]; +} + +interface BespokeWorkspace { + readonly name: string; + readonly workspace: string; +} + +const bespokeRunnerWorkspaces: readonly BespokeWorkspace[] = TEST_EXECUTORS.map( + (executor) => ({ + name: executor.name, + workspace: workspaceFromBespokeExecutor(executor.name), + }), +).filter((entry): entry is BespokeWorkspace => entry.workspace !== undefined); + +describe('bespoke-runner executors are wired by their workspace test script', () => { + it('has at least one bespoke runner executor to check', () => { + expect(bespokeRunnerWorkspaces.length).toBeGreaterThan(0); + }); + + for (const { name, workspace } of bespokeRunnerWorkspaces) { + it(`asserts packages/${workspace} test script invokes run-bun-tests.ts`, () => { + const pkg = readPackageJson( + join(repoRoot, 'packages', workspace, 'package.json'), + ); + const testScript = pkg.scripts?.['test'] ?? ''; + expect( + testScript, + `${name}: packages/${workspace} test script must invoke run-bun-tests.ts`, + ).toContain('run-bun-tests'); + }); + } +}); diff --git a/scripts/tests/bun-test-roots.bun.test.ts b/scripts/tests/bun-test-roots.bun.test.ts new file mode 100644 index 0000000000..d3ca2bf041 --- /dev/null +++ b/scripts/tests/bun-test-roots.bun.test.ts @@ -0,0 +1,637 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { + mkdirSync, + mkdtempSync, + readdirSync, + realpathSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { + BUN_TEST_ROOTS, + type BunTestRoot, + type BunTestRootDependencies, + BunTestRootStatError, + DEFAULT_TEST_FILE_PATTERN, + discoverTestFilesInDirectory, + getErrorCode, + isTestFileName, + resolveBunTestFiles, + resolveRoot, + resolveRootCwd, + selectsRoot, +} from '../bun-test-roots.js'; + +const repoRoot = resolve(import.meta.dir, '..', '..'); + +const realDeps: BunTestRootDependencies = { + stat: (path) => statSync(path), + readDirectory: (path) => readdirSync(path), + realpath: (path) => realpathSync(path), +}; + +/** + * Shared temp-directory helper. Registers its own beforeEach/afterEach hooks + * and returns a lazy accessor, so each describe block only needs one line of + * setup (RULES.md "DRY setup"). + */ +function useTempDir(): () => string { + let dir = ''; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'bun-test-roots-')); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + return () => { + if (dir === '') { + throw new Error('Temp directory accessed outside its lifecycle'); + } + return dir; + }; +} + +/** Creates a minimal test fixture: tempDir/src/a.test.ts. */ +function writeFixture(dir: string, relative: string, content = ''): string { + const fullPath = join(dir, relative); + mkdirSync(dirname(fullPath), { recursive: true }); + writeFileSync(fullPath, content); + return fullPath; +} + +// --------------------------------------------------------------------------- +// Root table structural guarantees (AC1) +// --------------------------------------------------------------------------- + +describe('BUN_TEST_ROOTS structural guarantees', () => { + it('exposes no files, include, or exclude member on any root', () => { + for (const root of BUN_TEST_ROOTS) { + expect( + 'files' in root || 'include' in root || 'exclude' in root, + `root "${root.root}" must not declare files/include/exclude`, + ).toBe(false); + } + }); + + it('has exactly the expected set of root tokens', () => { + const tokens = BUN_TEST_ROOTS.map((r) => r.root); + expect(tokens).toEqual([ + 'a2a-server', + 'agents', + 'providers', + 'tools', + 'mcp', + 'telemetry', + 'storage', + 'test-utils', + 'settings', + 'ide-integration', + 'vscode-ide-companion', + 'policy', + 'lsp', + 'test-setup', + 'scripts-tests', + 'evals', + 'integration-tests', + ]); + }); + + it('marks exactly the credentialed roots', () => { + const credentialed = BUN_TEST_ROOTS.filter((r) => r.credentialed === true); + expect(credentialed.map((r) => r.root).sort()).toEqual([ + 'evals', + 'integration-tests', + ]); + }); +}); + +// --------------------------------------------------------------------------- +// cwd resolution (§3.4) +// --------------------------------------------------------------------------- + +describe('resolveRootCwd', () => { + it('resolves undefined cwd to packages/', () => { + expect(resolveRootCwd('/repo', { root: 'core' })).toBe( + join('/repo', 'packages', 'core'), + ); + }); + + it("resolves '.' cwd to the repo root", () => { + expect(resolveRootCwd('/repo', { root: 'scripts', cwd: '.' })).toBe( + join('/repo', '.'), + ); + }); + + it('resolves a relative cwd by joining under the repo root', () => { + expect(resolveRootCwd('/repo', { root: 'evals', cwd: 'evals' })).toBe( + join('/repo', 'evals'), + ); + }); +}); + +// --------------------------------------------------------------------------- +// Root selection (§3.5) +// --------------------------------------------------------------------------- + +describe('selectsRoot', () => { + it('includes an ordinary root in an unfiltered run', () => { + expect(selectsRoot({ root: 'core' }, undefined)).toBe(true); + }); + + it('excludes a credentialed root from an unfiltered run', () => { + expect(selectsRoot({ root: 'evals', credentialed: true }, undefined)).toBe( + false, + ); + }); + + it('includes a credentialed root when named explicitly', () => { + expect(selectsRoot({ root: 'evals', credentialed: true }, 'evals')).toBe( + true, + ); + }); + + it('excludes any root that is not the named one', () => { + expect(selectsRoot({ root: 'core' }, 'cli')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// File discovery (walker) +// --------------------------------------------------------------------------- + +describe('discoverTestFilesInDirectory', () => { + const getDir = useTempDir(); + + it('resolves test files in nested directories', () => { + writeFixture(getDir(), 'src/deep/a.test.ts'); + writeFixture(getDir(), 'src/other/b.spec.ts'); + + const results = discoverTestFilesInDirectory( + getDir(), + DEFAULT_TEST_FILE_PATTERN, + realDeps, + ); + + expect(results).toHaveLength(2); + expect(results.some((f) => f.endsWith('a.test.ts'))).toBe(true); + expect(results.some((f) => f.endsWith('b.spec.ts'))).toBe(true); + }); + + it('does not resolve files under dist, node_modules, or dotted directories', () => { + writeFixture(getDir(), 'src/real.test.ts'); + writeFixture(getDir(), 'dist/hidden.test.ts'); + writeFixture(getDir(), 'node_modules/dep.test.ts'); + writeFixture(getDir(), '.hidden/secret.test.ts'); + writeFixture(getDir(), 'coverage/covered.test.ts'); + writeFixture(getDir(), 'tmp/temp.test.ts'); + writeFixture(getDir(), 'bundle/packed.test.ts'); + writeFixture(getDir(), '__snapshots__/snap.test.ts'); + + const results = discoverTestFilesInDirectory( + getDir(), + DEFAULT_TEST_FILE_PATTERN, + realDeps, + ); + + expect(results).toEqual([join(getDir(), 'src/real.test.ts')]); + }); + + it('discovers a dot-prefixed test file but prunes dot-prefixed directories', () => { + const dotFile = writeFixture(getDir(), '.hidden.test.ts'); + writeFixture(getDir(), '.hiddendir/inside.test.ts'); + + const results = discoverTestFilesInDirectory( + getDir(), + DEFAULT_TEST_FILE_PATTERN, + realDeps, + ); + + expect(results).toContain(dotFile); + expect(results.every((f) => !f.includes('.hiddendir'))).toBe(true); + }); + + it('fails loudly when a subdirectory cannot be read', () => { + writeFixture(getDir(), 'src/a.test.ts'); + writeFixture(getDir(), 'secret/b.test.ts'); + const throwingDeps: BunTestRootDependencies = { + stat: (path) => statSync(path), + readDirectory: (path) => { + if (path.endsWith(join('secret'))) { + throw Object.assign(new Error('permission denied'), { + code: 'EACCES', + }); + } + return readdirSync(path); + }, + realpath: (path) => realpathSync(path), + }; + + expect(() => + discoverTestFilesInDirectory( + getDir(), + DEFAULT_TEST_FILE_PATTERN, + throwingDeps, + ), + ).toThrow(BunTestRootStatError); + }); + + it('fails loudly when an entry cannot be stat-ed', () => { + writeFixture(getDir(), 'src/a.test.ts'); + const throwingDeps: BunTestRootDependencies = { + stat: (path) => { + if (path.endsWith('a.test.ts')) { + throw Object.assign(new Error('permission denied'), { + code: 'EACCES', + }); + } + return statSync(path); + }, + readDirectory: (path) => readdirSync(path), + realpath: (path) => realpathSync(path), + }; + + expect(() => + discoverTestFilesInDirectory( + getDir(), + DEFAULT_TEST_FILE_PATTERN, + throwingDeps, + ), + ).toThrow(BunTestRootStatError); + }); + + it('does not resolve .d.ts declaration files', () => { + writeFixture(getDir(), 'src/types.d.ts'); + writeFixture(getDir(), 'src/real.test.ts'); + + const results = discoverTestFilesInDirectory( + getDir(), + DEFAULT_TEST_FILE_PATTERN, + realDeps, + ); + + expect(results).toEqual([join(getDir(), 'src/real.test.ts')]); + }); + + it('deduplicates a symlink cycle so each real file appears once', () => { + writeFixture(getDir(), 'src/a.test.ts'); + // src/cycle -> repo root creates a cycle back into the scanned tree + symlinkSync(getDir(), join(getDir(), 'src', 'cycle')); + + const results = discoverTestFilesInDirectory( + getDir(), + DEFAULT_TEST_FILE_PATTERN, + realDeps, + ); + + // The scan directory's own real path is seeded into visited, so the + // symlink back to it (src/cycle -> root) is never re-walked. Exactly + // one entry: the real file under src/. + expect(results).toEqual([join(getDir(), 'src', 'a.test.ts')]); + }); +}); + +// --------------------------------------------------------------------------- +// isTestFileName +// --------------------------------------------------------------------------- + +describe('isTestFileName', () => { + it.each([ + ['a.test.ts', true], + ['a.spec.ts', true], + ['a.bun.ts', true], + ['a.test.tsx', true], + ['a.spec.tsx', true], + ['a.bun.tsx', true], + ['a.test.js', true], + ['a.spec.js', true], + ['a.bun.js', true], + ['a.ts', false], + ['a.d.ts', false], + ['a.test.d.ts', false], + ['readme.md', false], + ])('classifies %s as %s', (name, expected) => { + expect(isTestFileName(name)).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// resolveRoot — behavioral tests against temp fixtures +// --------------------------------------------------------------------------- + +describe('resolveRoot', () => { + const getDir = useTempDir(); + + it('discovers a newly added test file without any configuration edit', () => { + const root: BunTestRoot = { + root: 'fixture', + cwd: '.', + directories: ['src'], + }; + writeFixture(getDir(), 'src/first.test.ts'); + + const before = resolveRoot(root, getDir()); + expect(before).toHaveLength(1); + + writeFixture(getDir(), 'src/second.test.ts'); + const after = resolveRoot(root, getDir()); + expect(after).toHaveLength(2); + expect(after.some((f) => f.file.endsWith('second.test.ts'))).toBe(true); + }); + + it('fails loudly when a root discovers no test files', () => { + mkdirSync(join(getDir(), 'emptydir'), { recursive: true }); + const root: BunTestRoot = { + root: 'empty', + cwd: '.', + directories: ['emptydir'], + }; + expect(() => resolveRoot(root, getDir())).toThrow( + 'discovered no test files', + ); + }); + + it('propagates a readDirectory error instead of returning a short list', () => { + writeFixture(getDir(), 'src/a.test.ts'); + writeFixture(getDir(), 'secret/b.test.ts'); + const throwingDeps: BunTestRootDependencies = { + stat: (path) => statSync(path), + readDirectory: (path) => { + if (path.endsWith(join('secret'))) { + throw Object.assign(new Error('permission denied'), { + code: 'EACCES', + }); + } + return readdirSync(path); + }, + realpath: (path) => realpathSync(path), + }; + const root: BunTestRoot = { + root: 'fail', + cwd: '.', + directories: ['src', 'secret'], + }; + + expect(() => resolveRoot(root, getDir(), throwingDeps)).toThrow( + BunTestRootStatError, + ); + }); + + it('propagates a stat error instead of returning a short list', () => { + writeFixture(getDir(), 'src/a.test.ts'); + writeFixture(getDir(), 'src/b.test.ts'); + const throwingDeps: BunTestRootDependencies = { + stat: (path) => { + if (path.endsWith('b.test.ts')) { + throw Object.assign(new Error('permission denied'), { + code: 'EACCES', + }); + } + return statSync(path); + }, + readDirectory: (path) => readdirSync(path), + realpath: (path) => realpathSync(path), + }; + const root: BunTestRoot = { + root: 'fail', + cwd: '.', + directories: ['src'], + }; + + expect(() => resolveRoot(root, getDir(), throwingDeps)).toThrow( + BunTestRootStatError, + ); + }); + + it('fails when a declared preload path does not exist', () => { + writeFixture(getDir(), 'src/a.test.ts'); + const root: BunTestRoot = { + root: 'bad', + cwd: '.', + directories: ['src'], + preload: 'missing-preload.ts', + }; + expect(() => resolveRoot(root, getDir())).toThrow('missing preload/config'); + }); + + it('fails when a declared tsconfig path does not exist', () => { + writeFixture(getDir(), 'src/a.test.ts'); + const root: BunTestRoot = { + root: 'bad', + cwd: '.', + directories: ['src'], + tsconfig: 'missing-tsconfig.json', + }; + expect(() => resolveRoot(root, getDir())).toThrow('missing preload/config'); + }); + + it('fails when a declared globalSetup path does not exist', () => { + writeFixture(getDir(), 'src/a.test.ts'); + const root: BunTestRoot = { + root: 'bad', + cwd: '.', + directories: ['src'], + globalSetup: 'missing-setup.ts', + }; + expect(() => resolveRoot(root, getDir())).toThrow('missing preload/config'); + }); + + it('applies a timeout override only to the matching file', () => { + writeFixture(getDir(), 'src/slow.test.ts'); + writeFixture(getDir(), 'src/fast.test.ts'); + const root: BunTestRoot = { + root: 'mixed', + cwd: '.', + directories: ['src'], + timeoutOverrides: [{ pattern: /slow\.test\.ts$/, timeout: 300_000 }], + }; + + const files = resolveRoot(root, getDir()); + const slow = files.find((f) => f.file.endsWith('slow.test.ts')); + const fast = files.find((f) => f.file.endsWith('fast.test.ts')); + + expect(slow?.timeout).toBe(300_000); + expect(fast?.timeout).toBeUndefined(); + }); + + it('preserves root-level timeout for non-matching files', () => { + writeFixture(getDir(), 'src/a.test.ts'); + const root: BunTestRoot = { + root: 'timed', + cwd: '.', + directories: ['src'], + timeout: 60_000, + timeoutOverrides: [ + { pattern: /never-matches\.test\.ts$/, timeout: 300_000 }, + ], + }; + + const files = resolveRoot(root, getDir()); + expect(files[0]?.timeout).toBe(60_000); + }); + + it('resolves cwd undefined to packages/ relative to repoRoot', () => { + writeFixture(getDir(), 'packages/mypkg/src/a.test.ts'); + const root: BunTestRoot = { root: 'mypkg' }; + + const files = resolveRoot(root, getDir()); + expect(files[0]?.cwd).toBe(join(getDir(), 'packages', 'mypkg')); + expect(files[0]?.file).toBe(join(getDir(), 'packages/mypkg/src/a.test.ts')); + }); + + it('uses a custom pattern when declared', () => { + writeFixture(getDir(), 'evals/run.eval.ts'); + writeFixture(getDir(), 'evals/regular.test.ts'); + const root: BunTestRoot = { + root: 'evals-root', + cwd: '.', + directories: ['evals'], + pattern: /\.eval\.ts$/, + }; + + const files = resolveRoot(root, getDir()); + expect(files).toHaveLength(1); + expect(files[0]?.file).toContain('run.eval.ts'); + }); +}); + +// --------------------------------------------------------------------------- +// resolveBunTestFiles — integration against the real repository +// --------------------------------------------------------------------------- + +describe('resolveBunTestFiles (real repository)', () => { + it('returns an empty array for an unknown root', () => { + expect(resolveBunTestFiles(repoRoot, 'nonexistent-root')).toEqual([]); + }); + + it('excludes credentialed roots from an unfiltered run', () => { + const files = resolveBunTestFiles(repoRoot); + const credentialedRoots = BUN_TEST_ROOTS.filter( + (r) => r.credentialed === true, + ); + for (const root of credentialedRoots) { + const rootCwd = resolveRootCwd(repoRoot, root); + expect( + files.every((f) => !f.file.startsWith(rootCwd)), + `credentialed root "${root.root}" must not appear in unfiltered run`, + ).toBe(true); + } + }); + + it('includes a credentialed root when named explicitly', () => { + const files = resolveBunTestFiles(repoRoot, 'evals'); + expect(files.length).toBeGreaterThan(0); + const evalsCwd = resolveRootCwd(repoRoot, { + root: 'evals', + cwd: 'evals', + }); + expect(files.every((f) => f.cwd === evalsCwd)).toBe(true); + }); + + it('resolves the real providers root including previously-omitted files', () => { + const files = resolveBunTestFiles(repoRoot, 'providers'); + const filePaths = files.map((f) => f.file); + + expect(files.length).toBeGreaterThan(540); + + expect(filePaths).toContain( + join(repoRoot, 'packages/providers/src/utils/reasoningField.test.ts'), + ); + expect(filePaths).toContain( + join( + repoRoot, + 'packages/providers/src/anthropic/AnthropicPromptEnvelopeAuthParity.test.ts', + ), + ); + }); + + it('resolves the agents root to only test-bun files', () => { + const files = resolveBunTestFiles(repoRoot, 'agents'); + const agentsCwd = resolveRootCwd(repoRoot, { root: 'agents' }); + for (const file of files) { + expect(file.file).toContain(join(agentsCwd, 'test-bun')); + } + }); + + it('resolves every non-credentialed root to at least one file', () => { + const offlineRoots = BUN_TEST_ROOTS.filter( + (root) => root.credentialed !== true, + ); + for (const root of offlineRoots) { + const files = resolveBunTestFiles(repoRoot, root.root); + expect(files.length, `root "${root.root}"`).toBeGreaterThan(0); + } + }); + + it('applies the slow timeout override to the release-install smoke', () => { + const files = resolveBunTestFiles(repoRoot, 'scripts-tests'); + const slow = files.find((f) => + f.file.endsWith('issue-2603-release-install.test.ts'), + ); + expect(slow).toBeDefined(); + expect(slow?.timeout).toBe(300_000); + + const ordinary = files.find((f) => + f.file.endsWith('run_bun_tests.test.ts'), + ); + // Without this the negative case passes vacuously when the file is renamed. + expect(ordinary).toBeDefined(); + expect(ordinary?.timeout).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Error classification (ported from the manifest validation) +// --------------------------------------------------------------------------- + +describe('BunTestRootStatError', () => { + it('exposes path and code from the original error', () => { + const cause = Object.assign(new Error('permission denied'), { + code: 'EACCES', + path: '/cause/path', + }); + + const err = new BunTestRootStatError('/target', 'EACCES', cause); + expect(err.path).toBe('/target'); + expect(err.code).toBe('EACCES'); + expect(err.cause).toBe(cause); + expect(err.message).toContain('/target'); + expect(err.message).toContain('EACCES'); + }); + + it('handles undefined code gracefully', () => { + const err = new BunTestRootStatError('/target', undefined, new Error('x')); + expect(err.code).toBeUndefined(); + expect(err.message).not.toContain('undefined'); + }); +}); + +describe('getErrorCode', () => { + it('extracts a string code property', () => { + expect( + getErrorCode(Object.assign(new Error('e'), { code: 'ENOENT' })), + ).toBe('ENOENT'); + }); + + it('returns undefined for errors without a code', () => { + expect(getErrorCode(new Error('no code'))).toBeUndefined(); + }); + + it('returns undefined for non-objects', () => { + expect(getErrorCode('string')).toBeUndefined(); + expect(getErrorCode(null)).toBeUndefined(); + }); + + it('returns undefined when code is not a string', () => { + expect(getErrorCode({ code: 42 })).toBeUndefined(); + }); +}); diff --git a/scripts/tests/ci-docs-only-skip.bun.test.ts b/scripts/tests/ci-docs-only-skip.bun.test.ts index 57d7804ff3..764643e683 100644 --- a/scripts/tests/ci-docs-only-skip.bun.test.ts +++ b/scripts/tests/ci-docs-only-skip.bun.test.ts @@ -31,7 +31,6 @@ const HEAVY_JOBS = [ 'bun_native_modules_smoke', 'node_consumer_smoke', 'bun_test_orchestrator_smoke', - 'bun_native_test_parity', 'acp_conformance', ] as const; diff --git a/scripts/tests/issue-2994-lint-scoped.bun.test.ts b/scripts/tests/issue-2994-lint-scoped.bun.test.ts index 56c2ba8cb6..5ffbc9e39d 100644 --- a/scripts/tests/issue-2994-lint-scoped.bun.test.ts +++ b/scripts/tests/issue-2994-lint-scoped.bun.test.ts @@ -13,7 +13,7 @@ * hermetic temporary git repositories, and the real failure classifier. No * stubbing of git, ESLint, or the runner. * - * Runs under Bun's native runner (see scripts/bun-test-manifest.ts); vitest + * Runs under Bun's native runner (see scripts/bun-test-roots.ts); vitest * skips `*.bun.test.ts` files. */ diff --git a/scripts/tests/issue-planner-confinement.bun.test.ts b/scripts/tests/issue-planner-confinement.bun.test.ts index 867c652d7e..57131341d4 100644 --- a/scripts/tests/issue-planner-confinement.bun.test.ts +++ b/scripts/tests/issue-planner-confinement.bun.test.ts @@ -10,7 +10,7 @@ // chmod without -h cannot change it), `bun install`-materialized symlinks // false-positived the writable check and failed every issues-triggered run. // These run under Bun's native runner via the scripts-tests root (see -// scripts/bun-test-manifest.ts). +// scripts/bun-test-roots.ts). // // The textual suite guards the regression marker on hosts without bash; the // behavioral suite runs the actual confinement script under a POSIX shell. diff --git a/scripts/tests/issue-planner-enrichment.bun.test.ts b/scripts/tests/issue-planner-enrichment.bun.test.ts index 91c46afab0..60ce9b3c43 100644 --- a/scripts/tests/issue-planner-enrichment.bun.test.ts +++ b/scripts/tests/issue-planner-enrichment.bun.test.ts @@ -13,7 +13,7 @@ // the pattern established for the related-candidate step (#2972). // // These run under Bun's native runner via the scripts-tests root (see -// scripts/bun-test-manifest.ts). +// scripts/bun-test-roots.ts). import { describe, expect, it } from 'bun:test'; import * as fs from 'node:fs'; diff --git a/scripts/tests/ocr-review-workflow.bun.test.ts b/scripts/tests/ocr-review-workflow.bun.test.ts index 0e3b40ef5d..e8b7bcaf9d 100644 --- a/scripts/tests/ocr-review-workflow.bun.test.ts +++ b/scripts/tests/ocr-review-workflow.bun.test.ts @@ -113,10 +113,10 @@ describe('.github/workflows/ocr-review.yml', () => { } it('is discovered by the scripts test root used in CI', () => { - const manifest = readRootFile('scripts/bun-test-manifest.ts'); + const roots = readRootFile('scripts/bun-test-roots.ts'); - expect(manifest).toContain("workspace: 'scripts-tests'"); - expect(manifest).toContain("'scripts/tests/**/*.test.ts'"); + expect(roots).toContain("root: 'scripts-tests'"); + expect(roots).toContain("directories: ['scripts/tests']"); }); it('uses authorization-aware workflow concurrency around the complete run', () => { diff --git a/scripts/tests/pr-review-walkthrough-sanitize.bun.test.ts b/scripts/tests/pr-review-walkthrough-sanitize.bun.test.ts index f9969d3cea..1d09515b80 100644 --- a/scripts/tests/pr-review-walkthrough-sanitize.bun.test.ts +++ b/scripts/tests/pr-review-walkthrough-sanitize.bun.test.ts @@ -7,7 +7,7 @@ // Bun-native tests for the Mermaid sequenceDiagram sanitizer and the // sequenceDiagram prompt hardening added for issue #2944. These run under // Bun's native test runner via the scripts-tests root (see -// scripts/bun-test-manifest.ts). +// scripts/bun-test-roots.ts). import { describe, expect, it } from 'bun:test'; import { sanitizeSequenceDiagram } from '../pr-review-walkthrough-parse.ts'; diff --git a/scripts/tests/run_bun_tests.subprocess.test.ts b/scripts/tests/run_bun_tests.subprocess.test.ts index 748d8c1206..295865ea3b 100644 --- a/scripts/tests/run_bun_tests.subprocess.test.ts +++ b/scripts/tests/run_bun_tests.subprocess.test.ts @@ -17,7 +17,7 @@ import { import { tmpdir, platform } from 'node:os'; import { join, resolve } from 'node:path'; import { isChildSuccess, formatFailureDiagnostic } from '../run_bun_tests.js'; -import { resolveBunNativeTestFiles } from '../bun-test-manifest.js'; +import { resolveBunTestFiles } from '../bun-test-roots.js'; const repoRoot = resolve(__dirname, '..', '..'); @@ -147,11 +147,11 @@ function resolveBunBinary(): string | null { const bunBinary = resolveBunBinary(); /** - * How many files the `core` root selects, read from the manifest so these - * subprocess assertions track it rather than restating a literal. + * How many files the `test-setup` root selects, read from the resolver so + * these subprocess assertions track it rather than restating a literal. */ -function coreFileCount(): number { - return resolveBunNativeTestFiles(repoRoot, 'core').length; +function rootFileCount(): number { + return resolveBunTestFiles(repoRoot, 'test-setup').length; } describe('production Bun native test runner', () => { @@ -163,7 +163,7 @@ describe('production Bun native test runner', () => { [ resolve(repoRoot, 'scripts/run_bun_tests.ts'), '--workspace', - 'core', + 'test-setup', '--dry-run', ], { @@ -174,12 +174,12 @@ describe('production Bun native test runner', () => { ); expect(child.status, child.stderr).toBe(0); - // Derived from the manifest: hardcoding the count breaks whenever a file - // is added to the core root, which says nothing about the runner. + // Derived from the resolver: hardcoding the count breaks whenever a file + // is added to the test-setup root, which says nothing about the runner. expect(child.stdout).toContain( - `Dry run: ${coreFileCount()} files would be executed:`, + `Dry run: ${rootFileCount()} files would be executed:`, ); - expect(child.stdout).toContain('packages/core/src/utils/errors.test.ts'); + expect(child.stdout).toContain('test-setup/augment-bun-vi.test.ts'); }, ); @@ -191,7 +191,7 @@ describe('production Bun native test runner', () => { [ resolve(repoRoot, 'scripts/run_bun_tests.ts'), '--workspace', - 'core', + 'test-setup', '--tsconfig', resolve(repoRoot, 'tsconfig.json'), ], @@ -203,7 +203,7 @@ describe('production Bun native test runner', () => { ); expect(child.status, child.stderr).toBe(0); - const count = coreFileCount(); + const count = rootFileCount(); expect(child.stdout).toContain( `Passed ${count}/${count} isolated native Bun test files`, ); diff --git a/scripts/tests/test-file-coverage.bun.test.ts b/scripts/tests/test-file-coverage.bun.test.ts new file mode 100644 index 0000000000..be59a1eb38 --- /dev/null +++ b/scripts/tests/test-file-coverage.bun.test.ts @@ -0,0 +1,280 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { + mkdirSync, + mkdtempSync, + readdirSync, + realpathSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { + BunTestRootStatError, + type BunTestRootDependencies, + DEFAULT_TEST_FILE_PATTERN, + discoverTestFilesInDirectory, +} from '../bun-test-roots.js'; +import { + type TestExecutor, + discoverRepositoryTestFiles, + findDoublyExecutedTestFiles, + findUncoveredTestFiles, +} from '../check-test-file-coverage.js'; + +const repoRoot = resolve(import.meta.dir, '..', '..'); + +const realDeps: BunTestRootDependencies = { + stat: (path: string) => statSync(path), + readDirectory: (path: string) => readdirSync(path), + realpath: (path: string) => realpathSync(path), +}; + +/** + * Shared temp-directory helper (RULES.md "DRY setup"). Registers its own + * beforeEach/afterEach hooks and returns a lazy accessor, so each describe + * block needs only one line of setup. + */ +function useTempDir(): () => string { + let dir = ''; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'test-file-coverage-')); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + return () => { + if (dir === '') { + throw new Error('Temp directory accessed outside its lifecycle'); + } + return dir; + }; +} + +/** Writes a file (and any missing parent directories) inside a temp root. */ +function writeFile(root: string, relative: string): string { + const fullPath = join(root, relative); + mkdirSync(dirname(fullPath), { recursive: true }); + writeFileSync(fullPath, ''); + // Coverage identities are canonical paths, so the fixture must report the + // canonical path too — on macOS the temp root is reached through a symlink + // (/var -> /private/var) and a lexical path would never compare equal. + return realpathSync(fullPath); +} + +/** Builds an executor that scans a single workspace-relative directory. */ +function scanningExecutor(name: string, scanRelative: string): TestExecutor { + return { + name, + discover: (root: string): readonly string[] => + discoverTestFilesInDirectory( + join(root, scanRelative), + DEFAULT_TEST_FILE_PATTERN, + realDeps, + ), + }; +} + +// --------------------------------------------------------------------------- +// Headline assertions against the real repository (AC7, AC8) +// --------------------------------------------------------------------------- + +describe('test-file coverage guard (real repository)', () => { + it('reports zero uncovered test files (AC8)', () => { + const uncovered = findUncoveredTestFiles(repoRoot); + expect( + uncovered, + `Expected every repository test file to be run by some executor, but these were uncovered:\n${uncovered.join('\n')}`, + ).toEqual([]); + }); + + it('reports zero doubly-executed test files (AC7)', () => { + const duplicates = findDoublyExecutedTestFiles(repoRoot); + expect( + duplicates, + `Expected no test file to be run by two executors, but these were duplicated:\n${duplicates + .map((entry) => `${entry.file} ← ${entry.executors.join(', ')}`) + .join('\n')}`, + ).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Repository walk behavior +// --------------------------------------------------------------------------- + +describe('discoverRepositoryTestFiles', () => { + const getDir = useTempDir(); + + it('walks the repository and returns test files sorted', () => { + const a = writeFile(getDir(), 'packages/foo/src/b.test.ts'); + const b = writeFile(getDir(), 'packages/foo/src/a.test.ts'); + writeFile(getDir(), 'packages/foo/src/notatest.ts'); + + const files = discoverRepositoryTestFiles(getDir(), realDeps); + + expect(files).toEqual([b, a]); + }); + + it('includes eval files alongside test/spec/bun files', () => { + const evalFile = writeFile(getDir(), 'evals/run.eval.ts'); + const testFile = writeFile(getDir(), 'packages/x/y.test.ts'); + + const files = discoverRepositoryTestFiles(getDir(), realDeps); + + expect(files).toContain(evalFile); + expect(files).toContain(testFile); + }); + + it('does not walk skipped directories', () => { + writeFile(getDir(), 'node_modules/dep/skip.test.ts'); + writeFile(getDir(), 'dist/generated.test.ts'); + writeFile(getDir(), '.hidden/secret.test.ts'); + writeFile(getDir(), 'coverage/covered.test.ts'); + writeFile(getDir(), 'bundle/packed.test.ts'); + writeFile(getDir(), 'tmp/temp.test.ts'); + writeFile(getDir(), '__snapshots__/snap.test.ts'); + const real = writeFile(getDir(), 'packages/real/real.test.ts'); + + const files = discoverRepositoryTestFiles(getDir(), realDeps); + + expect(files).toEqual([real]); + }); + + it('discovers a dot-prefixed test file but prunes dot-prefixed directories', () => { + const dotFile = writeFile(getDir(), 'packages/x/.hidden.test.ts'); + writeFile(getDir(), 'packages/x/.hiddendir/inside.test.ts'); + + const files = discoverRepositoryTestFiles(getDir(), realDeps); + + expect(files).toContain(dotFile); + expect(files.every((f) => !f.includes(join('.hiddendir')))).toBe(true); + }); + + it('fails loudly when a subdirectory cannot be read', () => { + writeFile(getDir(), 'packages/a/real.test.ts'); + writeFile(getDir(), 'packages/b/orphan.test.ts'); + const throwingDeps: BunTestRootDependencies = { + stat: (path) => statSync(path), + readDirectory: (path) => { + if (path.endsWith(join('packages', 'b'))) { + throw Object.assign(new Error('permission denied'), { + code: 'EACCES', + }); + } + return readdirSync(path); + }, + realpath: (path) => realpathSync(path), + }; + + expect(() => discoverRepositoryTestFiles(getDir(), throwingDeps)).toThrow( + BunTestRootStatError, + ); + }); + + it('fails loudly when an entry cannot be stat-ed', () => { + writeFile(getDir(), 'packages/a/real.test.ts'); + const throwingDeps: BunTestRootDependencies = { + stat: (path) => { + if (path.endsWith('real.test.ts')) { + throw Object.assign(new Error('permission denied'), { + code: 'EACCES', + }); + } + return statSync(path); + }, + readDirectory: (path) => readdirSync(path), + realpath: (path) => realpathSync(path), + }; + + expect(() => discoverRepositoryTestFiles(getDir(), throwingDeps)).toThrow( + BunTestRootStatError, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Coverage logic against temp fixtures +// --------------------------------------------------------------------------- + +describe('findUncoveredTestFiles', () => { + const getDir = useTempDir(); + + it('reports a file under a scanned directory as covered', () => { + writeFile(getDir(), 'packages/scanned/a.test.ts'); + + const uncovered = findUncoveredTestFiles(getDir(), realDeps, [ + scanningExecutor('scanner', 'packages/scanned'), + ]); + + expect(uncovered).toEqual([]); + }); + + it('reports a file no executor scans as uncovered', () => { + const covered = writeFile(getDir(), 'packages/scanned/a.test.ts'); + const orphan = writeFile(getDir(), 'packages/orphan/b.test.ts'); + + const uncovered = findUncoveredTestFiles(getDir(), realDeps, [ + scanningExecutor('scanner', 'packages/scanned'), + ]); + + expect(uncovered).toEqual([orphan]); + expect(uncovered).not.toContain(covered); + }); +}); + +describe('findDoublyExecutedTestFiles', () => { + const getDir = useTempDir(); + + it('reports a file claimed by two executors as doubly executed', () => { + const shared = writeFile(getDir(), 'packages/shared/c.test.ts'); + + const duplicates = findDoublyExecutedTestFiles(getDir(), [ + scanningExecutor('executor-one', 'packages/shared'), + scanningExecutor('executor-two', 'packages/shared'), + ]); + + expect(duplicates).toHaveLength(1); + expect(duplicates[0]?.file).toBe(shared); + expect(duplicates[0]?.executors).toEqual(['executor-one', 'executor-two']); + }); + + it('reports nothing when each file has exactly one executor', () => { + writeFile(getDir(), 'packages/a/x.test.ts'); + writeFile(getDir(), 'packages/b/y.test.ts'); + + const duplicates = findDoublyExecutedTestFiles(getDir(), [ + scanningExecutor('executor-a', 'packages/a'), + scanningExecutor('executor-b', 'packages/b'), + ]); + + expect(duplicates).toEqual([]); + }); + + it('gives one real file a single coverage identity through a symlink alias', () => { + const real = writeFile(getDir(), 'packages/a/x.test.ts'); + // `packages/alias` is another route to the same directory, so an executor + // scanning it claims the same real file under a different lexical path. + symlinkSync(join(getDir(), 'packages/a'), join(getDir(), 'packages/alias')); + + const duplicates = findDoublyExecutedTestFiles(getDir(), [ + scanningExecutor('executor-direct', 'packages/a'), + scanningExecutor('executor-alias', 'packages/alias'), + ]); + + expect(duplicates).toHaveLength(1); + expect(duplicates[0]?.file).toBe(real); + expect(duplicates[0]?.executors).toEqual([ + 'executor-alias', + 'executor-direct', + ]); + }); +}); diff --git a/scripts/tests/test-shard-orchestrator.test.ts b/scripts/tests/test-shard-orchestrator.test.ts index 792caf1d7a..80d330b9c0 100644 --- a/scripts/tests/test-shard-orchestrator.test.ts +++ b/scripts/tests/test-shard-orchestrator.test.ts @@ -8,7 +8,13 @@ import { afterAll, describe, expect, it } from 'vitest'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { parseArgs, type CommandRunner, orchestrateTests } from '../test.ts'; +import { + parseArgs, + type CommandRunner, + orchestrateTests, + SCRIPTS_SHARD_ROOTS, + scriptsRootCommand, +} from '../test.ts'; // Shared recording runner factory; the identical helper in // test-orchestrator.test.ts serves that file's own describe blocks. Kept @@ -65,6 +71,26 @@ function createFixtureRepo(workspaces: FixtureWorkspace[]): string { return root; } +/** + * A repo fixture the scripts shard will act on: it needs a `scripts/tests` + * directory to exist before it issues any root invocation. + */ +function createScriptsShardFixture(): string { + const root = createFixtureRepo([ + { + dir: 'packages/cli', + name: '@scope/cli', + scripts: { test: 'vitest run' }, + }, + ]); + mkdirSync(join(root, 'scripts', 'tests'), { recursive: true }); + writeFileSync( + join(root, 'scripts', 'tests', 'dummy.test.ts'), + 'export default {};', + ); + return root; +} + describe('parseArgs (--shard)', () => { it('parses --shard with a value', () => { const opts = parseArgs(['--shard', 'cli']); @@ -276,84 +302,49 @@ describe('orchestrateTests (--shard)', () => { expect(summary.results.some((r) => !r.success)).toBe(true); }); - // Issue #2780: the long-running release-install smoke test - // (issue-2603-release-install.test.ts) must run as a SEPARATE Bun-native - // root, so the far larger timeout it needs does not weaken the budget that - // catches genuine hangs in the rest of the script harness. - it('runs the release-install smoke test as a separate root invocation', () => { + // Each scripts-shard root is its own invocation so that one root's timeout + // cannot weaken another's. The release-install smoke (issue #2780) is no + // longer a root of its own: it is discovered inside `scripts-tests` and gets + // its larger budget from a per-file timeout override. + it('runs each scripts-shard root as its own invocation', () => { const { runner, commands } = createRecordingRunner(); - const fixtureRoot = createFixtureRepo([ - { - dir: 'packages/cli', - name: '@scope/cli', - scripts: { test: 'vitest run' }, - }, - ]); - mkdirSync(join(fixtureRoot, 'scripts', 'tests'), { recursive: true }); - writeFileSync( - join(fixtureRoot, 'scripts', 'tests', 'dummy.test.ts'), - 'export default {};', - ); - orchestrateTests( - fixtureRoot, + createScriptsShardFixture(), { ...parseArgs(['--shard', 'scripts']) }, runner, ); - const scriptsCommands = commands.filter((c) => - c.command.includes('--root scripts-tests'), + const scriptsCommands = commands + .map((c) => c.command) + .filter((command) => command.includes('run_bun_tests.ts --root ')); + + expect(scriptsCommands).toEqual( + SCRIPTS_SHARD_ROOTS.map((root) => scriptsRootCommand(root)), ); - // Two invocations: the fast script harness and a dedicated invocation for - // the release-install smoke, which needs a far larger time budget. - expect(scriptsCommands).toHaveLength(2); - expect( - scriptsCommands.some((c) => c.command.endsWith('--root scripts-tests')), - ).toBe(true); - expect( - scriptsCommands.some((c) => - c.command.endsWith('--root scripts-tests-slow'), - ), - ).toBe(true); }); - it('skips the release-install smoke invocation when the main scripts suite fails', () => { + it('skips the remaining scripts roots when an earlier root fails', () => { const commands: Array<{ command: string; cwd: string }> = []; + const [firstRoot, ...remainingRoots] = SCRIPTS_SHARD_ROOTS; const runner: CommandRunner = (command, cwd) => { commands.push({ command, cwd }); - // Fail only the fast script harness; pass everything else so the slow - // invocation is the only thing that could still run. - if (command.endsWith('--root scripts-tests')) { + if (command === scriptsRootCommand(firstRoot)) { return { success: false, exitCode: 1 }; } return { success: true, exitCode: 0 }; }; - const fixtureRoot = createFixtureRepo([ - { - dir: 'packages/cli', - name: '@scope/cli', - scripts: { test: 'vitest run' }, - }, - ]); - mkdirSync(join(fixtureRoot, 'scripts', 'tests'), { recursive: true }); - writeFileSync( - join(fixtureRoot, 'scripts', 'tests', 'dummy.test.ts'), - 'export default {};', - ); - orchestrateTests( - fixtureRoot, + createScriptsShardFixture(), { ...parseArgs(['--shard', 'scripts']) }, runner, ); - // The slow smoke invocation must NOT run when the main suite failed - // (fail-fast semantics). - const slowCommand = commands.filter((c) => - c.command.endsWith('--root scripts-tests-slow'), - ); - expect(slowCommand).toHaveLength(0); + const executed = commands.map((c) => c.command); + expect(executed).toContain(scriptsRootCommand(firstRoot)); + for (const root of remainingRoots) { + expect(executed).not.toContain(scriptsRootCommand(root)); + } }); }); diff --git a/test-setup/augment-bun-vi.test.ts b/test-setup/augment-bun-vi.test.ts index 5977d39cb9..5e8023aa05 100644 --- a/test-setup/augment-bun-vi.test.ts +++ b/test-setup/augment-bun-vi.test.ts @@ -196,7 +196,12 @@ describe('Bun vi augmentation', () => { expect(vi.getTimerCount()).toBe(1); await vi.runOnlyPendingTimersAsync(); - expect(order).toEqual(['first@10', 'boundary@20', 'nested@35']); + // Bun drains Timer A's awaited continuation at A's fire time (t=10), so + // the nested timer is scheduled at 10+15=25 — matching Vitest's async + // advancement, which drains A's microtasks before firing B. The boundary + // guarantee this test checks (the nested timer does not run during the + // initial runOnlyPendingTimersAsync) is unchanged. + expect(order).toEqual(['first@10', 'boundary@20', 'nested@25']); } finally { vi.useRealTimers(); } diff --git a/test-setup/augment-bun-vi.ts b/test-setup/augment-bun-vi.ts index a29d0711c6..8c7e7d6292 100644 --- a/test-setup/augment-bun-vi.ts +++ b/test-setup/augment-bun-vi.ts @@ -106,33 +106,42 @@ const realClearAllTimers = (bunVi as BunViBase).clearAllTimers.bind(bunVi); const realIsFakeTimers = (bunVi as BunViBase).isFakeTimers.bind(bunVi); /** - * Captured before any fake-timer activation so async timer helpers can await - * a real event-loop turn to drain recursively queued microtasks. Under Bun's - * fake timers, `setImmediate` itself is faked and will not advance the real - * event loop, so the captured reference is used instead. - */ -const realSetImmediate: (callback: () => void) => NodeJS.Immediate = - setImmediate; - -/** - * Drains recursively queued microtasks from async timer callbacks. + * Drains microtasks queued by async timer callbacks between fake-clock + * advances. + * + * IMPORTANT: the settling boundary MUST be a microtask, never a macrotask. + * Under Bun's fake timers on Linux, macrotask primitives stop firing after + * certain advance operations: once a fake timer has fired and the clock is + * then advanced via `advanceTimersByTime` with no pending timers, a + * subsequent `setImmediate` (and likewise `setTimeout(_, 0)`) is gated by the + * fake-timer scheduler and never becomes "due", so awaiting it hangs until the + * fake timers are torn down. This was the root cause of issue #2979: every + * `packages/providers` test using `vi.advanceTimersByTimeAsync` timed out at + * exactly the per-test timeout on Linux CI while passing on macOS (where the + * macrotask happens to keep firing). It is a hang, not slowness, at any + * timeout. * - * On macOS, `setImmediate` fires promptly even under Bun's fake timers, so a - * single macrotask boundary suffices. On Linux CI, `setImmediate` may not - * fire under fake timers, causing tests that rely on async timer advancement - * (e.g. proactive-renewal) to hang indefinitely. + * Microtasks are never gated this way — they drain synchronously within the + * current macrotask, before the fake-timer scheduler regains control — so a + * microtask boundary is both sufficient to settle async callback continuations + * and guaranteed not to stall on either platform. (Note: a microtask always + * preempts a queued macrotask, so racing a macrotask against a microtask + * fallback resolves via the microtask anyway; the macrotask contributes + * nothing.) * - * The portable approach drains microtasks via chained `Promise.resolve()` - * calls first (each yielding one microtask round), then yields to a real - * macrotask via `setImmediate` as a final settling boundary. This works on - * both platforms without depending on `setImmediate` firing under fake timers. + * Each `await Promise.resolve()` yields one microtask round; the loop drains + * async callback chains for up to `MICROTASK_DRAIN_ROUNDS` rounds, and the + * final `queueMicrotask` is the settling boundary (replacing the former + * `setImmediate`). The bound is deliberate: a callback that reschedules + * microtasks forever degrades to a bounded delay rather than an infinite hang, + * and a chain deeper than the bound stays pending by design. (issue #2979) */ const MICROTASK_DRAIN_ROUNDS = 20; const flushPendingTasks = async (): Promise => { for (let i = 0; i < MICROTASK_DRAIN_ROUNDS; i++) { await Promise.resolve(); } - await new Promise((resolve) => realSetImmediate(resolve)); + await new Promise((resolve) => queueMicrotask(resolve)); }; const MAX_TIMER_ADVANCE = 4_294_967_295; diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json index cdc5b7f4e6..90e915f5bc 100644 --- a/tsconfig.scripts.json +++ b/tsconfig.scripts.json @@ -64,6 +64,7 @@ "scripts/token-divergence-corpus.ts", "scripts/test-shards.ts", "scripts/check-test-shards.ts", + "scripts/check-test-file-coverage.ts", "scripts/affected-test-shards.ts", "scripts/affected-lint-targets.ts", "scripts/check-affected-test-shards.ts", @@ -71,11 +72,12 @@ "scripts/lib/**/*.ts", "scripts/release-notes/**/*.ts", "scripts/version.ts", - "scripts/bun-test-manifest.ts", + "scripts/bun-test-roots.ts", "scripts/run_bun_tests.ts", "scripts/bun-junit-to-json-report.ts", - "scripts/tests/bun-test-manifest.test.ts", - "scripts/tests/bun-test-manifest.bun.test.ts", + "scripts/tests/bun-test-roots.bun.test.ts", + "scripts/tests/bun-test-root-ownership.bun.test.ts", + "scripts/tests/test-file-coverage.bun.test.ts", "scripts/tests/issue-2342.test.ts", "scripts/tests/run_bun_tests.test.ts", "scripts/tests/stub-helpers.test.ts",