| @@ -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 | }); |