Skip to content

Commit efb271c

Browse files
committed
test(desktop): add compiled-sidecar smoke harness
Spawns the compiled executor-sidecar binary, points it at a temp scope with an executor.jsonc that wires up a locally hosted OpenAPI spec, then drives the engine through MCP streamable HTTP to verify a tools.* call round-trips against the running server. Catches "works in dev, breaks in --compile" regressions across the bunfs asset surface (QuickJS WASM, embedded migrations, web UI) plus the MCP -> QuickJS -> http-fetch path.
1 parent ba34b6c commit efb271c

4 files changed

Lines changed: 284 additions & 6 deletions

File tree

apps/desktop/package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"package:mac": "electron-builder --mac --config electron-builder.config.ts",
1717
"package:win": "electron-builder --win --config electron-builder.config.ts",
1818
"package:linux": "electron-builder --linux --config electron-builder.config.ts",
19+
"test:smoke": "bun ./scripts/smoke-sidecar.ts",
1920
"typecheck": "tsgo --noEmit",
2021
"typecheck:slow": "tsc --noEmit"
2122
},
@@ -28,9 +29,6 @@
2829
"devDependencies": {
2930
"@executor-js/app": "workspace:*",
3031
"@executor-js/local": "workspace:*",
31-
"@executor-js/runtime-quickjs": "workspace:*",
32-
"@jitl/quickjs-wasmfile-release-sync": "catalog:",
33-
"quickjs-emscripten": "catalog:",
3432
"@executor-js/plugin-desktop-settings": "workspace:*",
3533
"@executor-js/plugin-file-secrets": "workspace:*",
3634
"@executor-js/plugin-google-discovery": "workspace:*",
@@ -39,11 +37,15 @@
3937
"@executor-js/plugin-mcp": "workspace:*",
4038
"@executor-js/plugin-onepassword": "workspace:*",
4139
"@executor-js/plugin-openapi": "workspace:*",
40+
"@executor-js/runtime-quickjs": "workspace:*",
41+
"@jitl/quickjs-wasmfile-release-sync": "catalog:",
42+
"@modelcontextprotocol/sdk": "^1.29.0",
4243
"@types/node": "catalog:",
4344
"bun-types": "catalog:",
4445
"electron": "41.2.1",
4546
"electron-builder": "^26",
4647
"electron-vite": "^5",
48+
"quickjs-emscripten": "catalog:",
4749
"typescript": "catalog:",
4850
"vite": "catalog:"
4951
}
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
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();

apps/desktop/src/sidecar/server.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,8 @@ if (typeof Bun !== "undefined" && (await Bun.file(wasmOnDisk).exists())) {
2323
const importFFI: QuickJSSyncVariant["importFFI"] = () =>
2424
import("@jitl/quickjs-wasmfile-release-sync/ffi").then((m) => m.QuickJSFFI);
2525
const importModuleLoader: QuickJSSyncVariant["importModuleLoader"] = async () => {
26-
const { default: original } = await import(
27-
"@jitl/quickjs-wasmfile-release-sync/emscripten-module"
28-
);
26+
const { default: original } =
27+
await import("@jitl/quickjs-wasmfile-release-sync/emscripten-module");
2928
return (moduleArg = {}) => original({ ...moduleArg, wasmBinary });
3029
};
3130
const variant: QuickJSSyncVariant = {

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)