import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it, beforeAll, afterAll } from "@effect/vitest"; import { Effect } from "effect"; import { createEmulator } from "@executor-js/emulate"; type LocalEmulator = Awaited>; import { makeSelfHostAppsRuntime, type SelfHostAppsRuntime } from "../plugin/self-host-runtime"; import { makeAppsHttpRoutes } from "../http/routes"; import { registerAppsMcp, type McpServerLike } from "../mcp/register"; import { makeInMemoryAppsStore } from "./index"; import { makeGithubRestResolver } from "./github-resolver"; import { dailyBriefFileSet } from "./daily-brief"; import type { Bindings } from "../plugin/bindings"; // --------------------------------------------------------------------------- // THE E2E PROOF (brief's proof artifact), runnable as one command: // bun run --filter='@executor-js/plugin-apps' test -- src/testing/e2e.test.ts // // It: boots the self-host apps runtime over real seam backings; stands up a // real-shaped GitHub via the emulate package; publishes the daily-brief set // OVER MCP (the apps_publish tool); binds a connection (the minted emulator // token); invokes the tool OVER HTTP (hits the real emulator, writes the scope // db); starts the workflow and sees it complete with a journal; fetches the ui // resource OVER MCP + the raw bundle OVER HTTP; sees an SSE invalidation after a // write; and lists + reads the skill OVER MCP. // --------------------------------------------------------------------------- const SCOPE = "rhys"; const CONNECTION = "rhys-github"; // A minimal in-memory McpServer stand-in capturing registered tools/resources // so the test can invoke them exactly as an MCP client would. class FakeMcpServer implements McpServerLike { readonly tools = new Map) => unknown>(); readonly resources = new Map unknown }>(); registerTool( name: string, _config: unknown, handler: (args: Record) => unknown, ) { this.tools.set(name, handler); return undefined; } registerResource( name: string, uri: string | { uriTemplate?: unknown }, _metadata: unknown, reader: (uri: URL) => unknown, ) { this.resources.set(name, { uriTemplate: String(uri), reader }); return undefined; } } const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect as never); describe("Executor apps e2e (self-host, real GitHub emulator)", () => { let github: LocalEmulator; let host: SelfHostAppsRuntime; let http: ReturnType; let mcp: FakeMcpServer; let token: string; let owner: string; const base = "http://apps.test"; beforeAll(async () => { // --- real-shaped GitHub (emulate) + seed a repo with two issues -------- github = await createEmulator({ service: "github" }); // oxlint-disable-next-line executor/no-double-cast -- test boundary: the emulator credential minter returns an opaque credential; this asserts the api-key shape it produces const cred = (await github.credentials.mint({ type: "api-key", })) as unknown as { token: string; login: string; }; token = cred.token; const ghHeaders = { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "content-type": "application/json", }; const repoRes = await fetch(`${github.url}/user/repos`, { method: "POST", headers: ghHeaders, body: JSON.stringify({ name: "app" }), }); const repo = (await repoRes.json()) as { owner: { login: string } }; owner = repo.owner.login; // One fresh issue and one that will read as stale (we backdate via title only; // the workflow's stale filter uses updated_at, which the emulator sets to now, // so we assert the run completes + journals rather than a specific stale count). for (const title of ["Fresh bug", "Second bug"]) { await fetch(`${github.url}/repos/${owner}/app/issues`, { method: "POST", headers: ghHeaders, body: JSON.stringify({ title, labels: ["bug"] }), }); } // --- boot the self-host apps runtime over the five seam backings ------- const store = makeInMemoryAppsStore(); const resolver = makeGithubRestResolver({ baseUrl: github.url, tokens: { [CONNECTION]: token }, }); host = makeSelfHostAppsRuntime({ dataDir: mkdtempSync(join(tmpdir(), "apps-e2e-")), store, resolver, inMemory: true, }); http = makeAppsHttpRoutes({ runtime: host.runtime }); mcp = new FakeMcpServer(); registerAppsMcp(mcp, { runtime: host.runtime, scope: SCOPE }); }, 60_000); afterAll(async () => { await host?.close(); await github?.close(); }); it("publishes the daily-brief set over MCP", async () => { const publishTool = mcp.tools.get("apps_publish")!; const result = (await publishTool({ files: Object.fromEntries(dailyBriefFileSet()), })) as { structuredContent: { tools: string[]; workflows: string[]; ui: string[]; skills: string[]; }; }; expect(result.structuredContent.tools.sort()).toEqual(["issues-sync", "search-all-mail"]); expect(result.structuredContent.workflows).toEqual(["morning-sync"]); expect(result.structuredContent.ui).toEqual(["dashboard"]); expect(result.structuredContent.skills).toEqual(["issues-brief"]); }); const bindings: Bindings = { github: { kind: "single", connection: CONNECTION }, }; it("invokes the published tool over HTTP, hitting the real GitHub emulator", async () => { const res = await http.handler( new Request(`${base}/api/apps/${SCOPE}/tools/issues-sync`, { method: "POST", body: JSON.stringify({ args: {}, bindings }), }), ); expect(res.status).toBe(200); const body = (await res.json()) as { result: { synced: number; repos: number }; }; expect(body.result.repos).toBe(1); expect(body.result.synced).toBe(2); // The emulator's request ledger proves the tool really called GitHub. const ledger = await github.ledger.list(); expect( ledger.some((e) => String(e.operationId ?? "") .toLowerCase() .includes("issue"), ), ).toBe(true); // The scope db now holds the two issues. const db = await run(host.scopeDb.forScope(SCOPE)); const rows = await run(db.exec<{ n: number }>("SELECT COUNT(*) AS n FROM issues")); expect(Number(rows[0].n)).toBe(2); }); it("starts the workflow and sees it complete with a journal", async () => { const res = await http.handler( new Request(`${base}/api/apps/${SCOPE}/workflows/morning-sync/start`, { method: "POST", body: JSON.stringify({ input: {}, bindings, runId: "e2e-morning" }), }), ); expect(res.status).toBe(200); const body = (await res.json()) as { run: { status: string; output: unknown }; }; expect(body.run.status).toBe("completed"); // History is queryable and the step.tool call is journaled. const histRes = await http.handler( new Request(`${base}/api/apps/${SCOPE}/workflows/runs/e2e-morning`, { method: "GET", }), ); const hist = (await histRes.json()) as { steps: { name: string; status: string }[]; }; expect(hist.steps.some((s) => s.name === "tool:issues-sync" && s.status === "completed")).toBe( true, ); }); it("serves the ui view as an MCP-Apps HTML document and a raw bundle over HTTP", async () => { // MCP Apps resource: a complete, self-booting HTML document (ui:// + _meta), // the shape a real host mounts. const resource = mcp.resources.get("apps-ui")!; const read = (await resource.reader(new URL(`ui://${SCOPE}/dashboard`))) as { contents: Array<{ mimeType: string; text: string; _meta?: { ui?: { title?: string } }; }>; }; expect(read.contents[0].mimeType).toContain("text/html"); expect(read.contents[0].text).toContain(""); // a real document expect(read.contents[0].text).toContain('
'); // a mount point expect(read.contents[0]._meta?.ui?.title).toBe("GitHub Issues"); // Raw bundle endpoint. const res = await http.handler( new Request(`${base}/api/apps/${SCOPE}/ui/dashboard`, { method: "GET" }), ); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toContain("javascript"); expect(res.headers.get("x-ui-title")).toBe("GitHub Issues"); }); it("delivers an SSE invalidation after a scope-db write", async () => { const res = await http.handler( new Request(`${base}/api/apps/${SCOPE}/live`, { method: "GET" }), ); expect(res.headers.get("content-type")).toContain("text/event-stream"); const reader = res.body!.getReader(); const decoder = new TextDecoder(); // Read the initial `ready` frame. const first = await reader.read(); expect(decoder.decode(first.value)).toContain("event: ready"); // Trigger a write on the scope db -> version bump -> invalidation. const db = await run(host.scopeDb.forScope(SCOPE)); await run( db.exec( "INSERT INTO issues (repo, number, title, labels, updated_at, url) VALUES ('acme/x', 99, 't', '[]', '2026-01-01', 'u')", ), ); // The next SSE frame is the invalidation for the issues table. const invalidation = await Promise.race([ reader.read().then((r) => decoder.decode(r.value)), // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- test boundary: race the SSE read against a wall-clock timeout so a hung stream fails fast new Promise((_, reject) => setTimeout(() => reject(new Error("SSE timeout")), 5000)), ]); expect(invalidation).toContain("event: invalidate"); expect(invalidation).toContain('"table":"issues"'); await reader.cancel(); }); it("lists and reads the published skill over MCP", async () => { const listSkills = mcp.tools.get("apps_list_skills")!; const listed = (await listSkills({})) as { structuredContent: { skills: { name: string; description: string }[] }; }; expect(listed.structuredContent.skills.map((s) => s.name)).toEqual(["issues-brief"]); const readSkill = mcp.tools.get("apps_read_skill")!; const body = (await readSkill({ name: "issues-brief" })) as { content: { text: string }[]; }; expect(body.content[0].text).toContain("GitHub issues brief"); }); });