238 lines · 7.6 KB · TypeScript
RawDownload
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<string, unknown>;  readonly blobs: Map<string, string>;} => {  const blobs = new Map<string, string>();  const rows = new Map<string, unknown>();  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<void>((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<string, unknown>;    readonly blobs: Map<string, string>;  };}; 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);    }),  );});