import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit } from "effect"; import { makeSelfHostAppsRuntime } from "./self-host-runtime"; import { makeInMemoryAppsStore, makeTestResolver, dailyBriefFileSet } from "../testing"; const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect); const githubHandlers = { github: { "repos.listForAuthenticatedUser": () => [{ full_name: "acme/app" }], "issues.listForRepo": () => [ { number: 1, title: "Fresh bug", labels: [{ name: "bug" }], assignee: { login: "rhys" }, updated_at: new Date().toISOString(), html_url: "https://github.com/acme/app/issues/1", }, { number: 2, title: "Old bug", labels: [], assignee: null, updated_at: "2020-01-01T00:00:00Z", html_url: "https://github.com/acme/app/issues/2", }, ], }, }; describe("AppsRuntime end-to-end (publish -> invoke)", () => { it("publishes daily-brief and invokes the tool through declared integrations", async () => { const store = makeInMemoryAppsStore(); const resolver = makeTestResolver(githubHandlers, [ { address: "tools.github.user.rhys-github", integration: "github", name: "rhys-github" }, ]); const host = makeSelfHostAppsRuntime({ dataDir: mkdtempSync(join(tmpdir(), "apps-rt-")), store, resolver, inMemory: true, }); const { runtime } = host; const published = await run(runtime.publish({ scope: "rhys", files: dailyBriefFileSet() })); expect(published.descriptor.tools.map((t) => t.name).sort()).toEqual([ "issues-sync", "search-all-mail", ]); const syncResult = (await run( runtime.invokeTool({ scope: "rhys", tool: "issues-sync", args: { github: "tools.github.user.rhys-github" }, }), )) as { synced: number; repos: number; issues: readonly { repo: string; number: number; title: string }[]; }; expect(syncResult).toEqual({ synced: 2, repos: 1, issues: [ { repo: "acme/app", number: 1, title: "Fresh bug" }, { repo: "acme/app", number: 2, title: "Old bug" }, ], }); await host.close(); }); it("applies the single available connection as the default", async () => { const store = makeInMemoryAppsStore(); const resolver = makeTestResolver( { dealcloud: { "deals.list": () => [], }, }, [{ address: "tools.dealcloud.user.crm-main", integration: "dealcloud", name: "crm-main" }], ); const host = makeSelfHostAppsRuntime({ dataDir: mkdtempSync(join(tmpdir(), "apps-default-")), store, resolver, inMemory: true, }); await run( host.runtime.publish({ scope: "s", files: new Map([ [ "tools/sync.ts", `import { defineTool, integration } from "executor:app"; import { z } from "zod"; export default defineTool({ description: "sync", integrations: { crm: integration("dealcloud") }, input: z.object({}), async handler(_input, { crm }) { await crm.deals.list({}); return { ok: true }; }, });`, ], ]), }), ); const out = await run(host.runtime.invokeTool({ scope: "s", tool: "sync", args: {} })); expect(out).toEqual({ ok: true }); expect(resolver.calls[0]).toEqual({ integration: "dealcloud", connection: "tools.dealcloud.user.crm-main", method: "deals.list", }); await host.close(); }); it("fails with BindingError when multiple connections exist and the role property is missing", async () => { const store = makeInMemoryAppsStore(); const resolver = makeTestResolver({}, [ { address: "tools.dealcloud.user.one", integration: "dealcloud" }, { address: "tools.dealcloud.user.two", integration: "dealcloud" }, ]); const host = makeSelfHostAppsRuntime({ dataDir: mkdtempSync(join(tmpdir(), "apps-missing-")), store, resolver, inMemory: true, }); await run( host.runtime.publish({ scope: "s", files: new Map([ [ "tools/sync.ts", `import { defineTool, integration } from "executor:app"; import { z } from "zod"; export default defineTool({ description: "sync", integrations: { crm: integration("dealcloud") }, input: z.object({}), async handler(){ return {}; }, });`, ], ]), }), ); const exit = await Effect.runPromiseExit( host.runtime.invokeTool({ scope: "s", tool: "sync", args: {} }), ); expect(Exit.isFailure(exit)).toBe(true); expect(JSON.stringify(exit)).toContain("BindingError"); expect(JSON.stringify(exit)).toContain("crm"); expect(JSON.stringify(exit)).toContain("dealcloud"); await host.close(); }); it("fails with BindingError when the named connection belongs to another integration", async () => { const store = makeInMemoryAppsStore(); const resolver = makeTestResolver({}, [ { address: "tools.github.user.main", integration: "github" }, ]); const host = makeSelfHostAppsRuntime({ dataDir: mkdtempSync(join(tmpdir(), "apps-wrong-")), store, resolver, inMemory: true, }); await run( host.runtime.publish({ scope: "s", files: new Map([ [ "tools/sync.ts", `import { defineTool, integration } from "executor:app"; import { z } from "zod"; export default defineTool({ description: "sync", integrations: { crm: integration("dealcloud") }, input: z.object({}), async handler(){ return {}; }, });`, ], ]), }), ); const exit = await Effect.runPromiseExit( host.runtime.invokeTool({ scope: "s", tool: "sync", args: { crm: "tools.github.user.main" }, }), ); expect(Exit.isFailure(exit)).toBe(true); expect(JSON.stringify(exit)).toContain("BindingError"); expect(JSON.stringify(exit)).toContain("github"); expect(JSON.stringify(exit)).toContain("dealcloud"); expect(JSON.stringify(exit)).toContain("tools.github.user.main"); await host.close(); }); it("accepts raw JSON Schema input end to end", async () => { const store = makeInMemoryAppsStore(); const resolver = makeTestResolver({}); const host = makeSelfHostAppsRuntime({ dataDir: mkdtempSync(join(tmpdir(), "apps-raw-")), store, resolver, inMemory: true, }); await run( host.runtime.publish({ scope: "s", files: new Map([ [ "tools/raw.ts", `import { defineTool } from "executor:app"; export default defineTool({ description: "raw", input: { type: "object", properties: { q: { type: "string" } }, required: ["q"] }, async handler(input){ return { q: input.q }; }, });`, ], ]), }), ); const out = await run(host.runtime.invokeTool({ scope: "s", tool: "raw", args: { q: "ok" } })); expect(out).toEqual({ q: "ok" }); await host.close(); }); });