History

apps: end-to-end proof over real GitHub emulator (publish MCP, invoke HTTP, workflow, ui, SSE, skills)

Rhys Sullivan committed 4 months ago

Commit efca3dd1 parent 76be9be3 files · +322 −0Browse files

Unified · Split

3 files
  1. packages/plugins/apps/package.json +1 −0
  2. packages/plugins/apps/src/testing/e2e.test.ts +240 −0
  3. packages/plugins/apps/src/testing/github-resolver.ts +81 −0
packages/plugins/apps/package.json+1−0
@@ -41,6 +41,7 @@ · 40 unmodified lines
41 "devDependencies": {41 "devDependencies": {
42 "@effect/vitest": "catalog:",42 "@effect/vitest": "catalog:",
43 "@executor-js/api": "workspace:*",43 "@executor-js/api": "workspace:*",
44 "@executor-js/emulate": "^0.13.2",
44 "@modelcontextprotocol/sdk": "^1.29.0",45 "@modelcontextprotocol/sdk": "^1.29.0",
45 "@types/node": "catalog:",46 "@types/node": "catalog:",
46 "bun-types": "catalog:",47 "bun-types": "catalog:",
packages/plugins/apps/src/testing/e2e.test.ts+240−0
@@ -0,0 +1,240 @@
1import { mkdtempSync } from "node:fs";
2import { tmpdir } from "node:os";
3import { join } from "node:path";
4
5import { describe, expect, it, beforeAll, afterAll } from "vitest";
6import { Effect } from "effect";
7import { createEmulator, type EmulatorClient } from "@executor-js/emulate";
8
9import { makeSelfHostAppsRuntime, type SelfHostAppsRuntime } from "../plugin/self-host-runtime";
10import { makeAppsHttpRoutes } from "../http/routes";
11import { registerAppsMcp, type McpServerLike } from "../mcp/register";
12import { makeInMemoryAppsStore } from "./index";
13import { makeGithubRestResolver } from "./github-resolver";
14import { dailyBriefFileSet } from "./daily-brief";
15import 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
30const SCOPE = "rhys";
31const 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.
35class 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
52const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect as never);
53
54describe("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});
packages/plugins/apps/src/testing/github-resolver.ts+81−0
@@ -0,0 +1,81 @@
1import { Effect } from "effect";
2
3import { BindingError, type ClientResolver } from "../plugin/bindings";
4
5// ---------------------------------------------------------------------------
6// A ClientResolver that maps the daily-brief github client methods to real
7// GitHub REST calls against a base URL (the emulate GitHub emulator in the
8// e2e). This stands in for the platform invoke path: in production the resolver
9// routes through the catalog (credentials injected platform-side, policy/audit
10// applied); here it makes the real authenticated HTTP call with the bound
11// connection's token. Everything crossing is JSON.
12// ---------------------------------------------------------------------------
13
14export interface GithubResolverOptions {
15 readonly baseUrl: string;
16 /** Bearer token per connection name. */
17 readonly tokens: Readonly<Record<string, string>>;
18}
19
20export const makeGithubRestResolver = (options: GithubResolverOptions): ClientResolver => {
21 const call = async (
22 connection: string,
23 path: readonly string[],
24 args: readonly unknown[],
25 ): Promise<unknown> => {
26 const token = options.tokens[connection];
27 const headers: Record<string, string> = {
28 accept: "application/vnd.github+json",
29 "content-type": "application/json",
30 };
31 if (token) headers.authorization = `Bearer ${token}`;
32 const method = path.join(".");
33 const input = (args[0] ?? {}) as Record<string, unknown>;
34
35 if (method === "repos.listForAuthenticatedUser") {
36 const perPage = input.per_page ?? 100;
37 const res = await fetch(`${options.baseUrl}/user/repos?per_page=${perPage}`, { headers });
38 return (await res.json()) as Array<{ full_name: string }>;
39 }
40 if (method === "issues.listForRepo") {
41 const owner = String(input.owner);
42 const repo = String(input.repo);
43 const state = String(input.state ?? "open");
44 const perPage = input.per_page ?? 100;
45 const since = input.since ? `&since=${encodeURIComponent(String(input.since))}` : "";
46 const res = await fetch(
47 `${options.baseUrl}/repos/${owner}/${repo}/issues?state=${state}&per_page=${perPage}${since}`,
48 { headers },
49 );
50 const issues = (await res.json()) as Array<Record<string, unknown>>;
51 // Normalize to the shape the tool expects.
52 return issues.map((i) => ({
53 number: i.number,
54 title: i.title,
55 labels: Array.isArray(i.labels)
56 ? (i.labels as Array<{ name?: string } | string>).map((l) =>
57 typeof l === "string" ? { name: l } : { name: l.name ?? "" },
58 )
59 : [],
60 assignee: i.assignee ?? null,
61 updated_at: i.updated_at,
62 html_url: i.html_url,
63 pull_request: i.pull_request,
64 }));
65 }
66 throw new Error(`unsupported github method: ${method}`);
67 };
68
69 return {
70 call: ({ connection, path, args }) =>
71 Effect.tryPromise({
72 try: () => call(connection, path, args),
73 catch: (cause) =>
74 new BindingError({
75 message: cause instanceof Error ? cause.message : String(cause),
76 role: "github",
77 surface: "github",
78 }),
79 }),
80 };
81};