Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton c5b4bbd0a7 test(server): harden HttpApi exercise coverage 2026-05-08 17:15:34 -04:00
11 changed files with 452 additions and 174 deletions
+5
View File
@@ -68,6 +68,11 @@ jobs:
env: env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Run HttpApi exerciser gates
if: runner.os == 'Linux'
working-directory: packages/opencode
run: bun run test:httpapi
- name: Publish unit reports - name: Publish unit reports
if: always() if: always()
uses: mikepenz/action-junit-report@v6 uses: mikepenz/action-junit-report@v6
+1
View File
@@ -9,6 +9,7 @@
"typecheck": "tsgo --noEmit", "typecheck": "tsgo --noEmit",
"test": "bun test --timeout 30000", "test": "bun test --timeout 30000",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip",
"build": "bun run script/build.ts", "build": "bun run script/build.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts", "fix-node-pty": "bun run script/fix-node-pty.ts",
"dev": "bun run --conditions=browser ./src/index.ts", "dev": "bun run --conditions=browser ./src/index.ts",
@@ -5,7 +5,6 @@ import { asc } from "drizzle-orm"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { inArray } from "drizzle-orm" import { inArray } from "drizzle-orm"
import { Project } from "@/project/project" import { Project } from "@/project/project"
import { Instance } from "@/project/instance"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { Auth } from "@/auth" import { Auth } from "@/auth"
@@ -646,7 +645,7 @@ export const layer = Layer.effect(
// "claim" this session so any future events coming from // "claim" this session so any future events coming from
// the old workspace are ignored // the old workspace are ignored
SyncEvent.claim(input.sessionID, input.workspaceID ?? Instance.project.id) SyncEvent.claim(input.sessionID, input.workspaceID ?? previous.projectID)
} }
} }
@@ -848,6 +848,41 @@ describe("workspace CRUD", () => {
}) })
}) })
test("sessionWarp detaches to the source project when invoked from a workspace instance", async () => {
await withInstance(async () => {
const projectID = Instance.project.id
await using workspaceTmp = await tmpdir({ git: true })
const previousType = unique("warp-detach-workspace-instance")
const previous = workspaceInfo(projectID, previousType)
insertWorkspace(previous)
registerAdapter(projectID, previousType, localAdapter(workspaceTmp.path, { createDir: false }).adapter)
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
attachSessionToWorkspace(session.id, previous.id)
const workspaceProjectID = await WithInstance.provide({
directory: workspaceTmp.path,
fn: async () => {
const id = Instance.project.id
expect(id).not.toBe(projectID)
await warpWorkspaceSession({ workspaceID: null, sessionID: session.id })
return id
},
})
expect(
Database.use((db) =>
db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, session.id))
.get(),
)?.workspaceID,
).toBeNull()
expect(sessionSequenceOwner(session.id)).toBe(projectID)
expect(sessionSequenceOwner(session.id)).not.toBe(workspaceProjectID)
})
})
it.live("sessionWarp syncs previous remote history, replays it, steals, and claims the sequence", () => { it.live("sessionWarp syncs previous remote history, replays it, steals, and claims the sequence", () => {
const calls: FetchCall[] = [] const calls: FetchCall[] = []
let historySessionID: SessionID | undefined let historySessionID: SessionID | undefined
@@ -23,13 +23,31 @@ export function call(
) )
} }
export function callAuthProbe(backend: Backend, scenario: ActiveScenario) { export function callAuthProbe(
return Effect.promise(async () => backend: Backend,
capture( scenario: ActiveScenario,
await app(await runtime(), backend, { auth: { password: "secret" } }).request(toAuthProbeRequest(scenario)), credentials: "missing" | "valid" = "missing",
scenario.capture, ) {
), 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>> = {} const appCache: Partial<Record<string, BackendApp>> = {}
@@ -78,14 +96,28 @@ function toRequest(scenario: ActiveScenario, ctx: SeededContext<unknown>) {
}) })
} }
function toAuthProbeRequest(scenario: ActiveScenario) { function toAuthProbeRequest(scenario: ActiveScenario, credentials: "missing" | "valid", signal: AbortSignal) {
return new Request(new URL(authProbePath(scenario.path), "http://localhost"), { 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, method: scenario.method,
headers: scenario.method === "GET" ? undefined : { "content-type": "application/json" }, headers,
body: scenario.method === "GET" ? undefined : JSON.stringify({}), 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) { function authProbePath(path: string) {
return path return path
.replace(/\{([^}]+)\}/g, (_match, key: string) => `auth_${key}`) .replace(/\{([^}]+)\}/g, (_match, key: string) => `auth_${key}`)
@@ -99,6 +131,7 @@ async function capture(response: Response, mode: CaptureMode): Promise<CallResul
contentType: response.headers.get("content-type") ?? "", contentType: response.headers.get("content-type") ?? "",
text, text,
body: parse(text), body: parse(text),
timedOut: false,
} }
} }
@@ -8,6 +8,7 @@ import type {
Comparison, Comparison,
Method, Method,
ProjectOptions, ProjectOptions,
RequestSpec,
ScenarioContext, ScenarioContext,
SeededContext, SeededContext,
TodoScenario, TodoScenario,
@@ -16,7 +17,7 @@ import type {
class ScenarioBuilder<S = undefined> { class ScenarioBuilder<S = undefined> {
private readonly state: BuilderState<S> private readonly state: BuilderState<S>
constructor(method: Method, path: string, name: string) { constructor(method: Method, path: string, name: string, auth: AuthPolicy) {
this.state = { this.state = {
method, method,
path, path,
@@ -25,10 +26,11 @@ class ScenarioBuilder<S = undefined> {
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The unseeded builder state is intentionally undefined until `.seeded(...)` narrows it. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The unseeded builder state is intentionally undefined until `.seeded(...)` narrows it.
seed: () => Effect.succeed(undefined as S), seed: () => Effect.succeed(undefined as S),
request: (ctx) => ({ path, headers: ctx.headers() }), request: (ctx) => ({ path, headers: ctx.headers() }),
authProbe: undefined,
capture: "full", capture: "full",
mutates: false, mutates: false,
reset: true, reset: true,
auth: "protected", auth,
} }
} }
@@ -48,6 +50,10 @@ class ScenarioBuilder<S = undefined> {
return this.clone({ request }) return this.clone({ request })
} }
probe(authProbe: RequestSpec) {
return this.clone({ authProbe })
}
mutating() { mutating() {
return this.clone({ mutates: true }) return this.clone({ mutates: true })
} }
@@ -124,7 +130,7 @@ class ScenarioBuilder<S = undefined> {
} }
private clone(next: Partial<BuilderState<S>>) { private clone(next: Partial<BuilderState<S>>) {
const builder = new ScenarioBuilder<S>(this.state.method, this.state.path, this.state.name) const builder = new ScenarioBuilder<S>(this.state.method, this.state.path, this.state.name, this.state.auth)
Object.assign(builder.state, this.state, next) Object.assign(builder.state, this.state, next)
return builder return builder
} }
@@ -134,7 +140,7 @@ class ScenarioBuilder<S = undefined> {
* for `.at(...)` and assertions, giving stateful route tests type-safe setup. * for `.at(...)` and assertions, giving stateful route tests type-safe setup.
*/ */
seeded<Next>(seed: (ctx: ScenarioContext) => Effect.Effect<Next>) { seeded<Next>(seed: (ctx: ScenarioContext) => Effect.Effect<Next>) {
const builder = new ScenarioBuilder<Next>(this.state.method, this.state.path, this.state.name) const builder = new ScenarioBuilder<Next>(this.state.method, this.state.path, this.state.name, this.state.auth)
Object.assign(builder.state, this.state, { seed }) Object.assign(builder.state, this.state, { seed })
return builder return builder
} }
@@ -151,6 +157,7 @@ class ScenarioBuilder<S = undefined> {
name: state.name, name: state.name,
project: state.project, project: state.project,
seed: state.seed, 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. // 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, seeded) => state.request({ ...ctx, state: seeded as S }),
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `.seeded(...)` preserves the paired assertion/state type inside the builder. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `.seeded(...)` preserves the paired assertion/state type inside the builder.
@@ -164,12 +171,19 @@ class ScenarioBuilder<S = undefined> {
} }
} }
const routes = (auth: AuthPolicy) => ({
get: (path: string, name: string) => new ScenarioBuilder("GET", path, name, auth),
post: (path: string, name: string) => new ScenarioBuilder("POST", path, name, auth),
put: (path: string, name: string) => new ScenarioBuilder("PUT", path, name, auth),
patch: (path: string, name: string) => new ScenarioBuilder("PATCH", path, name, auth),
delete: (path: string, name: string) => new ScenarioBuilder("DELETE", path, name, auth),
})
export const http = { export const http = {
get: (path: string, name: string) => new ScenarioBuilder("GET", path, name), protected: routes("protected"),
post: (path: string, name: string) => new ScenarioBuilder("POST", path, name), public: routes("public"),
put: (path: string, name: string) => new ScenarioBuilder("PUT", path, name), publicBypass: routes("public-bypass"),
patch: (path: string, name: string) => new ScenarioBuilder("PATCH", path, name), ticketBypass: routes("ticket-bypass"),
delete: (path: string, name: string) => new ScenarioBuilder("DELETE", path, name),
} }
export const pending = (method: Method, path: string, name: string, reason: string): TodoScenario => ({ export const pending = (method: Method, path: string, name: string, reason: string): TodoScenario => ({
@@ -11,7 +11,7 @@
* so this must never point at a developer's real session database. * so this must never point at a developer's real session database.
* *
* DSL shape: * DSL shape:
* - `http.get/post/...` starts a scenario for one OpenAPI route key. * - `http.protected.get/post/...` starts a scenario for one OpenAPI route key.
* - `.seeded(...)` creates typed per-scenario state using Effect helpers on `ctx`. * - `.seeded(...)` creates typed per-scenario state using Effect helpers on `ctx`.
* - `.at(...)` builds the request from that typed state. * - `.at(...)` builds the request from that typed state.
* - `.json(...)` / `.jsonEffect(...)` assert response shape and optional side effects. * - `.json(...)` / `.jsonEffect(...)` assert response shape and optional side effects.
@@ -32,7 +32,7 @@ import {
exerciseGlobalRoot, exerciseGlobalRoot,
} from "./environment" } from "./environment"
import { color, printHeader, printResults } from "./report" import { color, printHeader, printResults } from "./report"
import { coverageResult, matches, parseOptions, routeKey, routeKeys } from "./routing" import { coverageResult, parseOptions, routeKey, routeKeys, selectedScenarios } from "./routing"
import { runScenario } from "./runner" import { runScenario } from "./runner"
import { runtime } from "./runtime" import { runtime } from "./runtime"
import { type Scenario } from "./types" import { type Scenario } from "./types"
@@ -40,14 +40,14 @@ import { type Scenario } from "./types"
void (await import("@opencode-ai/core/util/log")).init({ print: false }) void (await import("@opencode-ai/core/util/log")).init({ print: false })
const scenarios: Scenario[] = [ const scenarios: Scenario[] = [
http http.protected
.get("/global/health", "global.health") .get("/global/health", "global.health")
.global() .global()
.json(200, (body) => { .json(200, (body) => {
object(body) object(body)
check(body.healthy === true, "server should report healthy") check(body.healthy === true, "server should report healthy")
}), }),
http http.protected
.get("/global/event", "global.event") .get("/global/event", "global.event")
.global() .global()
.stream() .stream()
@@ -60,8 +60,8 @@ const scenarios: Scenario[] = [
}), }),
"status", "status",
), ),
http.get("/global/config", "global.config.get").global().json(), http.protected.get("/global/config", "global.config.get").global().json(),
http http.protected
.patch("/global/config", "global.config.update") .patch("/global/config", "global.config.update")
.global() .global()
.seeded(() => .seeded(() =>
@@ -86,7 +86,7 @@ const scenarios: Scenario[] = [
}), }),
"status", "status",
), ),
http http.protected
.post("/global/dispose", "global.dispose") .post("/global/dispose", "global.dispose")
.global() .global()
.mutating() .mutating()
@@ -97,23 +97,37 @@ const scenarios: Scenario[] = [
}, },
"status", "status",
), ),
http.get("/path", "path.get").json(200, (body, ctx) => { http.protected.get("/path", "path.get").json(200, (body, ctx) => {
object(body) object(body)
check(body.directory === ctx.directory, "directory should resolve from x-opencode-directory") check(body.directory === ctx.directory, "directory should resolve from x-opencode-directory")
check(body.worktree === ctx.directory, "worktree should resolve from x-opencode-directory") check(body.worktree === ctx.directory, "worktree should resolve from x-opencode-directory")
}), }),
http.get("/vcs", "vcs.get").json(), http.protected.get("/vcs", "vcs.get").json(),
http http.protected.get("/vcs/status", "vcs.status").json(200, array),
http.protected
.get("/vcs/diff", "vcs.diff") .get("/vcs/diff", "vcs.diff")
.at((ctx) => ({ path: "/vcs/diff?mode=git", headers: ctx.headers() })) .at((ctx) => ({ path: "/vcs/diff?mode=git", headers: ctx.headers() }))
.json(200, array), .json(200, array),
http.get("/command", "command.list").json(200, array, "status"), http.protected.get("/vcs/diff/raw", "vcs.diff.raw").status(
http.get("/agent", "app.agents").json(200, array, "status"), 200,
http.get("/skill", "app.skills").json(200, array, "status"), (_ctx, result) =>
http.get("/lsp", "lsp.status").json(200, array), Effect.sync(() => {
http.get("/formatter", "formatter.status").json(200, array), check(typeof result.text === "string", "raw VCS diff should return text")
http.get("/config", "config.get").json(200, undefined, "status"), }),
http "status",
),
http.protected
.post("/vcs/apply", "vcs.apply")
.inProject({ git: false })
.at((ctx) => ({ path: "/vcs/apply", headers: ctx.headers(), body: { patch: "" } }))
.status(400, undefined, "status"),
http.protected.get("/command", "command.list").json(200, array, "status"),
http.protected.get("/agent", "app.agents").json(200, array, "status"),
http.protected.get("/skill", "app.skills").json(200, array, "status"),
http.protected.get("/lsp", "lsp.status").json(200, array),
http.protected.get("/formatter", "formatter.status").json(200, array),
http.protected.get("/config", "config.get").json(200, undefined, "status"),
http.protected
.patch("/config", "config.update") .patch("/config", "config.update")
.mutating() .mutating()
.at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: "httpapi-local" } })) .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: "httpapi-local" } }))
@@ -125,13 +139,13 @@ const scenarios: Scenario[] = [
}, },
"status", "status",
), ),
http http.protected
.patch("/config", "config.update.invalid") .patch("/config", "config.update.invalid")
.at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: 1 } })) .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: 1 } }))
.status(400), .status(400),
http.get("/config/providers", "config.providers").json(), http.protected.get("/config/providers", "config.providers").json(),
http.get("/project", "project.list").json(200, array, "status"), http.protected.get("/project", "project.list").json(200, array, "status"),
http.get("/project/current", "project.current").json( http.protected.get("/project/current", "project.current").json(
200, 200,
(body, ctx) => { (body, ctx) => {
object(body) object(body)
@@ -139,7 +153,7 @@ const scenarios: Scenario[] = [
}, },
"status", "status",
), ),
http http.protected
.patch("/project/{projectID}", "project.update") .patch("/project/{projectID}", "project.update")
.mutating() .mutating()
.seeded((ctx) => ctx.project()) .seeded((ctx) => ctx.project())
@@ -160,7 +174,7 @@ const scenarios: Scenario[] = [
}, },
"status", "status",
), ),
http http.protected
.post("/project/git/init", "project.initGit") .post("/project/git/init", "project.initGit")
.mutating() .mutating()
.inProject({ git: false }) .inProject({ git: false })
@@ -173,9 +187,9 @@ const scenarios: Scenario[] = [
}, },
"status", "status",
), ),
http.get("/provider", "provider.list").json(), http.protected.get("/provider", "provider.list").json(),
http.get("/provider/auth", "provider.auth").json(), http.protected.get("/provider/auth", "provider.auth").json(),
http http.protected
.post("/provider/{providerID}/oauth/authorize", "provider.oauth.authorize") .post("/provider/{providerID}/oauth/authorize", "provider.oauth.authorize")
.at((ctx) => ({ .at((ctx) => ({
path: route("/provider/{providerID}/oauth/authorize", { providerID: "httpapi" }), path: route("/provider/{providerID}/oauth/authorize", { providerID: "httpapi" }),
@@ -183,7 +197,7 @@ const scenarios: Scenario[] = [
body: { method: "bad" }, body: { method: "bad" },
})) }))
.status(400), .status(400),
http http.protected
.post("/provider/{providerID}/oauth/callback", "provider.oauth.callback") .post("/provider/{providerID}/oauth/callback", "provider.oauth.callback")
.at((ctx) => ({ .at((ctx) => ({
path: route("/provider/{providerID}/oauth/callback", { providerID: "httpapi" }), path: route("/provider/{providerID}/oauth/callback", { providerID: "httpapi" }),
@@ -191,8 +205,8 @@ const scenarios: Scenario[] = [
body: { method: "bad" }, body: { method: "bad" },
})) }))
.status(400), .status(400),
http.get("/permission", "permission.list").json(200, array), http.protected.get("/permission", "permission.list").json(200, array),
http http.protected
.post("/permission/{requestID}/reply", "permission.reply.invalid") .post("/permission/{requestID}/reply", "permission.reply.invalid")
.at((ctx) => ({ .at((ctx) => ({
path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }),
@@ -200,7 +214,7 @@ const scenarios: Scenario[] = [
body: { reply: "bad" }, body: { reply: "bad" },
})) }))
.status(400), .status(400),
http http.protected
.post("/permission/{requestID}/reply", "permission.reply") .post("/permission/{requestID}/reply", "permission.reply")
.at((ctx) => ({ .at((ctx) => ({
path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }),
@@ -210,8 +224,8 @@ const scenarios: Scenario[] = [
.json(200, (body) => { .json(200, (body) => {
check(body === true, "permission reply should return true even when request is no longer pending") check(body === true, "permission reply should return true even when request is no longer pending")
}), }),
http.get("/question", "question.list").json(200, array), http.protected.get("/question", "question.list").json(200, array),
http http.protected
.post("/question/{requestID}/reply", "question.reply.invalid") .post("/question/{requestID}/reply", "question.reply.invalid")
.at((ctx) => ({ .at((ctx) => ({
path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }),
@@ -219,7 +233,7 @@ const scenarios: Scenario[] = [
body: { answers: "Yes" }, body: { answers: "Yes" },
})) }))
.status(400), .status(400),
http http.protected
.post("/question/{requestID}/reply", "question.reply") .post("/question/{requestID}/reply", "question.reply")
.at((ctx) => ({ .at((ctx) => ({
path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }),
@@ -229,7 +243,7 @@ const scenarios: Scenario[] = [
.json(200, (body) => { .json(200, (body) => {
check(body === true, "question reply should return true even when request is no longer pending") check(body === true, "question reply should return true even when request is no longer pending")
}), }),
http http.protected
.post("/question/{requestID}/reject", "question.reject") .post("/question/{requestID}/reject", "question.reject")
.at((ctx) => ({ .at((ctx) => ({
path: route("/question/{requestID}/reject", { requestID: "que_httpapi_reject" }), path: route("/question/{requestID}/reject", { requestID: "que_httpapi_reject" }),
@@ -238,12 +252,12 @@ const scenarios: Scenario[] = [
.json(200, (body) => { .json(200, (body) => {
check(body === true, "question reject should return true even when request is no longer pending") check(body === true, "question reject should return true even when request is no longer pending")
}), }),
http http.protected
.get("/file", "file.list") .get("/file", "file.list")
.seeded((ctx) => ctx.file("hello.txt", "hello\n")) .seeded((ctx) => ctx.file("hello.txt", "hello\n"))
.at((ctx) => ({ path: `/file?${new URLSearchParams({ path: "." })}`, headers: ctx.headers() })) .at((ctx) => ({ path: `/file?${new URLSearchParams({ path: "." })}`, headers: ctx.headers() }))
.json(200, array), .json(200, array),
http http.protected
.get("/file/content", "file.read") .get("/file/content", "file.read")
.seeded((ctx) => ctx.file("hello.txt", "hello\n")) .seeded((ctx) => ctx.file("hello.txt", "hello\n"))
.at((ctx) => ({ path: `/file/content?${new URLSearchParams({ path: "hello.txt" })}`, headers: ctx.headers() })) .at((ctx) => ({ path: `/file/content?${new URLSearchParams({ path: "hello.txt" })}`, headers: ctx.headers() }))
@@ -251,20 +265,20 @@ const scenarios: Scenario[] = [
object(body) object(body)
check(body.content === "hello", `content should match seeded file: ${JSON.stringify(body)}`) check(body.content === "hello", `content should match seeded file: ${JSON.stringify(body)}`)
}), }),
http http.protected
.get("/file/content", "file.read.missing") .get("/file/content", "file.read.missing")
.at((ctx) => ({ path: `/file/content?${new URLSearchParams({ path: "missing.txt" })}`, headers: ctx.headers() })) .at((ctx) => ({ path: `/file/content?${new URLSearchParams({ path: "missing.txt" })}`, headers: ctx.headers() }))
.json(200, (body) => { .json(200, (body) => {
object(body) object(body)
check(body.type === "text" && body.content === "", "missing file content should return an empty text result") check(body.type === "text" && body.content === "", "missing file content should return an empty text result")
}), }),
http.get("/file/status", "file.status").json(200, array), http.protected.get("/file/status", "file.status").json(200, array),
http http.protected
.get("/find", "find.text") .get("/find", "find.text")
.seeded((ctx) => ctx.file("hello.txt", "hello\n")) .seeded((ctx) => ctx.file("hello.txt", "hello\n"))
.at((ctx) => ({ path: `/find?${new URLSearchParams({ pattern: "hello" })}`, headers: ctx.headers() })) .at((ctx) => ({ path: `/find?${new URLSearchParams({ pattern: "hello" })}`, headers: ctx.headers() }))
.json(200, array), .json(200, array),
http http.protected
.get("/find/file", "find.files") .get("/find/file", "find.files")
.seeded((ctx) => ctx.file("hello.txt", "hello\n")) .seeded((ctx) => ctx.file("hello.txt", "hello\n"))
.at((ctx) => ({ .at((ctx) => ({
@@ -272,12 +286,12 @@ const scenarios: Scenario[] = [
headers: ctx.headers(), headers: ctx.headers(),
})) }))
.json(200, array), .json(200, array),
http http.protected
.get("/find/symbol", "find.symbols") .get("/find/symbol", "find.symbols")
.seeded((ctx) => ctx.file("hello.ts", "export const hello = 1\n")) .seeded((ctx) => ctx.file("hello.ts", "export const hello = 1\n"))
.at((ctx) => ({ path: `/find/symbol?${new URLSearchParams({ query: "hello" })}`, headers: ctx.headers() })) .at((ctx) => ({ path: `/find/symbol?${new URLSearchParams({ query: "hello" })}`, headers: ctx.headers() }))
.json(200, array), .json(200, array),
http http.protected
.get("/event", "event.stream") .get("/event", "event.stream")
.stream() .stream()
.status( .status(
@@ -289,8 +303,8 @@ const scenarios: Scenario[] = [
}), }),
"status", "status",
), ),
http.get("/mcp", "mcp.status").json(), http.protected.get("/mcp", "mcp.status").json(),
http http.protected
.post("/mcp", "mcp.add") .post("/mcp", "mcp.add")
.mutating() .mutating()
.at((ctx) => ({ .at((ctx) => ({
@@ -307,7 +321,7 @@ const scenarios: Scenario[] = [
}, },
"status", "status",
), ),
http http.protected
.post("/mcp", "mcp.add.invalid") .post("/mcp", "mcp.add.invalid")
.at((ctx) => ({ .at((ctx) => ({
path: "/mcp", path: "/mcp",
@@ -315,7 +329,7 @@ const scenarios: Scenario[] = [
body: { name: "httpapi-invalid", config: { type: "invalid" } }, body: { name: "httpapi-invalid", config: { type: "invalid" } },
})) }))
.status(400), .status(400),
http http.protected
.post("/mcp/{name}/auth", "mcp.auth.start") .post("/mcp/{name}/auth", "mcp.auth.start")
.at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() })) .at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() }))
.json( .json(
@@ -326,7 +340,7 @@ const scenarios: Scenario[] = [
}, },
"status", "status",
), ),
http http.protected
.delete("/mcp/{name}/auth", "mcp.auth.remove") .delete("/mcp/{name}/auth", "mcp.auth.remove")
.mutating() .mutating()
.at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() })) .at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() }))
@@ -334,7 +348,7 @@ const scenarios: Scenario[] = [
object(body) object(body)
check(body.success === true, "MCP auth removal should return success") check(body.success === true, "MCP auth removal should return success")
}), }),
http http.protected
.post("/mcp/{name}/auth/authenticate", "mcp.auth.authenticate") .post("/mcp/{name}/auth/authenticate", "mcp.auth.authenticate")
.at((ctx) => ({ .at((ctx) => ({
path: route("/mcp/{name}/auth/authenticate", { name: "httpapi-missing" }), path: route("/mcp/{name}/auth/authenticate", { name: "httpapi-missing" }),
@@ -348,7 +362,7 @@ const scenarios: Scenario[] = [
}, },
"status", "status",
), ),
http http.protected
.post("/mcp/{name}/auth/callback", "mcp.auth.callback") .post("/mcp/{name}/auth/callback", "mcp.auth.callback")
.at((ctx) => ({ .at((ctx) => ({
path: route("/mcp/{name}/auth/callback", { name: "httpapi-missing" }), path: route("/mcp/{name}/auth/callback", { name: "httpapi-missing" }),
@@ -356,23 +370,23 @@ const scenarios: Scenario[] = [
body: { code: 1 }, body: { code: 1 },
})) }))
.status(400), .status(400),
http http.protected
.post("/mcp/{name}/connect", "mcp.connect") .post("/mcp/{name}/connect", "mcp.connect")
.mutating() .mutating()
.at((ctx) => ({ path: route("/mcp/{name}/connect", { name: "httpapi-missing" }), headers: ctx.headers() })) .at((ctx) => ({ path: route("/mcp/{name}/connect", { name: "httpapi-missing" }), headers: ctx.headers() }))
.json(200, (body) => { .json(200, (body) => {
check(body === true, "missing MCP connect should remain a no-op success") check(body === true, "missing MCP connect should remain a no-op success")
}), }),
http http.protected
.post("/mcp/{name}/disconnect", "mcp.disconnect") .post("/mcp/{name}/disconnect", "mcp.disconnect")
.mutating() .mutating()
.at((ctx) => ({ path: route("/mcp/{name}/disconnect", { name: "httpapi-missing" }), headers: ctx.headers() })) .at((ctx) => ({ path: route("/mcp/{name}/disconnect", { name: "httpapi-missing" }), headers: ctx.headers() }))
.json(200, (body) => { .json(200, (body) => {
check(body === true, "missing MCP disconnect should remain a no-op success") check(body === true, "missing MCP disconnect should remain a no-op success")
}), }),
http.get("/pty/shells", "pty.shells").json(200, array), http.protected.get("/pty/shells", "pty.shells").json(200, array),
http.get("/pty", "pty.list").json(200, array), http.protected.get("/pty", "pty.list").json(200, array),
http http.protected
.post("/pty", "pty.create") .post("/pty", "pty.create")
.mutating() .mutating()
.at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: controlledPtyInput("HTTP API PTY") })) .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: controlledPtyInput("HTTP API PTY") }))
@@ -386,15 +400,22 @@ const scenarios: Scenario[] = [
}, },
"status", "status",
), ),
http http.protected
.post("/pty", "pty.create.invalid") .post("/pty", "pty.create.invalid")
.at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: { command: 1 } })) .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: { command: 1 } }))
.status(400), .status(400),
http http.protected
.post("/pty/{ptyID}/connect-token", "pty.connectToken.invalid")
.at((ctx) => ({
path: route("/pty/{ptyID}/connect-token", { ptyID: "pty_httpapi_missing" }),
headers: ctx.headers(),
}))
.status(403, undefined, "status"),
http.protected
.get("/pty/{ptyID}", "pty.get") .get("/pty/{ptyID}", "pty.get")
.at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) .at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() }))
.status(404), .status(404),
http http.protected
.put("/pty/{ptyID}", "pty.update") .put("/pty/{ptyID}", "pty.update")
.mutating() .mutating()
.at((ctx) => ({ .at((ctx) => ({
@@ -403,20 +424,20 @@ const scenarios: Scenario[] = [
body: { size: { rows: 0, cols: 0 } }, body: { size: { rows: 0, cols: 0 } },
})) }))
.status(400), .status(400),
http http.protected
.delete("/pty/{ptyID}", "pty.remove") .delete("/pty/{ptyID}", "pty.remove")
.mutating() .mutating()
.at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) .at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() }))
.json(200, (body) => { .json(200, (body) => {
check(body === true, "PTY remove should return true") check(body === true, "PTY remove should return true")
}), }),
http http.protected
.get("/pty/{ptyID}/connect", "pty.connect") .get("/pty/{ptyID}/connect", "pty.connect")
.at((ctx) => ({ path: route("/pty/{ptyID}/connect", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) .at((ctx) => ({ path: route("/pty/{ptyID}/connect", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() }))
.status(404, undefined, "none"), .status(404, undefined, "none"),
http.get("/experimental/console", "experimental.console.get").json(), http.protected.get("/experimental/console", "experimental.console.get").json(),
http.get("/experimental/console/orgs", "experimental.console.listOrgs").json(), http.protected.get("/experimental/console/orgs", "experimental.console.listOrgs").json(),
http http.protected
.post("/experimental/console/switch", "experimental.console.switchOrg") .post("/experimental/console/switch", "experimental.console.switchOrg")
.at((ctx) => ({ .at((ctx) => ({
path: "/experimental/console/switch", path: "/experimental/console/switch",
@@ -424,14 +445,17 @@ const scenarios: Scenario[] = [
body: { accountID: "httpapi-account", orgID: "httpapi-org" }, body: { accountID: "httpapi-account", orgID: "httpapi-org" },
})) }))
.status(400, undefined, "none"), .status(400, undefined, "none"),
http.get("/experimental/workspace/adapter", "experimental.workspace.adapter.list").json(200, array), http.protected.get("/experimental/workspace/adapter", "experimental.workspace.adapter.list").json(200, array),
http.get("/experimental/workspace", "experimental.workspace.list").json(200, array), http.protected.get("/experimental/workspace", "experimental.workspace.list").json(200, array),
http.get("/experimental/workspace/status", "experimental.workspace.status").json(200, array), http.protected.get("/experimental/workspace/status", "experimental.workspace.status").json(200, array),
http http.protected
.post("/experimental/workspace", "experimental.workspace.create") .post("/experimental/workspace", "experimental.workspace.create")
.at((ctx) => ({ path: "/experimental/workspace", headers: ctx.headers(), body: {} })) .at((ctx) => ({ path: "/experimental/workspace", headers: ctx.headers(), body: {} }))
.status(400), .status(400),
http http.protected
.post("/experimental/workspace/sync-list", "experimental.workspace.syncList")
.status(204, undefined, "status"),
http.protected
.delete("/experimental/workspace/{id}", "experimental.workspace.remove") .delete("/experimental/workspace/{id}", "experimental.workspace.remove")
.mutating() .mutating()
.at((ctx) => ({ .at((ctx) => ({
@@ -439,7 +463,7 @@ const scenarios: Scenario[] = [
headers: ctx.headers(), headers: ctx.headers(),
})) }))
.status(200), .status(200),
http http.protected
.post("/experimental/workspace/warp", "experimental.workspace.warp") .post("/experimental/workspace/warp", "experimental.workspace.warp")
.at((ctx) => ({ .at((ctx) => ({
path: "/experimental/workspace/warp", path: "/experimental/workspace/warp",
@@ -447,16 +471,16 @@ const scenarios: Scenario[] = [
body: {}, body: {},
})) }))
.status(400), .status(400),
http http.protected
.get("/experimental/tool", "tool.list") .get("/experimental/tool", "tool.list")
.at((ctx) => ({ .at((ctx) => ({
path: `/experimental/tool?${new URLSearchParams({ provider: "opencode", model: "test" })}`, path: `/experimental/tool?${new URLSearchParams({ provider: "opencode", model: "test" })}`,
headers: ctx.headers(), headers: ctx.headers(),
})) }))
.json(200, array, "status"), .json(200, array, "status"),
http.get("/experimental/tool/ids", "tool.ids").json(200, array), http.protected.get("/experimental/tool/ids", "tool.ids").json(200, array),
http.get("/experimental/worktree", "worktree.list").json(200, array), http.protected.get("/experimental/worktree", "worktree.list").json(200, array),
http http.protected
.post("/experimental/worktree", "worktree.create") .post("/experimental/worktree", "worktree.create")
.mutating() .mutating()
.at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: "api-dsl" } })) .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: "api-dsl" } }))
@@ -470,11 +494,11 @@ const scenarios: Scenario[] = [
}), }),
"status", "status",
), ),
http http.protected
.post("/experimental/worktree", "worktree.create.invalid") .post("/experimental/worktree", "worktree.create.invalid")
.at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: 1 } })) .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: 1 } }))
.status(400), .status(400),
http http.protected
.delete("/experimental/worktree", "worktree.remove") .delete("/experimental/worktree", "worktree.remove")
.mutating() .mutating()
.seeded((ctx) => ctx.worktree({ name: "api-remove" })) .seeded((ctx) => ctx.worktree({ name: "api-remove" }))
@@ -482,7 +506,7 @@ const scenarios: Scenario[] = [
.json(200, (body) => { .json(200, (body) => {
check(body === true, "worktree remove should return true") check(body === true, "worktree remove should return true")
}), }),
http http.protected
.post("/experimental/worktree/reset", "worktree.reset") .post("/experimental/worktree/reset", "worktree.reset")
.mutating() .mutating()
.seeded((ctx) => ctx.worktree({ name: "api-reset" })) .seeded((ctx) => ctx.worktree({ name: "api-reset" }))
@@ -497,37 +521,41 @@ const scenarios: Scenario[] = [
yield* ctx.worktreeRemove(ctx.state.directory) yield* ctx.worktreeRemove(ctx.state.directory)
}), }),
), ),
http.get("/experimental/session", "experimental.session.list").json(200, array), http.protected.get("/experimental/session", "experimental.session.list").json(200, array),
http.get("/experimental/resource", "experimental.resource.list").json(), http.protected.get("/experimental/resource", "experimental.resource.list").json(),
http http.protected
.post("/sync/history", "sync.history.list") .post("/sync/history", "sync.history.list")
.at((ctx) => ({ path: "/sync/history", headers: ctx.headers(), body: {} })) .at((ctx) => ({ path: "/sync/history", headers: ctx.headers(), body: {} }))
.json(200, array), .json(200, array),
http http.protected
.post("/sync/replay", "sync.replay") .post("/sync/replay", "sync.replay")
.at((ctx) => ({ path: "/sync/replay", headers: ctx.headers(), body: { directory: ctx.directory, events: [] } })) .at((ctx) => ({ path: "/sync/replay", headers: ctx.headers(), body: { directory: ctx.directory, events: [] } }))
.status(400), .status(400),
http http.protected
.post("/sync/steal", "sync.steal.invalid")
.at((ctx) => ({ path: "/sync/steal", headers: ctx.headers(), body: {} }))
.status(400, undefined, "status"),
http.protected
.post("/sync/start", "sync.start") .post("/sync/start", "sync.start")
.mutating() .mutating()
.preserveDatabase() .preserveDatabase()
.json(200, (body) => { .json(200, (body) => {
check(body === true, "sync start should return true when no workspace sessions exist") check(body === true, "sync start should return true when no workspace sessions exist")
}), }),
http http.protected
.post("/instance/dispose", "instance.dispose") .post("/instance/dispose", "instance.dispose")
.mutating() .mutating()
.json(200, (body) => { .json(200, (body) => {
check(body === true, "instance dispose should return true") check(body === true, "instance dispose should return true")
}), }),
http http.protected
.post("/log", "app.log") .post("/log", "app.log")
.global() .global()
.at(() => ({ path: "/log", body: { service: "httpapi-exercise", level: "info", message: "route coverage" } })) .at(() => ({ path: "/log", body: { service: "httpapi-exercise", level: "info", message: "route coverage" } }))
.json(200, (body) => { .json(200, (body) => {
check(body === true, "log route should return true") check(body === true, "log route should return true")
}), }),
http http.protected
.put("/auth/{providerID}", "auth.set") .put("/auth/{providerID}", "auth.set")
.global() .global()
.at(() => ({ path: route("/auth/{providerID}", { providerID: "test" }), body: { type: "api", key: "test-key" } })) .at(() => ({ path: route("/auth/{providerID}", { providerID: "test" }), body: { type: "api", key: "test-key" } }))
@@ -539,7 +567,7 @@ const scenarios: Scenario[] = [
check(isRecord(auth.test) && auth.test.key === "test-key", "auth set should write isolated auth file") check(isRecord(auth.test) && auth.test.key === "test-key", "auth set should write isolated auth file")
}), }),
), ),
http http.protected
.delete("/auth/{providerID}", "auth.remove") .delete("/auth/{providerID}", "auth.remove")
.global() .global()
.seeded(() => .seeded(() =>
@@ -559,7 +587,63 @@ const scenarios: Scenario[] = [
check(auth.test === undefined, "auth remove should delete provider from isolated auth file") check(auth.test === undefined, "auth remove should delete provider from isolated auth file")
}), }),
), ),
http http.protected
.get("/api/session", "v2.session.list")
.at((ctx) => ({ path: "/api/session?roots=true", headers: ctx.headers() }))
.json(
200,
(body) => {
object(body)
array(body.items)
object(body.cursor)
},
"none",
),
http.protected
.get("/api/session/{sessionID}/context", "v2.session.context")
.at((ctx) => ({
path: route("/api/session/{sessionID}/context", { sessionID: "ses_httpapi_missing" }),
headers: ctx.headers(),
}))
.json(200, array, "none"),
http.protected
.get("/api/session/{sessionID}/message", "v2.session.messages")
.at((ctx) => ({
path: route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" }),
headers: ctx.headers(),
}))
.json(
200,
(body) => {
object(body)
array(body.items)
object(body.cursor)
},
"none",
),
http.protected
.post("/api/session/{sessionID}/prompt", "v2.session.prompt.invalid")
.at((ctx) => ({
path: route("/api/session/{sessionID}/prompt", { sessionID: "ses_httpapi_missing" }),
headers: ctx.headers(),
body: {},
}))
.status(400, undefined, "none"),
http.protected
.post("/api/session/{sessionID}/compact", "v2.session.compact")
.at((ctx) => ({
path: route("/api/session/{sessionID}/compact", { sessionID: "ses_httpapi_missing" }),
headers: ctx.headers(),
}))
.status(204, undefined, "none"),
http.protected
.post("/api/session/{sessionID}/wait", "v2.session.wait")
.at((ctx) => ({
path: route("/api/session/{sessionID}/wait", { sessionID: "ses_httpapi_missing" }),
headers: ctx.headers(),
}))
.status(204, undefined, "none"),
http.protected
.get("/session", "session.list") .get("/session", "session.list")
.seeded((ctx) => ctx.session({ title: "List me" })) .seeded((ctx) => ctx.session({ title: "List me" }))
.at((ctx) => ({ path: "/session?roots=true", headers: ctx.headers() })) .at((ctx) => ({ path: "/session?roots=true", headers: ctx.headers() }))
@@ -570,11 +654,11 @@ const scenarios: Scenario[] = [
"seeded session should be listed", "seeded session should be listed",
) )
}), }),
http http.protected
.get("/session/status", "session.status") .get("/session/status", "session.status")
.seeded((ctx) => ctx.session({ title: "Status session" })) .seeded((ctx) => ctx.session({ title: "Status session" }))
.json(200, object), .json(200, object),
http http.protected
.post("/session", "session.create") .post("/session", "session.create")
.mutating() .mutating()
.at((ctx) => ({ path: "/session", headers: ctx.headers(), body: { title: "Created session" } })) .at((ctx) => ({ path: "/session", headers: ctx.headers(), body: { title: "Created session" } }))
@@ -587,7 +671,7 @@ const scenarios: Scenario[] = [
}, },
"status", "status",
), ),
http http.protected
.get("/session/{sessionID}", "session.get") .get("/session/{sessionID}", "session.get")
.seeded((ctx) => ctx.session({ title: "Get me" })) .seeded((ctx) => ctx.session({ title: "Get me" }))
.at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers() })) .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers() }))
@@ -596,14 +680,14 @@ const scenarios: Scenario[] = [
check(body.id === ctx.state.id, "should return requested session") check(body.id === ctx.state.id, "should return requested session")
check(body.title === "Get me", "should preserve seeded title") check(body.title === "Get me", "should preserve seeded title")
}), }),
http http.protected
.get("/session/{sessionID}", "session.get.missing") .get("/session/{sessionID}", "session.get.missing")
.at((ctx) => ({ .at((ctx) => ({
path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }),
headers: ctx.headers(), headers: ctx.headers(),
})) }))
.status(404), .status(404),
http http.protected
.patch("/session/{sessionID}", "session.update") .patch("/session/{sessionID}", "session.update")
.mutating() .mutating()
.seeded((ctx) => ctx.session({ title: "Before rename" })) .seeded((ctx) => ctx.session({ title: "Before rename" }))
@@ -620,7 +704,7 @@ const scenarios: Scenario[] = [
}, },
"status", "status",
), ),
http http.protected
.patch("/session/{sessionID}", "session.update.invalid") .patch("/session/{sessionID}", "session.update.invalid")
.mutating() .mutating()
.at((ctx) => ({ .at((ctx) => ({
@@ -629,7 +713,7 @@ const scenarios: Scenario[] = [
body: { title: 1 }, body: { title: 1 },
})) }))
.status(400), .status(400),
http http.protected
.delete("/session/{sessionID}", "session.delete") .delete("/session/{sessionID}", "session.delete")
.mutating() .mutating()
.seeded((ctx) => ctx.session({ title: "Delete me" })) .seeded((ctx) => ctx.session({ title: "Delete me" }))
@@ -640,7 +724,7 @@ const scenarios: Scenario[] = [
check((yield* ctx.sessionGet(ctx.state.id)) === undefined, "deleted session should not remain in storage") check((yield* ctx.sessionGet(ctx.state.id)) === undefined, "deleted session should not remain in storage")
}), }),
), ),
http http.protected
.get("/session/{sessionID}/children", "session.children") .get("/session/{sessionID}/children", "session.children")
.seeded((ctx) => .seeded((ctx) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -660,7 +744,7 @@ const scenarios: Scenario[] = [
"children should include seeded child", "children should include seeded child",
) )
}), }),
http http.protected
.get("/session/{sessionID}/todo", "session.todo") .get("/session/{sessionID}/todo", "session.todo")
.seeded((ctx) => .seeded((ctx) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -677,12 +761,12 @@ const scenarios: Scenario[] = [
.json(200, (body, ctx) => { .json(200, (body, ctx) => {
check(stable(body) === stable(ctx.state.todos), "todos should match seeded state") check(stable(body) === stable(ctx.state.todos), "todos should match seeded state")
}), }),
http http.protected
.get("/session/{sessionID}/diff", "session.diff") .get("/session/{sessionID}/diff", "session.diff")
.seeded((ctx) => ctx.session({ title: "Diff session" })) .seeded((ctx) => ctx.session({ title: "Diff session" }))
.at((ctx) => ({ path: route("/session/{sessionID}/diff", { sessionID: ctx.state.id }), headers: ctx.headers() })) .at((ctx) => ({ path: route("/session/{sessionID}/diff", { sessionID: ctx.state.id }), headers: ctx.headers() }))
.json(200, array), .json(200, array),
http http.protected
.get("/session/{sessionID}/message", "session.messages") .get("/session/{sessionID}/message", "session.messages")
.seeded((ctx) => ctx.session({ title: "Messages session" })) .seeded((ctx) => ctx.session({ title: "Messages session" }))
.at((ctx) => ({ path: route("/session/{sessionID}/message", { sessionID: ctx.state.id }), headers: ctx.headers() })) .at((ctx) => ({ path: route("/session/{sessionID}/message", { sessionID: ctx.state.id }), headers: ctx.headers() }))
@@ -690,7 +774,7 @@ const scenarios: Scenario[] = [
array(body) array(body)
check(body.length === 0, "new session should have no messages") check(body.length === 0, "new session should have no messages")
}), }),
http http.protected
.get("/session/{sessionID}/message/{messageID}", "session.message") .get("/session/{sessionID}/message/{messageID}", "session.message")
.seeded((ctx) => .seeded((ctx) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -714,7 +798,7 @@ const scenarios: Scenario[] = [
"message should include seeded part", "message should include seeded part",
) )
}), }),
http http.protected
.patch("/session/{sessionID}/message/{messageID}/part/{partID}", "part.update") .patch("/session/{sessionID}/message/{messageID}/part/{partID}", "part.update")
.mutating() .mutating()
.seeded((ctx) => .seeded((ctx) =>
@@ -741,7 +825,7 @@ const scenarios: Scenario[] = [
}, },
"status", "status",
), ),
http http.protected
.delete("/session/{sessionID}/message/{messageID}/part/{partID}", "part.delete") .delete("/session/{sessionID}/message/{messageID}/part/{partID}", "part.delete")
.mutating() .mutating()
.seeded((ctx) => .seeded((ctx) =>
@@ -766,7 +850,7 @@ const scenarios: Scenario[] = [
check(messages[0]?.parts.length === 0, "deleted part should not remain on message") check(messages[0]?.parts.length === 0, "deleted part should not remain on message")
}), }),
), ),
http http.protected
.delete("/session/{sessionID}/message/{messageID}", "session.deleteMessage") .delete("/session/{sessionID}/message/{messageID}", "session.deleteMessage")
.mutating() .mutating()
.seeded((ctx) => .seeded((ctx) =>
@@ -789,7 +873,7 @@ const scenarios: Scenario[] = [
check((yield* ctx.messages(ctx.state.session.id)).length === 0, "deleted message should not remain") check((yield* ctx.messages(ctx.state.session.id)).length === 0, "deleted message should not remain")
}), }),
), ),
http http.protected
.post("/session/{sessionID}/fork", "session.fork") .post("/session/{sessionID}/fork", "session.fork")
.mutating() .mutating()
.seeded((ctx) => ctx.session({ title: "Fork source" })) .seeded((ctx) => ctx.session({ title: "Fork source" }))
@@ -806,7 +890,7 @@ const scenarios: Scenario[] = [
}, },
"status", "status",
), ),
http http.protected
.post("/session/{sessionID}/abort", "session.abort") .post("/session/{sessionID}/abort", "session.abort")
.mutating() .mutating()
.seeded((ctx) => ctx.session({ title: "Abort session" })) .seeded((ctx) => ctx.session({ title: "Abort session" }))
@@ -814,7 +898,7 @@ const scenarios: Scenario[] = [
.json(200, (body) => { .json(200, (body) => {
check(body === true, "abort should return true") check(body === true, "abort should return true")
}), }),
http http.protected
.post("/session/{sessionID}/abort", "session.abort.missing") .post("/session/{sessionID}/abort", "session.abort.missing")
.at((ctx) => ({ .at((ctx) => ({
path: route("/session/{sessionID}/abort", { sessionID: "ses_httpapi_missing" }), path: route("/session/{sessionID}/abort", { sessionID: "ses_httpapi_missing" }),
@@ -823,7 +907,7 @@ const scenarios: Scenario[] = [
.json(200, (body) => { .json(200, (body) => {
check(body === true, "missing session abort should remain a no-op success") check(body === true, "missing session abort should remain a no-op success")
}), }),
http http.protected
.post("/session/{sessionID}/init", "session.init") .post("/session/{sessionID}/init", "session.init")
.preserveDatabase() .preserveDatabase()
.withLlm() .withLlm()
@@ -847,7 +931,7 @@ const scenarios: Scenario[] = [
yield* ctx.llmWait(1) yield* ctx.llmWait(1)
}), }),
), ),
http http.protected
.post("/session/{sessionID}/message", "session.prompt") .post("/session/{sessionID}/message", "session.prompt")
.preserveDatabase() .preserveDatabase()
.withLlm() .withLlm()
@@ -882,7 +966,7 @@ const scenarios: Scenario[] = [
}), }),
"status", "status",
), ),
http http.protected
.post("/session/{sessionID}/prompt_async", "session.prompt_async") .post("/session/{sessionID}/prompt_async", "session.prompt_async")
.preserveDatabase() .preserveDatabase()
.withLlm() .withLlm()
@@ -908,7 +992,7 @@ const scenarios: Scenario[] = [
yield* ctx.llmWait(1) yield* ctx.llmWait(1)
}), }),
), ),
http http.protected
.post("/session/{sessionID}/command", "session.command") .post("/session/{sessionID}/command", "session.command")
.preserveDatabase() .preserveDatabase()
.withLlm() .withLlm()
@@ -935,7 +1019,7 @@ const scenarios: Scenario[] = [
}), }),
"status", "status",
), ),
http http.protected
.post("/session/{sessionID}/shell", "session.shell") .post("/session/{sessionID}/shell", "session.shell")
.preserveDatabase() .preserveDatabase()
.mutating() .mutating()
@@ -957,7 +1041,7 @@ const scenarios: Scenario[] = [
}, },
"status", "status",
), ),
http http.protected
.post("/session/{sessionID}/summarize", "session.summarize") .post("/session/{sessionID}/summarize", "session.summarize")
.preserveDatabase() .preserveDatabase()
.withLlm() .withLlm()
@@ -1018,7 +1102,7 @@ const scenarios: Scenario[] = [
}), }),
"status", "status",
), ),
http http.protected
.post("/session/{sessionID}/revert", "session.revert") .post("/session/{sessionID}/revert", "session.revert")
.mutating() .mutating()
.seeded((ctx) => .seeded((ctx) =>
@@ -1045,7 +1129,7 @@ const scenarios: Scenario[] = [
}, },
"status", "status",
), ),
http http.protected
.post("/session/{sessionID}/unrevert", "session.unrevert") .post("/session/{sessionID}/unrevert", "session.unrevert")
.mutating() .mutating()
.seeded((ctx) => ctx.session({ title: "Unrevert session" })) .seeded((ctx) => ctx.session({ title: "Unrevert session" }))
@@ -1061,7 +1145,7 @@ const scenarios: Scenario[] = [
}, },
"status", "status",
), ),
http http.protected
.post("/session/{sessionID}/permissions/{permissionID}", "permission.respond") .post("/session/{sessionID}/permissions/{permissionID}", "permission.respond")
.seeded((ctx) => ctx.session({ title: "Deprecated permission session" })) .seeded((ctx) => ctx.session({ title: "Deprecated permission session" }))
.at((ctx) => ({ .at((ctx) => ({
@@ -1075,7 +1159,7 @@ const scenarios: Scenario[] = [
.json(200, (body) => { .json(200, (body) => {
check(body === true, "deprecated permission response should return true") check(body === true, "deprecated permission response should return true")
}), }),
http http.protected
.post("/session/{sessionID}/share", "session.share") .post("/session/{sessionID}/share", "session.share")
.mutating() .mutating()
.seeded((ctx) => ctx.session({ title: "Share session" })) .seeded((ctx) => ctx.session({ title: "Share session" }))
@@ -1088,7 +1172,7 @@ const scenarios: Scenario[] = [
}, },
"status", "status",
), ),
http http.protected
.delete("/session/{sessionID}/share", "session.unshare") .delete("/session/{sessionID}/share", "session.unshare")
.mutating() .mutating()
.seeded((ctx) => ctx.session({ title: "Unshare session" })) .seeded((ctx) => ctx.session({ title: "Unshare session" }))
@@ -1101,25 +1185,25 @@ const scenarios: Scenario[] = [
}, },
"status", "status",
), ),
http http.protected
.post("/tui/append-prompt", "tui.appendPrompt") .post("/tui/append-prompt", "tui.appendPrompt")
.at((ctx) => ({ path: "/tui/append-prompt", headers: ctx.headers(), body: { text: "hello" } })) .at((ctx) => ({ path: "/tui/append-prompt", headers: ctx.headers(), body: { text: "hello" } }))
.json(200, boolean, "status"), .json(200, boolean, "status"),
http http.protected
.post("/tui/select-session", "tui.selectSession.invalid") .post("/tui/select-session", "tui.selectSession.invalid")
.at((ctx) => ({ path: "/tui/select-session", headers: ctx.headers(), body: { sessionID: "invalid" } })) .at((ctx) => ({ path: "/tui/select-session", headers: ctx.headers(), body: { sessionID: "invalid" } }))
.status(400), .status(400),
http.post("/tui/open-help", "tui.openHelp").json(200, boolean, "status"), http.protected.post("/tui/open-help", "tui.openHelp").json(200, boolean, "status"),
http.post("/tui/open-sessions", "tui.openSessions").json(200, boolean, "status"), http.protected.post("/tui/open-sessions", "tui.openSessions").json(200, boolean, "status"),
http.post("/tui/open-themes", "tui.openThemes").json(200, boolean, "status"), http.protected.post("/tui/open-themes", "tui.openThemes").json(200, boolean, "status"),
http.post("/tui/open-models", "tui.openModels").json(200, boolean, "status"), http.protected.post("/tui/open-models", "tui.openModels").json(200, boolean, "status"),
http.post("/tui/submit-prompt", "tui.submitPrompt").json(200, boolean, "status"), http.protected.post("/tui/submit-prompt", "tui.submitPrompt").json(200, boolean, "status"),
http.post("/tui/clear-prompt", "tui.clearPrompt").json(200, boolean, "status"), http.protected.post("/tui/clear-prompt", "tui.clearPrompt").json(200, boolean, "status"),
http http.protected
.post("/tui/execute-command", "tui.executeCommand") .post("/tui/execute-command", "tui.executeCommand")
.at((ctx) => ({ path: "/tui/execute-command", headers: ctx.headers(), body: { command: "agent_cycle" } })) .at((ctx) => ({ path: "/tui/execute-command", headers: ctx.headers(), body: { command: "agent_cycle" } }))
.json(200, boolean, "status"), .json(200, boolean, "status"),
http http.protected
.post("/tui/show-toast", "tui.showToast") .post("/tui/show-toast", "tui.showToast")
.at((ctx) => ({ .at((ctx) => ({
path: "/tui/show-toast", path: "/tui/show-toast",
@@ -1127,7 +1211,7 @@ const scenarios: Scenario[] = [
body: { title: "Exercise", message: "covered", variant: "info", duration: 1000 }, body: { title: "Exercise", message: "covered", variant: "info", duration: 1000 },
})) }))
.json(200, boolean, "status"), .json(200, boolean, "status"),
http http.protected
.post("/tui/publish", "tui.publish") .post("/tui/publish", "tui.publish")
.at((ctx) => ({ .at((ctx) => ({
path: "/tui/publish", path: "/tui/publish",
@@ -1135,16 +1219,16 @@ const scenarios: Scenario[] = [
body: { type: "tui.prompt.append", properties: { text: "published" } }, body: { type: "tui.prompt.append", properties: { text: "published" } },
})) }))
.json(200, boolean, "status"), .json(200, boolean, "status"),
http http.protected
.post("/tui/select-session", "tui.selectSession") .post("/tui/select-session", "tui.selectSession")
.seeded((ctx) => ctx.session({ title: "TUI select" })) .seeded((ctx) => ctx.session({ title: "TUI select" }))
.at((ctx) => ({ path: "/tui/select-session", headers: ctx.headers(), body: { sessionID: ctx.state.id } })) .at((ctx) => ({ path: "/tui/select-session", headers: ctx.headers(), body: { sessionID: ctx.state.id } }))
.json(200, boolean, "status"), .json(200, boolean, "status"),
http http.protected
.post("/tui/control/response", "tui.control.response") .post("/tui/control/response", "tui.control.response")
.at((ctx) => ({ path: "/tui/control/response", headers: ctx.headers(), body: { ok: true } })) .at((ctx) => ({ path: "/tui/control/response", headers: ctx.headers(), body: { ok: true } }))
.json(200, boolean, "status"), .json(200, boolean, "status"),
http http.protected
.get("/tui/control/next", "tui.control.next") .get("/tui/control/next", "tui.control.next")
.mutating() .mutating()
.seeded((ctx) => ctx.tuiRequest({ path: "/tui/exercise", body: { text: "queued" } })) .seeded((ctx) => ctx.tuiRequest({ path: "/tui/exercise", body: { text: "queued" } }))
@@ -1158,23 +1242,38 @@ const scenarios: Scenario[] = [
}, },
"status", "status",
), ),
http http.protected
.post("/global/upgrade", "global.upgrade") .post("/global/upgrade", "global.upgrade")
.global() .global()
.probe({ path: "/global/upgrade", body: { target: 1 } })
.at(() => ({ path: "/global/upgrade", body: { target: 1 } })) .at(() => ({ path: "/global/upgrade", body: { target: 1 } }))
.status(400), .status(400),
] ]
const llmScenarios = new Set([
"session.init",
"session.prompt",
"session.prompt_async",
"session.command",
"session.summarize",
])
const main = Effect.gen(function* () { const main = Effect.gen(function* () {
yield* Effect.addFinalizer(() => cleanupExercisePaths) yield* Effect.addFinalizer(() => cleanupExercisePaths)
const options = parseOptions(Bun.argv.slice(2)) const options = parseOptions(Bun.argv.slice(2))
const modules = yield* Effect.promise(() => runtime()) const modules = yield* Effect.promise(() => runtime())
const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi)) const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi))
const honoRoutes = routeKeys(yield* Effect.promise(() => modules.Server.openapiHono())) const honoRoutes = routeKeys(yield* Effect.promise(() => modules.Server.openapiHono()))
const selected = scenarios.filter((scenario) => matches(options, scenario)) const selected = selectedScenarios(options, scenarios)
const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario))) const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario)))
const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario))) const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario)))
for (const scenario of scenarios) {
if (scenario.kind === "active" && llmScenarios.has(scenario.name) && !scenario.project?.llm) {
return yield* Effect.fail(new Error(`${scenario.name} must use TestLLMServer via .withLlm()`))
}
}
printHeader(options, effectRoutes, honoRoutes, selected, missing, extra, { printHeader(options, effectRoutes, honoRoutes, selected, missing, extra, {
database: exerciseDatabasePath, database: exerciseDatabasePath,
global: exerciseGlobalRoot, global: exerciseGlobalRoot,
@@ -1183,7 +1282,15 @@ const main = Effect.gen(function* () {
const results = const results =
options.mode === "coverage" options.mode === "coverage"
? selected.map(coverageResult) ? selected.map(coverageResult)
: yield* Effect.forEach(selected, runScenario(options), { concurrency: 1 }) : yield* Effect.forEach(
selected,
(scenario) =>
Effect.gen(function* () {
if (options.progress) console.log(`${color.dim}RUN ${routeKey(scenario)} ${scenario.name}${color.reset}`)
return yield* runScenario(options)(scenario)
}),
{ concurrency: 1 },
)
printResults(results, missing, extra) printResults(results, missing, extra)
if (results.some((result) => result.status === "fail")) if (results.some((result) => result.status === "fail"))
@@ -23,7 +23,7 @@ export function printHeader(
console.log(`${color.dim}db=${paths.database}${color.reset}`) console.log(`${color.dim}db=${paths.database}${color.reset}`)
console.log(`${color.dim}global=${paths.global}${color.reset}`) console.log(`${color.dim}global=${paths.global}${color.reset}`)
console.log( console.log(
`${color.dim}mode=${options.mode} selected=${selected.length} effectRoutes=${effectRoutes.length} missing=${missing.length} extra=${extra.length} onlyEffect=${effectRoutes.filter((route) => !honoRoutes.includes(route)).length} onlyHono=${honoRoutes.filter((route) => !effectRoutes.includes(route)).length}${color.reset}`, `${color.dim}mode=${options.mode} selected=${selected.length} scenarioTimeout=${options.scenarioTimeout} effectRoutes=${effectRoutes.length} missing=${missing.length} extra=${extra.length} onlyEffect=${effectRoutes.filter((route) => !honoRoutes.includes(route)).length} onlyHono=${honoRoutes.filter((route) => !effectRoutes.includes(route)).length}${color.reset}`,
) )
console.log("") console.log("")
} }
@@ -24,8 +24,13 @@ export function parseOptions(args: string[]): Options {
return { return {
mode, mode,
include: option(args, "--include"), include: option(args, "--include"),
startAt: option(args, "--start-at"),
stopAt: option(args, "--stop-at"),
failOnMissing: args.includes("--fail-on-missing"), failOnMissing: args.includes("--fail-on-missing"),
failOnSkip: args.includes("--fail-on-skip"), failOnSkip: args.includes("--fail-on-skip"),
scenarioTimeout: option(args, "--scenario-timeout") ?? "30 seconds",
progress: args.includes("--progress"),
trace: args.includes("--trace"),
} }
} }
@@ -38,6 +43,21 @@ export function matches(options: Options, scenario: Scenario) {
) )
} }
export function selectedScenarios(options: Options, scenarios: Scenario[]) {
const included = scenarios.filter((scenario) => matches(options, scenario))
const start = options.startAt ? included.findIndex((scenario) => matchesName(options.startAt!, scenario)) : 0
const end = options.stopAt
? included.findIndex((scenario) => matchesName(options.stopAt!, scenario))
: included.length - 1
if (start === -1) throw new Error(`--start-at matched no scenario: ${options.startAt}`)
if (end === -1) throw new Error(`--stop-at matched no scenario: ${options.stopAt}`)
return included.slice(start, end + 1)
}
function matchesName(value: string, scenario: Scenario) {
return scenario.name.includes(value) || scenario.path.includes(value) || scenario.method.includes(value.toUpperCase())
}
function option(args: string[], name: string) { function option(args: string[], name: string) {
const index = args.indexOf(name) const index = args.indexOf(name)
if (index === -1) return undefined if (index === -1) return undefined
@@ -24,6 +24,10 @@ export function runScenario(options: Options) {
return (scenario: Scenario) => { return (scenario: Scenario) => {
if (scenario.kind === "todo") return Effect.succeed({ status: "skip", scenario } as Result) if (scenario.kind === "todo") return Effect.succeed({ status: "skip", scenario } as Result)
return runActive(options, scenario).pipe( return runActive(options, scenario).pipe(
Effect.timeoutOrElse({
duration: options.scenarioTimeout,
orElse: () => Effect.die(new Error(`scenario timed out after ${options.scenarioTimeout}`)),
}),
Effect.as({ status: "pass", scenario } as Result), Effect.as({ status: "pass", scenario } as Result),
Effect.catchCause((cause) => Effect.succeed({ status: "fail" as const, scenario, message: Cause.pretty(cause) })), Effect.catchCause((cause) => Effect.succeed({ status: "fail" as const, scenario, message: Cause.pretty(cause) })),
Effect.scoped, Effect.scoped,
@@ -36,20 +40,32 @@ function runActive(options: Options, scenario: ActiveScenario) {
if (options.mode === "parity" && scenario.mutates && scenario.compare !== "none") { if (options.mode === "parity" && scenario.mutates && scenario.compare !== "none") {
return Effect.gen(function* () { return Effect.gen(function* () {
const effect = yield* runBackend("effect", scenario) const effect = yield* runBackend(options, "effect", scenario)
const legacy = yield* runBackend("legacy", scenario) const legacy = yield* runBackend(options, "legacy", scenario)
yield* trace(options, scenario, "compare start")
yield* compare(scenario, effect, legacy) yield* compare(scenario, effect, legacy)
yield* trace(options, scenario, "compare done")
}) })
} }
return withContext(scenario, (ctx) => return withContext(options, scenario, "shared", (ctx) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* trace(options, scenario, "effect request start")
const effect = yield* call("effect", scenario, ctx) const effect = yield* call("effect", scenario, ctx)
yield* trace(options, scenario, `effect response ${effect.status}`)
yield* trace(options, scenario, "effect expect start")
yield* scenario.expect(ctx, ctx.state, effect) yield* scenario.expect(ctx, ctx.state, effect)
yield* trace(options, scenario, "effect expect done")
if (options.mode === "parity" && scenario.compare !== "none") { if (options.mode === "parity" && scenario.compare !== "none") {
yield* trace(options, scenario, "legacy request start")
const legacy = yield* call("legacy", scenario, ctx) const legacy = yield* call("legacy", scenario, ctx)
yield* trace(options, scenario, `legacy response ${legacy.status}`)
yield* trace(options, scenario, "legacy expect start")
yield* scenario.expect(ctx, ctx.state, legacy) yield* scenario.expect(ctx, ctx.state, legacy)
yield* trace(options, scenario, "legacy expect done")
yield* trace(options, scenario, "compare start")
yield* compare(scenario, effect, legacy) yield* compare(scenario, effect, legacy)
yield* trace(options, scenario, "compare done")
} }
}), }),
) )
@@ -57,61 +73,89 @@ function runActive(options: Options, scenario: ActiveScenario) {
function runAuth(scenario: ActiveScenario) { function runAuth(scenario: ActiveScenario) {
return Effect.gen(function* () { return Effect.gen(function* () {
const effect = yield* callAuthProbe("effect", scenario) const effect = yield* callAuthProbe("effect", scenario, "missing")
const legacy = yield* callAuthProbe("legacy", scenario) const legacy = yield* callAuthProbe("legacy", scenario, "missing")
if (scenario.auth === "protected") { if (scenario.auth === "protected") {
if (effect.status !== 401) throw new Error(`effect auth expected 401, got ${effect.status}`) if (effect.status !== 401) throw new Error(`effect auth expected 401, got ${effect.status}`)
if (legacy.status !== 401) throw new Error(`legacy auth expected 401, got ${legacy.status}`) if (legacy.status !== 401) throw new Error(`legacy auth expected 401, got ${legacy.status}`)
const effectAuthed = yield* callAuthProbe("effect", scenario, "valid")
const legacyAuthed = yield* callAuthProbe("legacy", scenario, "valid")
if (effectAuthed.status === 401) throw new Error("effect auth rejected valid credentials")
if (legacyAuthed.status === 401) throw new Error("legacy auth rejected valid credentials")
return return
} }
if (effect.status === 401) throw new Error("effect auth expected public access, got 401") if (effect.status === 401) throw new Error("effect auth expected public access, got 401")
if (legacy.status === 401) throw new Error("legacy auth expected public access, got 401") if (legacy.status === 401) throw new Error("legacy auth expected public access, got 401")
if (effect.timedOut) throw new Error("effect auth expected public access, probe timed out")
if (legacy.timedOut) throw new Error("legacy auth expected public access, probe timed out")
}) })
} }
function runBackend(backend: "effect" | "legacy", scenario: ActiveScenario) { function runBackend(options: Options, backend: "effect" | "legacy", scenario: ActiveScenario) {
return withContext(scenario, (ctx) => return withContext(options, scenario, backend, (ctx) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* trace(options, scenario, `${backend} request start`)
const result = yield* call(backend, scenario, ctx) const result = yield* call(backend, scenario, ctx)
yield* trace(options, scenario, `${backend} response ${result.status}`)
yield* trace(options, scenario, `${backend} expect start`)
yield* scenario.expect(ctx, ctx.state, result) yield* scenario.expect(ctx, ctx.state, result)
yield* trace(options, scenario, `${backend} expect done`)
return result return result
}), }),
) )
} }
function withContext<A, E>(scenario: ActiveScenario, use: (ctx: SeededContext<unknown>) => Effect.Effect<A, E>) { function withContext<A, E>(
options: Options,
scenario: ActiveScenario,
label: string,
use: (ctx: SeededContext<unknown>) => Effect.Effect<A, E>,
) {
return Effect.acquireRelease( return Effect.acquireRelease(
Effect.gen(function* () { Effect.gen(function* () {
yield* trace(options, scenario, `${label} context acquire start`)
const llm = scenario.project?.llm ? yield* TestLLMServer : undefined const llm = scenario.project?.llm ? yield* TestLLMServer : undefined
const project = scenario.project const project = scenario.project
const dir = project const dir = project
? yield* Effect.promise(async () => (await runtime()).tmpdir(projectOptions(project, llm?.url))) ? yield* Effect.promise(async () => (await runtime()).tmpdir(projectOptions(project, llm?.url)))
: undefined : undefined
yield* trace(options, scenario, `${label} context acquire done`)
return { dir, llm } return { dir, llm }
}), }),
(ctx) => (ctx) =>
Effect.promise(async () => { Effect.gen(function* () {
await ctx.dir?.[Symbol.asyncDispose]() yield* trace(options, scenario, `${label} tmpdir cleanup start`)
}).pipe(Effect.ignore), yield* Effect.promise(async () => {
await ctx.dir?.[Symbol.asyncDispose]()
}).pipe(Effect.ignore)
yield* trace(options, scenario, `${label} tmpdir cleanup done`)
}),
).pipe( ).pipe(
Effect.flatMap((context) => Effect.flatMap((context) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* trace(options, scenario, `${label} runtime start`)
const modules = yield* Effect.promise(() => runtime()) const modules = yield* Effect.promise(() => runtime())
yield* trace(options, scenario, `${label} runtime done`)
const path = context.dir?.path const path = context.dir?.path
const instance = path const instance = path
? yield* modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( ? yield* trace(options, scenario, `${label} instance load start`).pipe(
Effect.provide(modules.AppLayer), Effect.andThen(
Effect.catchCause((cause) => modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe(
Effect.sleep("100 millis").pipe( Effect.provide(modules.AppLayer),
Effect.andThen( Effect.catchCause((cause) =>
modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( Effect.sleep("100 millis").pipe(
Effect.provide(modules.AppLayer), Effect.andThen(
modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe(
Effect.provide(modules.AppLayer),
),
),
Effect.catchCause(() => Effect.failCause(cause)),
), ),
), ),
Effect.catchCause(() => Effect.failCause(cause)),
), ),
), ),
Effect.tap(() => trace(options, scenario, `${label} instance load done`)),
) )
: undefined : undefined
const run = <A, E, R>(effect: Effect.Effect<A, E, R>) => const run = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
@@ -184,14 +228,26 @@ function withContext<A, E>(scenario: ActiveScenario, use: (ctx: SeededContext<un
llmWait: (count) => Effect.suspend(() => llm().wait(count)), llmWait: (count) => Effect.suspend(() => llm().wait(count)),
tuiRequest: (request) => Effect.sync(() => modules.Tui.submitTuiRequest(request)), tuiRequest: (request) => Effect.sync(() => modules.Tui.submitTuiRequest(request)),
} }
yield* trace(options, scenario, `${label} seed start`)
const state = yield* scenario.seed(base) const state = yield* scenario.seed(base)
return yield* use({ ...base, state }) yield* trace(options, scenario, `${label} seed done`)
yield* trace(options, scenario, `${label} use start`)
const result = yield* use({ ...base, state })
yield* trace(options, scenario, `${label} use done`)
return result
}).pipe(Effect.ensuring(context.llm ? context.llm.reset : Effect.void)), }).pipe(Effect.ensuring(context.llm ? context.llm.reset : Effect.void)),
), ),
Effect.ensuring(scenario.reset ? resetState : Effect.void), Effect.ensuring(scenario.reset ? resetState : Effect.void),
) )
} }
function trace(options: Options, scenario: ActiveScenario, phase: string) {
return Effect.sync(() => {
if (!options.trace) return
console.log(`[trace] ${scenario.name}: ${phase}`)
})
}
function projectOptions( function projectOptions(
project: ProjectOptions, project: ProjectOptions,
llmUrl: string | undefined, llmUrl: string | undefined,
@@ -22,8 +22,13 @@ export type JsonObject = Record<string, unknown>
export type Options = { export type Options = {
mode: Mode mode: Mode
include: string | undefined include: string | undefined
startAt: string | undefined
stopAt: string | undefined
failOnMissing: boolean failOnMissing: boolean
failOnSkip: boolean failOnSkip: boolean
scenarioTimeout: string
progress: boolean
trace: boolean
} }
export type RequestSpec = { export type RequestSpec = {
@@ -37,6 +42,7 @@ export type CallResult = {
contentType: string contentType: string
body: unknown body: unknown
text: string text: string
timedOut: boolean
} }
export type BackendApp = { export type BackendApp = {
@@ -75,6 +81,7 @@ export type ActiveScenario = {
project: ProjectOptions | undefined project: ProjectOptions | undefined
seed: (ctx: ScenarioContext) => Effect.Effect<unknown> seed: (ctx: ScenarioContext) => Effect.Effect<unknown>
request: (ctx: ScenarioContext, state: unknown) => RequestSpec request: (ctx: ScenarioContext, state: unknown) => RequestSpec
authProbe: RequestSpec | undefined
expect: (ctx: ScenarioContext, state: unknown, result: CallResult) => Effect.Effect<void> expect: (ctx: ScenarioContext, state: unknown, result: CallResult) => Effect.Effect<void>
compare: Comparison compare: Comparison
capture: CaptureMode capture: CaptureMode
@@ -90,6 +97,7 @@ export type BuilderState<S> = {
project: ProjectOptions | undefined project: ProjectOptions | undefined
seed: (ctx: ScenarioContext) => Effect.Effect<S> seed: (ctx: ScenarioContext) => Effect.Effect<S>
request: (ctx: SeededContext<S>) => RequestSpec request: (ctx: SeededContext<S>) => RequestSpec
authProbe: RequestSpec | undefined
capture: CaptureMode capture: CaptureMode
mutates: boolean mutates: boolean
reset: boolean reset: boolean