refactor(opencode): simplify HTTP API exercise DSL
This commit is contained in:
parent
58201a32c1
commit
cf7d388a83
7 changed files with 284 additions and 503 deletions
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue