import { describe, expect, it } from "@effect/vitest"; import { Effect, Result } from "effect"; import { AppPublishConflictError, type AppsStore } from "../plugin/store"; import { makeInProcessAppToolExecutor } from "../executor/app-tool-executor"; import type { AppDescriptor } from "./descriptor"; import { discover } from "./discover"; import { publish } from "./publish"; const encoder = new TextEncoder(); const file = (path: string, body: string) => ({ path, bytes: encoder.encode(body) }); const tool = (description = "Tool") => ` import { z } from "zod"; import { defineTool } from "executor:app"; export default defineTool({ description: ${JSON.stringify(description)}, input: z.object({ value: z.string() }), handler(input) { return input; }, }); `; const makeMemoryStore = (): AppsStore & { readonly rows: Map; readonly blobs: Map; } => { const blobs = new Map(); const rows = new Map(); let descriptor: AppDescriptor | null = null; let descriptorKey: string | null = null; return { rows, blobs, putBlob: (body) => Effect.sync(() => { const key = `blob:${body.length}:${blobs.size}`; blobs.set(key, body); return key; }), getBlob: (key) => Effect.sync(() => blobs.get(key) ?? null), getDescriptorRecord: () => Effect.sync(() => descriptor ? { sourceRef: descriptor.sourceRef, descriptorKey: descriptorKey ?? "" } : null, ), putPublished: (next, nextDescriptorKey, _owner, expectedSourceRef) => Effect.gen(function* () { const actualSourceRef = descriptor?.sourceRef ?? null; if (actualSourceRef !== expectedSourceRef) { return yield* new AppPublishConflictError({ app: next.app, expectedSourceRef, actualSourceRef, }); } descriptor = next; descriptorKey = nextDescriptorKey; for (const key of rows.keys()) rows.delete(key); for (const item of next.tools) rows.set(item.name, item); }), listActiveTools: () => Effect.sync(() => descriptor?.tools ?? []), getTool: (name) => Effect.sync(() => { const row = descriptor?.tools.find((item) => item.name === name); return row ? { app: descriptor!.app, name: row.name, bundleKey: row.bundleKey, description: row.description, inputSchema: row.inputSchema, outputSchema: row.outputSchema, integrations: row.integrations, annotations: row.annotations, } : null; }), }; }; const makeBarrierStore = () => { const store = makeMemoryStore(); let reads = 0; let release: (() => void) | null = null; const barrier = new Promise((resolve) => { release = resolve; }); return { ...store, getDescriptorRecord: () => Effect.promise(async () => { reads += 1; if (reads === 2) release?.(); if (reads <= 2) await barrier; return null; }), } satisfies AppsStore & { readonly rows: Map; readonly blobs: Map; }; }; describe("discover", () => { it("reports non-tool files under tools and reserved folders", () => { const result = discover( new Map([ ["tools/readme.md", ""], ["workflows/a.ts", ""], ["ui/panel.tsx", ""], ["skills/guide.md", ""], ]), ); expect(result).toMatchObject({ _tag: "PublishError", diagnostics: [{ path: "tools/readme.md" }], }); }); }); describe("publish", () => { it.effect("publishes tools from an in-memory file set", () => Effect.gen(function* () { const store = makeMemoryStore(); const result = yield* publish( { store, executor: makeInProcessAppToolExecutor(), now: () => 10 }, { app: "crm", sourceRef: "sha-1", files: [file("tools/sync.ts", tool()), file("workflows/later.ts", "")], }, ); expect(result.publishedTools).toEqual(["sync"]); expect(result.skipped).toEqual([{ path: "workflows/later.ts", reason: "not supported yet" }]); expect(result.descriptor.toolchain.executor.name).toBe("in-process-data-url"); expect(store.blobs.size).toBe(2); }), ); it.effect("treats identical sourceRef as a no-op", () => Effect.gen(function* () { const store = makeMemoryStore(); const input = { app: "crm", sourceRef: "sha-1", files: [file("tools/sync.ts", tool())], }; yield* publish({ store, executor: makeInProcessAppToolExecutor() }, input); const second = yield* publish({ store, executor: makeInProcessAppToolExecutor() }, input); expect(second.noop).toBe(true); expect(second.publishedTools).toEqual([]); }), ); it.effect("rejects nondeterministic factories", () => Effect.gen(function* () { const store = makeMemoryStore(); yield* publish( { store, executor: makeInProcessAppToolExecutor() }, { app: "crm", sourceRef: "sha-1", files: [ file( "tools/sync.ts", ` import { z } from "zod"; import { defineTool } from "executor:app"; export default () => ({ [String(Math.random()).slice(2)]: defineTool({ description: "Random", input: z.object({}), handler() { return {}; }, }), }); `, ), ], }, ).pipe(Effect.flip); expect(store.rows.size).toBe(0); }), ); it.effect("leaves no rows or blobs when one tool fails collect", () => Effect.gen(function* () { const store = makeMemoryStore(); yield* publish( { store, executor: makeInProcessAppToolExecutor() }, { app: "crm", sourceRef: "sha-1", files: [ file("tools/ok.ts", tool("OK")), file( "tools/bad.ts", ` import { z } from "zod"; import { defineTool, integration } from "executor:app"; export default defineTool({ description: "Bad", integrations: { crm: { kind: "integration", slug: "dealcloud", mode: "bad" } }, input: z.object({}), handler(input) { return input; }, }); `, ), ], }, ).pipe(Effect.flip); expect(store.rows.size).toBe(0); expect(store.blobs.size).toBe(0); }), ); it.effect("guards concurrent publishes for the same app", () => Effect.gen(function* () { const store = makeBarrierStore(); const results = yield* Effect.all( [ publish( { store, executor: makeInProcessAppToolExecutor(), now: () => 1 }, { app: "crm", sourceRef: "sha-a", files: [file("tools/a.ts", tool("A"))] }, ).pipe(Effect.result), publish( { store, executor: makeInProcessAppToolExecutor(), now: () => 2 }, { app: "crm", sourceRef: "sha-b", files: [file("tools/b.ts", tool("B"))] }, ).pipe(Effect.result), ], { concurrency: "unbounded" }, ); const successes = results.filter(Result.isSuccess); const failures = results.filter(Result.isFailure); expect(successes).toHaveLength(1); expect(failures).toHaveLength(1); expect([...store.rows.keys()]).toHaveLength(1); }), ); });