From 235302324c64036e683af5eb1b12a33a3b24a8f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 21:36:43 +0000 Subject: [PATCH 1/2] =?UTF-8?q?ci(bridge):=20test=20the=20bridge=20under?= =?UTF-8?q?=20{Node,=20Deno,=20Bun}=20=E2=80=94=20unit=20suite=20+=20boot?= =?UTF-8?q?=20smoke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands plan §7 E5 with the matrix widened to include Bun. The bridge's unit tests were not run by any workflow (only the path-claims bench), which is how a bare Deno.* reference shipped and broke both the npx/ Node and Bun install paths at import time (fixed in PR #211; Bun has no Deno global — only the dogfooded Deno runtime was clean). - New bridge-tests job in e2e.yml: 3-leg matrix, each running the full unit suite under its runtime (node --test / deno test / bun test — all three natively execute the node:test files; verified 52/52 each) plus an MCP boot smoke of main.js. - New mcp-bridge/tests/boot_smoke.js: spawns the bridge under a given runtime command, performs a real initialize → notifications/ initialized → tools/list handshake over stdio, asserts serverInfo + non-empty tools + exit 0. Needed because the unit suite imports lib/ but never boots main.js, whose stdio/exit paths are runtime-gated. Verified locally under all three runtimes (68 tools, exit 0). - e2e.yml pull_request paths now include the workflow file itself (push paths already did; workflow-only PRs previously didn't run it). - Plan doc §7 E5 updated to record the landed state and the corrected Bun finding. setup-bun pinned to 0c5077e51419868618aeaa5fe8019c62421857d6 (v2.2.0, SHA verified via git ls-remote against oven-sh/setup-bun). https://claude.ai/code/session_01PRi6uSn6qucCMCCy7mqUr4 --- .github/workflows/e2e.yml | 58 ++++++++++ .../v1-critical-chain-2026-06-11.adoc | 22 ++-- mcp-bridge/tests/boot_smoke.js | 105 ++++++++++++++++++ 3 files changed, 177 insertions(+), 8 deletions(-) create mode 100644 mcp-bridge/tests/boot_smoke.js diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 90826a51..1a11d971 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -27,6 +27,7 @@ on: - 'mcp-bridge/**' - 'tests/**' - 'src/**' + - '.github/workflows/e2e.yml' workflow_dispatch: permissions: read-all @@ -163,6 +164,63 @@ jobs: path: ffi/zig/zig-out/bench* retention-days: 30 + # ─── Bridge Tests: unit suite + boot smoke × {Node, Deno, Bun} ───── + # The README blesses three runtimes for the zero-dependency bridge: + # Deno (documented runtime), Node (the npx install path every client + # quickstart uses), and Bun (zero-install). Only Deno is exercised by + # development, so a runtime leak in the other two ships silently — a + # bare `Deno.env` reference once broke the entire npx path at import + # time (fixed in PR #211). This job runs the full unit suite AND an + # MCP initialize → tools/list boot smoke of main.js under each + # runtime. The smoke matters separately: the unit suite imports lib/ + # but never boots main.js, whose stdio/exit paths are runtime-gated. + bridge-tests: + name: Bridge — ${{ matrix.runtime }} (unit + boot smoke) + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - runtime: node + unit: node --test mcp-bridge/tests/dispatch_test.js mcp-bridge/tests/http_transport_test.js mcp-bridge/tests/path_claims_test.js + boot: node mcp-bridge/main.js + - runtime: deno + unit: deno test --allow-read --allow-env --allow-run --allow-net mcp-bridge/tests/dispatch_test.js mcp-bridge/tests/http_transport_test.js mcp-bridge/tests/path_claims_test.js + boot: deno run -A mcp-bridge/main.js + - runtime: bun + unit: bun test mcp-bridge/tests/dispatch_test.js mcp-bridge/tests/http_transport_test.js mcp-bridge/tests/path_claims_test.js + boot: bun mcp-bridge/main.js + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node + # Node is needed on every leg: it is the subject on the node + # leg and the boot-smoke orchestrator on all three. + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '22' + + - name: Install Deno + if: matrix.runtime == 'deno' + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 + with: + deno-version: v2.x + + - name: Install Bun + if: matrix.runtime == 'bun' + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: '1.x' + + - name: Unit suite (${{ matrix.runtime }}) + run: ${{ matrix.unit }} + + - name: Boot smoke (${{ matrix.runtime }}) + run: node mcp-bridge/tests/boot_smoke.js ${{ matrix.boot }} + # ─── Bench Bridge: mcp-bridge JS perf (path-claims) ──────────────── # Separate job from `benchmarks` above because the bridge has no Zig # toolchain dependency — keeps logs untangled and lets the JS bench diff --git a/docs/planning/v1-critical-chain-2026-06-11.adoc b/docs/planning/v1-critical-chain-2026-06-11.adoc index 5b650a75..2e521aa4 100644 --- a/docs/planning/v1-critical-chain-2026-06-11.adoc +++ b/docs/planning/v1-critical-chain-2026-06-11.adoc @@ -325,14 +325,20 @@ BUILD-FROM-SOURCE/DEV. A newcomer should never see `asdf install idris2` unless they are hacking the ABI. E5. *Close the regression class found in this session.* The bridge's -own unit tests (`node --test mcp-bridge/tests/`) are not run by any CI -workflow — only a bench script is. Consequence: a bare `Deno.env`/ -`Deno.pid` call in `nickel-validator.js` shipped and broke the *entire -npx/Node install path* at import time (fixed in this session's commit; -52/52 tests now pass under Node). Add a bridge-tests CI job with a -{Node, Deno} matrix so the primary install path cannot silently break -again. Same PR can pick up the two broken gates already catalogued in -#199 (Zig download URL, ABI-grep false positive). +own unit tests (`node --test mcp-bridge/tests/`) were not run by any CI +workflow — only a bench script was. Consequence: a bare `Deno.env`/ +`Deno.pid` call in `nickel-validator.js` shipped and broke the npx/Node +install path *and* the Bun path at import time (Bun has no `Deno` +global either; only the dogfooded Deno runtime was clean). Fixed in +PR #211. *LANDED (follow-up PR):* a `bridge-tests` job in `e2e.yml` +runs the full unit suite *plus* an MCP initialize → tools/list boot +smoke of `main.js` under a {Node, Deno, Bun} matrix — all three +runtimes verified green locally before wiring (52/52 units each; boot +smoke answers with 68 tools and exits 0). Bun is in the matrix because +the README blesses it as an install path; the boot smoke exists because +the unit suite imports `lib/` but never boots `main.js`, whose +stdio/exit paths are runtime-gated. The two broken gates catalogued in +#199 (Zig download URL, ABI-grep false positive) stay tracked there. E6. *Quickstart trace test.* Once per release, someone (or a scripted container) executes USER.adoc literally, from clean, and the elapsed diff --git a/mcp-bridge/tests/boot_smoke.js b/mcp-bridge/tests/boot_smoke.js new file mode 100644 index 00000000..6c0e3cf7 --- /dev/null +++ b/mcp-bridge/tests/boot_smoke.js @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// BoJ Server — bridge boot smoke (runtime portability gate) +// +// Spawns the MCP bridge (`main.js`) under a given runtime command, +// performs a real JSON-RPC handshake over stdio (initialize → +// notifications/initialized → tools/list), and asserts the bridge +// answers with serverInfo and a non-empty tool list, then exits 0 +// when stdin closes. +// +// The unit suite (dispatch_test.js etc.) imports the lib/ modules but +// never boots main.js itself — so a runtime-specific leak in the boot +// path (e.g. a bare `Deno.*` reference under Node/Bun, the PR #211 +// bug class) would pass units and still break `npx`/`bun main.js`. +// This smoke closes that gap. No REST backend is required: initialize +// and tools/list are bridge-local (offline manifest). +// +// Usage (orchestrator always runs under Node; subject runtime varies): +// node mcp-bridge/tests/boot_smoke.js node mcp-bridge/main.js +// node mcp-bridge/tests/boot_smoke.js deno run -A mcp-bridge/main.js +// node mcp-bridge/tests/boot_smoke.js bun mcp-bridge/main.js + +import { spawn } from "node:child_process"; +import process from "node:process"; + +const TIMEOUT_MS = 30_000; + +const argv = process.argv.slice(2); +if (argv.length === 0) { + console.error("usage: node boot_smoke.js "); + process.exit(2); +} + +const requests = [ + { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "boot-smoke", version: "0.0.0" }, + }, + }, + { jsonrpc: "2.0", method: "notifications/initialized" }, + { jsonrpc: "2.0", id: 2, method: "tools/list" }, +]; + +const child = spawn(argv[0], argv.slice(1), { + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, BOJ_TRANSPORT: "stdio" }, +}); + +let stdout = ""; +let stderr = ""; +child.stdout.on("data", (d) => (stdout += d)); +child.stderr.on("data", (d) => (stderr += d)); + +const killTimer = setTimeout(() => { + console.error(`FAIL: bridge did not exit within ${TIMEOUT_MS}ms`); + child.kill("SIGKILL"); +}, TIMEOUT_MS); + +child.stdin.write(requests.map((r) => JSON.stringify(r)).join("\n") + "\n"); +child.stdin.end(); + +child.on("close", (code) => { + clearTimeout(killTimer); + const fail = (msg) => { + console.error(`FAIL: ${msg}`); + console.error(`--- subject: ${argv.join(" ")}`); + console.error(`--- exit code: ${code}`); + console.error(`--- stdout ---\n${stdout}`); + console.error(`--- stderr ---\n${stderr}`); + process.exit(1); + }; + + if (code !== 0) return fail(`expected exit 0, got ${code}`); + + const responses = []; + for (const line of stdout.split("\n")) { + const s = line.trim(); + if (!s.startsWith("{")) continue; + try { + responses.push(JSON.parse(s)); + } catch { + return fail(`non-JSON line on stdout: ${s.slice(0, 200)}`); + } + } + + const init = responses.find((r) => r.id === 1); + if (!init?.result?.serverInfo?.name) { + return fail("no initialize response with result.serverInfo.name"); + } + const tools = responses.find((r) => r.id === 2); + if (!Array.isArray(tools?.result?.tools) || tools.result.tools.length === 0) { + return fail("no tools/list response with a non-empty result.tools"); + } + + console.log( + `OK: ${argv[0]} boot smoke — serverInfo.name=${init.result.serverInfo.name}, ` + + `tools=${tools.result.tools.length}, exit=0`, + ); +}); From a33de5d94892b29b8292a401879687167f69fea5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 22:12:41 +0000 Subject: [PATCH 2/2] ci(bridge): boot smoke uses main.js's scoped Deno perms, not -A A panic-attack assail scan of the bridge flagged the deno boot leg's `-A` (all-permissions) grant. Tightened to main.js's own shebang set (--allow-net --allow-env --allow-read). This is a real improvement, not cosmetic: booting under -A could mask a missing-permission bug that a scoped real install would hit, so the smoke now exercises the exact grant a user gets. Re-verified: all three legs boot (68 tools, exit 0) and the assail finding on boot_smoke.js clears. https://claude.ai/code/session_01PRi6uSn6qucCMCCy7mqUr4 --- .github/workflows/e2e.yml | 5 ++++- mcp-bridge/tests/boot_smoke.js | 6 ++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 1a11d971..db76a0fc 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -187,7 +187,10 @@ jobs: boot: node mcp-bridge/main.js - runtime: deno unit: deno test --allow-read --allow-env --allow-run --allow-net mcp-bridge/tests/dispatch_test.js mcp-bridge/tests/http_transport_test.js mcp-bridge/tests/path_claims_test.js - boot: deno run -A mcp-bridge/main.js + # Scoped perms matching main.js's shebang, NOT -A: the boot smoke + # must exercise the exact permission set a real install uses, or it + # would mask a missing-grant bug that scoped users would hit. + boot: deno run --allow-net --allow-env --allow-read mcp-bridge/main.js - runtime: bun unit: bun test mcp-bridge/tests/dispatch_test.js mcp-bridge/tests/http_transport_test.js mcp-bridge/tests/path_claims_test.js boot: bun mcp-bridge/main.js diff --git a/mcp-bridge/tests/boot_smoke.js b/mcp-bridge/tests/boot_smoke.js index 6c0e3cf7..f42487e0 100644 --- a/mcp-bridge/tests/boot_smoke.js +++ b/mcp-bridge/tests/boot_smoke.js @@ -16,9 +16,11 @@ // This smoke closes that gap. No REST backend is required: initialize // and tools/list are bridge-local (offline manifest). // -// Usage (orchestrator always runs under Node; subject runtime varies): +// Usage (orchestrator always runs under Node; subject runtime varies). +// The deno leg uses main.js's own scoped grant, not -A, so the smoke +// exercises the exact permission set a real install uses: // node mcp-bridge/tests/boot_smoke.js node mcp-bridge/main.js -// node mcp-bridge/tests/boot_smoke.js deno run -A mcp-bridge/main.js +// node mcp-bridge/tests/boot_smoke.js deno run --allow-net --allow-env --allow-read mcp-bridge/main.js // node mcp-bridge/tests/boot_smoke.js bun mcp-bridge/main.js import { spawn } from "node:child_process";