diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 90826a51..db76a0fc 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,66 @@ 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 + # 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 + + 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..f42487e0 --- /dev/null +++ b/mcp-bridge/tests/boot_smoke.js @@ -0,0 +1,107 @@ +// 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). +// 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 --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"; +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`, + ); +});