|
| 1 | +/** |
| 2 | + * End-to-end smoke test for the compiled sidecar binary. |
| 3 | + * |
| 4 | + * Catches "works in dev, breaks in --compile" regressions: bunfs asset |
| 5 | + * loading (QuickJS WASM, embedded migrations, embedded web UI), native |
| 6 | + * .node loaders (keychain), and the MCP → engine → QuickJS → tool path. |
| 7 | + * |
| 8 | + * Flow: |
| 9 | + * 1. Spin up a tiny local OpenAPI server (one operation, returns 42). |
| 10 | + * 2. Write a temp executor.jsonc that points at it as a source. |
| 11 | + * 3. Spawn the compiled `executor-sidecar` binary with EXECUTOR_PORT=0 |
| 12 | + * and parse the `EXECUTOR_READY:<port>` sentinel. |
| 13 | + * 4. Connect via MCP streamable HTTP, call the `execute` tool with code |
| 14 | + * that invokes the OpenAPI tool, assert the answer round-trips as 42. |
| 15 | + * |
| 16 | + * Run after `bun ./scripts/build-sidecar.ts`. Exits non-zero on any |
| 17 | + * deviation so it can gate CI. |
| 18 | + */ |
| 19 | +import { mkdtemp, rm, writeFile } from "node:fs/promises"; |
| 20 | +import { tmpdir } from "node:os"; |
| 21 | +import { join, resolve } from "node:path"; |
| 22 | +import { spawn, type Subprocess } from "bun"; |
| 23 | +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 24 | +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; |
| 25 | + |
| 26 | +const ROOT = resolve(import.meta.dir, ".."); |
| 27 | +const BINARY = resolve( |
| 28 | + ROOT, |
| 29 | + "resources/sidecar", |
| 30 | + process.platform === "win32" ? "executor-sidecar.exe" : "executor-sidecar", |
| 31 | +); |
| 32 | + |
| 33 | +const AUTH_PASSWORD = "smoke-test-password"; |
| 34 | +const AUTH_HEADER = `Basic ${btoa(`executor:${AUTH_PASSWORD}`)}`; |
| 35 | +const READY_TIMEOUT_MS = 30_000; |
| 36 | + |
| 37 | +const fail = (msg: string): never => { |
| 38 | + console.error(`[smoke-sidecar] FAIL: ${msg}`); |
| 39 | + process.exit(1); |
| 40 | +}; |
| 41 | + |
| 42 | +// Petstore-style spec: GET list + GET by id. Exercises path params, |
| 43 | +// multi-step orchestration, and array/object response shapes against a real |
| 44 | +// running HTTP server, all the way through the compiled binary → |
| 45 | +// MCP → QuickJS → openapi-invoker → HttpClient chain. |
| 46 | +const startOpenApiServer = () => { |
| 47 | + const Pet = { |
| 48 | + type: "object", |
| 49 | + properties: { |
| 50 | + id: { type: "integer" }, |
| 51 | + name: { type: "string" }, |
| 52 | + tag: { type: "string" }, |
| 53 | + }, |
| 54 | + required: ["id", "name"], |
| 55 | + }; |
| 56 | + |
| 57 | + const spec = { |
| 58 | + openapi: "3.0.0", |
| 59 | + info: { title: "Petstore Smoke API", version: "0.0.1" }, |
| 60 | + paths: { |
| 61 | + "/pets": { |
| 62 | + get: { |
| 63 | + operationId: "listPets", |
| 64 | + responses: { |
| 65 | + "200": { |
| 66 | + description: "ok", |
| 67 | + content: { |
| 68 | + "application/json": { schema: { type: "array", items: Pet } }, |
| 69 | + }, |
| 70 | + }, |
| 71 | + }, |
| 72 | + }, |
| 73 | + }, |
| 74 | + "/pets/{petId}": { |
| 75 | + get: { |
| 76 | + operationId: "getPet", |
| 77 | + parameters: [ |
| 78 | + { |
| 79 | + name: "petId", |
| 80 | + in: "path", |
| 81 | + required: true, |
| 82 | + schema: { type: "integer" }, |
| 83 | + }, |
| 84 | + ], |
| 85 | + responses: { |
| 86 | + "200": { |
| 87 | + description: "ok", |
| 88 | + content: { "application/json": { schema: Pet } }, |
| 89 | + }, |
| 90 | + "404": { description: "not found" }, |
| 91 | + }, |
| 92 | + }, |
| 93 | + }, |
| 94 | + }, |
| 95 | + }; |
| 96 | + |
| 97 | + // Seed the in-memory store so the GET-driven smoke can verify list + |
| 98 | + // path-param round-trips. Body-bearing POST/PUT is gated by the |
| 99 | + // executor's approval flow and is covered by separate non-compiled tests. |
| 100 | + const pets: Array<{ id: number; name: string; tag?: string }> = [ |
| 101 | + { id: 1, name: "Fido", tag: "dog" }, |
| 102 | + { id: 2, name: "Whiskers", tag: "cat" }, |
| 103 | + ]; |
| 104 | + |
| 105 | + const server = Bun.serve({ |
| 106 | + port: 0, |
| 107 | + hostname: "127.0.0.1", |
| 108 | + fetch(req) { |
| 109 | + const url = new URL(req.url); |
| 110 | + if (url.pathname === "/openapi.json") return Response.json(spec); |
| 111 | + |
| 112 | + if (url.pathname === "/pets" && req.method === "GET") { |
| 113 | + return Response.json(pets); |
| 114 | + } |
| 115 | + |
| 116 | + const match = /^\/pets\/(\d+)$/.exec(url.pathname); |
| 117 | + if (match && req.method === "GET") { |
| 118 | + const pet = pets.find((p) => p.id === Number(match[1])); |
| 119 | + if (!pet) return new Response("not found", { status: 404 }); |
| 120 | + return Response.json(pet); |
| 121 | + } |
| 122 | + |
| 123 | + return new Response("not found", { status: 404 }); |
| 124 | + }, |
| 125 | + }); |
| 126 | + return { server, origin: `http://127.0.0.1:${server.port}` }; |
| 127 | +}; |
| 128 | + |
| 129 | +const waitForReadyPort = (proc: Subprocess<"ignore", "pipe", "pipe">): Promise<number> => |
| 130 | + new Promise((resolveReady, rejectReady) => { |
| 131 | + const deadline = setTimeout(() => { |
| 132 | + rejectReady(new Error(`sidecar did not announce ready within ${READY_TIMEOUT_MS}ms`)); |
| 133 | + }, READY_TIMEOUT_MS); |
| 134 | + |
| 135 | + let stdoutBuf = ""; |
| 136 | + const decoder = new TextDecoder(); |
| 137 | + const reader = proc.stdout.getReader(); |
| 138 | + |
| 139 | + const stderrReader = proc.stderr.getReader(); |
| 140 | + void (async () => { |
| 141 | + while (true) { |
| 142 | + const { value, done } = await stderrReader.read(); |
| 143 | + if (done) return; |
| 144 | + process.stderr.write(`[sidecar-stderr] ${decoder.decode(value)}`); |
| 145 | + } |
| 146 | + })(); |
| 147 | + |
| 148 | + void (async () => { |
| 149 | + while (true) { |
| 150 | + const { value, done } = await reader.read(); |
| 151 | + if (done) { |
| 152 | + clearTimeout(deadline); |
| 153 | + rejectReady(new Error("sidecar stdout closed before ready")); |
| 154 | + return; |
| 155 | + } |
| 156 | + const chunk = decoder.decode(value); |
| 157 | + process.stdout.write(`[sidecar-stdout] ${chunk}`); |
| 158 | + stdoutBuf += chunk; |
| 159 | + const match = /EXECUTOR_READY:(\d+)/.exec(stdoutBuf); |
| 160 | + if (match) { |
| 161 | + clearTimeout(deadline); |
| 162 | + resolveReady(parseInt(match[1]!, 10)); |
| 163 | + return; |
| 164 | + } |
| 165 | + } |
| 166 | + })(); |
| 167 | + }); |
| 168 | + |
| 169 | +const main = async () => { |
| 170 | + if (!(await Bun.file(BINARY).exists())) { |
| 171 | + fail( |
| 172 | + `binary not found at ${BINARY}. Run \`bun ./scripts/build-sidecar.ts\` from apps/desktop first.`, |
| 173 | + ); |
| 174 | + } |
| 175 | + |
| 176 | + const scopeDir = await mkdtemp(join(tmpdir(), "executor-smoke-")); |
| 177 | + const openapi = startOpenApiServer(); |
| 178 | + |
| 179 | + console.log(`[smoke-sidecar] scope: ${scopeDir}`); |
| 180 | + console.log(`[smoke-sidecar] openapi: ${openapi.origin}`); |
| 181 | + |
| 182 | + const config = { |
| 183 | + sources: [ |
| 184 | + { |
| 185 | + kind: "openapi", |
| 186 | + spec: `${openapi.origin}/openapi.json`, |
| 187 | + baseUrl: openapi.origin, |
| 188 | + namespace: "petstore", |
| 189 | + }, |
| 190 | + ], |
| 191 | + }; |
| 192 | + await writeFile(join(scopeDir, "executor.jsonc"), JSON.stringify(config, null, 2)); |
| 193 | + |
| 194 | + const proc = spawn({ |
| 195 | + cmd: [BINARY], |
| 196 | + env: { |
| 197 | + ...process.env, |
| 198 | + EXECUTOR_PORT: "0", |
| 199 | + EXECUTOR_HOST: "127.0.0.1", |
| 200 | + EXECUTOR_AUTH_PASSWORD: AUTH_PASSWORD, |
| 201 | + EXECUTOR_SCOPE_DIR: scopeDir, |
| 202 | + }, |
| 203 | + stdin: "ignore", |
| 204 | + stdout: "pipe", |
| 205 | + stderr: "pipe", |
| 206 | + }); |
| 207 | + |
| 208 | + let exitCode: number | null = null; |
| 209 | + void proc.exited.then((code) => { |
| 210 | + exitCode = code; |
| 211 | + }); |
| 212 | + |
| 213 | + const cleanup = async () => { |
| 214 | + if (exitCode === null) { |
| 215 | + proc.kill("SIGTERM"); |
| 216 | + await Promise.race([proc.exited, Bun.sleep(3000)]); |
| 217 | + if (exitCode === null) proc.kill("SIGKILL"); |
| 218 | + } |
| 219 | + openapi.server.stop(true); |
| 220 | + await rm(scopeDir, { recursive: true, force: true }).catch(() => {}); |
| 221 | + }; |
| 222 | + |
| 223 | + try { |
| 224 | + const port = await waitForReadyPort(proc); |
| 225 | + const mcpUrl = new URL(`http://127.0.0.1:${port}/mcp`); |
| 226 | + console.log(`[smoke-sidecar] ready on ${mcpUrl.origin}`); |
| 227 | + |
| 228 | + const transport = new StreamableHTTPClientTransport(mcpUrl, { |
| 229 | + requestInit: { headers: { Authorization: AUTH_HEADER } }, |
| 230 | + }); |
| 231 | + const client = new Client({ name: "smoke-test", version: "0.0.1" }); |
| 232 | + await client.connect(transport); |
| 233 | + |
| 234 | + const tools = await client.listTools(); |
| 235 | + const hasExecute = tools.tools.some((t) => t.name === "execute"); |
| 236 | + if (!hasExecute) fail(`MCP tools/list missing "execute": ${JSON.stringify(tools.tools)}`); |
| 237 | + |
| 238 | + // Drive the running OpenAPI server through a multi-step orchestration |
| 239 | + // in one execute. Covers: array list response, path param dispatch, and |
| 240 | + // object responses — all going out over real HTTP from inside QuickJS. |
| 241 | + const code = ` |
| 242 | +const list = await tools.petstore.pets.listPets({}); |
| 243 | +const fetched = await tools.petstore.pets.getPet({ petId: list[1].id }); |
| 244 | +return { |
| 245 | + count: list.length, |
| 246 | + names: list.map((p) => p.name), |
| 247 | + fetched: { id: fetched.id, name: fetched.name }, |
| 248 | +}; |
| 249 | +`; |
| 250 | + |
| 251 | + const result = await client.callTool({ name: "execute", arguments: { code } }); |
| 252 | + if (result.isError) { |
| 253 | + fail(`execute returned isError: ${JSON.stringify(result.content)}`); |
| 254 | + } |
| 255 | + const structured = result.structuredContent as { result?: unknown } | undefined; |
| 256 | + const expected = { |
| 257 | + count: 2, |
| 258 | + names: ["Fido", "Whiskers"], |
| 259 | + fetched: { id: 2, name: "Whiskers" }, |
| 260 | + }; |
| 261 | + if (JSON.stringify(structured?.result) !== JSON.stringify(expected)) { |
| 262 | + fail( |
| 263 | + `expected ${JSON.stringify(expected)}, got ${JSON.stringify(structured?.result)} (content: ${JSON.stringify(result.content)})`, |
| 264 | + ); |
| 265 | + } |
| 266 | + |
| 267 | + await client.close(); |
| 268 | + console.log( |
| 269 | + `[smoke-sidecar] OK — listPets + getPet({petId:2}) round-tripped through the running OpenAPI server`, |
| 270 | + ); |
| 271 | + } finally { |
| 272 | + await cleanup(); |
| 273 | + } |
| 274 | +}; |
| 275 | + |
| 276 | +await main(); |
0 commit comments