import { Effect } from "effect" import { looksJson } from "./assertions" import type { ActiveScenario, BuilderState, CallResult, Comparison, Method, ProjectOptions, ScenarioContext, SeededContext, TodoScenario, } from "./types" class ScenarioBuilder { private readonly state: BuilderState constructor(method: Method, path: string, name: string) { this.state = { method, path, name, project: { git: true }, seed: () => Effect.succeed(undefined as S), request: (ctx) => ({ path, headers: ctx.headers() }), capture: "full", mutates: false, reset: true, } } global() { return this.clone({ project: undefined, request: () => ({ path: this.state.path }) }) } inProject(project: ProjectOptions = { git: true }) { return this.clone({ project }) } withLlm() { return this.clone({ project: { ...(this.state.project ?? { git: true }), llm: true } }) } at(request: BuilderState["request"]) { return this.clone({ request }) } mutating() { return this.clone({ mutates: true }) } preserveDatabase() { return this.clone({ reset: false }) } stream() { return this.clone({ capture: "stream" }) } /** 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, result: CallResult) => Effect.Effect, compare: Comparison = "status", ) { return this.done(compare, (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) }), ) } /** Assert JSON status/content-type plus an optional synchronous body check. */ json(status = 200, inspect?: (body: unknown, ctx: SeededContext) => void, compare: Comparison = "json") { return this.jsonEffect(status, inspect ? (body, ctx) => Effect.sync(() => inspect(body, ctx)) : undefined, compare) } /** Assert JSON status/content-type plus optional Effect assertions, e.g. DB side effects. */ jsonEffect( status = 200, inspect?: (body: unknown, ctx: SeededContext) => Effect.Effect, compare: Comparison = "json", ) { return this.done(compare, (ctx, result) => Effect.gen(function* () { if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) if (!looksJson(result)) throw new Error(`expected JSON response, got ${result.contentType || "no content-type"}`) if (inspect) yield* inspect(result.body, ctx) }), ) } private clone(next: Partial>) { const builder = new ScenarioBuilder(this.state.method, this.state.path, this.state.name) Object.assign(builder.state, this.state, next) return builder } /** * Seed typed state before the HTTP request. The returned value becomes `ctx.state` * for `.at(...)` and assertions, giving stateful route tests type-safe setup. */ seeded(seed: (ctx: ScenarioContext) => Effect.Effect) { const builder = new ScenarioBuilder(this.state.method, this.state.path, this.state.name) Object.assign(builder.state, this.state, { seed }) return builder } private done( compare: Comparison, expect: (ctx: SeededContext, result: CallResult) => Effect.Effect, ): ActiveScenario { const state = this.state return { kind: "active", method: state.method, path: state.path, name: state.name, project: state.project, seed: state.seed, request: (ctx, seeded) => state.request({ ...ctx, state: seeded as S }), expect: (ctx, seeded, result) => expect({ ...ctx, state: seeded as S }, result), compare, capture: state.capture, mutates: state.mutates, reset: state.reset, } } } export const http = { get: (path: string, name: string) => new ScenarioBuilder("GET", path, name), post: (path: string, name: string) => new ScenarioBuilder("POST", path, name), put: (path: string, name: string) => new ScenarioBuilder("PUT", path, name), patch: (path: string, name: string) => new ScenarioBuilder("PATCH", path, name), delete: (path: string, name: string) => new ScenarioBuilder("DELETE", path, name), } export const pending = (method: Method, path: string, name: string, reason: string): TodoScenario => ({ kind: "todo", method, path, name, reason, }) export function route(template: string, params: Record) { return Object.entries(params).reduce( (next, [key, value]) => next.replaceAll(`{${key}}`, value).replaceAll(`:${key}`, value), template, ) } export function controlledPtyInput(title: string | undefined) { return { command: "/bin/sh", args: ["-c", "sleep 30"], ...(title ? { title } : {}), } }