diff --git a/.github/actions/setup-project-bun/action.yml b/.github/actions/setup-project-bun/action.yml index 1213761b085..f758ad64ff8 100644 --- a/.github/actions/setup-project-bun/action.yml +++ b/.github/actions/setup-project-bun/action.yml @@ -1,8 +1,18 @@ name: Setup project Bun description: >- - Install the Bun runtime for a job. Installs the version declared in - package.json (dependencies.bun), keeping the runtime SOT in one place so - version bumps only touch package.json and bun.lock. + Install the package.json Bun pin for a job's role: dependencies.bun for + runtime (default), or testRunnerBun for test-runner. + +# Runtime 1.4.2 fixes #6684's Windows Bun.fetch stream truncation. The test runner +# stays on 1.4.0: #4821 reverted #4064 after 1.4.2's multi-file --isolate preload +# segfault; #6713 run 37631558263 reproduces it on Linux 1/4, macOS 2/2, Windows 9/9. +# Rejoin only after the upstream runner crash is fixed and a green lane=all +# dispatch proves the unified version; then delete testRunnerBun and this role input. +inputs: + role: + description: Bun role to install (runtime or test-runner). + required: false + default: runtime outputs: version: @@ -15,14 +25,28 @@ runs: - name: Resolve project Bun version id: resolve shell: bash + env: + BUN_ROLE: ${{ inputs.role }} run: | - version="$(node -p "require('./package.json').dependencies.bun")" - if [ -z "$version" ]; then - echo "::error::Could not resolve Bun version from package.json" - exit 1 - fi - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "Using Bun $version from package.json" + node <<'NODE' + const pkg = require('./package.json'); + const role = process.env.BUN_ROLE; + let key; + let version; + switch (role) { + case 'runtime': key = 'dependencies.bun'; version = pkg.dependencies?.bun; break; + case 'test-runner': key = 'testRunnerBun'; version = pkg.testRunnerBun; break; + default: + console.error(`::error::Unknown Bun role: ${role}`); + process.exit(1); + } + if (typeof version !== 'string' || version !== version.trim() || !/^\d+\.\d+\.\d+$/.test(version)) { + console.error(`::error::Missing or invalid package.json ${key} for Bun role ${role}`); + process.exit(1); + } + require('node:fs').appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`); + console.log(`Using Bun ${version} from package.json ${key} (${role})`); + NODE - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab43fa93915..e5624b4b2c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -549,6 +549,11 @@ jobs: printf 'Scheduled run: workflow from %s, testing dev at %s\n' "$GITHUB_SHA" "$sha" >> "$GITHUB_STEP_SUMMARY" printf 'scheduled run tests dev at %s (workflow %s)\n' "$sha" "$GITHUB_SHA" + # Bun roles (#6713): runtime 1.4.2 fixes #6684's Windows Bun.fetch streaming; + # test-runner 1.4.0 avoids 1.4.2's multi-file --isolate preload segfault (#4821 + # reverted #4064; run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9). + # Rejoin after the upstream runner crash is fixed and a green lane=all dispatch + # proves the unified version, then delete testRunnerBun and the action's role input. # The suite, split by file across four Linux runners. # # `scripts/ci/run-bun-test-batches.sh` assigns files to shards by the per-file @@ -603,6 +608,8 @@ jobs: - name: Setup project Bun uses: ./.github/actions/setup-project-bun + with: + role: test-runner # See the Bun roles contract above test. # The GUI install is NOT optional here, however unrelated it looks to a # test shard. Several files under tests/ import JSX-bearing modules from @@ -649,6 +656,8 @@ jobs: - name: Setup project Bun uses: ./.github/actions/setup-project-bun + with: + role: test-runner # See the Bun roles contract above test. - name: Install dependencies run: | @@ -689,6 +698,8 @@ jobs: - name: Setup project Bun uses: ./.github/actions/setup-project-bun + with: + role: test-runner # See the Bun roles contract above test. - name: Install dependencies run: | @@ -725,6 +736,8 @@ jobs: - name: Setup project Bun uses: ./.github/actions/setup-project-bun + with: + role: test-runner # See the Bun roles contract above test. - name: Install dependencies run: | @@ -824,6 +837,8 @@ jobs: - name: Setup project Bun uses: ./.github/actions/setup-project-bun + with: + role: test-runner # See the Bun roles contract above test. - name: Install dependencies run: | @@ -893,6 +908,8 @@ jobs: - name: Setup project Bun uses: ./.github/actions/setup-project-bun + with: + role: test-runner # See the Bun roles contract above test. - name: Install dependencies run: | @@ -1035,6 +1052,8 @@ jobs: - name: Setup project Bun uses: ./.github/actions/setup-project-bun + with: + role: test-runner # See the Bun roles contract above test. - name: Install dependencies run: | @@ -1240,8 +1259,8 @@ jobs: run: bun run structure:check # The composite Bun setup, run on each runner family it serves, when a change touches only the - # action (see the setup_action filter). One step past the action proves it installed the runtime - # package.json declares; nothing else runs, so this never grows into a suite. + # action (see the setup_action filter). Each role proves its resolved and installed + # version matches package.json; nothing else runs, so this never grows into a suite. setup-action: name: setup action ${{ matrix.os }} needs: changes @@ -1277,6 +1296,26 @@ jobs: exit 1 fi + - name: Setup project Bun test runner + id: bun-test-runner + uses: ./.github/actions/setup-project-bun + with: + role: test-runner + + - name: Require the test runner package.json declares + shell: bash + env: + RESOLVED: ${{ steps.bun-test-runner.outputs.version }} + run: | + set -euo pipefail + declared="$(node -p "require('./package.json').testRunnerBun")" + installed="$(bun --version)" + echo "declared=$declared resolved=$RESOLVED installed=$installed" + if [ "$RESOLVED" != "$declared" ] || [ "$installed" != "$declared" ]; then + echo "::error::setup-project-bun resolved '$RESOLVED' and installed '$installed', but package.json testRunnerBun declares '$declared'" + exit 1 + fi + # The Rust remote-workspace helper, when a change touches it (see the remote_helper filter). # Formatting once, then clippy and the crate's tests on each platform: the sandbox and the live # confinement tests compile only on macOS and Windows, and Linux covers the protocol and stub. @@ -1432,10 +1471,8 @@ jobs: persist-credentials: false ref: ${{ needs.changes.outputs.checkout_ref }} - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: 1.3.14 + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun - name: Install dependencies run: | diff --git a/.github/workflows/dev-version-bump.yml b/.github/workflows/dev-version-bump.yml index b54a6d76d31..99a8946c3e4 100644 --- a/.github/workflows/dev-version-bump.yml +++ b/.github/workflows/dev-version-bump.yml @@ -78,10 +78,13 @@ jobs: persist-credentials: false # The repository-owned composite action, not a hand-pinned setup-bun SHA: it - # resolves the Bun version from package.json so the runtime SOT stays in one - # place. An independently pinned action here would drift from every other job. + # selects package.json's test-runner pin even for this single-file test so + # validation follows the CI runner contract (see setup-project-bun/action.yml). + # This job edits version metadata; it does not compile a shipped runtime. - name: Setup project Bun uses: ./.github/actions/setup-project-bun + with: + role: test-runner - name: Install dependencies run: bun install --frozen-lockfile diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2e28dac23c2..4c74e70a2d1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,8 +33,8 @@ For local development commands, architecture notes, and release workflow details contributing guide above instead of duplicating instructions here. Source development requires the `bun` CLI on your `PATH`. The published npm package bundles its own -Bun runtime for end users, but contributor commands such as `bun install`, `bun run test`, and -`bun run prepush` run from your local Bun installation. +Bun runtime for end users. Package scripts such as `bun run test` and `bun run prepush` may resolve +Bun through that bundled dependency. ## Pull request contract @@ -61,6 +61,10 @@ A ready-for-review PR is the author's claim that the change is complete, underst ## Local validation and hooks +The suite uses Bun 1.4.0 (`testRunnerBun`) while the shipped runtime is 1.4.2. +`scripts/test.ts` finds a 1.4.0 binary on PATH or in `~/.bun/bin`, or uses +`OCX_TEST_RUNNER_BUN`; install that version locally if it is missing. + Run `bun run test` before review readiness. If the full local suite is too costly for the task or available resources, run at least focused regression tests for the changed behavior. Document the reason, commands, results, and remaining diff --git a/Dockerfile b/Dockerfile index f8cfc4002b8..c8e587fe934 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,12 @@ # syntax=docker/dockerfile:1 # Keep the runtime aligned with package.json and pin the multi-platform image index. -ARG BUN_IMAGE=oven/bun:1.4.0@sha256:5ff609364c049b54eb0ff560ec96319729a972078ef2c755d758f0c6ef89c2d6 +# Runtime 1.4.2 fixes #6684's Windows Bun.fetch streaming; CI tests stay on 1.4.0 +# because 1.4.2 segfaults reloading preloads in multi-file --isolate (#4821; +# #6713 run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9). Rejoin after the +# upstream crash fix passes a unified-version lane=all dispatch, then delete +# package.json's testRunnerBun pin and setup-project-bun's role input. +ARG BUN_IMAGE=oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895 FROM ${BUN_IMAGE} AS manifest WORKDIR /home/bun/app diff --git a/README.md b/README.md index ec815156577..82fb6bacf3b 100644 --- a/README.md +++ b/README.md @@ -458,8 +458,8 @@ Acknowledging a report is not the same as triaging it, and no first-response tar ## Development -Source development requires the `bun` CLI on your `PATH`. This is separate from the published npm -package's bundled Bun runtime, which is used only by installed `ocx` commands. +Source development requires the `bun` CLI on your `PATH`. The published npm package bundles its own +Bun runtime for installed `ocx` commands; package scripts may also resolve Bun through that bundled dependency. ```bash git clone https://github.com/lidge-jun/opencodex.git diff --git a/bun.lock b/bun.lock index 46893143a37..063207baa8b 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,7 @@ "@bufbuild/protobuf": "^2.14.0", "@modelcontextprotocol/sdk": "^1.30.0", "@napi-rs/keyring": "1.3.0", - "bun": "1.4.0", + "bun": "1.4.2", "zod": "4.4.3", }, "devDependencies": { @@ -63,29 +63,29 @@ "@napi-rs/keyring-win32-x64-msvc": ["@napi-rs/keyring-win32-x64-msvc@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg=="], - "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.4.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GCpf8QuFLsyioVawP5HrMxA1ZRBlu6Hq9RNnSc3UTUWAzIxBso9trjoZczw1HdgpqSssFkszfIV2zmOzFTjhkw=="], + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MXdZkP1featqxZ+/VTXWG1BVjM4OGBehVY2Q88EeUj/7L0UMeCGItmyPYTN+wxvlGJ6F66JEtzsw+GvQWewnag=="], - "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.4.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cIrhwOr0SPEraewznhC+c/k6TG8bwFn5uZ4EJuXwjiKJLcAF36q7/bGjWkeXSe48JwMcPRUR054JXF7+cRwSSA=="], + "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.4.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-gZTxZuLjkUhAWjTETu3tw0WhsEdNkJ64daj60ybhPf835a2yollV3yTkK9JozvzKPx4TRFzLSl8C+U525pxVbw=="], - "@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.4.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-09x7wnjMR6M5KGBDBhVl2CpfoCIQOkVDbPX2KfIhpXv4N6grbWE7dfLPw/Ydi9gaUMGhU7UKhoz444Nu6RCycA=="], + "@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.4.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-SMNItMw1Z8QeeQVKnw8jA7xQNkeXdP+OPgin4Wi/QTx/B8RHHLnuZfqmFy7NtVeT2NF0kKYppW4WWd2CCYZjhQ=="], - "@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.4.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dRwzti/qJqV1HWplU27iUWUqp+f2DtFSf2yqQKSb+HH2dDOC//Uqd9u/A5h1DMsLszfP5OGP9UwQIKxVwFODaA=="], + "@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.4.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-THbPKXhO54N0DpFRKZNDZpQ7dpbX0bWASuARckAUS9wRtFIHsiY+uULXJvxJGo2YD1YewvXQ4G8Fj7XT5oBCiw=="], - "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y5yAtCbHK6JjprXEtkdklDQFPADgs+CkfcliyY5g4JJ8baGHyQSrfpSkX3XVJ2C+aBLsdwNDdW+oczMsAwx6uA=="], + "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-3BBP9ovJ2RGHFH6Ae1CAtxNtG1+YY6GD6rmYbsUosoAk9+OEl6zeDQ/k4fBkc6dYOJCtWnx8hUxzNzQATSmvYQ=="], - "@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.4.0", "", { "os": "android", "cpu": "arm64" }, "sha512-HpPIxJfDNPBPhiBNMyZoo/dOLijARfsx5j72vNuLtaTvl0Hh7HUculxjsOQ2WSyGoCgqXMEr1Qqjab1im9u1RA=="], + "@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.4.2", "", { "os": "android", "cpu": "arm64" }, "sha512-3mZKO2rhsNgbAUtAHC1UKUlF2zTxFraDZT/Elv8wzyH0fJL9h+Iv3TgB9lO63w89PRn3eFe+NRA1bhVgikKNPQ=="], - "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RUjAAkJ/CdNV++zVxyANWshPc73CECYsfhk0fWAkoJjtywxJ2BwXzI6nopBBDMfs0HS+fhRGn6zGwU8ccxLeJg=="], + "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-+Sm6y+lSiSFBOtXmnekp5Q6n1tUKlyv71FCPWBc61Cgb14T5eBs8SN/nh4MUCOKzONkI3O+as3MGUgikS4aCBQ=="], - "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Du44zebtPXJujvMLmtIxEQ6ykOhYt7L/Q+YIGVm+Yy+Pj/fpOnq60ggwIpKp/pGAFbYHNiTrA3JTjuZ9MTbZIg=="], + "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-9/E/UXOTpSo3YsV5g+FhtTd/qTpiWoKuxS12cqtuYA1ssu9fRAoPQnipFgGyck3tWO63iUdxBiygq+kELFawng=="], - "@oven/bun-linux-x64-android": ["@oven/bun-linux-x64-android@1.4.0", "", { "os": "android", "cpu": "x64" }, "sha512-u++KyLlfMn36yWz+AgJs+fZtS46UFDNpSSZhrcitkytONtNwq0X6Q9BDVEFXxYl/+Eec0xme1rb6MgW+U35WeA=="], + "@oven/bun-linux-x64-android": ["@oven/bun-linux-x64-android@1.4.2", "", { "os": "android", "cpu": "x64" }, "sha512-6HC5tzcC79113n2IHCTJMWv+HsQImv4ZFEK2XpYLxY6HbT8tM4cUM2Zv1bHZBQsS3jv/zYBamDJ1UX7If0d5tw=="], - "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-C1Dv+ISL8YKEKM9jAHzNifOcRUoziy6UMxh+yVXjUCP6QnbRhENDHLaIWWkQZJyBLTn0I3xozflorAlHiGzGqA=="], + "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-vVTKUg1bnPhRP/Hp73jIVoFh2vPFNYEqYX0ERKfZBOQEEHitNAeukZzzuUDZS0SoDCIpuWUGSpd/CDMbjdR+Uw=="], - "@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.4.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-FBAYaQpJBP0asgqzL6NFUfjdQqsV+kvTpJ/eWxPKj+RcDgIfPSuE8kvQuPYu5pa8u8JTujYMjmuyvHxVuQsInA=="], + "@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.4.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-8EJ1ST7339WJE3poPW5nBgVW/lWf9HBz4W27ZUNhburKmcBLOByPyE6DP9fHD8FQGm5c+ilUN2hX1mrW0jxq9Q=="], - "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.4.0", "", { "os": "win32", "cpu": "x64" }, "sha512-jRKv1NPLznMSZY5BEWciMF7zv0Tiyo2pQSxAJ3w+YWJ6y3VWNJQQQdLlV5Jx8lbOFDrJdrc9dD3GV17k3BP41A=="], + "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-+bN6OuVld/9diT/RLSXSW7JE6CvNE3gL9XsAEjULi1nUsXd6DNO6GuA9jNdNb3r8PdJFnYHr5aypNV1Oj3Rd9g=="], "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], @@ -139,7 +139,7 @@ "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], - "bun": ["bun@1.4.0", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.4.0", "@oven/bun-darwin-x64": "1.4.0", "@oven/bun-freebsd-aarch64": "1.4.0", "@oven/bun-freebsd-x64": "1.4.0", "@oven/bun-linux-aarch64": "1.4.0", "@oven/bun-linux-aarch64-android": "1.4.0", "@oven/bun-linux-aarch64-musl": "1.4.0", "@oven/bun-linux-x64": "1.4.0", "@oven/bun-linux-x64-android": "1.4.0", "@oven/bun-linux-x64-musl": "1.4.0", "@oven/bun-windows-aarch64": "1.4.0", "@oven/bun-windows-x64": "1.4.0" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-iRiFkc2W7UVpCyZXO9tod45TP9QCyN19fWqbpeN/jaM/K7uzeHYx/OSPsahMJazGKBgPsnxRt+4Jc43d8BcHZw=="], + "bun": ["bun@1.4.2", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.4.2", "@oven/bun-darwin-x64": "1.4.2", "@oven/bun-freebsd-aarch64": "1.4.2", "@oven/bun-freebsd-x64": "1.4.2", "@oven/bun-linux-aarch64": "1.4.2", "@oven/bun-linux-aarch64-android": "1.4.2", "@oven/bun-linux-aarch64-musl": "1.4.2", "@oven/bun-linux-x64": "1.4.2", "@oven/bun-linux-x64-android": "1.4.2", "@oven/bun-linux-x64-musl": "1.4.2", "@oven/bun-windows-aarch64": "1.4.2", "@oven/bun-windows-x64": "1.4.2" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-TrSXo6HJfIEaczpb3kjX82I2pL47vK1QUNmHRCUdz9IzaOwa9lzOXSWwu2l18YHE3sNfGRapVLd4nNm+22vVVA=="], "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], diff --git a/docs-site/src/content/docs/contributing.md b/docs-site/src/content/docs/contributing.md index a7c1dd547d0..888b80644a2 100644 --- a/docs-site/src/content/docs/contributing.md +++ b/docs-site/src/content/docs/contributing.md @@ -6,7 +6,7 @@ description: Develop opencodex — setup, layout, conventions, and how to add a ## Setup Source development requires the `bun` CLI on your `PATH`. The published npm package bundles its own -Bun runtime for users, but this checkout's scripts run through your local Bun installation. +Bun runtime for users; package scripts may resolve Bun through that bundled dependency. ```bash git clone https://github.com/lidge-jun/opencodex.git @@ -45,6 +45,10 @@ bun run privacy:scan # credential/privacy scan used by CI bun run prepare:package # refresh package launchers/assets ``` +The suite uses Bun 1.4.0 (`package.json`'s `testRunnerBun`) while the shipped runtime is 1.4.2. +`scripts/test.ts` finds a 1.4.0 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`. +Install that version locally if it is missing; the resolver never downloads it. + Run `bun run test` by default. If a full run is disproportionately expensive for the task size, available machine resources, or concurrent worktrees, you must at least run focused regression tests that exercise the changed behavior, such as `bun test tests//.test.ts`. Explain the diff --git a/docs-site/src/content/docs/fr/contributing.md b/docs-site/src/content/docs/fr/contributing.md index b27a50689f2..279b582c754 100644 --- a/docs-site/src/content/docs/fr/contributing.md +++ b/docs-site/src/content/docs/fr/contributing.md @@ -6,7 +6,7 @@ description: Développez opencodex — installation, structure, conventions et a ## Configuration Le développement depuis les sources exige la CLI `bun` dans votre `PATH`. Le paquet npm publié fournit son propre -runtime Bun aux utilisateurs, mais les scripts de ce dépôt utilisent votre installation locale de Bun. +runtime Bun aux utilisateurs ; les scripts du paquet peuvent résoudre Bun via cette dépendance incluse. ```bash git clone https://github.com/lidge-jun/opencodex.git diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index b82d731aeb7..a363084187c 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -557,7 +557,7 @@ input checks. opencodex does not publish an official container image. The repository does maintain a source-build [`Dockerfile`](https://github.com/lidge-jun/opencodex/blob/main/Dockerfile), [`compose.yaml`](https://github.com/lidge-jun/opencodex/blob/main/compose.yaml), and a narrow -`.dockerignore`. The build pins the multi-platform Bun 1.4.0 image index by digest, runs the proxy as +`.dockerignore`. The build pins the multi-platform Bun 1.4.2 image index by digest, runs the proxy as the non-root `bun` user, keeps the root filesystem read-only, drops Linux capabilities, and publishes only the data listener on the host's `127.0.0.1:10100` by default. The foreground process uses `OCX_SERVICE=1`, so stopping or recreating the container preserves routed Codex state instead diff --git a/docs-site/src/content/docs/tr/contributing.md b/docs-site/src/content/docs/tr/contributing.md index 4189a7ad5b6..ff5c86f2cb3 100644 --- a/docs-site/src/content/docs/tr/contributing.md +++ b/docs-site/src/content/docs/tr/contributing.md @@ -7,8 +7,8 @@ description: opencodex geliştirme — kurulum, düzen, kurallar ve yeni bir sa Kaynak kod üzerinde geliştirme yapmak için `PATH` ortam değişkeninizde `bun` CLI aracının bulunması gerekir. Yayınlanan npm paketi kullanıcılar için kendi Bun -çalışma zamanını paketler, ancak bu depodaki betikler yerel Bun kurulumunuz -üzerinden çalışır. +çalışma zamanını paketler; paket betikleri Bun'u bu paketlenmiş bağımlılıktan +bulabilir. ```bash git clone https://github.com/lidge-jun/opencodex.git diff --git a/gui/package.json b/gui/package.json index 33b37040cfb..c547ce4d0c7 100644 --- a/gui/package.json +++ b/gui/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "oxlint .", - "test": "bun test tests", + "test": "bun ../scripts/test-with-pinned-bun.ts tests", "lint:i18n": "oxlint src/pages src/components src/App.tsx src/main.tsx src/ui.tsx src/provider-workspace-data.ts", "doctor": "npx --yes react-doctor@0.9.11 --verbose --scope changed --base origin/main --no-telemetry", "doctor:full": "npx --yes react-doctor@0.9.11 --verbose --scope full --no-telemetry", diff --git a/package.json b/package.json index 0ec77640418..59b051d6298 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "trustedDependencies": [ "bun" ], + "testRunnerBun": "1.4.0", "scripts": { "dev": "bun run src/cli/index.ts start", "dev:proxy": "bun run src/cli/index.ts start", @@ -82,7 +83,7 @@ "@bufbuild/protobuf": "^2.14.0", "@modelcontextprotocol/sdk": "^1.30.0", "@napi-rs/keyring": "1.3.0", - "bun": "1.4.0", + "bun": "1.4.2", "zod": "4.4.3" }, "devDependencies": { diff --git a/readme/README.fr.md b/readme/README.fr.md index 36f7d4ed06b..5b3b9ff30a1 100644 --- a/readme/README.fr.md +++ b/readme/README.fr.md @@ -465,9 +465,9 @@ le trier, et aucun délai de première réponse n'est promis. ## Développement -Le développement depuis les sources nécessite la CLI `bun` dans votre `PATH`. Elle est distincte de -l'environnement d'exécution Bun inclus dans le paquet npm publié, lequel est uniquement utilisé par les -commandes `ocx` installées. +Le développement depuis les sources nécessite la CLI `bun` dans votre `PATH`. Le paquet npm publié inclut +son propre environnement d'exécution Bun pour les commandes `ocx` installées ; les scripts du paquet peuvent +également résoudre Bun via cette dépendance incluse. ```bash git clone https://github.com/lidge-jun/opencodex.git diff --git a/readme/README.ja.md b/readme/README.ja.md index 30fc3c1fe24..9f18657542e 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -460,8 +460,8 @@ opencodex は既定で `127.0.0.1` にバインドし、追加の認証を必要 ## 開発 -ソース開発には `PATH` に `bun` CLI が必要です。これは公開 npm パッケージが同梱する Bun ランタイム -とは別物で、同梱ランタイムはインストール済みの `ocx` コマンドだけが使います。 +ソース開発には `PATH` に `bun` CLI が必要です。公開 npm パッケージはインストール済みの `ocx` +コマンド用に Bun ランタイムを同梱し、パッケージのスクリプトもこの同梱依存関係から Bun を解決することがあります。 ```bash git clone https://github.com/lidge-jun/opencodex.git diff --git a/readme/README.ko.md b/readme/README.ko.md index f91c6eb460e..924231ea686 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -443,8 +443,8 @@ npm uninstall -g @bitkyc08/opencodex ## 개발 -소스 개발에는 `PATH`에 `bun` CLI가 있어야 합니다. 배포된 npm 패키지가 번들하는 Bun 런타임과는 -별개이며, 그 런타임은 설치된 `ocx` 명령만 씁니다. +소스 개발에는 `PATH`에 `bun` CLI가 있어야 합니다. 배포된 npm 패키지는 설치된 `ocx` 명령용 Bun +런타임을 번들하며, package script도 이 번들 의존성에서 Bun을 선택할 수 있습니다. ```bash git clone https://github.com/lidge-jun/opencodex.git diff --git a/readme/README.ru.md b/readme/README.ru.md index 025aa91d33b..ec0b28197fb 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -473,8 +473,8 @@ npm uninstall -g @bitkyc08/opencodex ## Разработка -Разработка из исходников требует CLI `bun` в вашем `PATH`. Это отдельно от встроенного рантайма Bun -опубликованного npm-пакета, который используют только установленные команды `ocx`. +Разработка из исходников требует CLI `bun` в вашем `PATH`. Опубликованный npm-пакет содержит свой рантайм Bun +для установленных команд `ocx`; скрипты пакета также могут находить Bun через эту встроенную зависимость. ```bash git clone https://github.com/lidge-jun/opencodex.git diff --git a/readme/README.tr.md b/readme/README.tr.md index 49226a1040b..a18af76323d 100644 --- a/readme/README.tr.md +++ b/readme/README.tr.md @@ -468,8 +468,8 @@ için bir süre taahhüt edilmez. ## Geliştirme -Kaynak geliştirmesi `PATH` üzerinde `bun` CLI gerektirir. Bu, yalnızca kurulu `ocx` komutlarının -kullandığı, yayımlanmış npm paketiyle gelen Bun çalışma zamanından ayrıdır. +Kaynak geliştirmesi `PATH` üzerinde `bun` CLI gerektirir. Yayımlanmış npm paketi kurulu `ocx` komutları +için kendi Bun çalışma zamanını içerir; paket betikleri de Bun'u bu paketlenmiş bağımlılıktan bulabilir. ```bash git clone https://github.com/lidge-jun/opencodex.git diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index 7f3734069eb..467fcbba5e6 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -440,8 +440,8 @@ CLI/配置/管理 API 参考 —— 由 [`docs-site/`](../docs-site) 构建, ## 开发 -源码开发需要 `PATH` 上的 `bun` CLI。它与已发布 npm 包捆绑的 Bun 运行时是分开的, -后者只给已安装的 `ocx` 命令使用。 +源码开发需要 `PATH` 上的 `bun` CLI。已发布的 npm 包为已安装的 `ocx` 命令捆绑自己的 Bun +运行时;包脚本也可能通过这个捆绑依赖解析 Bun。 ```bash git clone https://github.com/lidge-jun/opencodex.git diff --git a/readme/README.zh-TW.md b/readme/README.zh-TW.md index cdf1e2f1331..a775a5e0cb7 100644 --- a/readme/README.zh-TW.md +++ b/readme/README.zh-TW.md @@ -437,8 +437,8 @@ CLI/設定/管理 API 參考——由 [`docs-site/`](../docs-site) 建置, ## 開發 -從原始碼開發需要 `PATH` 上有 `bun` CLI。這與已發布 npm -套件打包的 Bun 執行環境不同,後者只給已安裝的 `ocx` 命令使用。 +從原始碼開發需要 `PATH` 上有 `bun` CLI。已發布的 npm 套件為已安裝的 `ocx` 命令打包自己的 +Bun 執行環境;套件指令碼也可能透過這個打包依賴解析 Bun。 ```bash git clone https://github.com/lidge-jun/opencodex.git diff --git a/readme/i18n-manifest.json b/readme/i18n-manifest.json index 8679e008891..8b3f0fc22b3 100644 --- a/readme/i18n-manifest.json +++ b/readme/i18n-manifest.json @@ -6,43 +6,43 @@ "file": "readme/README.fr.md", "label": "Français", "docsPath": "fr", - "sourceSha256": "b3da588aaecf8d25db77191c432bc0667936ced887fe4e62080b30f175ffa7dd" + "sourceSha256": "c97c3c4203d82eb8ffa1408f6d7111f1d5144312e8be1dc528747ff533e84556" }, "ko": { "file": "readme/README.ko.md", "label": "한국어", "docsPath": "ko", - "sourceSha256": "b3da588aaecf8d25db77191c432bc0667936ced887fe4e62080b30f175ffa7dd" + "sourceSha256": "c97c3c4203d82eb8ffa1408f6d7111f1d5144312e8be1dc528747ff533e84556" }, "zh-CN": { "file": "readme/README.zh-CN.md", "label": "简体中文", "docsPath": "zh-cn", - "sourceSha256": "b3da588aaecf8d25db77191c432bc0667936ced887fe4e62080b30f175ffa7dd" + "sourceSha256": "c97c3c4203d82eb8ffa1408f6d7111f1d5144312e8be1dc528747ff533e84556" }, "zh-TW": { "file": "readme/README.zh-TW.md", "label": "繁體中文", "docsPath": "zh-tw", - "sourceSha256": "b3da588aaecf8d25db77191c432bc0667936ced887fe4e62080b30f175ffa7dd" + "sourceSha256": "c97c3c4203d82eb8ffa1408f6d7111f1d5144312e8be1dc528747ff533e84556" }, "ru": { "file": "readme/README.ru.md", "label": "Русский", "docsPath": "ru", - "sourceSha256": "b3da588aaecf8d25db77191c432bc0667936ced887fe4e62080b30f175ffa7dd" + "sourceSha256": "c97c3c4203d82eb8ffa1408f6d7111f1d5144312e8be1dc528747ff533e84556" }, "ja": { "file": "readme/README.ja.md", "label": "日本語", "docsPath": "ja", - "sourceSha256": "b3da588aaecf8d25db77191c432bc0667936ced887fe4e62080b30f175ffa7dd" + "sourceSha256": "c97c3c4203d82eb8ffa1408f6d7111f1d5144312e8be1dc528747ff533e84556" }, "tr": { "file": "readme/README.tr.md", "label": "Türkçe", "docsPath": "tr", - "sourceSha256": "b3da588aaecf8d25db77191c432bc0667936ced887fe4e62080b30f175ffa7dd" + "sourceSha256": "c97c3c4203d82eb8ffa1408f6d7111f1d5144312e8be1dc528747ff533e84556" } } } diff --git a/scripts/lib/test-runner-bun.ts b/scripts/lib/test-runner-bun.ts new file mode 100644 index 00000000000..8763806104a --- /dev/null +++ b/scripts/lib/test-runner-bun.ts @@ -0,0 +1,68 @@ +import { execFileSync } from "node:child_process"; +import { homedir } from "node:os"; +import { posix, win32 } from "node:path"; +import { testRunnerBun } from "../../package.json"; + +export interface TestRunnerBunDeps { + pin: string; + currentVersion: string; + execPath: string; + env: Readonly>; + pathEntries: readonly string[]; + homeDir: string; + platform: NodeJS.Platform; + probeVersion: (binary: string) => string | undefined; +} + +/** Select the test pin without executing the bundled runtime or installing anything. */ +export function resolveTestRunnerBun(deps: TestRunnerBunDeps): string { + const { pin, currentVersion, execPath, env, probeVersion } = deps; + if (!/^\d+\.\d+\.\d+$/.test(pin)) throw new Error("package.json testRunnerBun must be an exact Bun version."); + if (currentVersion === pin) return execPath; + const path = deps.platform === "win32" ? win32 : posix; + const probe = (binary: string) => { + try { return probeVersion(binary)?.trim(); } catch { return undefined; } + }; + const guidance = `Running Bun ${currentVersion}; tests require Bun ${pin} (package.json testRunnerBun). ` + + "The bundled runtime can crash in multi-file bun test --isolate; see " + + "https://github.com/lidge-jun/opencodex/pull/6713 and https://github.com/lidge-jun/opencodex/pull/4821. " + + `Install Bun ${pin} (curl -fsSL https://bun.sh/install | bash -s "bun-v${pin}") ` + + `or point OCX_TEST_RUNNER_BUN at a Bun ${pin} binary. No download was attempted.`; + if (env.OCX_TEST_RUNNER_BUN !== undefined) { + const override = path.resolve(env.OCX_TEST_RUNNER_BUN); + const version = env.OCX_TEST_RUNNER_BUN ? probe(override) : undefined; + if (version === pin) return override; + throw new Error(`OCX_TEST_RUNNER_BUN reports ${version ?? "no usable version"}. ${guidance}`); + } + const seen = new Set(); + for (const entry of [...deps.pathEntries, path.join(deps.homeDir, ".bun", "bin")]) { + if (!entry) continue; + const directory = path.resolve(entry.replace(/^"(.*)"$/, "$1")); + if (directory.split(/[\\/]/).some(segment => segment.toLowerCase() === "node_modules")) continue; + const binary = path.join(directory, deps.platform === "win32" ? "bun.exe" : "bun"); + const key = deps.platform === "win32" ? binary.toLowerCase() : binary; + if (seen.has(key)) continue; + seen.add(key); + if (probe(binary) === pin) return binary; + } + throw new Error(`No matching test runner found outside node_modules on PATH or in ~/.bun/bin. ${guidance}`); +} + +/** Resolve before test HOME isolation, using the repository pin rather than the caller's cwd. */ +export function getTestRunnerBun(): string { + const pathKey = Object.keys(process.env).find(key => key.toLowerCase() === "path"); + const binary = resolveTestRunnerBun({ + pin: testRunnerBun, + currentVersion: Bun.version, + execPath: process.execPath, + env: process.env, + pathEntries: (process.env[pathKey ?? "PATH"] ?? "").split(process.platform === "win32" ? ";" : ":"), + homeDir: homedir(), + platform: process.platform, + probeVersion: candidate => execFileSync(candidate, ["--version"], { + encoding: "utf8", timeout: 5_000, maxBuffer: 1024, windowsHide: true, stdio: ["ignore", "pipe", "ignore"], + }), + }); + if (binary !== process.execPath) console.log(`test runner: Bun ${testRunnerBun} (${binary})`); + return binary; +} diff --git a/scripts/openai-provider-option-final-gates.ts b/scripts/openai-provider-option-final-gates.ts index e157179c420..018aeedd6c8 100644 --- a/scripts/openai-provider-option-final-gates.ts +++ b/scripts/openai-provider-option-final-gates.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; import { scanEvidence } from "./openai-provider-option-evidence-scan"; +import { getTestRunnerBun } from "./lib/test-runner-bun"; export interface GateSpec { name: string; @@ -126,7 +127,7 @@ if (import.meta.main) { const paths = ["030_e2e.json", "030_client_history.json", "030_runtime_smoke.json", "030_gate_summary.txt"].map(name => join(evidenceDir, name)); const run = async (gate: GateSpec): Promise => { process.stdout.write(`[gate] ${gate.name}\n`); - const child = Bun.spawn(gate.command, { + const child = Bun.spawn(gate.command[0] === "bun" && gate.command[1] === "test" ? [getTestRunnerBun(), ...gate.command.slice(1)] : gate.command, { cwd: gate.cwd, env: gate.env ? { ...process.env, ...gate.env } : process.env, stdin: "ignore", diff --git a/scripts/release.ts b/scripts/release.ts index 07c84d5ae4e..82d6fd2fcbd 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -28,6 +28,7 @@ */ import { commandInvocation } from "../src/lib/win-exec"; import { VERSION_SOURCE_PATHS } from "./release-version-sources"; +import { getTestRunnerBun } from "./lib/test-runner-bun"; import { compareVersions as compareReleaseVersions, nextPreviewRelease, @@ -561,6 +562,7 @@ await runLoud(["bun", "run", "audit:high"]); console.log("→ typecheck"); await runLoud(["bun", "x", "tsc", "--noEmit"]); console.log("→ test suite"); +const testRunner = getTestRunnerBun(); // Match CI's isolation policy instead of inventing a second one. `ci.yml` runs // the storage-policy and api-usage harnesses in DEDICATED jobs and excludes them // from the general shards (`scripts/ci/run-bun-test-batches.sh` @@ -573,10 +575,8 @@ console.log("→ test suite"); // was green: the worst kind of gate, one that blocks a good release and teaches // you to distrust it. Same files and same coverage as before (915), now in the // same groups CI uses. -// Every command here stays a `bun` invocation. The release-helper suite shims -// exactly `bun`, `gh`, `git` and `npm` onto a scratch PATH to record calls -// without executing them; a `bash` step would miss that shim, escape into the -// real suite, and fail the helper tests with exit 127. +// Test commands use the pinned runner; audit, typecheck and privacy keep the runtime. +// The release-helper fixture mocks runner resolution to keep test calls on its shim. const ISOLATED_TEST_FILES = [ "./tests/storage/api-storage-policy-already-running.test.ts", "./tests/storage/api-storage-policy-mutation-busy.test.ts", @@ -587,13 +587,13 @@ const ISOLATED_TEST_FILES = [ "./tests/server/api-usage.test.ts", ]; await runLoud([ - "bun", "test", "--isolate", "tests", + testRunner, "test", "--isolate", "tests", "--path-ignore-patterns=**/api-storage-policy*.test.ts", "--path-ignore-patterns=**/api-storage.test.ts", "--path-ignore-patterns=**/api-usage.test.ts", ]); for (const isolated of ISOLATED_TEST_FILES) { - await runLoud(["bun", "test", "--isolate", isolated]); + await runLoud([testRunner, "test", "--isolate", isolated]); } console.log("→ privacy scan"); await runLoud(["bun", "run", "privacy:scan"]); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 5a6f6c0f21f..7cdc105cc69 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1815,8 +1815,7 @@ "terminal-guard-server.test.ts": "server", "terminal-guard.test.ts": "server", "test-home-guard.test.ts": "ci-workflows", - "test-runner.test.ts": "ci-workflows", - "test-sandbox-cleanup.test.ts": "ci-workflows", + "test-runner.test.ts": "ci-workflows", "test-runner-bun.test.ts": "ci-workflows", "test-sandbox-cleanup.test.ts": "ci-workflows", "thought-signature-credential-scope.test.ts": "responses", "token-estimate.test.ts": "lib", "token-guardian.test.ts": "codex-integration", diff --git a/scripts/test-layout/verify.ts b/scripts/test-layout/verify.ts index 461e763a29c..a0184b61bc6 100644 --- a/scripts/test-layout/verify.ts +++ b/scripts/test-layout/verify.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node import { basename, join } from "node:path"; import { LAYOUT_PATH, loadLayout, scanEscapes } from "./schema"; import { listTestFiles, parseDomainArgs, repoRootFromHere } from "./plan"; +import { getTestRunnerBun } from "../lib/test-runner-bun"; /** * Verify one or more migrated domains: @@ -142,7 +143,7 @@ export function runVerify(options: VerifyOptions): VerifyReport { let testExit = 0; if (!options.skipTests && domains.length > 0) { - const proc = Bun.spawnSync(["bun", "test", "--isolate", ...domains.map(d => `tests/${d}`)], { cwd: root, stdout: "inherit", stderr: "inherit" }); + const proc = Bun.spawnSync([getTestRunnerBun(), "test", "--isolate", ...domains.map(d => `tests/${d}`)], { cwd: root, stdout: "inherit", stderr: "inherit" }); testExit = proc.exitCode; log(`bun test exit ${testExit}`); } diff --git a/scripts/test-with-pinned-bun.ts b/scripts/test-with-pinned-bun.ts new file mode 100644 index 00000000000..ecb23ec7012 --- /dev/null +++ b/scripts/test-with-pinned-bun.ts @@ -0,0 +1,23 @@ +import { getTestRunnerBun } from "./lib/test-runner-bun"; + +// Preserve the caller's test arguments, working directory and Bun test configuration. +const child = Bun.spawn([getTestRunnerBun(), "test", ...process.argv.slice(2)], { + stdin: "inherit", stdout: "inherit", stderr: "inherit", +}); +const signalExitCodes = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 } as const; +let interruptionExitCode: number | undefined; +const handlers = (["SIGINT", "SIGTERM", "SIGHUP"] as const).map(signal => { + const handler = () => { + interruptionExitCode ??= signalExitCodes[signal]; + try { child.kill(signal); } catch { /* child already exited */ } + }; + process.on(signal, handler); + return { signal, handler }; +}); +let exitCode: number; +try { + exitCode = await child.exited; +} finally { + for (const { signal, handler } of handlers) process.off(signal, handler); +} +process.exit(interruptionExitCode ?? exitCode); diff --git a/scripts/test.ts b/scripts/test.ts index 7adeeee74d5..68a60162ece 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, mkdtempSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { basename, join } from "node:path"; +import { getTestRunnerBun } from "./lib/test-runner-bun"; import { acquireTestRunLock, resolveWrappedTestRunLockPath, @@ -552,6 +553,7 @@ export async function runTestLane( stdout: (value: string) => { process.stdout.write(value); }, stderr: (value: string) => { process.stderr.write(value); }, }, + testRunner = getTestRunnerBun(), ): Promise<{ exitCode: number; output: string }> { const isolated = createIsolatedTestEnvironment({ ...process.env, @@ -565,7 +567,7 @@ export async function runTestLane( }); const startedAt = Date.now(); let interrupted: NodeJS.Signals | null = null; - const child = Bun.spawn([process.execPath, "test", ...lane.args], { + const child = Bun.spawn([testRunner, "test", ...lane.args], { env: isolated.env, stdin: "inherit", stdout: capture ? "pipe" : "inherit", @@ -663,6 +665,11 @@ export function ensureGuiDependencies(io: { } if (import.meta.main) { + let testRunner: string; + try { testRunner = getTestRunnerBun(); } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } const requestedTests = process.argv.slice(2); const guiDependencies = ensureGuiDependencies(); if (guiDependencies.kind === "failed") { @@ -708,7 +715,7 @@ if (import.meta.main) { let exitCode = 0; let captured = ""; for (const lane of resolveBunTestPlan(requestedTests, changedRun?.comparisonCommit)) { - const result = await runTestLane(lane, runId, inheritedLock, Boolean(changedRun)); + const result = await runTestLane(lane, runId, inheritedLock, Boolean(changedRun), undefined, testRunner); captured += result.output; if (result.exitCode !== 0 && exitCode === 0) exitCode = result.exitCode; if ([124, 130, 143].includes(result.exitCode)) break; diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index c0c1000c855..aec68b4b1fa 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -229,9 +229,9 @@ the existing repository-scoped open-PR lookup runs; absent or ambiguous matches | `.github/workflows/react-doctor.yml` | `pull_request` (opened, synchronize, reopened, ready_for_review) and `push` to `main`; no path filter | React-focused static review. Findings fail the job; write-scoped outputs stay disabled, a contract pinned by `tests/ci-workflows/ci-workflows.test.ts`. | | `.github/workflows/stale-needs-info.yml` | `schedule` only (daily 06:15 UTC); deliberately no manual dispatch | Closes issues left in needs-info past the grace period. Manual dispatch is omitted so a branch-selected run cannot execute that branch's body with issue write scope. | -`pull_request_target`, `issues`, and `schedule` workflows always load from the repository default -branch, not from `dev`. Landing a change to one of them on `dev` does not change live behavior until -it is promoted, so those files follow the promotion model rather than ordinary integration. +`pull_request_target`, `issues`, and `schedule` workflows always load from the repository default branch, not from `dev`. Landing a change to one of them on `dev` does not change live behavior until it is promoted, so those files follow the promotion model rather than ordinary integration. + +CI setup uses the [Bun runtime and test-runner pins](../runtime.md#bun-runtime-and-test-runner): test jobs (including development-version validation) choose `test-runner`, while release packaging, desktop/widget compilation and runtime smokes keep the default `runtime` role. Local layout verification resolves that test pin, and `gui/package.json` runs `scripts/test-with-pinned-bun.ts` to preserve its working directory, configuration and arguments on the same pin. The wrapper forwards SIGINT, SIGTERM and SIGHUP, waits for child exit and preserves interruption status (130, 143 or 129). `scripts/test.ts` owns `SERIAL_FULL_SUITE_FILES`, the shared process-isolation roster. Local full-suite runs, both macOS paths, and `scripts/ci/run-bun-test-batches.sh` execute those files @@ -338,7 +338,7 @@ so the management route stays the single domain schema. The source runs on Bun, but the published package does **not** require a user-installed Bun. `package.json` `bin` points at `bin/ocx.mjs` (a Node shim), and the Bun runtime ships as the `bun` -npm dependency (esbuild-style: a tiny main package plus platform-specific `@oven/bun-*` +npm dependency pinned to `1.4.2` (esbuild-style: a tiny main package plus platform-specific `@oven/bun-*` `optionalDependencies`, finalized by the dependency's own `postinstall: node install.js`). Invariants: @@ -401,7 +401,7 @@ respawn runs the replaced files at the same path, and refuses while that version Package release is npm-focused. `package.json` exposes `opencodex` and `ocx`, `prepublishOnly` runs typecheck and GUI build. `scripts/release.ts` accepts either an explicit version or `--bump patch|minor|major`; the stable and preview channels use separate resolvers in -`scripts/version-line.ts`. It runs local typecheck, `bun test --isolate tests`, and +`scripts/version-line.ts`. It runs local typecheck, tests on the pin selected by `scripts/lib/test-runner-bun.ts`, and `bun run privacy:scan` before the version bump, commit/push, Cross-platform CI wait, and GitHub Release workflow dispatch. Docs publishing is separate from npm release publishing. diff --git a/structure/runtime.md b/structure/runtime.md index d3eac545039..e9a7bbd1e34 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -2,22 +2,23 @@ The minute sweep checks persisted activation deadlines locally; only missing deadlines trigger metadata discovery. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). +## Bun runtime and test runner + +`package.json` pins the shipped npm runtime (`dependencies.bun`) to 1.4.2 and the test runner (`testRunnerBun`) to 1.4.0; `@types/bun` remains unchanged. Runtime 1.4.2 fixes #6684's Windows Bun.fetch truncation after the first cleartext upstream body chunk. +The runner pin avoids 1.4.2's multi-file `bun test --isolate` preload reload segfault (`load_preloads -> JSModuleLoader::loadModule -> JSPromise::status`, address 0x10): #4064 introduced 1.4.2 on 2026-09-09 and #4821 reverted it on 2026-09-17. +#6713 run 37631558263 reproduces it on Linux 1/4, macOS 2/2 and Windows 9/9; single-file success does not prove the multi-file runner works. + +`.github/actions/setup-project-bun/action.yml` defaults to role `runtime` (`dependencies.bun`); `test-runner` resolves `testRunnerBun`. Unknown roles and missing or invalid pins fail closed. `.github/workflows/ci.yml` verifies both resolved and installed versions in `setup-action`; `tests/ci-workflows/ci-scope-gaps.test.ts` guards job roles. +Test jobs, including single-file development-version validation, select `test-runner`. They also build dashboard fixtures; `gates` uploads a dashboard preview and checks release-helper syntax on that pin. +Release packaging, Docker, installed-artifact and service smoke jobs, desktop shell and widget preparation use `runtime`. Shipped binaries compile through `process.execPath` in `scripts/build-standalone.ts` and `desktop/scripts/prepare-sidecar.ts` on the runtime pin. + +`scripts/lib/test-runner-bun.ts` selects the test pin for `scripts/test.ts`, `scripts/release.ts` preflight, `scripts/test-layout/verify.ts`, provider-option gates and GUI package tests via `scripts/test-with-pinned-bun.ts`: matching current Bun first, then a version-checked `OCX_TEST_RUNNER_BUN`, then PATH outside node_modules and `~/.bun/bin`. Missing or mismatched binaries fail with install guidance, without downloading; package-script PATH shadowing cannot select the shipped runtime for tests. Rejoin only after an upstream fix and green unified `lane=all` proof; then remove the runner pin, resolver and CI role split. + ## Resolved static model policy -`src/router.ts` attaches one frozen `ResolvedModelPolicy` to every `RouteResult`. Fast observation, persistence and cost provenance follow the [response-tier authority contract](transports/responses.md#response-tier-observation-authority); outbound Fast policy is unchanged. Policy/combo -route spreads retain that object. Every initial, fallback, and recovery route is recaptured for the -request's original inbound protocol before route-dependent normalization, and all adapter rebuilds -consume its recorded adapter. A translated Chat or Anthropic replay therefore cannot inherit a -Responses-only default. Credential, account, quota, health, cooldown, and observed transport -evidence remain late and cannot widen a captured static limit. - -Virtual models are the sole model-identity transition: the ordinary and compact paths preserve the -selected public id in diagnostics, rewrite `route.modelId` to the upstream wire id, and atomically -replace `route.staticPolicy` before adapter or capability decisions continue. Model aliases are -resolved before the route result is built, so their policy is already keyed by the native wire id. `src/router.ts` applies bare `blockedModelRedirects` keys after this resolution; ordinary targets substitute one upstream model id on the selected provider/account, including slash-valued targets, while only targets explicitly naming a different configured provider reroute. Qualified source keys take precedence only for those cross-provider targets. A cross-provider redirect sets `RouteResult.credentialDomainRewrite`, which Responses and Chat header scoping treat like combo/policy routes, so a caller credential for the source route is never forwarded. `src/cli/alias.ts` consumes the JSON output flag before choosing its default list action. `ocx alias --json` and both flag positions around an explicit action preserve the selected read or mutation. -Live selector hints obey the [credential-scoped cache contract](catalog.md): a selection change -cannot reuse the previous credential's roster to choose an alias target. Passive OAuth observation -neither refreshes credentials nor repairs their storage. Cross-provider redirects require an enabled destination with a resolved key, usable stored OAuth account, or local/keyless auth (never source-caller forward auth), and share one visited set and five-edge budget across alias, provider, combo and policy resolution; every policy redirect must end at an eligible declared candidate, including same-provider substitutions and candidate fallback retries, and pinned account selectors reject provider changes. Decision traces name the final provider/model while policy candidates retain selection evidence and combos retain their route kind and redirect reason. +`src/router.ts` attaches one frozen `ResolvedModelPolicy` to every `RouteResult`. Fast observation, persistence and cost provenance follow the [response-tier authority contract](transports/responses.md#response-tier-observation-authority); outbound Fast policy is unchanged. Policy/combo route spreads retain that object. Every initial, fallback, and recovery route is recaptured for the request's original inbound protocol before route-dependent normalization, and all adapter rebuilds consume its recorded adapter. A translated Chat or Anthropic replay therefore cannot inherit a Responses-only default. Credential, account, quota, health, cooldown, and observed transport evidence remain late and cannot widen a captured static limit. + +Virtual models are the sole model-identity transition: the ordinary and compact paths preserve the selected public id in diagnostics, rewrite `route.modelId` to the upstream wire id, and atomically replace `route.staticPolicy` before adapter or capability decisions continue. Model aliases are resolved before the route result is built, so their policy is already keyed by the native wire id. `src/router.ts` applies bare `blockedModelRedirects` keys after this resolution; ordinary targets substitute one upstream model id on the selected provider/account, including slash-valued targets, while only targets explicitly naming a different configured provider reroute. Qualified source keys take precedence only for those cross-provider targets. A cross-provider redirect sets `RouteResult.credentialDomainRewrite`, which Responses and Chat header scoping treat like combo/policy routes, so a caller credential for the source route is never forwarded. `src/cli/alias.ts` consumes the JSON output flag before choosing its default list action. `ocx alias --json` and both flag positions around an explicit action preserve the selected read or mutation. Live selector hints obey the [credential-scoped cache contract](catalog.md): a selection change cannot reuse the previous credential's roster to choose an alias target. Passive OAuth observation neither refreshes credentials nor repairs their storage. Cross-provider redirects require an enabled destination with a resolved key, usable stored OAuth account, or local/keyless auth (never source-caller forward auth), and share one visited set and five-edge budget across alias, provider, combo and policy resolution; every policy redirect must end at an eligible declared candidate, including same-provider substitutions and candidate fallback retries, and pinned account selectors reject provider changes. Decision traces name the final provider/model while policy candidates retain selection evidence and combos retain their route kind and redirect reason. Routed Meta Muse requests use the registry-owned [Muse effort and header contract](providers-and-adapters.md); `max` reaches the provider through the existing reasoning mapper. @@ -25,8 +26,7 @@ Native result continuations and function-result injection follow [the mode-speci Native steering follows [the shared WebSocket contract](transports/streaming-health.md#experimental-native-mid-turn-steering); this surface's defaults remain unchanged. -Responses admission and finalization are composed through the -[core module ownership](transports/responses.md#core-module-ownership). Kiro's optional account-load admission is process-local and request-owned; its slot ends with the response body or cancellation. Other providers retain their admission path. Cancellation after a slot grant and before the request resumes releases the slot and returns cancellation without dispatch. +Responses admission and finalization are composed through the [core module ownership](transports/responses.md#core-module-ownership). Kiro's optional account-load admission is process-local and request-owned; its slot ends with the response body or cancellation. Other providers retain their admission path. Cancellation after a slot grant and before the request resumes releases the slot and returns cancellation without dispatch. Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote-catalog-http-proxy-routing). CLI model lists expose shared catalog estimates as `price`; `models price` retains the saved override in `cost` and adds `effectiveCost`. Explicit zero overrides win; unknown prices remain null, and automatic defaults are never persisted to `modelCosts`. Covered by `tests/cli/cli-models.test.ts` and `tests/cli/cli-models-price.test.ts`. Ordinary model discovery, startup sync, and explicit sync apply the [new-arrival catalog contract](catalog.md#shared-catalog) before publishing rows. The scoped persisted decision also updates the live config merge baseline, so subsequent visibility writes preserve manual re-enablement. A file-loaded inventory that already differs from disk can publish HTTP rows through a detached policy projection without adding unsaved live disables; retained catalog writes still refuse stale inventory, and its load provenance follows the [config contract](config.md). diff --git a/tests/ci-workflows/ci-scope-gaps.test.ts b/tests/ci-workflows/ci-scope-gaps.test.ts index a64fb261b0c..0169eae7f96 100644 --- a/tests/ci-workflows/ci-scope-gaps.test.ts +++ b/tests/ci-workflows/ci-scope-gaps.test.ts @@ -8,7 +8,9 @@ * suite, and an arm in the aggregate gate. */ import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; type Step = { id?: string; name?: string; uses?: string; run?: string; env?: Record; with?: Record }; @@ -43,6 +45,153 @@ describe("an edit to the setup action alone", () => { expect(scriptOf(job)).toContain("bun --version"); expect(job.strategy?.matrix?.os).toEqual(["ubuntu-latest", "windows-latest", "macos-latest"]); }); + + test("checks resolved and installed versions for both roles", () => { + const steps = jobs["setup-action"]?.steps ?? []; + const setups = steps.filter(step => step.uses === "./.github/actions/setup-project-bun"); + expect(setups.map(step => step.with?.role ?? "runtime")).toEqual(["runtime", "test-runner"]); + for (const [index, key] of ["dependencies.bun", "testRunnerBun"].entries()) { + const setup = setups[index]!; + const check = steps[steps.indexOf(setup) + 1]!; + expect(check.env?.RESOLVED).toBe(`\${{ steps.${setup.id}.outputs.version }}`); + expect(check.run).toContain(`require('./package.json').${key}`); + expect(check.run).toContain('installed="$(bun --version)"'); + expect(check.run).toContain('[ "$RESOLVED" != "$declared" ] || [ "$installed" != "$declared" ]'); + expect(check.run).toContain("exit 1"); + } + }); +}); + +// #6713: user runtimes and compiled artifacts need 1.4.2; isolate runners need 1.4.0. +// Check the active setup at each test command, so a later role switch cannot evade the guard. +const runsBunTests = (step: Step): boolean => + /\bbun\s+test\b|\bbun\s+run\s+test(?::changed)?(?:\s|$)|\bbun\s+(?:run\s+)?scripts\/test\.ts\b|scripts\/ci\/run-bun-test-batches\.sh/.test( + (step.run ?? "").split("\n").filter(line => !line.trimStart().startsWith("#")).join("\n"), + ); +const workflowJobs = new Map(readdirSync(repoPath(".github", "workflows")) + .filter(file => file.endsWith(".yml")) + .sort() + .map(file => [file, (Bun.YAML.parse(readFileSync(repoPath(".github", "workflows", file), "utf8")) as { + jobs: Record; + }).jobs])); + +describe("CI Bun roles", () => { + test("recognizes direct tests, both suite wrappers, and keeps Swift tests on runtime", () => { + for (const run of ["bun test --isolate tests", "bun run test", "bun run test:changed", + "bun scripts/test.ts", "bun run scripts/test.ts", "bash scripts/ci/run-bun-test-batches.sh 1/4"]) { + expect(runsBunTests({ run })).toBeTrue(); + } + expect(runsBunTests({ run: "bun run test:macos" })).toBeFalse(); + expect(runsBunTests({ run: "# bun run test" })).toBeFalse(); + }); + + test("covers every known Bun test job including single-file version validation", () => { + const testing = [...workflowJobs].flatMap(([file, fileJobs]) => Object.entries(fileJobs) + .filter(([, job]) => job.steps?.some(runsBunTests)).map(([name]) => `${file}/${name}`)); + expect(testing).toEqual(expect.arrayContaining([ + "ci.yml/api-usage", "ci.yml/gates", "ci.yml/macos-control", "ci.yml/platform-macos", + "ci.yml/platform-windows", "ci.yml/storage-policy", "ci.yml/test", + "dev-version-bump.yml/open-bump-pr", + ])); + }); + + for (const [file, fileJobs] of workflowJobs) { + test(`${file}: test commands use test-runner; other jobs keep runtime`, () => { + for (const [name, job] of Object.entries(fileJobs)) { + if (file === "ci.yml" && name === "setup-action") continue; // Both roles are verified above. + const steps = job.steps ?? []; + const testing = steps.some(runsBunTests); + let activeSetup: Step | undefined; + for (const step of steps) { + if (step.uses === "./.github/actions/setup-project-bun") { + activeSetup = step; + expect(`${name}:${step.with?.role ?? "runtime"}`).toBe(`${name}:${testing ? "test-runner" : "runtime"}`); + } else if (step.uses?.startsWith("oven-sh/setup-bun@")) { + activeSetup = undefined; + } + if (runsBunTests(step)) { + expect(`${name}:${activeSetup?.with?.role}`).toBe(`${name}:test-runner`); + } + } + } + }); + } + + test("release, packaged runtime and sidecar jobs use the default runtime action", () => { + const runtimeJobs: Record = { + "ci.yml": ["desktop-shell", "widget", "docker-smoke", "keyring-smoke"], + "release.yml": ["preflight", "package-standalone", "package-desktop", "verify-release", "publish", "attach-release"], + "service-lifecycle.yml": ["linux-systemd", "macos-launchd", "windows-schtasks"], + "desktop-installed-gate.yml": ["macos", "windows", "linux"], + }; + for (const [file, names] of Object.entries(runtimeJobs)) { + for (const name of names) { + const setups = workflowJobs.get(file)?.[name]?.steps?.filter(step => step.uses === "./.github/actions/setup-project-bun") ?? []; + expect(`${file}/${name}:${setups.length}`).toBe(`${file}/${name}:1`); + expect(setups[0]?.with?.role).toBeUndefined(); + } + } + }); +}); + +type BunAction = { + inputs: { role: { default: string } }; + outputs: { version: { value: string } }; + runs: { steps: Step[] }; +}; +const bunAction = Bun.YAML.parse(readFileSync(repoPath(...ACTION_PATH.split("/")), "utf8")) as BunAction; +const resolveStep = bunAction.runs.steps.find(step => step.id === "resolve")!; +const resolver = resolveStep.run?.match(/node <<'NODE'\n([\s\S]*?)\nNODE/)?.[1]; + +function resolveBunPin(pkg: unknown, role = bunAction.inputs.role.default) { + if (!resolver) throw new Error("setup-project-bun's Node resolver is missing"); + const scratch = repoPath(".tmp"); + mkdirSync(scratch, { recursive: true }); + const fixture = mkdtempSync(join(scratch, "bun-role-")); + const output = join(fixture, "output"); + try { + writeFileSync(join(fixture, "package.json"), JSON.stringify(pkg)); + writeFileSync(output, ""); + const result = Bun.spawnSync(["node", "-e", resolver], { + cwd: fixture, env: { ...process.env, BUN_ROLE: role, GITHUB_OUTPUT: output }, + }); + return { status: result.exitCode, stderr: result.stderr.toString(), output: readFileSync(output, "utf8") }; + } finally { + removeTreeWithRetry(fixture); + } +} + +describe("setup-project-bun pin resolution", () => { + const pkg = { dependencies: { bun: "1.4.2" }, testRunnerBun: "1.4.0" }; + test("defaults to runtime and routes the requested role without changing the output contract", () => { + expect(bunAction.inputs.role.default).toBe("runtime"); + expect(resolveStep.env?.BUN_ROLE).toBe("${{ inputs.role }}"); + expect(bunAction.outputs.version.value).toBe("${{ steps.resolve.outputs.version }}"); + const setup = bunAction.runs.steps.find(step => step.uses?.startsWith("oven-sh/setup-bun@")); + expect(setup?.with?.["bun-version"]).toBe("${{ steps.resolve.outputs.version }}"); + expect(resolveBunPin(pkg)).toMatchObject({ status: 0, output: "version=1.4.2\n" }); + expect(resolveBunPin(pkg, "test-runner")).toMatchObject({ status: 0, output: "version=1.4.0\n" }); + }); + + test("unknown and empty roles fail without emitting a version", () => { + for (const role of ["", "testing", "runtime; echo injected"]) { + const result = resolveBunPin(pkg, role); + expect(result).toMatchObject({ status: 1, output: "" }); + expect(result.stderr).toContain("Unknown Bun role"); + } + }); + + test("each role fails closed on missing, empty, non-string and unpinned values", () => { + for (const value of [undefined, null, "", "undefined", 140, "^1.4.0", "1.4.0\n", "1.4.0\nversion=1.4.2"]) { + for (const role of ["runtime", "test-runner"]) { + const broken = role === "runtime" ? { ...pkg, dependencies: { bun: value } } : { ...pkg, testRunnerBun: value }; + const result = resolveBunPin(broken, role); + expect(result).toMatchObject({ status: 1, output: "" }); + expect(result.stderr).toContain("Missing or invalid package.json"); + } + } + expect(resolveBunPin({}, "runtime")).toMatchObject({ status: 1, output: "" }); + }); }); describe("an edit to the remote-workspace helper alone", () => { diff --git a/tests/ci-workflows/docs-bun-source-requirement.test.ts b/tests/ci-workflows/docs-bun-source-requirement.test.ts index a0891e50048..ff34387f8bb 100644 --- a/tests/ci-workflows/docs-bun-source-requirement.test.ts +++ b/tests/ci-workflows/docs-bun-source-requirement.test.ts @@ -3,8 +3,8 @@ import { repoPath } from "../helpers/repo-root"; /** * Every contributor entry point must say that building from source needs a local `bun`, and - * must keep that separate from the bundled runtime that ships inside the npm package. Users - * who install `ocx` never need their own Bun; contributors always do. + * explain the bundled runtime and package-script PATH shadowing. Users who install `ocx` + * never need their own Bun; contributors need the CLI and the separately pinned test runner. * * Each file is checked as one whole normalized paragraph rather than as scattered fragments. * Matching fragments independently across a whole file passes even after the explanatory @@ -19,20 +19,20 @@ const CASES = [ path: "CONTRIBUTING.md", paragraph: "Source development requires the `bun` CLI on your `PATH`. The published npm package bundles its own" - + " Bun runtime for end users, but contributor commands such as `bun install`, `bun run test`, and" - + " `bun run prepush` run from your local Bun installation.", + + " Bun runtime for end users. Package scripts such as `bun run test` and `bun run prepush` may resolve" + + " Bun through that bundled dependency.", }, { path: "README.md", paragraph: - "Source development requires the `bun` CLI on your `PATH`. This is separate from the published npm" - + " package's bundled Bun runtime, which is used only by installed `ocx` commands.", + "Source development requires the `bun` CLI on your `PATH`. The published npm package bundles its own" + + " Bun runtime for installed `ocx` commands; package scripts may also resolve Bun through that bundled dependency.", }, { path: "docs-site/src/content/docs/contributing.md", paragraph: "Source development requires the `bun` CLI on your `PATH`. The published npm package bundles its own" - + " Bun runtime for users, but this checkout's scripts run through your local Bun installation.", + + " Bun runtime for users; package scripts may resolve Bun through that bundled dependency.", }, ] as const; @@ -52,3 +52,12 @@ test("source development docs require a local Bun CLI while preserving the bundl expect(normalizedRequirementParagraph(text)).toBe(entry.paragraph); } }); + +test("contributor docs report the test and shipped Bun pins from package.json", async () => { + const pkg = await Bun.file(repoPath("package.json")).json(); + for (const path of ["CONTRIBUTING.md", "docs-site/src/content/docs/contributing.md"]) { + const text = await Bun.file(repoPath(path)).text(); + const versions = /The suite uses Bun (\d+\.\d+\.\d+) \([^)]*testRunnerBun[^)]*\) while the shipped runtime is (\d+\.\d+\.\d+)\./.exec(text); + expect(versions?.slice(1)).toEqual([pkg.testRunnerBun, pkg.dependencies.bun]); + } +}); diff --git a/tests/ci-workflows/install-scripts.test.ts b/tests/ci-workflows/install-scripts.test.ts index 2d1436d4726..e529af33ff0 100644 --- a/tests/ci-workflows/install-scripts.test.ts +++ b/tests/ci-workflows/install-scripts.test.ts @@ -57,6 +57,7 @@ describe("install scripts", () => { main?: string; exports?: { "."?: { bun?: string; default?: string } }; dependencies?: Record; + testRunnerBun?: string; devDependencies?: Record; scripts?: Record; files?: string[]; @@ -65,8 +66,9 @@ describe("install scripts", () => { expect(pkg.main).toBe("./bin/package-main.mjs"); expect(pkg.exports?.["."]?.bun).toBe("./src/index.ts"); expect(pkg.exports?.["."]?.default).toBe("./bin/package-main.mjs"); - // Bun stays at 1.4.0 until 1.4.2's test-runner crash is fixed upstream. 1.4.2 segfaults - // while RE-LOADING the bunfig preload that `--isolate` re-enters once per test file: + // Bun 1.4.2 fixes the Windows fetch streaming regression reported in #6684. + // CI's testRunnerBun stays on 1.4.0: 1.4.2 segfaults while RE-LOADING the bunfig + // preload that `--isolate` re-enters once per test file: // `load_preloads -> JSModuleLoader::loadModule -> JSPromise::status` dereferences a dead // promise and the process dies with "Segmentation fault at address 0x10". It is a crash in // the interpreter, not a test result, and no test content avoids it. @@ -82,9 +84,13 @@ describe("install scripts", () => { // 35087572377, 35093667426 and 35098735960, always at the 67th file, and the file sitting at // that position changed between them. // - // Moving this back to 1.4.2, or on to a later release, needs a green `lane=all` dispatch as - // the evidence -- an ordinary PR run cannot show it, because the Linux sweep masks it. - expect(pkg.dependencies?.bun).toBe("1.4.0"); + // #4821 (2026-09-17) reverted #4064 (2026-09-09). #6713 run 37631558263 reproduced + // the crash on Linux 1/4, macOS 2/2 and Windows 9/9; individual files pass. + // Today's diagnostic sweep cannot turn a crashed primary batch green. + // Rejoin after the upstream runner crash is fixed and a green `lane=all` dispatch + // proves the unified version; then delete testRunnerBun and the setup action's role. + expect(pkg.dependencies?.bun).toBe("1.4.2"); + expect(pkg.testRunnerBun).toBe("1.4.0"); expect(pkg.dependencies?.zod).toBe("4.4.3"); expect(pkg.devDependencies?.typescript).toBe("7.0.2"); expect(pkg.devDependencies?.["@types/bun"]).toBe("1.4.0"); diff --git a/tests/ci-workflows/release-helper.test.ts b/tests/ci-workflows/release-helper.test.ts index a1414f4715c..8bc7b4f31ce 100644 --- a/tests/ci-workflows/release-helper.test.ts +++ b/tests/ci-workflows/release-helper.test.ts @@ -275,6 +275,14 @@ async function runRelease(releaseArgs: string | string[], scenario: ReleaseScena for (const name of ["bun", "gh", "git", "npm"] as const) { installCommandShim(shimDir, name); } + // The pinned runner is an absolute binary, so PATH shims alone no longer contain tests. + // Mock only the resolver in this child; keep the release gates and launcher real. + const preloadPath = join(shimDir, "test-runner-preload.ts"); + writeFileSync(preloadPath, `import { mock } from "bun:test"; +mock.module(${JSON.stringify(join(repoRoot, "scripts", "lib", "test-runner-bun.ts"))}, () => ({ + getTestRunnerBun: () => ${JSON.stringify(join(shimDir, process.platform === "win32" ? "bun.cmd" : "bun"))}, +})); +`, "utf8"); // Windows names the variable `Path`, and `...process.env` copies it in under // that spelling. Adding a separate `PATH` key leaves BOTH present, and which @@ -316,7 +324,7 @@ async function runRelease(releaseArgs: string | string[], scenario: ReleaseScena try { const result = await runCaptured( process.execPath, - [releaseScriptPath, ...(typeof releaseArgs === "string" ? [releaseArgs] : releaseArgs)], + ["--preload", preloadPath, releaseScriptPath, ...(typeof releaseArgs === "string" ? [releaseArgs] : releaseArgs)], { cwd: repoRoot, env, diff --git a/tests/ci-workflows/test-runner-bun.test.ts b/tests/ci-workflows/test-runner-bun.test.ts new file mode 100644 index 00000000000..6f197bbe34a --- /dev/null +++ b/tests/ci-workflows/test-runner-bun.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveTestRunnerBun, type TestRunnerBunDeps } from "../../scripts/lib/test-runner-bun"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath } from "../helpers/repo-root"; +import { testRunnerBun } from "../../package.json"; + +function fixture(overrides: Partial = {}): TestRunnerBunDeps { + return { + pin: "1.4.0", + currentVersion: "1.4.2", + execPath: "/project/node_modules/bun/bin/bun.exe", + env: {}, + pathEntries: [], + homeDir: "/home/contributor", + platform: "linux", + probeVersion: () => undefined, + ...overrides, + }; +} + +describe("pinned test runner Bun", () => { + test("reuses the running executable when its version matches, before checking overrides", () => { + const deps = fixture({ + currentVersion: "1.4.0", + env: { OCX_TEST_RUNNER_BUN: "/wrong/bun" }, + probeVersion: () => { throw new Error("must not probe"); }, + }); + expect(resolveTestRunnerBun(deps)).toBe("/project/node_modules/bun/bin/bun.exe"); + }); + + test("accepts a matching explicit override before a matching PATH binary", () => { + const probed: string[] = []; + const binary = resolveTestRunnerBun(fixture({ + env: { OCX_TEST_RUNNER_BUN: "/opt/pinned/bun" }, + pathEntries: ["/another"], + probeVersion: path => { probed.push(path); return "1.4.0\n"; }, + })); + expect(binary).toBe("/opt/pinned/bun"); + expect(probed).toEqual(["/opt/pinned/bun"]); + }); + + test.each(["1.4.2", undefined, ""])("rejects an override reporting %s without PATH fallback", version => { + const probed: string[] = []; + const deps = fixture({ + env: { OCX_TEST_RUNNER_BUN: "/override/bun" }, + pathEntries: ["/good"], + probeVersion: path => { probed.push(path); return path === "/good/bun" ? "1.4.0" : version; }, + }); + expect(() => resolveTestRunnerBun(deps)).toThrow("OCX_TEST_RUNNER_BUN reports"); + expect(probed).toEqual(["/override/bun"]); + }); + + test("skips every node_modules PATH directory even if its binary matches", () => { + const probed: string[] = []; + const binary = resolveTestRunnerBun(fixture({ + pathEntries: ["/project/node_modules/.bin", "/project/node_modules/bun/bin", "/external"], + probeVersion: path => { probed.push(path); return "1.4.0"; }, + })); + expect(binary).toBe("/external/bun"); + expect(probed).toEqual(["/external/bun"]); + }); + + test("continues past missing, failing and wrong-version probes; first matching PATH entry wins", () => { + const probed: string[] = []; + const binary = resolveTestRunnerBun(fixture({ + pathEntries: ["", "/missing", "/broken", "/runtime", "/first", "/later"], + probeVersion: path => { + probed.push(path); + if (path === "/missing/bun") return undefined; + if (path === "/broken/bun") throw new Error("not executable"); + return path === "/runtime/bun" ? "1.4.2" : "1.4.0"; + }, + })); + expect(binary).toBe("/first/bun"); + expect(probed).toEqual(["/missing/bun", "/broken/bun", "/runtime/bun", "/first/bun"]); + }); + + test("falls back to the real home's ~/.bun/bin outside PATH", () => { + const binary = resolveTestRunnerBun(fixture({ + pathEntries: ["/runtime"], + probeVersion: path => path === "/home/contributor/.bun/bin/bun" ? "1.4.0" : "1.4.2", + })); + expect(binary).toBe("/home/contributor/.bun/bin/bun"); + }); + + test("uses bun.exe and case-insensitive node_modules filtering for Windows PATH", () => { + const probed: string[] = []; + const binary = resolveTestRunnerBun(fixture({ + platform: "win32", + homeDir: "C:\\Users\\contributor", + pathEntries: ["C:\\project\\NODE_MODULES\\.bin", '"C:\\Bun tools"'], + probeVersion: path => { probed.push(path); return "1.4.0"; }, + })); + expect(binary).toBe("C:\\Bun tools\\bun.exe"); + expect(probed).toEqual(["C:\\Bun tools\\bun.exe"]); + }); + + test("reports both versions, crash references and manual setup when no binary matches", () => { + let message = ""; + try { resolveTestRunnerBun(fixture()); } catch (error) { message = (error as Error).message; } + for (const expected of ["1.4.2", "1.4.0", "OCX_TEST_RUNNER_BUN", "6713", "4821", "bun-v1.4.0", "No download"]) { + expect(message).toContain(expected); + } + }); + + test("rejects an empty override rather than silently selecting PATH", () => { + expect(() => resolveTestRunnerBun(fixture({ + env: { OCX_TEST_RUNNER_BUN: "" }, + pathEntries: ["/good"], + probeVersion: () => "1.4.0", + }))).toThrow("OCX_TEST_RUNNER_BUN reports no usable version"); + }); + + test("rejects an unpinned version before executing probes", () => { + expect(() => resolveTestRunnerBun(fixture({ pin: "^1.4.0" }))).toThrow("exact Bun version"); + }); + + test("GUI package uses the pinned wrapper, preserving cwd, filters and failures", () => { + const guiPackage = JSON.parse(readFileSync(repoPath("gui", "package.json"), "utf8")); + expect(guiPackage.scripts.test).toBe("bun ../scripts/test-with-pinned-bun.ts tests"); + const root = mkdtempSync(join(tmpdir(), "ocx-gui-test-runner-")); + const file = join(root, "runner.test.ts"); + const cwd = repoPath("gui"); + writeFileSync(file, `import { expect, test } from "bun:test"; +test("selected", () => { + expect(Bun.version).toBe(${JSON.stringify(testRunnerBun)}); + expect(process.cwd()).toBe(${JSON.stringify(cwd)}); +}); +test("excluded failure", () => { throw new Error("fixture failure"); }); +`); + try { + const run = (filter: string) => Bun.spawnSync([ + process.execPath, repoPath("scripts", "test-with-pinned-bun.ts"), "--isolate", file, "-t", filter, + ], { cwd, stdout: "pipe", stderr: "pipe" }); + const pass = run("^selected$"); + expect(pass.exitCode).toBe(0); + expect(pass.stderr.toString()).toContain("1 pass"); + const fail = run("^excluded failure$"); + expect(fail.exitCode).not.toBe(0); + expect(fail.stderr.toString()).toContain("fixture failure"); + } finally { + removeTreeWithRetry(root); + } + }); + + // Windows kill("SIGTERM") terminates directly rather than delivering a catchable signal. + test.skipIf(process.platform === "win32")("wrapper forwards SIGTERM, waits for the child and preserves exit 143", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-test-wrapper-signal-")); + const file = join(root, "signal.test.ts"); + const pidFile = join(root, "child.pid"); + const signalFile = join(root, "child.signal"); + writeFileSync(file, `import { test } from "bun:test"; +import { writeFileSync } from "node:fs"; +test("sleep until interrupted", async () => { + process.on("SIGTERM", () => { + writeFileSync(${JSON.stringify(signalFile)}, "SIGTERM"); + setTimeout(() => process.exit(0), 75); + }); + writeFileSync(${JSON.stringify(pidFile)}, String(process.pid)); + await new Promise(() => {}); +}, 60_000); +`); + const wrapper = Bun.spawn([ + process.execPath, repoPath("scripts", "test-with-pinned-bun.ts"), "--isolate", file, + ], { cwd: root, stdout: "ignore", stderr: "ignore" }); + let exitTimer: ReturnType | undefined; + try { + const readyDeadline = Date.now() + 10_000; + while (!existsSync(pidFile)) { + if (Date.now() >= readyDeadline || wrapper.exitCode !== null) throw new Error("wrapper child did not become ready"); + await Bun.sleep(20); + } + const childPid = Number(readFileSync(pidFile, "utf8")); + expect(childPid).toBeGreaterThan(0); + wrapper.kill("SIGTERM"); + const exitCode = await Promise.race([ + wrapper.exited, + new Promise((_, reject) => { + exitTimer = setTimeout(() => reject(new Error("wrapper did not exit after SIGTERM")), 5_000); + }), + ]); + expect(exitCode).toBe(143); + let childStatus: string | undefined; + try { process.kill(childPid, 0); } catch (error) { childStatus = (error as NodeJS.ErrnoException).code; } + expect(childStatus).toBe("ESRCH"); + expect(readFileSync(signalFile, "utf8")).toBe("SIGTERM"); + } finally { + clearTimeout(exitTimer); + if (wrapper.exitCode === null) try { wrapper.kill("SIGKILL"); } catch { /* already exited */ } + if (existsSync(pidFile)) { + const pid = Number(readFileSync(pidFile, "utf8")); + if (Number.isSafeInteger(pid) && pid > 0) { + try { process.kill(pid, "SIGKILL"); } catch { /* child already exited */ } + } + } + await Promise.race([wrapper.exited, Bun.sleep(1_000)]); + removeTreeWithRetry(root); + } + }, 20_000); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 700841acd0a..d01434b4b55 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1027,7 +1027,7 @@ "system-routes.test.ts": "server", "systemd-install-cleanup-hardening.test.ts": "service", "tencent-siliconflow-providers.test.ts": "gui", "terminal-continuation-owner-rotation.test.ts": "adapters", "terminal-guard-server.test.ts": "server", "terminal-guard.test.ts": "server", - "test-home-guard.test.ts": "ci-workflows", "test-runner.test.ts": "ci-workflows", + "test-home-guard.test.ts": "ci-workflows", "test-runner.test.ts": "ci-workflows", "test-runner-bun.test.ts": "ci-workflows", "test-sandbox-cleanup.test.ts": "ci-workflows", "thought-signature-credential-scope.test.ts": "responses", "token-estimate.test.ts": "lib", "token-guardian.test.ts": "codex-integration", "tool-argument-integers.test.ts": "adapters", "tool-catalog-nudge.test.ts": "adapters", diff --git a/tests/storage/storage-worker-teardown-isolate.test.ts b/tests/storage/storage-worker-teardown-isolate.test.ts index 82bf5e50045..a756564093b 100644 --- a/tests/storage/storage-worker-teardown-isolate.test.ts +++ b/tests/storage/storage-worker-teardown-isolate.test.ts @@ -15,8 +15,8 @@ * is what ruled out an unjoined worker of ours: Bun destroyed the VM while * native work that had left the thread was still outstanding. * - * Bun 1.4.0 — the version this repository pins (package.json `dependencies.bun`, - * consumed by .github/actions/setup-project-bun) — rewrote that lifetime model: + * Bun 1.4.0 introduced the lifetime model retained by the bundled runtime + * (package.json `dependencies.bun`, consumed by .github/actions/setup-project-bun): * worker threads are parent-owned and joined before the parent VM disappears, * native resources including bun:sqlite are torn down before JSC is destroyed, * and a termination gate stops native callbacks entering a stopping worker diff --git a/tests/test-layout-tooling.test.ts b/tests/test-layout-tooling.test.ts index c675c9ce639..01ead503993 100644 --- a/tests/test-layout-tooling.test.ts +++ b/tests/test-layout-tooling.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; @@ -7,6 +7,7 @@ import { repoPath, repoRoot } from "./helpers/repo-root"; import { listTestFiles, planMoves } from "../scripts/test-layout/plan"; import { runMove } from "../scripts/test-layout/move"; import { runVerify } from "../scripts/test-layout/verify"; +import { testRunnerBun } from "../package.json"; import { anchors, currentPath, @@ -429,4 +430,28 @@ describe("move end to end", () => { cleanup(); } }); + + test("domain verification launches isolate tests through the pinned runner", () => { + const { root, cleanup } = scratchRepo(); + const layoutPath = join(root, "scripts", "test-layout", "layout.json"); + try { + runMove({ root, domains: ["server", "providers"], dryRun: false, layoutPath, skipVerify: true, log: () => {} }); + const spawn = spyOn(Bun, "spawnSync").mockReturnValue({ + exitCode: 0, stdout: Buffer.from(""), stderr: Buffer.from(""), + } as ReturnType); + try { + const report = runVerify({ root, domains: ["server", "providers"], layoutPath, log: () => {} }); + expect(report.testExit).toBe(0); + // The test itself runs on the required pin, so its executable is the resolver's first choice. + expect(Bun.version).toBe(testRunnerBun); + expect(spawn.mock.calls.at(-1)?.[0]).toEqual([ + process.execPath, "test", "--isolate", "tests/server", "tests/providers", + ]); + } finally { + spawn.mockRestore(); + } + } finally { + cleanup(); + } + }); });