8a5d19c376
The exerciser harness builds requests directly as Request objects, which means it never exercises the SDK client's auto-injection of ?directory= / ?workspace= on GETs. That's structurally how the entire #26569 / #26581 family of regressions slipped through — the SDK was sending requests the typed query schemas didn't accept, but the harness was sending requests directly. Add an opt-in `.viaSdk((sdk, ctx) => sdk.X.Y(...))` builder method that runs the scenario through a real `createOpencodeClient` wired to the in-process exerciser router. The SDK applies its real request transforms so route tests catch the SDK-vs-server-shape drift class at write time. The runner normalizes the SDK's `{data, error, response}` (or thrown Error with `.cause = {body, status}`) back into the existing `CallResult` shape so all the existing assertions (`.json()`, `.status()`, `.ok()`, etc.) continue to work unchanged. Existing `.at(...)` scenarios are not touched. Convert two scenarios as proof: `app.agents.via_sdk` and `command.list.via_sdk` both pass alongside the 139 existing scenarios (141/141 PASS). Subsequent PRs will migrate the standalone `httpapi-query-schema-drift` scenarios into the exerciser using `.viaSdk(...)` and delete that file as the bug class becomes a structural guarantee inside the harness.
184 lines
6.5 KiB
TypeScript
184 lines
6.5 KiB
TypeScript
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<unknown>,
|
|
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<unknown>) {
|
|
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<Record<string, BackendApp>> = {}
|
|
|
|
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<unknown>) {
|
|
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<CallResult> {
|
|
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)
|
|
}
|