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
4141 "devDependencies": {
4242 "@effect/vitest": "catalog:",
4343 "@executor-js/api": "workspace:*",
44+ "@executor-js/emulate": "^0.13.2",
4445 "@modelcontextprotocol/sdk": "^1.29.0",
4546 "@types/node": "catalog:",
4647 "bun-types": "catalog:",
packages/plugins/apps/src/testing/e2e.test.ts+240−0
@@ -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+});
packages/plugins/apps/src/testing/github-resolver.ts+81−0
@@ -0,0 +1,81 @@
1+import { Effect } from "effect";
2+
3+import { 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+
14+export interface GithubResolverOptions {
15+ readonly baseUrl: string;
16+ /** Bearer token per connection name. */
17+ readonly tokens: Readonly<Record<string, string>>;
18+}
19+
20+export 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+};