| @@ -0,0 +1,240 @@ |
| 1 | +import { mkdtempSync } from "node:fs"; |
| 2 | +import { tmpdir } from "node:os"; |
| 3 | +import { join } from "node:path"; |
| 4 | + |
| 5 | +import { describe, expect, it, beforeAll, afterAll } from "vitest"; |
| 6 | +import { Effect } from "effect"; |
| 7 | +import { createEmulator, type EmulatorClient } from "@executor-js/emulate"; |
| 8 | + |
| 9 | +import { makeSelfHostAppsRuntime, type SelfHostAppsRuntime } from "../plugin/self-host-runtime"; |
| 10 | +import { makeAppsHttpRoutes } from "../http/routes"; |
| 11 | +import { registerAppsMcp, type McpServerLike } from "../mcp/register"; |
| 12 | +import { makeInMemoryAppsStore } from "./index"; |
| 13 | +import { makeGithubRestResolver } from "./github-resolver"; |
| 14 | +import { dailyBriefFileSet } from "./daily-brief"; |
| 15 | +import type { Bindings } from "../plugin/bindings"; |
| 16 | + |
| 17 | +// --------------------------------------------------------------------------- |
| 18 | +// THE E2E PROOF (brief's proof artifact), runnable as one command: |
| 19 | +// bun run --filter='@executor-js/plugin-apps' test -- src/testing/e2e.test.ts |
| 20 | +// |
| 21 | +// It: boots the self-host apps runtime over real seam backings; stands up a |
| 22 | +// real-shaped GitHub via the emulate package; publishes the daily-brief set |
| 23 | +// OVER MCP (the apps_publish tool); binds a connection (the minted emulator |
| 24 | +// token); invokes the tool OVER HTTP (hits the real emulator, writes the scope |
| 25 | +// db); starts the workflow and sees it complete with a journal; fetches the ui |
| 26 | +// resource OVER MCP + the raw bundle OVER HTTP; sees an SSE invalidation after a |
| 27 | +// write; and lists + reads the skill OVER MCP. |
| 28 | +// --------------------------------------------------------------------------- |
| 29 | + |
| 30 | +const SCOPE = "rhys"; |
| 31 | +const CONNECTION = "rhys-github"; |
| 32 | + |
| 33 | +// A minimal in-memory McpServer stand-in capturing registered tools/resources |
| 34 | +// so the test can invoke them exactly as an MCP client would. |
| 35 | +class FakeMcpServer implements McpServerLike { |
| 36 | + readonly tools = new Map<string, (args: Record<string, unknown>) => unknown>(); |
| 37 | + readonly resources = new Map<string, { uriTemplate: string; reader: (uri: URL) => unknown }>(); |
| 38 | + registerTool( |
| 39 | + name: string, |
| 40 | + _config: unknown, |
| 41 | + handler: (args: Record<string, unknown>) => unknown, |
| 42 | + ) { |
| 43 | + this.tools.set(name, handler); |
| 44 | + return undefined; |
| 45 | + } |
| 46 | + registerResource(name: string, uri: string, _metadata: unknown, reader: (uri: URL) => unknown) { |
| 47 | + this.resources.set(name, { uriTemplate: uri, reader }); |
| 48 | + return undefined; |
| 49 | + } |
| 50 | +} |
| 51 | + |
| 52 | +const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect as never); |
| 53 | + |
| 54 | +describe("Executor apps e2e (self-host, real GitHub emulator)", () => { |
| 55 | + let github: EmulatorClient; |
| 56 | + let host: SelfHostAppsRuntime; |
| 57 | + let http: ReturnType<typeof makeAppsHttpRoutes>; |
| 58 | + let mcp: FakeMcpServer; |
| 59 | + let token: string; |
| 60 | + let owner: string; |
| 61 | + const base = "http://apps.test"; |
| 62 | + |
| 63 | + beforeAll(async () => { |
| 64 | + // --- real-shaped GitHub (emulate) + seed a repo with two issues -------- |
| 65 | + github = await createEmulator({ service: "github" }); |
| 66 | + const cred = (await github.credentials.mint({ type: "api-key" })) as unknown as { |
| 67 | + token: string; |
| 68 | + login: string; |
| 69 | + }; |
| 70 | + token = cred.token; |
| 71 | + const ghHeaders = { |
| 72 | + authorization: `Bearer ${token}`, |
| 73 | + accept: "application/vnd.github+json", |
| 74 | + "content-type": "application/json", |
| 75 | + }; |
| 76 | + const repoRes = await fetch(`${github.url}/user/repos`, { |
| 77 | + method: "POST", |
| 78 | + headers: ghHeaders, |
| 79 | + body: JSON.stringify({ name: "app" }), |
| 80 | + }); |
| 81 | + const repo = (await repoRes.json()) as { owner: { login: string } }; |
| 82 | + owner = repo.owner.login; |
| 83 | + // One fresh issue and one that will read as stale (we backdate via title only; |
| 84 | + // the workflow's stale filter uses updated_at, which the emulator sets to now, |
| 85 | + // so we assert the run completes + journals rather than a specific stale count). |
| 86 | + for (const title of ["Fresh bug", "Second bug"]) { |
| 87 | + await fetch(`${github.url}/repos/${owner}/app/issues`, { |
| 88 | + method: "POST", |
| 89 | + headers: ghHeaders, |
| 90 | + body: JSON.stringify({ title, labels: ["bug"] }), |
| 91 | + }); |
| 92 | + } |
| 93 | + |
| 94 | + // --- boot the self-host apps runtime over the five seam backings ------- |
| 95 | + const store = makeInMemoryAppsStore(); |
| 96 | + const resolver = makeGithubRestResolver({ |
| 97 | + baseUrl: github.url, |
| 98 | + tokens: { [CONNECTION]: token }, |
| 99 | + }); |
| 100 | + host = makeSelfHostAppsRuntime({ |
| 101 | + dataDir: mkdtempSync(join(tmpdir(), "apps-e2e-")), |
| 102 | + store, |
| 103 | + resolver, |
| 104 | + inMemory: true, |
| 105 | + }); |
| 106 | + http = makeAppsHttpRoutes({ runtime: host.runtime }); |
| 107 | + mcp = new FakeMcpServer(); |
| 108 | + registerAppsMcp(mcp, { runtime: host.runtime, scope: SCOPE }); |
| 109 | + }, 60_000); |
| 110 | + |
| 111 | + afterAll(async () => { |
| 112 | + await host?.close(); |
| 113 | + await github?.close(); |
| 114 | + }); |
| 115 | + |
| 116 | + it("publishes the daily-brief set over MCP", async () => { |
| 117 | + const publishTool = mcp.tools.get("apps_publish")!; |
| 118 | + const result = (await publishTool({ |
| 119 | + files: Object.fromEntries(dailyBriefFileSet()), |
| 120 | + })) as { |
| 121 | + structuredContent: { tools: string[]; workflows: string[]; ui: string[]; skills: string[] }; |
| 122 | + }; |
| 123 | + expect(result.structuredContent.tools.sort()).toEqual(["issues-sync", "search-all-mail"]); |
| 124 | + expect(result.structuredContent.workflows).toEqual(["morning-sync"]); |
| 125 | + expect(result.structuredContent.ui).toEqual(["dashboard"]); |
| 126 | + expect(result.structuredContent.skills).toEqual(["issues-brief"]); |
| 127 | + }); |
| 128 | + |
| 129 | + const bindings: Bindings = { github: { kind: "single", connection: CONNECTION } }; |
| 130 | + |
| 131 | + it("invokes the published tool over HTTP, hitting the real GitHub emulator", async () => { |
| 132 | + const res = await http.handler( |
| 133 | + new Request(`${base}/api/apps/${SCOPE}/tools/issues-sync`, { |
| 134 | + method: "POST", |
| 135 | + body: JSON.stringify({ args: {}, bindings }), |
| 136 | + }), |
| 137 | + ); |
| 138 | + expect(res.status).toBe(200); |
| 139 | + const body = (await res.json()) as { result: { synced: number; repos: number } }; |
| 140 | + expect(body.result.repos).toBe(1); |
| 141 | + expect(body.result.synced).toBe(2); |
| 142 | + |
| 143 | + // The emulator's request ledger proves the tool really called GitHub. |
| 144 | + const ledger = await github.ledger.list(); |
| 145 | + expect( |
| 146 | + ledger.some((e) => |
| 147 | + String(e.operationId ?? "") |
| 148 | + .toLowerCase() |
| 149 | + .includes("issue"), |
| 150 | + ), |
| 151 | + ).toBe(true); |
| 152 | + |
| 153 | + // The scope db now holds the two issues. |
| 154 | + const db = await run(host.scopeDb.forScope(SCOPE)); |
| 155 | + const rows = await run(db.exec<{ n: number }>("SELECT COUNT(*) AS n FROM issues")); |
| 156 | + expect(Number(rows[0].n)).toBe(2); |
| 157 | + }); |
| 158 | + |
| 159 | + it("starts the workflow and sees it complete with a journal", async () => { |
| 160 | + const res = await http.handler( |
| 161 | + new Request(`${base}/api/apps/${SCOPE}/workflows/morning-sync/start`, { |
| 162 | + method: "POST", |
| 163 | + body: JSON.stringify({ input: {}, bindings, runId: "e2e-morning" }), |
| 164 | + }), |
| 165 | + ); |
| 166 | + expect(res.status).toBe(200); |
| 167 | + const body = (await res.json()) as { run: { status: string; output: unknown } }; |
| 168 | + expect(body.run.status).toBe("completed"); |
| 169 | + |
| 170 | + // History is queryable and the step.tool call is journaled. |
| 171 | + const histRes = await http.handler( |
| 172 | + new Request(`${base}/api/apps/${SCOPE}/workflows/runs/e2e-morning`, { method: "GET" }), |
| 173 | + ); |
| 174 | + const hist = (await histRes.json()) as { steps: { name: string; status: string }[] }; |
| 175 | + expect(hist.steps.some((s) => s.name === "tool:issues-sync" && s.status === "completed")).toBe( |
| 176 | + true, |
| 177 | + ); |
| 178 | + }); |
| 179 | + |
| 180 | + it("serves the ui view as an MCP resource and a raw bundle over HTTP", async () => { |
| 181 | + // MCP Apps resource (ui:// + _meta). |
| 182 | + const resource = mcp.resources.get("apps-ui")!; |
| 183 | + const read = (await resource.reader(new URL(`ui://${SCOPE}/dashboard`))) as { |
| 184 | + contents: Array<{ mimeType: string; text: string; _meta?: { ui?: { title?: string } } }>; |
| 185 | + }; |
| 186 | + expect(read.contents[0].mimeType).toContain("mcp-resource"); |
| 187 | + expect(read.contents[0].text).toContain("issues"); // compiled bundle mentions the table |
| 188 | + expect(read.contents[0]._meta?.ui?.title).toBe("GitHub Issues"); |
| 189 | + |
| 190 | + // Raw bundle endpoint. |
| 191 | + const res = await http.handler( |
| 192 | + new Request(`${base}/api/apps/${SCOPE}/ui/dashboard`, { method: "GET" }), |
| 193 | + ); |
| 194 | + expect(res.status).toBe(200); |
| 195 | + expect(res.headers.get("content-type")).toContain("javascript"); |
| 196 | + expect(res.headers.get("x-ui-title")).toBe("GitHub Issues"); |
| 197 | + }); |
| 198 | + |
| 199 | + it("delivers an SSE invalidation after a scope-db write", async () => { |
| 200 | + const res = await http.handler( |
| 201 | + new Request(`${base}/api/apps/${SCOPE}/live`, { method: "GET" }), |
| 202 | + ); |
| 203 | + expect(res.headers.get("content-type")).toContain("text/event-stream"); |
| 204 | + const reader = res.body!.getReader(); |
| 205 | + const decoder = new TextDecoder(); |
| 206 | + |
| 207 | + // Read the initial `ready` frame. |
| 208 | + const first = await reader.read(); |
| 209 | + expect(decoder.decode(first.value)).toContain("event: ready"); |
| 210 | + |
| 211 | + // Trigger a write on the scope db -> version bump -> invalidation. |
| 212 | + const db = await run(host.scopeDb.forScope(SCOPE)); |
| 213 | + await run( |
| 214 | + db.exec( |
| 215 | + "INSERT INTO issues (repo, number, title, labels, updated_at, url) VALUES ('acme/x', 99, 't', '[]', '2026-01-01', 'u')", |
| 216 | + ), |
| 217 | + ); |
| 218 | + |
| 219 | + // The next SSE frame is the invalidation for the issues table. |
| 220 | + const invalidation = await Promise.race([ |
| 221 | + reader.read().then((r) => decoder.decode(r.value)), |
| 222 | + new Promise<string>((_, reject) => setTimeout(() => reject(new Error("SSE timeout")), 5000)), |
| 223 | + ]); |
| 224 | + expect(invalidation).toContain("event: invalidate"); |
| 225 | + expect(invalidation).toContain('"table":"issues"'); |
| 226 | + await reader.cancel(); |
| 227 | + }); |
| 228 | + |
| 229 | + it("lists and reads the published skill over MCP", async () => { |
| 230 | + const listSkills = mcp.tools.get("apps_list_skills")!; |
| 231 | + const listed = (await listSkills({})) as { |
| 232 | + structuredContent: { skills: { name: string; description: string }[] }; |
| 233 | + }; |
| 234 | + expect(listed.structuredContent.skills.map((s) => s.name)).toEqual(["issues-brief"]); |
| 235 | + |
| 236 | + const readSkill = mcp.tools.get("apps_read_skill")!; |
| 237 | + const body = (await readSkill({ name: "issues-brief" })) as { content: { text: string }[] }; |
| 238 | + expect(body.content[0].text).toContain("GitHub issues brief"); |
| 239 | + }); |
| 240 | +}); |