diff --git a/packages/httpapi-codegen/test/effect.ts b/packages/httpapi-codegen/test/effect.ts index 3accad3cc93..bc7f85e5ae7 100644 --- a/packages/httpapi-codegen/test/effect.ts +++ b/packages/httpapi-codegen/test/effect.ts @@ -7,22 +7,24 @@ type Body = Effect.Effect | (() => Effect.Effect) const layer = Layer.mergeAll(TestConsole.layer, TestClock.layer()) -const effect = (name: string, body: Body, options?: Parameters[2]) => - test( - name, - () => - Effect.gen(function* () { - const exit = yield* Effect.suspend(() => (typeof body === "function" ? body() : body)).pipe( - Effect.scoped, - Effect.provide(layer), - Effect.exit, - ) - if (Exit.isFailure(exit)) { - yield* Effect.forEach(Cause.prettyErrors(exit.cause), Effect.logError, { discard: true }) - } - return yield* exit - }).pipe(Effect.runPromise), - options, - ) +const make = + (testLayer: Layer.Layer) => + (name: string, body: Body, options?: Parameters[2]) => + test( + name, + () => + Effect.gen(function* () { + const exit = yield* Effect.suspend(() => (typeof body === "function" ? body() : body)).pipe( + Effect.scoped, + Effect.provide(testLayer), + Effect.exit, + ) + if (Exit.isFailure(exit)) { + yield* Effect.forEach(Cause.prettyErrors(exit.cause), Effect.logError, { discard: true }) + } + return yield* exit + }).pipe(Effect.runPromise), + options, + ) -export const it = { effect } +export const it = { effect: make(layer), live: make(TestConsole.layer) } diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts index e3dde0ba226..7b8f5a7af24 100644 --- a/packages/httpapi-codegen/test/generate.test.ts +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -20,6 +20,7 @@ import { emitPromise, generate, GenerationError, + type Output, } from "../src" import { it } from "./effect" import { Api as FixtureApi, Missing } from "./fixture" @@ -32,6 +33,21 @@ function compile(sour return emitEffect(compileContract(source)) } +async function emittedModule(output: Output) { + const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + const dispose = () => rm(directory, { recursive: true, force: true }) + + try { + // Finish each write before cleanup can run, even when a later write fails. + await Array.fromAsync(output.files, (file) => Bun.write(join(directory, file.path), file.content)) + const module = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) + return { module, [Symbol.asyncDispose]: dispose } + } catch (cause) { + await dispose() + throw cause + } +} + describe("HttpApiCodegen.generate", () => { test("compiles one contract for Promise and Effect emitters", () => { const contract = compileContract( @@ -352,27 +368,21 @@ describe("HttpApiCodegen.generate", () => { ), ) const output = emitPromise(compileContract(source)) - const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + await using emitted = await emittedModule(output) const methods: Array = [] - try { - await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) - const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) - const client = generated.OpenCode.make({ - baseUrl: "https://example.com", - fetch: async (_input: RequestInfo | URL, init?: RequestInit) => { - methods.push(init?.method ?? "GET") - return Response.json("ok") - }, - }) + const client = emitted.module.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async (_input: RequestInfo | URL, init?: RequestInit) => { + methods.push(init?.method ?? "GET") + return Response.json("ok") + }, + }) - expect(await client.session.instructions.list()).toBe("ok") - expect(await client.session.instructions.put()).toBe("ok") - expect(await client.session.instructions.remove()).toBe("ok") - expect(methods).toEqual(["GET", "PUT", "DELETE"]) - } finally { - await rm(directory, { recursive: true, force: true }) - } + expect(await client.session.instructions.list()).toBe("ok") + expect(await client.session.instructions.put()).toBe("ok") + expect(await client.session.instructions.remove()).toBe("ok") + expect(methods).toEqual(["GET", "PUT", "DELETE"]) }) test("rejects duplicate and leaf-namespace endpoint paths", () => { @@ -825,26 +835,19 @@ describe("HttpApiCodegen.generate", () => { ), ), ) - const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + await using emitted = await emittedModule(output) + let request: Request | undefined + const client = emitted.module.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async (input: RequestInfo | URL) => { + request = input instanceof Request ? input : new Request(input) + return Response.json({ data: "hello" }) + }, + }) - try { - await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) - const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) - let request: Request | undefined - const client = generated.OpenCode.make({ - baseUrl: "https://example.com", - fetch: async (input: RequestInfo | URL) => { - request = input instanceof Request ? input : new Request(input) - return Response.json({ data: "hello" }) - }, - }) - - expect(await client.session.get({ sessionID: "a/b" })).toBe("hello") - expect(request?.method).toBe("GET") - expect(request?.url).toBe("https://example.com/session/a%2Fb") - } finally { - await rm(directory, { recursive: true, force: true }) - } + expect(await client.session.get({ sessionID: "a/b" })).toBe("hello") + expect(request?.method).toBe("GET") + expect(request?.url).toBe("https://example.com/session/a%2Fb") }) test("maps an emitted no-content response to undefined", async () => { @@ -858,20 +861,13 @@ describe("HttpApiCodegen.generate", () => { ), ), ) - const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + await using emitted = await emittedModule(output) + const client = emitted.module.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async () => new Response(null, { status: 204 }), + }) - try { - await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) - const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) - const client = generated.OpenCode.make({ - baseUrl: "https://example.com", - fetch: async () => new Response(null, { status: 204 }), - }) - - expect(await client.session.interrupt({ sessionID: "session" })).toBeUndefined() - } finally { - await rm(directory, { recursive: true, force: true }) - } + expect(await client.session.interrupt({ sessionID: "session" })).toBeUndefined() }) test("executes an emitted binary wildcard GET through fetch", async () => { @@ -885,28 +881,21 @@ describe("HttpApiCodegen.generate", () => { ), ), ) - const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + await using emitted = await emittedModule(output) + let request: Request | undefined + const client = emitted.module.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async (input: RequestInfo | URL) => { + request = input instanceof Request ? input : new Request(input) + return new Response(new Uint8Array([1, 2, 3])) + }, + }) - try { - await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) - const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) - let request: Request | undefined - const client = generated.OpenCode.make({ - baseUrl: "https://example.com", - fetch: async (input: RequestInfo | URL) => { - request = input instanceof Request ? input : new Request(input) - return new Response(new Uint8Array([1, 2, 3])) - }, - }) - - const result = await client.session.read({ path: "src/a b#c.ts", token: "x/y" }) - expect(result).toBeInstanceOf(Uint8Array) - expect(Array.from(result)).toEqual([1, 2, 3]) - expect(request?.method).toBe("GET") - expect(request?.url).toBe("https://example.com/file/src/a%20b%23c.ts?token=x%2Fy") - } finally { - await rm(directory, { recursive: true, force: true }) - } + const result = await client.session.read({ path: "src/a b#c.ts", token: "x/y" }) + expect(result).toBeInstanceOf(Uint8Array) + expect(Array.from(result)).toEqual([1, 2, 3]) + expect(request?.method).toBe("GET") + expect(request?.url).toBe("https://example.com/file/src/a%20b%23c.ts?token=x%2Fy") }) test("serializes flattened query, header, and JSON payload inputs", async () => { @@ -923,29 +912,22 @@ describe("HttpApiCodegen.generate", () => { ), ), ) - const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + await using emitted = await emittedModule(output) + let request: Request | undefined + const client = emitted.module.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + request = input instanceof Request ? input : new Request(input, init) + return Response.json({ data: "admitted" }) + }, + }) - try { - await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) - const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) - let request: Request | undefined - const client = generated.OpenCode.make({ - baseUrl: "https://example.com", - fetch: async (input: RequestInfo | URL, init?: RequestInit) => { - request = input instanceof Request ? input : new Request(input, init) - return Response.json({ data: "admitted" }) - }, - }) - - expect( - await client.session.prompt({ sessionID: "session", resume: true, traceID: "trace", prompt: "hello" }), - ).toBe("admitted") - expect(request?.url).toBe("https://example.com/session/session?resume=true") - expect(request?.headers.get("traceID")).toBe("trace") - expect(await request?.json()).toEqual({ prompt: "hello" }) - } finally { - await rm(directory, { recursive: true, force: true }) - } + expect(await client.session.prompt({ sessionID: "session", resume: true, traceID: "trace", prompt: "hello" })).toBe( + "admitted", + ) + expect(request?.url).toBe("https://example.com/session/session?resume=true") + expect(request?.headers.get("traceID")).toBe("trace") + expect(await request?.json()).toEqual({ prompt: "hello" }) }) test("serializes an opaque union payload as the direct JSON body", async () => { @@ -962,26 +944,19 @@ describe("HttpApiCodegen.generate", () => { ), ), ) - const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + await using emitted = await emittedModule(output) + let request: Request | undefined + const client = emitted.module.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + request = input instanceof Request ? input : new Request(input, init) + return new Response(null, { status: 204 }) + }, + }) - try { - await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) - const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) - let request: Request | undefined - const client = generated.OpenCode.make({ - baseUrl: "https://example.com", - fetch: async (input: RequestInfo | URL, init?: RequestInit) => { - request = input instanceof Request ? input : new Request(input, init) - return new Response(null, { status: 204 }) - }, - }) + await client.session.configure({ payload: { type: "local", command: ["opencode"] } }) - await client.session.configure({ payload: { type: "local", command: ["opencode"] } }) - - expect(await request?.json()).toEqual({ type: "local", command: ["opencode"] }) - } finally { - await rm(directory, { recursive: true, force: true }) - } + expect(await request?.json()).toEqual({ type: "local", command: ["opencode"] }) }) test("serializes explicit null query values", async () => { @@ -995,26 +970,19 @@ describe("HttpApiCodegen.generate", () => { ), ), ) - const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + await using emitted = await emittedModule(output) + let request: Request | undefined + const client = emitted.module.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + request = input instanceof Request ? input : new Request(input, init) + return Response.json({ data: [] }) + }, + }) - try { - await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) - const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) - let request: Request | undefined - const client = generated.OpenCode.make({ - baseUrl: "https://example.com", - fetch: async (input: RequestInfo | URL, init?: RequestInit) => { - request = input instanceof Request ? input : new Request(input, init) - return Response.json({ data: [] }) - }, - }) + await client.session.list({ parentID: null }) - await client.session.list({ parentID: null }) - - expect(request?.url).toBe("https://example.com/session?parentID=null") - } finally { - await rm(directory, { recursive: true, force: true }) - } + expect(request?.url).toBe("https://example.com/session?parentID=null") }) test("rejects with declared tagged errors and exports a type guard", async () => { @@ -1029,22 +997,15 @@ describe("HttpApiCodegen.generate", () => { ), ), ) - const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + await using emitted = await emittedModule(output) + const client = emitted.module.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async () => Response.json({ _tag: "Missing", message: "gone" }, { status: 404 }), + }) - try { - await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) - const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) - const client = generated.OpenCode.make({ - baseUrl: "https://example.com", - fetch: async () => Response.json({ _tag: "Missing", message: "gone" }, { status: 404 }), - }) - - const error = await client.session.get({ sessionID: "missing" }).catch((cause: unknown) => cause) - expect(error).toEqual({ _tag: "Missing", message: "gone" }) - expect(generated.isMissing(error)).toBeTrue() - } finally { - await rm(directory, { recursive: true, force: true }) - } + const error = await client.session.get({ sessionID: "missing" }).catch((cause: unknown) => cause) + expect(error).toEqual({ _tag: "Missing", message: "gone" }) + expect(emitted.module.isMissing(error)).toBeTrue() }) test("iterates an emitted SSE stream lazily without reconnecting", async () => { @@ -1060,42 +1021,35 @@ describe("HttpApiCodegen.generate", () => { ), ), ) - const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + await using emitted = await emittedModule(output) + let requests = 0 + let url: string | undefined + const client = emitted.module.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async (input: RequestInfo | URL) => { + requests++ + url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + const encoder = new TextEncoder() + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"type":"ready","count":"1"}\r')) + controller.enqueue(encoder.encode("\n\r\n")) + controller.close() + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ) + }, + }) + const events = client.session.subscribe({ after: 2 }) - try { - await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) - const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) - let requests = 0 - let url: string | undefined - const client = generated.OpenCode.make({ - baseUrl: "https://example.com", - fetch: async (input: RequestInfo | URL) => { - requests++ - url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url - const encoder = new TextEncoder() - return new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode('data: {"type":"ready","count":"1"}\r')) - controller.enqueue(encoder.encode("\n\r\n")) - controller.close() - }, - }), - { headers: { "content-type": "text/event-stream" } }, - ) - }, - }) - const events = client.session.subscribe({ after: 2 }) - - expect(requests).toBe(0) - const received = [] - for await (const event of events) received.push(event) - expect(received).toEqual([{ type: "ready", count: "1" }]) - expect(requests).toBe(1) - expect(url).toBe("https://example.com/event?after=2") - } finally { - await rm(directory, { recursive: true, force: true }) - } + expect(requests).toBe(0) + const received = [] + for await (const event of events) received.push(event) + expect(received).toEqual([{ type: "ready", count: "1" }]) + expect(requests).toBe(1) + expect(url).toBe("https://example.com/event?after=2") }) test("preserves public group and endpoint identifiers exactly", () => { @@ -1138,7 +1092,7 @@ describe("HttpApiCodegen.generate", () => { for (const file of output.files) expect(() => transpiler.transformSync(file.content)).not.toThrow() }) - it.effect("keeps the strict generated-consumer fixture current", () => + it.live("keeps the strict generated-consumer fixture current", () => Effect.gen(function* () { const output = compile(FixtureApi) const actual = yield* Effect.promise(() =>