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<ReturnType<typeof createEmulator>>; 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<string, (args: Record<string, unknown>) => unknown>(); readonly resources = new Map<string, { uriTemplate: string; reader: (uri: URL) => unknown }>(); registerTool( name: string, _config: unknown, handler: (args: Record<string, unknown>) => 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 = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect as never); describe("Executor apps e2e (self-host, real GitHub emulator)", () => { let github: LocalEmulator; let host: SelfHostAppsRuntime; let http: ReturnType<typeof makeAppsHttpRoutes>; 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("<!doctype html>"); // a real document expect(read.contents[0].text).toContain('<div id="root">'); // 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<string>((_, 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"); });});
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<ReturnType<typeof createEmulator>>;
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<string, (args: Record<string, unknown>) => unknown>();
readonly resources = new Map<string, { uriTemplate: string; reader: (uri: URL) => unknown }>();
registerTool(
name: string,
_config: unknown,
handler: (args: Record<string, unknown>) => 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 = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect as never);
describe("Executor apps e2e (self-host, real GitHub emulator)", () => {
let github: LocalEmulator;
let host: SelfHostAppsRuntime;
let http: ReturnType<typeof makeAppsHttpRoutes>;
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("<!doctype html>"); // a real document
expect(read.contents[0].text).toContain('<div id="root">'); // 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<string>((_, 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");
});
});