import { ConfigProvider, Effect, Layer } from "effect" import { HttpRouter } from "effect/unstable/http" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { parse } from "./assertions" import { runtime, type Runtime } from "./runtime" import type { ActiveScenario, Backend, BackendApp, CallResult, CaptureMode, SeededContext } from "./types" type CallOptions = { auth?: { password?: string username?: string } } export function call( backend: Backend, scenario: ActiveScenario, ctx: SeededContext, options: CallOptions = {}, ) { return Effect.promise(async () => { const handler = app(await runtime(), backend, options) if (scenario.sdkCall) return callViaSdk(handler, scenario, ctx) return capture(await handler.request(toRequest(scenario, ctx)), scenario.capture) }) } /** * Run the scenario through a real `createOpencodeClient` wired to the * in-process exerciser router. The SDK applies its real request transforms * (auto-injected `?directory=...` / `?workspace=...` on GETs, header * rewrites, etc.), so any drift between what the SDK sends and what the * server's typed query schemas accept fails the scenario at write time. */ async function callViaSdk(handler: BackendApp, scenario: ActiveScenario, ctx: SeededContext) { const sdk = createOpencodeClient({ baseUrl: "http://localhost", directory: ctx.directory, fetch: ((input: Request | URL | string, init?: RequestInit) => handler.request(input, init)) as unknown as typeof fetch, }) let result: unknown let thrown: unknown try { result = await scenario.sdkCall!(sdk, ctx) } catch (err) { thrown = err } return normalizeSdkResult(result, thrown) } function normalizeSdkResult(result: unknown, thrown: unknown): CallResult { // SDK returns either { data, error, response } when not throwing, or // throws an Error with `.cause = { body, status }` when throwOnError: true. const tuple = result as { data?: unknown; error?: unknown; response?: Response } | undefined const cause = (thrown as { cause?: { status?: number; body?: unknown } } | undefined)?.cause const response = tuple?.response const status = response?.status ?? cause?.status ?? (thrown ? 0 : 200) const contentType = response?.headers.get("content-type") ?? "application/json" const body = tuple?.data ?? tuple?.error ?? cause?.body ?? thrown const text = typeof body === "string" ? body : JSON.stringify(body ?? null) return { status, contentType, body, text, timedOut: false } } export function callAuthProbe( backend: Backend, scenario: ActiveScenario, credentials: "missing" | "valid" = "missing", ) { return Effect.promise(async () => { const controller = new AbortController() return Promise.race([ Promise.resolve( app(await runtime(), backend, { auth: { password: "secret" } }).request( toAuthProbeRequest(scenario, credentials, controller.signal), ), ).then((response) => capture(response, scenario.capture)), Bun.sleep(1_000).then(() => { controller.abort("auth probe timed out") return { status: 0, contentType: "", text: "auth probe timed out", body: undefined, timedOut: true, } }), ]) }) } const appCache: Partial> = {} function app(modules: Runtime, backend: Backend, options: CallOptions) { const username = options.auth?.username const password = options.auth?.password const cacheKey = `${backend}:${username ?? ""}:${password ?? ""}` if (appCache[cacheKey]) return appCache[cacheKey] const handler = HttpRouter.toWebHandler( modules.ExperimentalHttpApiServer.routes.pipe( Layer.provide( ConfigProvider.layer( ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: password, OPENCODE_SERVER_USERNAME: username }), ), ), ), { disableLogger: true }, ).handler return (appCache[cacheKey] = { request(input: string | URL | Request, init?: RequestInit) { return handler( input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), modules.ExperimentalHttpApiServer.context, ) }, }) } function toRequest(scenario: ActiveScenario, ctx: SeededContext) { const spec = scenario.request(ctx, ctx.state) return new Request(new URL(spec.path, "http://localhost"), { method: scenario.method, headers: spec.body === undefined ? spec.headers : { "content-type": "application/json", ...spec.headers }, body: spec.body === undefined ? undefined : JSON.stringify(spec.body), }) } function toAuthProbeRequest(scenario: ActiveScenario, credentials: "missing" | "valid", signal: AbortSignal) { const spec = scenario.authProbe ?? { path: authProbePath(scenario.path), body: scenario.method === "GET" ? undefined : {}, } const headers = { ...(spec.body === undefined ? {} : { "content-type": "application/json" }), ...spec.headers, ...(credentials === "valid" ? { authorization: basic("opencode", "secret") } : {}), } return new Request(new URL(spec.path, "http://localhost"), { method: scenario.method, headers, body: spec.body === undefined ? undefined : JSON.stringify(spec.body), signal, }) } function basic(username: string, password: string) { return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` } function authProbePath(path: string) { return path .replace(/\{([^}]+)\}/g, (_match, key: string) => `auth_${key}`) .replace(/:([^/]+)/g, (_match, key: string) => `auth_${key}`) } async function capture(response: Response, mode: CaptureMode): Promise { const text = mode === "stream" ? await captureStream(response) : await response.text() return { status: response.status, contentType: response.headers.get("content-type") ?? "", text, body: parse(text), timedOut: false, } } async function captureStream(response: Response) { if (!response.body) return "" const reader = response.body.getReader() const read = reader.read().then( (result) => ({ result }), (error: unknown) => ({ error }), ) const winner = await Promise.race([read, Bun.sleep(1_000).then(() => ({ timeout: true }))]) if ("timeout" in winner) { await reader.cancel("timed out waiting for stream chunk").catch(() => undefined) throw new Error("timed out waiting for stream chunk") } if ("error" in winner) throw winner.error await reader.cancel().catch(() => undefined) if (winner.result.done) return "" return new TextDecoder().decode(winner.result.value) }