import { mkdtempSync } from "node:fs";import { tmpdir } from "node:os";import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest";import { Client } from "@modelcontextprotocol/sdk/client/index.js";import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";import { createEmulator } from "@executor-js/emulate"; import { dailyBriefFileSet } from "@executor-js/plugin-apps/testing"; import { mintInviteCode } from "./testing/mint-invite"; // ===========================================================================// THE BOOTED-HOST WIRE E2E (Fix 5): prove the apps subsystem over real HTTP +// a real MCP client, against the ACTUAL self-host app (the same composition// serve.ts uses), bound to an ephemeral socket.//// One runnable command:// bun run --filter='@executor-js/host-selfhost' test -- src/apps-wire.node.test.ts//// The chain, all over the wire (nothing faked, no FakeMcpServer):// 1. boot makeSelfHostApiHandler on an ephemeral Bun.serve port// 2. stand up a real-shaped GitHub via @executor-js/emulate; seed a repo+issues// 3. sign up (Better Auth) -> bearer token; register the emulator as the// `github` integration and create a connection to it (its minted token)// 4. connect a REAL MCP Client over StreamableHTTP to /mcp// 5. publish daily-brief over the `apps_publish` MCP door// 6. tools/list over MCP shows the published tool AS A CATALOG TOOL// 7. invoke it through the catalog path; the emulator's ledger proves the// upstream GitHub call landed// 8. start the workflow (manual) over HTTP; see it complete with a journal// 9. read the ui:// resource over MCP (resources/read)// 10. make a scope-db write; observe the SSE invalidation frame over HTTP// 11. list + read the published skill over MCP// =========================================================================== // Better Auth needs a secret + a bootstrap admin before the app graph imports.const dataDir = mkdtempSync(join(tmpdir(), "eh-apps-wire-"));process.env.EXECUTOR_DATA_DIR = dataDir;process.env.BETTER_AUTH_SECRET = "apps-wire-secret-0123456789-abcdefghij-klmnop";process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@apps-wire.test";process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456";// The apps ClientResolver dials the emulator over loopback; allow it.process.env.EXECUTOR_ALLOW_LOCAL_NETWORK = "true"; const SCOPE = "default";const GITHUB_INTEGRATION = "github";// The connection name matches the integration slug, which is the apps binding// contract (a role defaults to a connection of the same name as its integration).// The catalog-invoke path uses that default binding, and the resolver now// requires an EXACT connection match (no silent conns[0] fallback, Fix 4), so the// connection MUST be named after the integration for the default binding to// resolve.const GITHUB_CONNECTION = "github"; type LocalEmulator = Awaited<ReturnType<typeof createEmulator>>; describe("Executor apps wire e2e (booted self-host, real MCP client + GitHub emulator)", () => { let handler!: (request: Request) => Promise<Response>; let dispose: () => Promise<void> = async () => {}; let github: LocalEmulator; // The booted app is an in-process web handler; the "wire" is a real MCP client // + real JSON-RPC/SSE framing over a fetch bound to it (vitest runs under Node, // so there is no ambient Bun.serve to bind an ephemeral socket to). `origin` is // the URL the transport dials; the injected fetch routes it to the handler. const origin = "http://apps-wire.internal"; let token = ""; let owner = ""; // A JSON fetch that carries the bearer, aimed at the booted handler. const api = (path: string, init?: RequestInit) => handler( new Request(`${origin}${path}`, { ...init, headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...(init?.headers ?? {}), }, }), ); // Fetch shim the MCP StreamableHTTP transport uses: forward WHATWG fetch calls // straight into the booted handler. const wireFetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { const request = input instanceof Request ? input : new Request(input, init); return handler(request); }; let client: Client; 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; }; const ghToken = cred.token; const ghHeaders = { authorization: `Bearer ${ghToken}`, 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; 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 REAL self-host app (the composition serve.ts uses) -------- const { makeSelfHostApiHandler } = await import("./app"); const app = await makeSelfHostApiHandler(); handler = app.handler; dispose = app.dispose; // --- sign up -> bearer token -------------------------------------------- const inviteCode = await mintInviteCode(handler); const su = await handler( new Request(`${origin}/api/auth/sign-up/email`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "u@apps-wire.test", password: "password-12345678", name: "U", inviteCode, }), }), ); token = su.headers.get("set-auth-token") ?? ""; expect(token).not.toBe(""); // --- register the emulator as the `github` integration + a connection ---- // An OpenAPI-shaped integration whose baseUrl points at the loopback // emulator: the apps ClientResolver reads that base URL from the integration // record and dispatches the published tool's github.* calls there. A minimal // spec is enough; the resolver builds REST paths itself (it doesn't drive the // spec's operations), so the integration exists only to carry the base URL + // hold the connection's credential. const spec = JSON.stringify({ openapi: "3.0.0", info: { title: "GitHub (emulated)", version: "1.0.0" }, servers: [{ url: github.url }], paths: { "/user/repos": { get: { operationId: "listRepos", responses: { "200": { description: "ok" } }, }, }, }, }); const addSpec = await api("/api/openapi/specs", { method: "POST", body: JSON.stringify({ spec: { kind: "blob", value: spec }, slug: GITHUB_INTEGRATION, baseUrl: github.url, }), }); expect(addSpec.status).toBe(200); const conn = await api("/api/connections", { method: "POST", body: JSON.stringify({ owner: "user", name: GITHUB_CONNECTION, integration: GITHUB_INTEGRATION, template: "bearer", value: ghToken, }), }); expect(conn.status).toBe(200); // --- connect a REAL MCP client over StreamableHTTP to /mcp -------------- client = new Client({ name: "apps-wire-client", version: "1.0.0" }); const transport = new StreamableHTTPClientTransport(new URL(`${origin}/mcp`), { // oxlint-disable-next-line executor/no-double-cast -- test boundary: the in-process wire handler stands in for the global fetch the MCP transport expects fetch: wireFetch as unknown as typeof globalThis.fetch, requestInit: { headers: { authorization: `Bearer ${token}` } }, }); await client.connect(transport); }, 90_000); afterAll(async () => { // oxlint-disable-next-line executor/no-promise-catch -- boundary: best-effort teardown of the raw MCP client; a close failure must not fail the suite await client?.close().catch(() => {}); await dispose(); await github?.close(); }); it("publishes the daily-brief set over the MCP publish door", async () => { const result = (await client.callTool({ name: "apps_publish", arguments: { files: Object.fromEntries(dailyBriefFileSet()) }, })) as { structuredContent?: { tools?: string[]; workflows?: string[]; ui?: string[]; skills?: string[]; }; }; const sc = result.structuredContent!; expect((sc.tools ?? []).sort()).toEqual(["issues-sync", "search-all-mail"]); expect(sc.workflows).toEqual(["morning-sync"]); expect(sc.ui).toEqual(["dashboard"]); expect(sc.skills).toEqual(["issues-brief"]); }); it("wires the published app into the catalog; the tool becomes a catalog citizen", async () => { // Wire the scope into the catalog through the REAL request path: the built-in // `executor.apps.connect_catalog` tool registers the `apps` integration and // creates the apps/<scope> connection for the caller. Invoked over MCP via // the `execute` sandbox, exactly as an agent would. const connect = (await client.callTool({ name: "execute", arguments: { code: "export default await tools.executor.apps.connect_catalog({});", }, })) as { isError?: boolean; structuredContent?: { status?: string } }; expect(connect.isError ?? false).toBe(false); expect(connect.structuredContent?.status).toBe("completed"); // The published tool is now discoverable as a catalog tool through the same // `tools.search` an agent uses (executor exposes catalog tools inside the // `execute` sandbox, not as flat MCP tools — `execute` IS the catalog door). const search = (await client.callTool({ name: "execute", arguments: { code: 'export default (await tools.search({ query: "sync github issues into the scope table", limit: 30 })).items.map((m) => m.path)', }, })) as { structuredContent?: { result?: string[] } }; const paths = search.structuredContent?.result ?? []; // Addressed as tools.apps.<owner>.<connection>.<tool> — a real catalog citizen. expect(paths.some((p) => p.includes("apps.") && p.includes("issues-sync"))).toBe(true); }); it("invokes the published tool through the catalog path; the ledger proves the upstream GitHub call landed", async () => { const before = (await github.ledger.list()).length; // Invoke via the MCP `execute` sandbox, addressing the published tool through // the catalog: tools.apps.<owner>.<connection>.issues-sync(...) routes through // resolveTools/invokeTool -> the sandbox -> the per-request ClientResolver -> // the GitHub emulator, then writes the scope db. const call = (await client.callTool({ name: "execute", arguments: { code: `export default await tools.apps.user.appsdefault['issues-sync']({ repos: ["${owner}/app"] });`, }, })) as { isError?: boolean; structuredContent?: { status?: string; result?: { ok?: boolean; data?: { synced?: number } }; }; }; expect(call.isError ?? false).toBe(false); expect(call.structuredContent?.status).toBe("completed"); // The execute sandbox wraps a tool result as { ok, data }. expect(call.structuredContent?.result?.data?.synced).toBe(2); // The emulator's request ledger proves the tool really called GitHub. const ledger = await github.ledger.list(); expect(ledger.length).toBeGreaterThan(before); expect( ledger.some( (e) => String(e.operationId ?? "") .toLowerCase() .includes("issue") || String((e as { path?: string }).path ?? "") .toLowerCase() .includes("issues"), ), ).toBe(true); }); it("starts the workflow (manual) and sees it complete with a journal", async () => { // Start the workflow through the REAL request path (the `start_workflow` // built-in over the MCP execute sandbox), so its `step.tool` external call // reaches the emulator through the caller's connection + per-request resolver. const started = (await client.callTool({ name: "execute", arguments: { code: "export default await tools.executor.apps.start_workflow(" + '{ workflow: "morning-sync", runId: "wire-morning" });', }, })) as { isError?: boolean; structuredContent?: { status?: string; result?: { data?: { status?: string } }; }; }; expect(started.isError ?? false).toBe(false); expect(started.structuredContent?.result?.data?.status).toBe("completed"); const hist = await api(`/api/apps/${SCOPE}/workflows/runs/wire-morning`, { method: "GET", }); const steps = ((await hist.json()) as { steps: { name: string; status: string }[] }).steps; expect(steps.some((s) => s.name === "tool:issues-sync" && s.status === "completed")).toBe(true); }); it("reads the ui:// resource over MCP (resources/read)", async () => { const read = await client.readResource({ uri: `ui://${SCOPE}/dashboard` }); const first = read.contents[0] as { mimeType?: string; text?: string; _meta?: { ui?: { title?: string } }; }; expect(first.mimeType).toContain("mcp"); expect(first.text).toContain("issues"); expect(first._meta?.ui?.title).toBe("GitHub Issues"); }); it("delivers an SSE invalidation frame over HTTP after a scope-db write", async () => { const res = await api(`/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(); const first = await reader.read(); expect(decoder.decode(first.value)).toContain("event: ready"); // Re-invoke issues-sync via HTTP to write the scope db (a fresh write bumps // the version and fires the invalidation). await api(`/api/apps/${SCOPE}/tools/issues-sync`, { method: "POST", body: JSON.stringify({ args: { repos: [`${owner}/app`] }, bindings: { github: { kind: "single", connection: GITHUB_CONNECTION } }, }), }); const invalidation = await Promise.race([ (async () => { // Drain frames until we see an invalidate (there may be keepalives). for (let i = 0; i < 10; i++) { const chunk = await reader.read(); if (chunk.done) break; const text = decoder.decode(chunk.value); if (text.includes("event: invalidate")) return text; } return ""; })(), // 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")), 8000)), ]); expect(invalidation).toContain("event: invalidate"); expect(invalidation).toContain('"table":"issues"'); await reader.cancel(); }); it("lists and reads the published skill over MCP", async () => { const listed = (await client.callTool({ name: "apps_list_skills", arguments: {}, })) as { structuredContent?: { skills?: { name: string }[] } }; expect((listed.structuredContent?.skills ?? []).map((s) => s.name)).toEqual(["issues-brief"]); const readSkill = (await client.callTool({ name: "apps_read_skill", arguments: { name: "issues-brief" }, })) as { content?: { text: string }[] }; expect(readSkill.content?.[0]?.text).toContain("GitHub issues brief"); });});
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { createEmulator } from "@executor-js/emulate";
import { dailyBriefFileSet } from "@executor-js/plugin-apps/testing";
import { mintInviteCode } from "./testing/mint-invite";
// ===========================================================================
// THE BOOTED-HOST WIRE E2E (Fix 5): prove the apps subsystem over real HTTP +
// a real MCP client, against the ACTUAL self-host app (the same composition
// serve.ts uses), bound to an ephemeral socket.
//
// One runnable command:
// bun run --filter='@executor-js/host-selfhost' test -- src/apps-wire.node.test.ts
//
// The chain, all over the wire (nothing faked, no FakeMcpServer):
// 1. boot makeSelfHostApiHandler on an ephemeral Bun.serve port
// 2. stand up a real-shaped GitHub via @executor-js/emulate; seed a repo+issues
// 3. sign up (Better Auth) -> bearer token; register the emulator as the
// `github` integration and create a connection to it (its minted token)
// 4. connect a REAL MCP Client over StreamableHTTP to /mcp
// 5. publish daily-brief over the `apps_publish` MCP door
// 6. tools/list over MCP shows the published tool AS A CATALOG TOOL
// 7. invoke it through the catalog path; the emulator's ledger proves the
// upstream GitHub call landed
// 8. start the workflow (manual) over HTTP; see it complete with a journal
// 9. read the ui:// resource over MCP (resources/read)
// 10. make a scope-db write; observe the SSE invalidation frame over HTTP
// 11. list + read the published skill over MCP
// ===========================================================================
// Better Auth needs a secret + a bootstrap admin before the app graph imports.
const dataDir = mkdtempSync(join(tmpdir(), "eh-apps-wire-"));
process.env.EXECUTOR_DATA_DIR = dataDir;
process.env.BETTER_AUTH_SECRET = "apps-wire-secret-0123456789-abcdefghij-klmnop";
process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@apps-wire.test";
process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456";
// The apps ClientResolver dials the emulator over loopback; allow it.
process.env.EXECUTOR_ALLOW_LOCAL_NETWORK = "true";
const SCOPE = "default";
const GITHUB_INTEGRATION = "github";
// The connection name matches the integration slug, which is the apps binding
// contract (a role defaults to a connection of the same name as its integration).
// The catalog-invoke path uses that default binding, and the resolver now
// requires an EXACT connection match (no silent conns[0] fallback, Fix 4), so the
// connection MUST be named after the integration for the default binding to
// resolve.
const GITHUB_CONNECTION = "github";
type LocalEmulator = Awaited<ReturnType<typeof createEmulator>>;
describe("Executor apps wire e2e (booted self-host, real MCP client + GitHub emulator)", () => {
let handler!: (request: Request) => Promise<Response>;
let dispose: () => Promise<void> = async () => {};
let github: LocalEmulator;
// The booted app is an in-process web handler; the "wire" is a real MCP client
// + real JSON-RPC/SSE framing over a fetch bound to it (vitest runs under Node,
// so there is no ambient Bun.serve to bind an ephemeral socket to). `origin` is
// the URL the transport dials; the injected fetch routes it to the handler.
const origin = "http://apps-wire.internal";
let token = "";
let owner = "";
// A JSON fetch that carries the bearer, aimed at the booted handler.
const api = (path: string, init?: RequestInit) =>
handler(
new Request(`${origin}${path}`, {
...init,
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
}),
);
// Fetch shim the MCP StreamableHTTP transport uses: forward WHATWG fetch calls
// straight into the booted handler.
const wireFetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const request = input instanceof Request ? input : new Request(input, init);
return handler(request);
};
let client: Client;
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;
};
const ghToken = cred.token;
const ghHeaders = {
authorization: `Bearer ${ghToken}`,
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;
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 REAL self-host app (the composition serve.ts uses) --------
const { makeSelfHostApiHandler } = await import("./app");
const app = await makeSelfHostApiHandler();
handler = app.handler;
dispose = app.dispose;
// --- sign up -> bearer token --------------------------------------------
const inviteCode = await mintInviteCode(handler);
const su = await handler(
new Request(`${origin}/api/auth/sign-up/email`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
email: "u@apps-wire.test",
password: "password-12345678",
name: "U",
inviteCode,
}),
}),
);
token = su.headers.get("set-auth-token") ?? "";
expect(token).not.toBe("");
// --- register the emulator as the `github` integration + a connection ----
// An OpenAPI-shaped integration whose baseUrl points at the loopback
// emulator: the apps ClientResolver reads that base URL from the integration
// record and dispatches the published tool's github.* calls there. A minimal
// spec is enough; the resolver builds REST paths itself (it doesn't drive the
// spec's operations), so the integration exists only to carry the base URL +
// hold the connection's credential.
const spec = JSON.stringify({
openapi: "3.0.0",
info: { title: "GitHub (emulated)", version: "1.0.0" },
servers: [{ url: github.url }],
paths: {
"/user/repos": {
get: {
operationId: "listRepos",
responses: { "200": { description: "ok" } },
},
},
},
});
const addSpec = await api("/api/openapi/specs", {
method: "POST",
body: JSON.stringify({
spec: { kind: "blob", value: spec },
slug: GITHUB_INTEGRATION,
baseUrl: github.url,
}),
});
expect(addSpec.status).toBe(200);
const conn = await api("/api/connections", {
method: "POST",
body: JSON.stringify({
owner: "user",
name: GITHUB_CONNECTION,
integration: GITHUB_INTEGRATION,
template: "bearer",
value: ghToken,
}),
});
expect(conn.status).toBe(200);
// --- connect a REAL MCP client over StreamableHTTP to /mcp --------------
client = new Client({ name: "apps-wire-client", version: "1.0.0" });
const transport = new StreamableHTTPClientTransport(new URL(`${origin}/mcp`), {
// oxlint-disable-next-line executor/no-double-cast -- test boundary: the in-process wire handler stands in for the global fetch the MCP transport expects
fetch: wireFetch as unknown as typeof globalThis.fetch,
requestInit: { headers: { authorization: `Bearer ${token}` } },
});
await client.connect(transport);
}, 90_000);
afterAll(async () => {
// oxlint-disable-next-line executor/no-promise-catch -- boundary: best-effort teardown of the raw MCP client; a close failure must not fail the suite
await client?.close().catch(() => {});
await dispose();
await github?.close();
});
it("publishes the daily-brief set over the MCP publish door", async () => {
const result = (await client.callTool({
name: "apps_publish",
arguments: { files: Object.fromEntries(dailyBriefFileSet()) },
})) as {
structuredContent?: {
tools?: string[];
workflows?: string[];
ui?: string[];
skills?: string[];
};
};
const sc = result.structuredContent!;
expect((sc.tools ?? []).sort()).toEqual(["issues-sync", "search-all-mail"]);
expect(sc.workflows).toEqual(["morning-sync"]);
expect(sc.ui).toEqual(["dashboard"]);
expect(sc.skills).toEqual(["issues-brief"]);
});
it("wires the published app into the catalog; the tool becomes a catalog citizen", async () => {
// Wire the scope into the catalog through the REAL request path: the built-in
// `executor.apps.connect_catalog` tool registers the `apps` integration and
// creates the apps/<scope> connection for the caller. Invoked over MCP via
// the `execute` sandbox, exactly as an agent would.
const connect = (await client.callTool({
name: "execute",
arguments: {
code: "export default await tools.executor.apps.connect_catalog({});",
},
})) as { isError?: boolean; structuredContent?: { status?: string } };
expect(connect.isError ?? false).toBe(false);
expect(connect.structuredContent?.status).toBe("completed");
// The published tool is now discoverable as a catalog tool through the same
// `tools.search` an agent uses (executor exposes catalog tools inside the
// `execute` sandbox, not as flat MCP tools — `execute` IS the catalog door).
const search = (await client.callTool({
name: "execute",
arguments: {
code: 'export default (await tools.search({ query: "sync github issues into the scope table", limit: 30 })).items.map((m) => m.path)',
},
})) as { structuredContent?: { result?: string[] } };
const paths = search.structuredContent?.result ?? [];
// Addressed as tools.apps.<owner>.<connection>.<tool> — a real catalog citizen.
expect(paths.some((p) => p.includes("apps.") && p.includes("issues-sync"))).toBe(true);
});
it("invokes the published tool through the catalog path; the ledger proves the upstream GitHub call landed", async () => {
const before = (await github.ledger.list()).length;
// Invoke via the MCP `execute` sandbox, addressing the published tool through
// the catalog: tools.apps.<owner>.<connection>.issues-sync(...) routes through
// resolveTools/invokeTool -> the sandbox -> the per-request ClientResolver ->
// the GitHub emulator, then writes the scope db.
const call = (await client.callTool({
name: "execute",
arguments: {
code: `export default await tools.apps.user.appsdefault['issues-sync']({ repos: ["${owner}/app"] });`,
},
})) as {
isError?: boolean;
structuredContent?: {
status?: string;
result?: { ok?: boolean; data?: { synced?: number } };
};
};
expect(call.isError ?? false).toBe(false);
expect(call.structuredContent?.status).toBe("completed");
// The execute sandbox wraps a tool result as { ok, data }.
expect(call.structuredContent?.result?.data?.synced).toBe(2);
// The emulator's request ledger proves the tool really called GitHub.
const ledger = await github.ledger.list();
expect(ledger.length).toBeGreaterThan(before);
expect(
ledger.some(
(e) =>
String(e.operationId ?? "")
.toLowerCase()
.includes("issue") ||
String((e as { path?: string }).path ?? "")
.toLowerCase()
.includes("issues"),
),
).toBe(true);
});
it("starts the workflow (manual) and sees it complete with a journal", async () => {
// Start the workflow through the REAL request path (the `start_workflow`
// built-in over the MCP execute sandbox), so its `step.tool` external call
// reaches the emulator through the caller's connection + per-request resolver.
const started = (await client.callTool({
name: "execute",
arguments: {
code:
"export default await tools.executor.apps.start_workflow(" +
'{ workflow: "morning-sync", runId: "wire-morning" });',
},
})) as {
isError?: boolean;
structuredContent?: {
status?: string;
result?: { data?: { status?: string } };
};
};
expect(started.isError ?? false).toBe(false);
expect(started.structuredContent?.result?.data?.status).toBe("completed");
const hist = await api(`/api/apps/${SCOPE}/workflows/runs/wire-morning`, {
method: "GET",
});
const steps = ((await hist.json()) as { steps: { name: string; status: string }[] }).steps;
expect(steps.some((s) => s.name === "tool:issues-sync" && s.status === "completed")).toBe(true);
});
it("reads the ui:// resource over MCP (resources/read)", async () => {
const read = await client.readResource({ uri: `ui://${SCOPE}/dashboard` });
const first = read.contents[0] as {
mimeType?: string;
text?: string;
_meta?: { ui?: { title?: string } };
};
expect(first.mimeType).toContain("mcp");
expect(first.text).toContain("issues");
expect(first._meta?.ui?.title).toBe("GitHub Issues");
});
it("delivers an SSE invalidation frame over HTTP after a scope-db write", async () => {
const res = await api(`/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();
const first = await reader.read();
expect(decoder.decode(first.value)).toContain("event: ready");
// Re-invoke issues-sync via HTTP to write the scope db (a fresh write bumps
// the version and fires the invalidation).
await api(`/api/apps/${SCOPE}/tools/issues-sync`, {
method: "POST",
body: JSON.stringify({
args: { repos: [`${owner}/app`] },
bindings: { github: { kind: "single", connection: GITHUB_CONNECTION } },
}),
});
const invalidation = await Promise.race([
(async () => {
// Drain frames until we see an invalidate (there may be keepalives).
for (let i = 0; i < 10; i++) {
const chunk = await reader.read();
if (chunk.done) break;
const text = decoder.decode(chunk.value);
if (text.includes("event: invalidate")) return text;
}
return "";
})(),
// 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")), 8000)),
]);
expect(invalidation).toContain("event: invalidate");
expect(invalidation).toContain('"table":"issues"');
await reader.cancel();
});
it("lists and reads the published skill over MCP", async () => {
const listed = (await client.callTool({
name: "apps_list_skills",
arguments: {},
})) as { structuredContent?: { skills?: { name: string }[] } };
expect((listed.structuredContent?.skills ?? []).map((s) => s.name)).toEqual(["issues-brief"]);
const readSkill = (await client.callTool({
name: "apps_read_skill",
arguments: { name: "issues-brief" },
})) as { content?: { text: string }[] };
expect(readSkill.content?.[0]?.text).toContain("GitHub issues brief");
});
});