refactor(opencode): simplify HTTP API exercise DSL

This commit is contained in:
Kit Langton 2026-07-13 11:05:46 -04:00
commit cf7d388a83
7 changed files with 284 additions and 503 deletions

View file

@ -11,9 +11,9 @@ type CallOptions = {
}
}
export function call(scenario: ActiveScenario, ctx: SeededContext<unknown>, options: CallOptions = {}) {
export function call(scenario: ActiveScenario, ctx: SeededContext<unknown>) {
return Effect.promise(async () =>
capture(await app(await runtime(), options).request(toRequest(scenario, ctx)), scenario.capture),
capture(await app(await runtime(), {}).request(toRequest(scenario, ctx)), scenario.capture),
)
}
@ -78,7 +78,7 @@ function app(modules: Runtime, options: CallOptions) {
}
function toRequest(scenario: ActiveScenario, ctx: SeededContext<unknown>) {
const spec = scenario.request(ctx, ctx.state)
const spec = scenario.request(ctx)
return new Request(new URL(spec.path, "http://localhost"), {
method: scenario.method,
headers: spec.body === undefined ? spec.headers : { "content-type": "application/json", ...spec.headers },

View file

@ -5,7 +5,6 @@ import type {
AuthPolicy,
BuilderState,
CallResult,
Comparison,
Method,
ProjectOptions,
RequestSpec,
@ -28,7 +27,6 @@ class ScenarioBuilder<S = undefined> {
request: (ctx) => ({ path, headers: ctx.headers() }),
authProbe: undefined,
capture: "full",
mutates: false,
reset: true,
auth,
}
@ -54,11 +52,7 @@ class ScenarioBuilder<S = undefined> {
return this.clone({ authProbe })
}
mutating() {
return this.clone({ mutates: true })
}
preserveDatabase() {
preserveState() {
return this.clone({ reset: false })
}
@ -66,41 +60,8 @@ class ScenarioBuilder<S = undefined> {
return this.clone({ capture: "stream" })
}
protected() {
return this.auth("protected")
}
public() {
return this.auth("public")
}
publicBypass() {
return this.auth("public-bypass")
}
ticketBypass() {
return this.auth("ticket-bypass")
}
private auth(auth: AuthPolicy) {
return this.clone({ auth })
}
/** Assert a non-JSON or shape-only response. */
ok(status = 200, compare: Comparison = "status") {
return this.done(compare, (_ctx, result) =>
Effect.sync(() => {
if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`)
}),
)
}
status(
status = 200,
inspect?: (ctx: SeededContext<S>, result: CallResult) => Effect.Effect<void>,
compare: Comparison = "status",
) {
return this.done(compare, (ctx, result) =>
status(status = 200, inspect?: (ctx: SeededContext<S>, result: CallResult) => Effect.Effect<void>) {
return this.done((ctx, result) =>
Effect.gen(function* () {
if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`)
if (inspect) yield* inspect(ctx, result)
@ -109,17 +70,13 @@ class ScenarioBuilder<S = undefined> {
}
/** Assert JSON status/content-type plus an optional synchronous body check. */
json(status = 200, inspect?: (body: unknown, ctx: SeededContext<S>) => void, compare: Comparison = "json") {
return this.jsonEffect(status, inspect ? (body, ctx) => Effect.sync(() => inspect(body, ctx)) : undefined, compare)
json(status = 200, inspect?: (body: unknown, ctx: SeededContext<S>) => void) {
return this.jsonEffect(status, inspect ? (body, ctx) => Effect.sync(() => inspect(body, ctx)) : undefined)
}
/** Assert JSON status/content-type plus optional Effect assertions, e.g. DB side effects. */
jsonEffect(
status = 200,
inspect?: (body: unknown, ctx: SeededContext<S>) => Effect.Effect<void>,
compare: Comparison = "json",
) {
return this.done(compare, (ctx, result) =>
jsonEffect(status = 200, inspect?: (body: unknown, ctx: SeededContext<S>) => Effect.Effect<void>) {
return this.done((ctx, result) =>
Effect.gen(function* () {
if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`)
if (!looksJson(result))
@ -145,10 +102,7 @@ class ScenarioBuilder<S = undefined> {
return builder
}
private done(
compare: Comparison,
expect: (ctx: SeededContext<S>, result: CallResult) => Effect.Effect<void>,
): ActiveScenario {
private done(expect: (ctx: SeededContext<S>, result: CallResult) => Effect.Effect<void>): ActiveScenario {
const state = this.state
return {
kind: "active",
@ -159,12 +113,10 @@ class ScenarioBuilder<S = undefined> {
seed: state.seed,
authProbe: state.authProbe,
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `.seeded(...)` preserves the paired request/state type inside the builder.
request: (ctx, seeded) => state.request({ ...ctx, state: seeded as S }),
request: (ctx) => state.request(ctx as SeededContext<S>),
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `.seeded(...)` preserves the paired assertion/state type inside the builder.
expect: (ctx, seeded, result) => expect({ ...ctx, state: seeded as S }, result),
compare,
expect: (ctx, result) => expect(ctx as SeededContext<S>, result),
capture: state.capture,
mutates: state.mutates,
reset: state.reset,
auth: state.auth,
}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
import { Duration } from "effect"
import { indent, pad } from "./assertions"
import { routeKey } from "./routing"
import type { Options, Result, Scenario } from "./types"
export const color = {
@ -60,7 +61,3 @@ export function printResults(results: Result[], missing: string[], extra: Scenar
`\n${color.dim}summary pass=${results.filter((result) => result.status === "pass").length} fail=${results.filter((result) => result.status === "fail").length} skip=${results.filter((result) => result.status === "skip").length} missing=${missing.length} extra=${extra.length}${color.reset}`,
)
}
function routeKey(scenario: Scenario) {
return `${scenario.method} ${scenario.path}`
}

View file

@ -3,9 +3,6 @@ import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Cause, Duration, Effect, Layer, Scope } from "effect"
import { TestLLMServer } from "../../lib/llm-server"
import type { Config } from "../../../src/config/config"
import type { MessageV2 } from "../../../src/session/message-v2"
import { MessageID, PartID } from "../../../src/session/schema"
import { call, callAuthProbe, disposeApps } from "./backend"
import { original } from "./environment"
@ -38,7 +35,7 @@ function runActive(options: Options, scenario: ActiveScenario) {
const result = yield* call(scenario, ctx)
yield* trace(options, scenario, `response ${result.status}`)
yield* trace(options, scenario, "expect start")
yield* scenario.expect(ctx, ctx.state, result)
yield* scenario.expect(ctx, result)
yield* trace(options, scenario, "expect done")
}),
)

View file

@ -7,7 +7,6 @@ export type Runtime = {
InstanceStore: (typeof import("../../../src/project/instance-store"))["InstanceStore"]
Session: (typeof import("../../../src/session/session"))["Session"]
Worktree: (typeof import("../../../src/worktree"))["Worktree"]
Project: (typeof import("../../../src/project/project"))["Project"]
Tui: typeof import("../../../src/server/shared/tui-control")
disposeAllInstances: (typeof import("../../fixture/fixture"))["disposeAllInstances"]
tmpdir: (typeof import("../../fixture/fixture"))["tmpdir"]
@ -26,7 +25,6 @@ export function runtime() {
const instanceStore = await import("../../../src/project/instance-store")
const session = await import("../../../src/session/session")
const worktree = await import("../../../src/worktree")
const project = await import("../../../src/project/project")
const tui = await import("../../../src/server/shared/tui-control")
const fixture = await import("../../fixture/fixture")
const db = await import("../../fixture/db")
@ -39,7 +37,6 @@ export function runtime() {
InstanceStore: instanceStore.InstanceStore,
Session: session.Session,
Worktree: worktree.Worktree,
Project: project.Project,
Tui: tui,
disposeAllInstances: fixture.disposeAllInstances,
tmpdir: fixture.tmpdir,

View file

@ -1,10 +1,8 @@
import type { Duration, Effect } from "effect"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import type { Config } from "../../../src/config/config"
import type { Project } from "../../../src/project/project"
import type { Worktree } from "../../../src/worktree"
import type { MessageV2 } from "../../../src/session/message-v2"
import type { SessionID } from "../../../src/session/schema"
export const OpenApiMethods = ["get", "post", "put", "delete", "patch"] as const
@ -13,7 +11,6 @@ export const Methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const
export type Method = (typeof Methods)[number]
export type OpenApiMethod = (typeof OpenApiMethods)[number]
export type Mode = "effect" | "coverage" | "auth"
export type Comparison = "none" | "status" | "json"
export type CaptureMode = "full" | "stream"
export type AuthPolicy = "protected" | "public" | "public-bypass" | "ticket-bypass"
export type ProjectOptions = { git?: boolean; config?: Partial<ConfigV1.Info>; llm?: boolean }
@ -80,12 +77,10 @@ export type ActiveScenario = {
name: string
project: ProjectOptions | undefined
seed: (ctx: ScenarioContext) => Effect.Effect<unknown>
request: (ctx: ScenarioContext, state: unknown) => RequestSpec
request: (ctx: SeededContext<unknown>) => RequestSpec
authProbe: RequestSpec | undefined
expect: (ctx: ScenarioContext, state: unknown, result: CallResult) => Effect.Effect<void>
compare: Comparison
expect: (ctx: SeededContext<unknown>, result: CallResult) => Effect.Effect<void>
capture: CaptureMode
mutates: boolean
reset: boolean
auth: AuthPolicy
}
@ -99,7 +94,6 @@ export type BuilderState<S> = {
request: (ctx: SeededContext<S>) => RequestSpec
authProbe: RequestSpec | undefined
capture: CaptureMode
mutates: boolean
reset: boolean
auth: AuthPolicy
}