492 lines · 17.5 KB · TypeScript
RawDownload
/** * End-to-end smoke test for the bundled executor binary. * * Catches "works in dev, breaks in --compile" regressions: bunfs asset * loading (embedded web UI, QuickJS WASM), native * .node loaders (keychain), and the MCP → engine → QuickJS → tool path. * * Flow: *   1. Spin up a tiny local OpenAPI server (one operation, returns 42). *   2. Spawn the compiled `executor daemon run --foreground --port 0` *      and parse the ready URL. *   3. Connect via MCP streamable HTTP, call the `execute` tool with code *      that registers and invokes the OpenAPI tool, assert the answer *      round-trips as 42. * * Run after `bun ./scripts/build-sidecar.ts`. Exits non-zero on any * deviation so it can gate CI. */import { mkdtemp, rm } from "node:fs/promises";import { createHash } from "node:crypto";import { tmpdir } from "node:os";import { basename, join, resolve } from "node:path";import { spawn, type Subprocess } from "bun";import { Database } from "bun:sqlite";import { Client } from "@modelcontextprotocol/sdk/client/index.js";import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const ROOT = resolve(import.meta.dir, "..");const APPS_LOCAL_DRIZZLE = resolve(ROOT, "../local/drizzle-legacy-v1");const BINARY = resolve(  ROOT,  "resources/executor",  process.platform === "win32" ? "executor.exe" : "executor",); const AUTH_TOKEN = "smoke-test-token";const AUTH_HEADER = `Bearer ${AUTH_TOKEN}`;const READY_TIMEOUT_MS = 30_000; // Throw instead of process.exit so main()'s finally still tears down the// spawned daemon + temp dirs — exiting here leaks a running process.const fail = (msg: string): never => {  // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: standalone smoke harness surfaces failures as a thrown error  throw new Error(`[smoke-sidecar] FAIL: ${msg}`);}; type ToolCallResult = Awaited<ReturnType<Client["callTool"]>>; const isRecord = (value: unknown): value is Record<string, unknown> =>  typeof value === "object" && value !== null && !Array.isArray(value); const makeScopeId = (cwd: string): string => {  const folder = basename(cwd) || cwd;  const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 8);  return `${folder}-${hash}`;}; const readLegacyMigrations = async (): Promise<readonly { sql: string; hash: string }[]> => {  const journal = (await Bun.file(join(APPS_LOCAL_DRIZZLE, "meta/_journal.json")).json()) as {    readonly entries: readonly { readonly idx: number; readonly tag: string }[];  };   const migrations: { sql: string; hash: string }[] = [];  for (const entry of [...journal.entries].sort((left, right) => left.idx - right.idx)) {    const query = await Bun.file(join(APPS_LOCAL_DRIZZLE, `${entry.tag}.sql`)).text();    migrations.push({      sql: query,      hash: createHash("sha256").update(query).digest("hex"),    });  }  return migrations;}; const seedLegacyScopedSqlite = async (dataDir: string, scopeId: string): Promise<void> => {  const db = new Database(join(dataDir, "data.db"));  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: standalone smoke harness closes the SQLite handle before spawning the sidecar  try {    // Replay the real legacy v1 chain so the fixture matches the migration    // history the sidecar's embedded copy expects. A hand-rolled partial    // schema that claims the full history is applied makes the v1→v2 data    // migration read tables that don't exist.    const migrations = await readLegacyMigrations();    for (const migration of migrations) {      // `--> statement-breakpoint` markers are SQL line comments, so the      // whole file executes as one multi-statement batch.      db.exec(migration.sql);    }     db.exec(`      CREATE TABLE "__drizzle_migrations" (        id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,        hash text NOT NULL,        created_at numeric      );    `);    const insertMigration = db.prepare(      `INSERT INTO "__drizzle_migrations" (hash, created_at) VALUES (?, ?)`,    );    for (const migration of migrations) {      insertMigration.run(migration.hash, Date.now());    }     db.prepare(      `INSERT INTO source (        scope_id, id, plugin_id, kind, name, url, can_remove, can_refresh, can_edit, created_at, updated_at      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,    ).run(      scopeId,      "legacy-smoke",      "smoke",      "remote",      "Legacy Smoke Source",      null,      1,      0,      1,      1_700_000_000_000,      1_700_000_001_000,    );    db.prepare("INSERT INTO blob (namespace, key, value) VALUES (?, ?, ?)").run(      `${scopeId}/smoke`,      "legacy",      "{}",    );    db.exec("PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode = DELETE;");  } finally {    db.close();  }}; // The v1→v2 migration moves the legacy SQLite file set aside as// `data.db.v1-v2-<ts>-<nonce>` before writing the new v2 database. Assert the// backup exists and still holds the seeded legacy row — that proves the// migration path ran (rather than the sidecar treating the DB as fresh) and// preserved the original data.const assertV1MigrationCompleted = async (dataDir: string): Promise<void> => {  const entries = await Array.fromAsync(new Bun.Glob("data.db.v1-v2-*").scan({ cwd: dataDir }));  const backups = entries.filter((name) => !name.endsWith("-wal") && !name.endsWith("-shm"));  if (backups.length !== 1) {    fail(`expected exactly one v1→v2 backup in ${dataDir}, found: ${JSON.stringify(entries)}`);  }   const backup = new Database(join(dataDir, backups[0]!), { readonly: true });  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: standalone smoke harness closes the SQLite handle it opened  try {    const row = backup.prepare("SELECT name FROM source WHERE id = 'legacy-smoke'").get() as {      name?: string;    } | null;    if (row?.name !== "Legacy Smoke Source") {      fail(`v1→v2 backup is missing the seeded legacy source row: ${JSON.stringify(row)}`);    }  } finally {    backup.close();  }}; // Petstore-style spec: GET list + GET by id. Exercises path params,// multi-step orchestration, and array/object response shapes against a real// running HTTP server, all the way through the compiled binary →// MCP → QuickJS → openapi-invoker → HttpClient chain.const startOpenApiServer = () => {  const Pet = {    type: "object",    properties: {      id: { type: "integer" },      name: { type: "string" },      tag: { type: "string" },    },    required: ["id", "name"],  };   const spec = {    openapi: "3.0.0",    info: { title: "Petstore Smoke API", version: "0.0.1" },    paths: {      "/pets": {        get: {          operationId: "listPets",          responses: {            "200": {              description: "ok",              content: {                "application/json": { schema: { type: "array", items: Pet } },              },            },          },        },      },      "/pets/{petId}": {        get: {          operationId: "getPet",          parameters: [            {              name: "petId",              in: "path",              required: true,              schema: { type: "integer" },            },          ],          responses: {            "200": {              description: "ok",              content: { "application/json": { schema: Pet } },            },            "404": { description: "not found" },          },        },      },    },  };   // Seed the in-memory store so the GET-driven smoke can verify list +  // path-param round-trips. Body-bearing POST/PUT is gated by the  // executor's approval flow and is covered by separate non-compiled tests.  const pets: Array<{ id: number; name: string; tag?: string }> = [    { id: 1, name: "Fido", tag: "dog" },    { id: 2, name: "Whiskers", tag: "cat" },  ];   const server = Bun.serve({    port: 0,    hostname: "127.0.0.1",    fetch(req) {      const url = new URL(req.url);      if (url.pathname === "/openapi.json") return Response.json(spec);       if (url.pathname === "/pets" && req.method === "GET") {        return Response.json(pets);      }       const match = /^\/pets\/(\d+)$/.exec(url.pathname);      if (match && req.method === "GET") {        const pet = pets.find((p) => p.id === Number(match[1]));        if (!pet) return new Response("not found", { status: 404 });        return Response.json(pet);      }       return new Response("not found", { status: 404 });    },  });  return { server, origin: `http://127.0.0.1:${server.port}` };}; const waitForReadyPort = (proc: Subprocess<"ignore", "pipe", "pipe">): Promise<number> =>  // oxlint-disable-next-line executor/no-promise-reject -- boundary: standalone build-time smoke harness, no Effect runtime  new Promise((resolveReady, rejectReady) => {    const deadline = setTimeout(() => {      // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: standalone smoke harness reporting a build-time timeout      rejectReady(new Error(`daemon did not announce ready within ${READY_TIMEOUT_MS}ms`));    }, READY_TIMEOUT_MS);     let stdoutBuf = "";    const decoder = new TextDecoder();    const reader = proc.stdout.getReader();     const stderrReader = proc.stderr.getReader();    void (async () => {      while (true) {        const { value, done } = await stderrReader.read();        if (done) return;        process.stderr.write(`[executor-stderr] ${decoder.decode(value)}`);      }    })();     void (async () => {      while (true) {        const { value, done } = await reader.read();        if (done) {          clearTimeout(deadline);          // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: standalone smoke harness, stdout-closed surfaced as rejection          rejectReady(new Error("daemon stdout closed before ready"));          return;        }        const chunk = decoder.decode(value);        process.stdout.write(`[executor-stdout] ${chunk}`);        stdoutBuf += chunk;        const match = /Daemon ready on http:\/\/(?:\[[^\]]+\]|[^:\s]+):(\d+)/.exec(stdoutBuf);        if (match) {          clearTimeout(deadline);          resolveReady(parseInt(match[1]!, 10));          return;        }      }    })();  }); const completePausedResult = async (  client: Client,  initial: ToolCallResult,): Promise<Record<string, unknown>> => {  let result = initial;  for (let attempt = 0; attempt < 5; attempt++) {    if (result.isError) {      fail(`tool returned isError: ${JSON.stringify(result.content)}`);    }     const structured = result.structuredContent;    if (!isRecord(structured)) {      fail(`tool returned no structured content: ${JSON.stringify(result.content)}`);    }    const structuredRecord = structured as Record<string, unknown>;     if (structuredRecord.status !== "waiting_for_interaction") {      return structuredRecord;    }     const executionId = structuredRecord.executionId;    if (typeof executionId !== "string" || executionId.length === 0) {      fail(`paused result missing executionId: ${JSON.stringify(structuredRecord)}`);    }     console.log(`[smoke-sidecar] auto-accepting paused execution ${executionId}`);    result = await client.callTool({      name: "resume",      arguments: { executionId, action: "accept", content: "{}" },    });  }   return fail("execute still paused after 5 resume attempts");}; const main = async () => {  if (!(await Bun.file(BINARY).exists())) {    fail(      `binary not found at ${BINARY}. Run \`bun ./scripts/build-sidecar.ts\` from apps/desktop first.`,    );  }   const scopeDir = await mkdtemp(join(tmpdir(), "executor-smoke-scope-"));  const dataDir = await mkdtemp(join(tmpdir(), "executor-smoke-data-"));  await seedLegacyScopedSqlite(dataDir, makeScopeId(scopeDir));  // v2 connections reference credentials by provider item instead of carrying  // raw values, so seed the file-secrets provider (auth.json under  // XDG_DATA_HOME) with the token the sandbox's connections.create points at.  const xdgDir = await mkdtemp(join(tmpdir(), "executor-smoke-xdg-"));  await Bun.write(    join(xdgDir, "executor", "auth.json"),    `${JSON.stringify({ "petstore-token": "smoke-token" })}\n`,  );  const openapi = startOpenApiServer();   console.log(`[smoke-sidecar] scope:   ${scopeDir}`);  console.log(`[smoke-sidecar] data:    ${dataDir}`);  console.log(`[smoke-sidecar] openapi: ${openapi.origin}`);   const proc = spawn({    cmd: [      BINARY,      "daemon",      "run",      "--foreground",      "--port",      "0",      "--hostname",      "127.0.0.1",      "--auth-token",      AUTH_TOKEN,    ],    env: {      ...process.env,      EXECUTOR_SCOPE_DIR: scopeDir,      EXECUTOR_DATA_DIR: dataDir,      EXECUTOR_CLIENT: "desktop",      XDG_DATA_HOME: xdgDir,    },    stdin: "ignore",    stdout: "pipe",    stderr: "pipe",  });   let exitCode: number | null = null;  void proc.exited.then((code) => {    exitCode = code;  });   const cleanup = async () => {    if (exitCode === null) {      proc.kill("SIGTERM");      await Promise.race([proc.exited, Bun.sleep(3000)]);      if (exitCode === null) proc.kill("SIGKILL");    }    openapi.server.stop(true);    // oxlint-disable-next-line executor/no-promise-catch -- boundary: best-effort tempdir cleanup in a standalone smoke harness    await rm(scopeDir, { recursive: true, force: true }).catch(() => {});    // oxlint-disable-next-line executor/no-promise-catch -- boundary: best-effort tempdir cleanup in a standalone smoke harness    await rm(dataDir, { recursive: true, force: true }).catch(() => {});    // oxlint-disable-next-line executor/no-promise-catch -- boundary: best-effort tempdir cleanup in a standalone smoke harness    await rm(xdgDir, { recursive: true, force: true }).catch(() => {});  };   // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: standalone smoke harness needs a finally to tear down the spawned binary + http server  try {    const port = await waitForReadyPort(proc);    await assertV1MigrationCompleted(dataDir);    const mcpUrl = new URL(`http://127.0.0.1:${port}/mcp`);    console.log(`[smoke-sidecar] ready on ${mcpUrl.origin}`);     const transport = new StreamableHTTPClientTransport(mcpUrl, {      requestInit: { headers: { Authorization: AUTH_HEADER } },    });    const client = new Client({ name: "smoke-test", version: "0.0.1" });    await client.connect(transport);     const tools = await client.listTools();    const hasExecute = tools.tools.some((t) => t.name === "execute");    if (!hasExecute) fail(`MCP tools/list missing "execute": ${JSON.stringify(tools.tools)}`);    const hasResume = tools.tools.some((t) => t.name === "resume");    if (!hasResume) fail(`MCP tools/list missing "resume": ${JSON.stringify(tools.tools)}`);     // Shared sandbox helper, prepended to both execute calls.    const unwrapHelper = `const unwrapToolData = (value) => {  if (value && typeof value === "object" && "ok" in value) {    if (!value.ok) throw new Error(value.error?.message ?? "Tool failed");    value = value.data;  }  if (value && typeof value === "object" && "data" in value) return value.data;  return value;};`;     // Execute #1 — register the integration and create its connection. v2    // produces tools per connection, and the sandbox snapshots the tool tree    // when an execution starts, so the invocation has to happen in a second    // execute call.    const setupCode = `${unwrapHelper}unwrapToolData(await tools.executor.openapi.addSpec({  spec: { kind: "url", url: ${JSON.stringify(`${openapi.origin}/openapi.json`)} },  baseUrl: ${JSON.stringify(openapi.origin)},  slug: "petstore",}));unwrapToolData(await tools.executor.coreTools.connections.create({  owner: "org",  name: "main",  integration: "petstore",  template: "apiKey",  from: { provider: "file", id: "petstore-token" },}));return "setup-ok";`;     const setupResult = await completePausedResult(      client,      await client.callTool({        name: "execute",        arguments: { code: setupCode },      }),    );    if (setupResult.result !== "setup-ok") {      fail(`integration setup failed: ${JSON.stringify(setupResult)}`);    }     // Execute #2 — drive the running OpenAPI server. Covers per-connection    // tool registration, array list response, path param dispatch, and object    // responses — all going out over real HTTP from inside QuickJS, via the    // v2 `tools.<integration>.<owner>.<connection>.<tool>` address.    const invokeCode = `${unwrapHelper}const list = unwrapToolData(await tools.petstore.org.main.pets.listPets({}));const fetchedData = unwrapToolData(await tools.petstore.org.main.pets.getPet({ petId: list[1].id }));return {  count: list.length,  names: list.map((p) => p.name),  fetched: { id: fetchedData.id, name: fetchedData.name },};`;     const result = await client.callTool({      name: "execute",      arguments: { code: invokeCode },    });    const structured = await completePausedResult(client, result);    const expected = {      count: 2,      names: ["Fido", "Whiskers"],      fetched: { id: 2, name: "Whiskers" },    };    if (JSON.stringify(structured.result) !== JSON.stringify(expected)) {      fail(        `expected ${JSON.stringify(expected)}, got ${JSON.stringify(structured.result)} (content: ${JSON.stringify(result.content)})`,      );    }     await client.close();    console.log(      `[smoke-sidecar] OK — listPets + getPet({petId:2}) round-tripped through the running OpenAPI server`,    );  } finally {    await cleanup();  }}; await main();