test(server): harden HttpApi exercise coverage (#26425)

This commit is contained in:
Kit Langton
2026-05-08 20:50:01 -04:00
committed by GitHub
parent 21d055be19
commit 0745162eab
9 changed files with 451 additions and 174 deletions

View File

@@ -68,6 +68,11 @@ jobs:
env:
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
if: always()
uses: mikepenz/action-junit-report@v6

View File

@@ -9,6 +9,7 @@
"typecheck": "tsgo --noEmit",
"test": "bun test --timeout 30000",
"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",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"dev": "bun run --conditions=browser ./src/index.ts",

View File

@@ -23,13 +23,31 @@ export function call(
)
}
export function callAuthProbe(backend: Backend, scenario: ActiveScenario) {
return Effect.promise(async () =>
capture(
await app(await runtime(), backend, { auth: { password: "secret" } }).request(toAuthProbeRequest(scenario)),
scenario.capture,
),
)
export function callAuthProbe(
backend: Backend,
scenario: ActiveScenario,
credentials: "missing" | "valid" = "missing",
) {
return Effect.promise(async () => {
const controller = new AbortController()
return Promise.race([
Promise.resolve(
app(await runtime(), backend, { auth: { password: "secret" } }).request(
toAuthProbeRequest(scenario, credentials, controller.signal),
),
).then((response) => capture(response, scenario.capture)),
Bun.sleep(1_000).then(() => {
controller.abort("auth probe timed out")
return {
status: 0,
contentType: "",
text: "auth probe timed out",
body: undefined,
timedOut: true,
}
}),
])
})
}
const appCache: Partial<Record<string, BackendApp>> = {}
@@ -78,14 +96,28 @@ function toRequest(scenario: ActiveScenario, ctx: SeededContext<unknown>) {
})
}
function toAuthProbeRequest(scenario: ActiveScenario) {
return new Request(new URL(authProbePath(scenario.path), "http://localhost"), {
function toAuthProbeRequest(scenario: ActiveScenario, credentials: "missing" | "valid", signal: AbortSignal) {
const spec = scenario.authProbe ?? {
path: authProbePath(scenario.path),
body: scenario.method === "GET" ? undefined : {},
}
const headers = {
...(spec.body === undefined ? {} : { "content-type": "application/json" }),
...spec.headers,
...(credentials === "valid" ? { authorization: basic("opencode", "secret") } : {}),
}
return new Request(new URL(spec.path, "http://localhost"), {
method: scenario.method,
headers: scenario.method === "GET" ? undefined : { "content-type": "application/json" },
body: scenario.method === "GET" ? undefined : JSON.stringify({}),
headers,
body: spec.body === undefined ? undefined : JSON.stringify(spec.body),
signal,
})
}
function basic(username: string, password: string) {
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
}
function authProbePath(path: string) {
return path
.replace(/\{([^}]+)\}/g, (_match, key: string) => `auth_${key}`)
@@ -99,6 +131,7 @@ async function capture(response: Response, mode: CaptureMode): Promise<CallResul
contentType: response.headers.get("content-type") ?? "",
text,
body: parse(text),
timedOut: false,
}
}

View File

@@ -8,6 +8,7 @@ import type {
Comparison,
Method,
ProjectOptions,
RequestSpec,
ScenarioContext,
SeededContext,
TodoScenario,
@@ -16,7 +17,7 @@ import type {
class ScenarioBuilder<S = undefined> {
private readonly state: BuilderState<S>
constructor(method: Method, path: string, name: string) {
constructor(method: Method, path: string, name: string, auth: AuthPolicy) {
this.state = {
method,
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.
seed: () => Effect.succeed(undefined as S),
request: (ctx) => ({ path, headers: ctx.headers() }),
authProbe: undefined,
capture: "full",
mutates: false,
reset: true,
auth: "protected",
auth,
}
}
@@ -48,6 +50,10 @@ class ScenarioBuilder<S = undefined> {
return this.clone({ request })
}
probe(authProbe: RequestSpec) {
return this.clone({ authProbe })
}
mutating() {
return this.clone({ mutates: true })
}
@@ -124,7 +130,7 @@ class ScenarioBuilder<S = undefined> {
}
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)
return builder
}
@@ -134,7 +140,7 @@ class ScenarioBuilder<S = undefined> {
* for `.at(...)` and assertions, giving stateful route tests type-safe setup.
*/
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 })
return builder
}
@@ -151,6 +157,7 @@ class ScenarioBuilder<S = undefined> {
name: state.name,
project: state.project,
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 }),
// 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 = {
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),
protected: routes("protected"),
public: routes("public"),
publicBypass: routes("public-bypass"),
ticketBypass: routes("ticket-bypass"),
}
export const pending = (method: Method, path: string, name: string, reason: string): TodoScenario => ({

View File

@@ -11,7 +11,7 @@
* so this must never point at a developer's real session database.
*
* 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`.
* - `.at(...)` builds the request from that typed state.
* - `.json(...)` / `.jsonEffect(...)` assert response shape and optional side effects.
@@ -32,7 +32,7 @@ import {
exerciseGlobalRoot,
} from "./environment"
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 { runtime } from "./runtime"
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 })
const scenarios: Scenario[] = [
http
http.protected
.get("/global/health", "global.health")
.global()
.json(200, (body) => {
object(body)
check(body.healthy === true, "server should report healthy")
}),
http
http.protected
.get("/global/event", "global.event")
.global()
.stream()
@@ -60,8 +60,8 @@ const scenarios: Scenario[] = [
}),
"status",
),
http.get("/global/config", "global.config.get").global().json(),
http
http.protected.get("/global/config", "global.config.get").global().json(),
http.protected
.patch("/global/config", "global.config.update")
.global()
.seeded(() =>
@@ -86,7 +86,7 @@ const scenarios: Scenario[] = [
}),
"status",
),
http
http.protected
.post("/global/dispose", "global.dispose")
.global()
.mutating()
@@ -97,23 +97,37 @@ const scenarios: Scenario[] = [
},
"status",
),
http.get("/path", "path.get").json(200, (body, ctx) => {
http.protected.get("/path", "path.get").json(200, (body, ctx) => {
object(body)
check(body.directory === ctx.directory, "directory 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
http.protected.get("/vcs", "vcs.get").json(),
http.protected.get("/vcs/status", "vcs.status").json(200, array),
http.protected
.get("/vcs/diff", "vcs.diff")
.at((ctx) => ({ path: "/vcs/diff?mode=git", headers: ctx.headers() }))
.json(200, array),
http.get("/command", "command.list").json(200, array, "status"),
http.get("/agent", "app.agents").json(200, array, "status"),
http.get("/skill", "app.skills").json(200, array, "status"),
http.get("/lsp", "lsp.status").json(200, array),
http.get("/formatter", "formatter.status").json(200, array),
http.get("/config", "config.get").json(200, undefined, "status"),
http
http.protected.get("/vcs/diff/raw", "vcs.diff.raw").status(
200,
(_ctx, result) =>
Effect.sync(() => {
check(typeof result.text === "string", "raw VCS diff should return text")
}),
"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")
.mutating()
.at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: "httpapi-local" } }))
@@ -125,13 +139,13 @@ const scenarios: Scenario[] = [
},
"status",
),
http
http.protected
.patch("/config", "config.update.invalid")
.at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: 1 } }))
.status(400),
http.get("/config/providers", "config.providers").json(),
http.get("/project", "project.list").json(200, array, "status"),
http.get("/project/current", "project.current").json(
http.protected.get("/config/providers", "config.providers").json(),
http.protected.get("/project", "project.list").json(200, array, "status"),
http.protected.get("/project/current", "project.current").json(
200,
(body, ctx) => {
object(body)
@@ -139,7 +153,7 @@ const scenarios: Scenario[] = [
},
"status",
),
http
http.protected
.patch("/project/{projectID}", "project.update")
.mutating()
.seeded((ctx) => ctx.project())
@@ -160,7 +174,7 @@ const scenarios: Scenario[] = [
},
"status",
),
http
http.protected
.post("/project/git/init", "project.initGit")
.mutating()
.inProject({ git: false })
@@ -173,9 +187,9 @@ const scenarios: Scenario[] = [
},
"status",
),
http.get("/provider", "provider.list").json(),
http.get("/provider/auth", "provider.auth").json(),
http
http.protected.get("/provider", "provider.list").json(),
http.protected.get("/provider/auth", "provider.auth").json(),
http.protected
.post("/provider/{providerID}/oauth/authorize", "provider.oauth.authorize")
.at((ctx) => ({
path: route("/provider/{providerID}/oauth/authorize", { providerID: "httpapi" }),
@@ -183,7 +197,7 @@ const scenarios: Scenario[] = [
body: { method: "bad" },
}))
.status(400),
http
http.protected
.post("/provider/{providerID}/oauth/callback", "provider.oauth.callback")
.at((ctx) => ({
path: route("/provider/{providerID}/oauth/callback", { providerID: "httpapi" }),
@@ -191,8 +205,8 @@ const scenarios: Scenario[] = [
body: { method: "bad" },
}))
.status(400),
http.get("/permission", "permission.list").json(200, array),
http
http.protected.get("/permission", "permission.list").json(200, array),
http.protected
.post("/permission/{requestID}/reply", "permission.reply.invalid")
.at((ctx) => ({
path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }),
@@ -200,7 +214,7 @@ const scenarios: Scenario[] = [
body: { reply: "bad" },
}))
.status(400),
http
http.protected
.post("/permission/{requestID}/reply", "permission.reply")
.at((ctx) => ({
path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }),
@@ -210,8 +224,8 @@ const scenarios: Scenario[] = [
.json(200, (body) => {
check(body === true, "permission reply should return true even when request is no longer pending")
}),
http.get("/question", "question.list").json(200, array),
http
http.protected.get("/question", "question.list").json(200, array),
http.protected
.post("/question/{requestID}/reply", "question.reply.invalid")
.at((ctx) => ({
path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }),
@@ -219,7 +233,7 @@ const scenarios: Scenario[] = [
body: { answers: "Yes" },
}))
.status(400),
http
http.protected
.post("/question/{requestID}/reply", "question.reply")
.at((ctx) => ({
path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }),
@@ -229,7 +243,7 @@ const scenarios: Scenario[] = [
.json(200, (body) => {
check(body === true, "question reply should return true even when request is no longer pending")
}),
http
http.protected
.post("/question/{requestID}/reject", "question.reject")
.at((ctx) => ({
path: route("/question/{requestID}/reject", { requestID: "que_httpapi_reject" }),
@@ -238,12 +252,12 @@ const scenarios: Scenario[] = [
.json(200, (body) => {
check(body === true, "question reject should return true even when request is no longer pending")
}),
http
http.protected
.get("/file", "file.list")
.seeded((ctx) => ctx.file("hello.txt", "hello\n"))
.at((ctx) => ({ path: `/file?${new URLSearchParams({ path: "." })}`, headers: ctx.headers() }))
.json(200, array),
http
http.protected
.get("/file/content", "file.read")
.seeded((ctx) => ctx.file("hello.txt", "hello\n"))
.at((ctx) => ({ path: `/file/content?${new URLSearchParams({ path: "hello.txt" })}`, headers: ctx.headers() }))
@@ -251,20 +265,20 @@ const scenarios: Scenario[] = [
object(body)
check(body.content === "hello", `content should match seeded file: ${JSON.stringify(body)}`)
}),
http
http.protected
.get("/file/content", "file.read.missing")
.at((ctx) => ({ path: `/file/content?${new URLSearchParams({ path: "missing.txt" })}`, headers: ctx.headers() }))
.json(200, (body) => {
object(body)
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
http.protected.get("/file/status", "file.status").json(200, array),
http.protected
.get("/find", "find.text")
.seeded((ctx) => ctx.file("hello.txt", "hello\n"))
.at((ctx) => ({ path: `/find?${new URLSearchParams({ pattern: "hello" })}`, headers: ctx.headers() }))
.json(200, array),
http
http.protected
.get("/find/file", "find.files")
.seeded((ctx) => ctx.file("hello.txt", "hello\n"))
.at((ctx) => ({
@@ -272,12 +286,12 @@ const scenarios: Scenario[] = [
headers: ctx.headers(),
}))
.json(200, array),
http
http.protected
.get("/find/symbol", "find.symbols")
.seeded((ctx) => ctx.file("hello.ts", "export const hello = 1\n"))
.at((ctx) => ({ path: `/find/symbol?${new URLSearchParams({ query: "hello" })}`, headers: ctx.headers() }))
.json(200, array),
http
http.protected
.get("/event", "event.stream")
.stream()
.status(
@@ -289,8 +303,8 @@ const scenarios: Scenario[] = [
}),
"status",
),
http.get("/mcp", "mcp.status").json(),
http
http.protected.get("/mcp", "mcp.status").json(),
http.protected
.post("/mcp", "mcp.add")
.mutating()
.at((ctx) => ({
@@ -307,7 +321,7 @@ const scenarios: Scenario[] = [
},
"status",
),
http
http.protected
.post("/mcp", "mcp.add.invalid")
.at((ctx) => ({
path: "/mcp",
@@ -315,7 +329,7 @@ const scenarios: Scenario[] = [
body: { name: "httpapi-invalid", config: { type: "invalid" } },
}))
.status(400),
http
http.protected
.post("/mcp/{name}/auth", "mcp.auth.start")
.at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() }))
.json(
@@ -326,7 +340,7 @@ const scenarios: Scenario[] = [
},
"status",
),
http
http.protected
.delete("/mcp/{name}/auth", "mcp.auth.remove")
.mutating()
.at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() }))
@@ -334,7 +348,7 @@ const scenarios: Scenario[] = [
object(body)
check(body.success === true, "MCP auth removal should return success")
}),
http
http.protected
.post("/mcp/{name}/auth/authenticate", "mcp.auth.authenticate")
.at((ctx) => ({
path: route("/mcp/{name}/auth/authenticate", { name: "httpapi-missing" }),
@@ -348,7 +362,7 @@ const scenarios: Scenario[] = [
},
"status",
),
http
http.protected
.post("/mcp/{name}/auth/callback", "mcp.auth.callback")
.at((ctx) => ({
path: route("/mcp/{name}/auth/callback", { name: "httpapi-missing" }),
@@ -356,23 +370,23 @@ const scenarios: Scenario[] = [
body: { code: 1 },
}))
.status(400),
http
http.protected
.post("/mcp/{name}/connect", "mcp.connect")
.mutating()
.at((ctx) => ({ path: route("/mcp/{name}/connect", { name: "httpapi-missing" }), headers: ctx.headers() }))
.json(200, (body) => {
check(body === true, "missing MCP connect should remain a no-op success")
}),
http
http.protected
.post("/mcp/{name}/disconnect", "mcp.disconnect")
.mutating()
.at((ctx) => ({ path: route("/mcp/{name}/disconnect", { name: "httpapi-missing" }), headers: ctx.headers() }))
.json(200, (body) => {
check(body === true, "missing MCP disconnect should remain a no-op success")
}),
http.get("/pty/shells", "pty.shells").json(200, array),
http.get("/pty", "pty.list").json(200, array),
http
http.protected.get("/pty/shells", "pty.shells").json(200, array),
http.protected.get("/pty", "pty.list").json(200, array),
http.protected
.post("/pty", "pty.create")
.mutating()
.at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: controlledPtyInput("HTTP API PTY") }))
@@ -386,15 +400,22 @@ const scenarios: Scenario[] = [
},
"status",
),
http
http.protected
.post("/pty", "pty.create.invalid")
.at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: { command: 1 } }))
.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")
.at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() }))
.status(404),
http
http.protected
.put("/pty/{ptyID}", "pty.update")
.mutating()
.at((ctx) => ({
@@ -403,20 +424,20 @@ const scenarios: Scenario[] = [
body: { size: { rows: 0, cols: 0 } },
}))
.status(400),
http
http.protected
.delete("/pty/{ptyID}", "pty.remove")
.mutating()
.at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() }))
.json(200, (body) => {
check(body === true, "PTY remove should return true")
}),
http
http.protected
.get("/pty/{ptyID}/connect", "pty.connect")
.at((ctx) => ({ path: route("/pty/{ptyID}/connect", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() }))
.status(404, undefined, "none"),
http.get("/experimental/console", "experimental.console.get").json(),
http.get("/experimental/console/orgs", "experimental.console.listOrgs").json(),
http
http.protected.get("/experimental/console", "experimental.console.get").json(),
http.protected.get("/experimental/console/orgs", "experimental.console.listOrgs").json(),
http.protected
.post("/experimental/console/switch", "experimental.console.switchOrg")
.at((ctx) => ({
path: "/experimental/console/switch",
@@ -424,14 +445,17 @@ const scenarios: Scenario[] = [
body: { accountID: "httpapi-account", orgID: "httpapi-org" },
}))
.status(400, undefined, "none"),
http.get("/experimental/workspace/adapter", "experimental.workspace.adapter.list").json(200, array),
http.get("/experimental/workspace", "experimental.workspace.list").json(200, array),
http.get("/experimental/workspace/status", "experimental.workspace.status").json(200, array),
http
http.protected.get("/experimental/workspace/adapter", "experimental.workspace.adapter.list").json(200, array),
http.protected.get("/experimental/workspace", "experimental.workspace.list").json(200, array),
http.protected.get("/experimental/workspace/status", "experimental.workspace.status").json(200, array),
http.protected
.post("/experimental/workspace", "experimental.workspace.create")
.at((ctx) => ({ path: "/experimental/workspace", headers: ctx.headers(), body: {} }))
.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")
.mutating()
.at((ctx) => ({
@@ -439,7 +463,7 @@ const scenarios: Scenario[] = [
headers: ctx.headers(),
}))
.status(200),
http
http.protected
.post("/experimental/workspace/warp", "experimental.workspace.warp")
.at((ctx) => ({
path: "/experimental/workspace/warp",
@@ -447,16 +471,16 @@ const scenarios: Scenario[] = [
body: {},
}))
.status(400),
http
http.protected
.get("/experimental/tool", "tool.list")
.at((ctx) => ({
path: `/experimental/tool?${new URLSearchParams({ provider: "opencode", model: "test" })}`,
headers: ctx.headers(),
}))
.json(200, array, "status"),
http.get("/experimental/tool/ids", "tool.ids").json(200, array),
http.get("/experimental/worktree", "worktree.list").json(200, array),
http
http.protected.get("/experimental/tool/ids", "tool.ids").json(200, array),
http.protected.get("/experimental/worktree", "worktree.list").json(200, array),
http.protected
.post("/experimental/worktree", "worktree.create")
.mutating()
.at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: "api-dsl" } }))
@@ -470,11 +494,11 @@ const scenarios: Scenario[] = [
}),
"status",
),
http
http.protected
.post("/experimental/worktree", "worktree.create.invalid")
.at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: 1 } }))
.status(400),
http
http.protected
.delete("/experimental/worktree", "worktree.remove")
.mutating()
.seeded((ctx) => ctx.worktree({ name: "api-remove" }))
@@ -482,7 +506,7 @@ const scenarios: Scenario[] = [
.json(200, (body) => {
check(body === true, "worktree remove should return true")
}),
http
http.protected
.post("/experimental/worktree/reset", "worktree.reset")
.mutating()
.seeded((ctx) => ctx.worktree({ name: "api-reset" }))
@@ -497,37 +521,41 @@ const scenarios: Scenario[] = [
yield* ctx.worktreeRemove(ctx.state.directory)
}),
),
http.get("/experimental/session", "experimental.session.list").json(200, array),
http.get("/experimental/resource", "experimental.resource.list").json(),
http
http.protected.get("/experimental/session", "experimental.session.list").json(200, array),
http.protected.get("/experimental/resource", "experimental.resource.list").json(),
http.protected
.post("/sync/history", "sync.history.list")
.at((ctx) => ({ path: "/sync/history", headers: ctx.headers(), body: {} }))
.json(200, array),
http
http.protected
.post("/sync/replay", "sync.replay")
.at((ctx) => ({ path: "/sync/replay", headers: ctx.headers(), body: { directory: ctx.directory, events: [] } }))
.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")
.mutating()
.preserveDatabase()
.json(200, (body) => {
check(body === true, "sync start should return true when no workspace sessions exist")
}),
http
http.protected
.post("/instance/dispose", "instance.dispose")
.mutating()
.json(200, (body) => {
check(body === true, "instance dispose should return true")
}),
http
http.protected
.post("/log", "app.log")
.global()
.at(() => ({ path: "/log", body: { service: "httpapi-exercise", level: "info", message: "route coverage" } }))
.json(200, (body) => {
check(body === true, "log route should return true")
}),
http
http.protected
.put("/auth/{providerID}", "auth.set")
.global()
.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")
}),
),
http
http.protected
.delete("/auth/{providerID}", "auth.remove")
.global()
.seeded(() =>
@@ -559,7 +587,63 @@ const scenarios: Scenario[] = [
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")
.seeded((ctx) => ctx.session({ title: "List me" }))
.at((ctx) => ({ path: "/session?roots=true", headers: ctx.headers() }))
@@ -570,11 +654,11 @@ const scenarios: Scenario[] = [
"seeded session should be listed",
)
}),
http
http.protected
.get("/session/status", "session.status")
.seeded((ctx) => ctx.session({ title: "Status session" }))
.json(200, object),
http
http.protected
.post("/session", "session.create")
.mutating()
.at((ctx) => ({ path: "/session", headers: ctx.headers(), body: { title: "Created session" } }))
@@ -587,7 +671,7 @@ const scenarios: Scenario[] = [
},
"status",
),
http
http.protected
.get("/session/{sessionID}", "session.get")
.seeded((ctx) => ctx.session({ title: "Get me" }))
.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.title === "Get me", "should preserve seeded title")
}),
http
http.protected
.get("/session/{sessionID}", "session.get.missing")
.at((ctx) => ({
path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }),
headers: ctx.headers(),
}))
.status(404),
http
http.protected
.patch("/session/{sessionID}", "session.update")
.mutating()
.seeded((ctx) => ctx.session({ title: "Before rename" }))
@@ -620,7 +704,7 @@ const scenarios: Scenario[] = [
},
"status",
),
http
http.protected
.patch("/session/{sessionID}", "session.update.invalid")
.mutating()
.at((ctx) => ({
@@ -629,7 +713,7 @@ const scenarios: Scenario[] = [
body: { title: 1 },
}))
.status(400),
http
http.protected
.delete("/session/{sessionID}", "session.delete")
.mutating()
.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")
}),
),
http
http.protected
.get("/session/{sessionID}/children", "session.children")
.seeded((ctx) =>
Effect.gen(function* () {
@@ -660,7 +744,7 @@ const scenarios: Scenario[] = [
"children should include seeded child",
)
}),
http
http.protected
.get("/session/{sessionID}/todo", "session.todo")
.seeded((ctx) =>
Effect.gen(function* () {
@@ -677,12 +761,12 @@ const scenarios: Scenario[] = [
.json(200, (body, ctx) => {
check(stable(body) === stable(ctx.state.todos), "todos should match seeded state")
}),
http
http.protected
.get("/session/{sessionID}/diff", "session.diff")
.seeded((ctx) => ctx.session({ title: "Diff session" }))
.at((ctx) => ({ path: route("/session/{sessionID}/diff", { sessionID: ctx.state.id }), headers: ctx.headers() }))
.json(200, array),
http
http.protected
.get("/session/{sessionID}/message", "session.messages")
.seeded((ctx) => ctx.session({ title: "Messages session" }))
.at((ctx) => ({ path: route("/session/{sessionID}/message", { sessionID: ctx.state.id }), headers: ctx.headers() }))
@@ -690,7 +774,7 @@ const scenarios: Scenario[] = [
array(body)
check(body.length === 0, "new session should have no messages")
}),
http
http.protected
.get("/session/{sessionID}/message/{messageID}", "session.message")
.seeded((ctx) =>
Effect.gen(function* () {
@@ -714,7 +798,7 @@ const scenarios: Scenario[] = [
"message should include seeded part",
)
}),
http
http.protected
.patch("/session/{sessionID}/message/{messageID}/part/{partID}", "part.update")
.mutating()
.seeded((ctx) =>
@@ -741,7 +825,7 @@ const scenarios: Scenario[] = [
},
"status",
),
http
http.protected
.delete("/session/{sessionID}/message/{messageID}/part/{partID}", "part.delete")
.mutating()
.seeded((ctx) =>
@@ -766,7 +850,7 @@ const scenarios: Scenario[] = [
check(messages[0]?.parts.length === 0, "deleted part should not remain on message")
}),
),
http
http.protected
.delete("/session/{sessionID}/message/{messageID}", "session.deleteMessage")
.mutating()
.seeded((ctx) =>
@@ -789,7 +873,7 @@ const scenarios: Scenario[] = [
check((yield* ctx.messages(ctx.state.session.id)).length === 0, "deleted message should not remain")
}),
),
http
http.protected
.post("/session/{sessionID}/fork", "session.fork")
.mutating()
.seeded((ctx) => ctx.session({ title: "Fork source" }))
@@ -806,7 +890,7 @@ const scenarios: Scenario[] = [
},
"status",
),
http
http.protected
.post("/session/{sessionID}/abort", "session.abort")
.mutating()
.seeded((ctx) => ctx.session({ title: "Abort session" }))
@@ -814,7 +898,7 @@ const scenarios: Scenario[] = [
.json(200, (body) => {
check(body === true, "abort should return true")
}),
http
http.protected
.post("/session/{sessionID}/abort", "session.abort.missing")
.at((ctx) => ({
path: route("/session/{sessionID}/abort", { sessionID: "ses_httpapi_missing" }),
@@ -823,7 +907,7 @@ const scenarios: Scenario[] = [
.json(200, (body) => {
check(body === true, "missing session abort should remain a no-op success")
}),
http
http.protected
.post("/session/{sessionID}/init", "session.init")
.preserveDatabase()
.withLlm()
@@ -847,7 +931,7 @@ const scenarios: Scenario[] = [
yield* ctx.llmWait(1)
}),
),
http
http.protected
.post("/session/{sessionID}/message", "session.prompt")
.preserveDatabase()
.withLlm()
@@ -882,7 +966,7 @@ const scenarios: Scenario[] = [
}),
"status",
),
http
http.protected
.post("/session/{sessionID}/prompt_async", "session.prompt_async")
.preserveDatabase()
.withLlm()
@@ -908,7 +992,7 @@ const scenarios: Scenario[] = [
yield* ctx.llmWait(1)
}),
),
http
http.protected
.post("/session/{sessionID}/command", "session.command")
.preserveDatabase()
.withLlm()
@@ -935,7 +1019,7 @@ const scenarios: Scenario[] = [
}),
"status",
),
http
http.protected
.post("/session/{sessionID}/shell", "session.shell")
.preserveDatabase()
.mutating()
@@ -957,7 +1041,7 @@ const scenarios: Scenario[] = [
},
"status",
),
http
http.protected
.post("/session/{sessionID}/summarize", "session.summarize")
.preserveDatabase()
.withLlm()
@@ -1018,7 +1102,7 @@ const scenarios: Scenario[] = [
}),
"status",
),
http
http.protected
.post("/session/{sessionID}/revert", "session.revert")
.mutating()
.seeded((ctx) =>
@@ -1045,7 +1129,7 @@ const scenarios: Scenario[] = [
},
"status",
),
http
http.protected
.post("/session/{sessionID}/unrevert", "session.unrevert")
.mutating()
.seeded((ctx) => ctx.session({ title: "Unrevert session" }))
@@ -1061,7 +1145,7 @@ const scenarios: Scenario[] = [
},
"status",
),
http
http.protected
.post("/session/{sessionID}/permissions/{permissionID}", "permission.respond")
.seeded((ctx) => ctx.session({ title: "Deprecated permission session" }))
.at((ctx) => ({
@@ -1075,7 +1159,7 @@ const scenarios: Scenario[] = [
.json(200, (body) => {
check(body === true, "deprecated permission response should return true")
}),
http
http.protected
.post("/session/{sessionID}/share", "session.share")
.mutating()
.seeded((ctx) => ctx.session({ title: "Share session" }))
@@ -1088,7 +1172,7 @@ const scenarios: Scenario[] = [
},
"status",
),
http
http.protected
.delete("/session/{sessionID}/share", "session.unshare")
.mutating()
.seeded((ctx) => ctx.session({ title: "Unshare session" }))
@@ -1101,25 +1185,25 @@ const scenarios: Scenario[] = [
},
"status",
),
http
http.protected
.post("/tui/append-prompt", "tui.appendPrompt")
.at((ctx) => ({ path: "/tui/append-prompt", headers: ctx.headers(), body: { text: "hello" } }))
.json(200, boolean, "status"),
http
http.protected
.post("/tui/select-session", "tui.selectSession.invalid")
.at((ctx) => ({ path: "/tui/select-session", headers: ctx.headers(), body: { sessionID: "invalid" } }))
.status(400),
http.post("/tui/open-help", "tui.openHelp").json(200, boolean, "status"),
http.post("/tui/open-sessions", "tui.openSessions").json(200, boolean, "status"),
http.post("/tui/open-themes", "tui.openThemes").json(200, boolean, "status"),
http.post("/tui/open-models", "tui.openModels").json(200, boolean, "status"),
http.post("/tui/submit-prompt", "tui.submitPrompt").json(200, boolean, "status"),
http.post("/tui/clear-prompt", "tui.clearPrompt").json(200, boolean, "status"),
http
http.protected.post("/tui/open-help", "tui.openHelp").json(200, boolean, "status"),
http.protected.post("/tui/open-sessions", "tui.openSessions").json(200, boolean, "status"),
http.protected.post("/tui/open-themes", "tui.openThemes").json(200, boolean, "status"),
http.protected.post("/tui/open-models", "tui.openModels").json(200, boolean, "status"),
http.protected.post("/tui/submit-prompt", "tui.submitPrompt").json(200, boolean, "status"),
http.protected.post("/tui/clear-prompt", "tui.clearPrompt").json(200, boolean, "status"),
http.protected
.post("/tui/execute-command", "tui.executeCommand")
.at((ctx) => ({ path: "/tui/execute-command", headers: ctx.headers(), body: { command: "agent_cycle" } }))
.json(200, boolean, "status"),
http
http.protected
.post("/tui/show-toast", "tui.showToast")
.at((ctx) => ({
path: "/tui/show-toast",
@@ -1127,7 +1211,7 @@ const scenarios: Scenario[] = [
body: { title: "Exercise", message: "covered", variant: "info", duration: 1000 },
}))
.json(200, boolean, "status"),
http
http.protected
.post("/tui/publish", "tui.publish")
.at((ctx) => ({
path: "/tui/publish",
@@ -1135,16 +1219,16 @@ const scenarios: Scenario[] = [
body: { type: "tui.prompt.append", properties: { text: "published" } },
}))
.json(200, boolean, "status"),
http
http.protected
.post("/tui/select-session", "tui.selectSession")
.seeded((ctx) => ctx.session({ title: "TUI select" }))
.at((ctx) => ({ path: "/tui/select-session", headers: ctx.headers(), body: { sessionID: ctx.state.id } }))
.json(200, boolean, "status"),
http
http.protected
.post("/tui/control/response", "tui.control.response")
.at((ctx) => ({ path: "/tui/control/response", headers: ctx.headers(), body: { ok: true } }))
.json(200, boolean, "status"),
http
http.protected
.get("/tui/control/next", "tui.control.next")
.mutating()
.seeded((ctx) => ctx.tuiRequest({ path: "/tui/exercise", body: { text: "queued" } }))
@@ -1158,23 +1242,38 @@ const scenarios: Scenario[] = [
},
"status",
),
http
http.protected
.post("/global/upgrade", "global.upgrade")
.global()
.probe({ path: "/global/upgrade", body: { target: 1 } })
.at(() => ({ path: "/global/upgrade", body: { target: 1 } }))
.status(400),
]
const llmScenarios = new Set([
"session.init",
"session.prompt",
"session.prompt_async",
"session.command",
"session.summarize",
])
const main = Effect.gen(function* () {
yield* Effect.addFinalizer(() => cleanupExercisePaths)
const options = parseOptions(Bun.argv.slice(2))
const modules = yield* Effect.promise(() => runtime())
const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi))
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 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, {
database: exerciseDatabasePath,
global: exerciseGlobalRoot,
@@ -1183,7 +1282,15 @@ const main = Effect.gen(function* () {
const results =
options.mode === "coverage"
? 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)
if (results.some((result) => result.status === "fail"))

View File

@@ -1,3 +1,4 @@
import { Duration } from "effect"
import { indent, pad } from "./assertions"
import type { Options, Result, Scenario } from "./types"
@@ -23,7 +24,7 @@ export function printHeader(
console.log(`${color.dim}db=${paths.database}${color.reset}`)
console.log(`${color.dim}global=${paths.global}${color.reset}`)
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=${Duration.format(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("")
}

View File

@@ -1,5 +1,27 @@
import { Duration } from "effect"
import { OpenApiMethods, type OpenApiSpec, type Options, type Result, type Scenario } from "./types"
type ScenarioTimeout = `${number} ${Duration.Unit}`
const durationUnits = new Set<string>([
"nano",
"nanos",
"micro",
"micros",
"milli",
"millis",
"second",
"seconds",
"minute",
"minutes",
"hour",
"hours",
"day",
"days",
"week",
"weeks",
])
export function routeKeys(spec: OpenApiSpec) {
return Object.entries(spec.paths ?? {})
.flatMap(([path, item]) =>
@@ -24,8 +46,13 @@ export function parseOptions(args: string[]): Options {
return {
mode,
include: option(args, "--include"),
startAt: option(args, "--start-at"),
stopAt: option(args, "--stop-at"),
failOnMissing: args.includes("--fail-on-missing"),
failOnSkip: args.includes("--fail-on-skip"),
scenarioTimeout: parseScenarioTimeout(option(args, "--scenario-timeout") ?? "30 seconds"),
progress: args.includes("--progress"),
trace: args.includes("--trace"),
}
}
@@ -38,8 +65,33 @@ 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) {
const index = args.indexOf(name)
if (index === -1) return undefined
return args[index + 1]
}
function parseScenarioTimeout(input: string) {
if (!isScenarioTimeout(input)) throw new Error(`invalid --scenario-timeout ${input}`)
return Duration.fromInputUnsafe(input)
}
function isScenarioTimeout(input: string): input is ScenarioTimeout {
const [amount, unit, extra] = input.trim().split(/\s+/)
return extra === undefined && amount !== undefined && Number.isFinite(Number(amount)) && durationUnits.has(unit ?? "")
}

View File

@@ -1,5 +1,5 @@
import { Flag } from "@opencode-ai/core/flag/flag"
import { Cause, Effect } from "effect"
import { Cause, Duration, Effect } from "effect"
import { TestLLMServer } from "../../lib/llm-server"
import type { Config } from "../../../src/config/config"
import { ModelID, ProviderID } from "../../../src/provider/schema"
@@ -24,6 +24,10 @@ export function runScenario(options: Options) {
return (scenario: Scenario) => {
if (scenario.kind === "todo") return Effect.succeed({ status: "skip", scenario } as Result)
return runActive(options, scenario).pipe(
Effect.timeoutOrElse({
duration: options.scenarioTimeout,
orElse: () => Effect.die(new Error(`scenario timed out after ${Duration.format(options.scenarioTimeout)}`)),
}),
Effect.as({ status: "pass", scenario } as Result),
Effect.catchCause((cause) => Effect.succeed({ status: "fail" as const, scenario, message: Cause.pretty(cause) })),
Effect.scoped,
@@ -36,20 +40,32 @@ function runActive(options: Options, scenario: ActiveScenario) {
if (options.mode === "parity" && scenario.mutates && scenario.compare !== "none") {
return Effect.gen(function* () {
const effect = yield* runBackend("effect", scenario)
const legacy = yield* runBackend("legacy", scenario)
const effect = yield* runBackend(options, "effect", scenario)
const legacy = yield* runBackend(options, "legacy", scenario)
yield* trace(options, scenario, "compare start")
yield* compare(scenario, effect, legacy)
yield* trace(options, scenario, "compare done")
})
}
return withContext(scenario, (ctx) =>
return withContext(options, scenario, "shared", (ctx) =>
Effect.gen(function* () {
yield* trace(options, scenario, "effect request start")
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* trace(options, scenario, "effect expect done")
if (options.mode === "parity" && scenario.compare !== "none") {
yield* trace(options, scenario, "legacy request start")
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* trace(options, scenario, "legacy expect done")
yield* trace(options, scenario, "compare start")
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) {
return Effect.gen(function* () {
const effect = yield* callAuthProbe("effect", scenario)
const legacy = yield* callAuthProbe("legacy", scenario)
const effect = yield* callAuthProbe("effect", scenario, "missing")
const legacy = yield* callAuthProbe("legacy", scenario, "missing")
if (scenario.auth === "protected") {
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}`)
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
}
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 (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) {
return withContext(scenario, (ctx) =>
function runBackend(options: Options, backend: "effect" | "legacy", scenario: ActiveScenario) {
return withContext(options, scenario, backend, (ctx) =>
Effect.gen(function* () {
yield* trace(options, scenario, `${backend} request start`)
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* trace(options, scenario, `${backend} expect done`)
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(
Effect.gen(function* () {
yield* trace(options, scenario, `${label} context acquire start`)
const llm = scenario.project?.llm ? yield* TestLLMServer : undefined
const project = scenario.project
const dir = project
? yield* Effect.promise(async () => (await runtime()).tmpdir(projectOptions(project, llm?.url)))
: undefined
yield* trace(options, scenario, `${label} context acquire done`)
return { dir, llm }
}),
(ctx) =>
Effect.promise(async () => {
await ctx.dir?.[Symbol.asyncDispose]()
}).pipe(Effect.ignore),
Effect.gen(function* () {
yield* trace(options, scenario, `${label} tmpdir cleanup start`)
yield* Effect.promise(async () => {
await ctx.dir?.[Symbol.asyncDispose]()
}).pipe(Effect.ignore)
yield* trace(options, scenario, `${label} tmpdir cleanup done`)
}),
).pipe(
Effect.flatMap((context) =>
Effect.gen(function* () {
yield* trace(options, scenario, `${label} runtime start`)
const modules = yield* Effect.promise(() => runtime())
yield* trace(options, scenario, `${label} runtime done`)
const path = context.dir?.path
const instance = path
? yield* modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe(
Effect.provide(modules.AppLayer),
Effect.catchCause((cause) =>
Effect.sleep("100 millis").pipe(
Effect.andThen(
modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe(
Effect.provide(modules.AppLayer),
? yield* trace(options, scenario, `${label} instance load start`).pipe(
Effect.andThen(
modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe(
Effect.provide(modules.AppLayer),
Effect.catchCause((cause) =>
Effect.sleep("100 millis").pipe(
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
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)),
tuiRequest: (request) => Effect.sync(() => modules.Tui.submitTuiRequest(request)),
}
yield* trace(options, scenario, `${label} seed start`)
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)),
),
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(
project: ProjectOptions,
llmUrl: string | undefined,

View File

@@ -1,4 +1,4 @@
import type { Effect } from "effect"
import type { Duration, Effect } from "effect"
import type { Config } from "../../../src/config/config"
import type { Project } from "../../../src/project/project"
import type { Worktree } from "../../../src/worktree"
@@ -22,8 +22,13 @@ export type JsonObject = Record<string, unknown>
export type Options = {
mode: Mode
include: string | undefined
startAt: string | undefined
stopAt: string | undefined
failOnMissing: boolean
failOnSkip: boolean
scenarioTimeout: Duration.Duration
progress: boolean
trace: boolean
}
export type RequestSpec = {
@@ -37,6 +42,7 @@ export type CallResult = {
contentType: string
body: unknown
text: string
timedOut: boolean
}
export type BackendApp = {
@@ -75,6 +81,7 @@ export type ActiveScenario = {
project: ProjectOptions | undefined
seed: (ctx: ScenarioContext) => Effect.Effect<unknown>
request: (ctx: ScenarioContext, state: unknown) => RequestSpec
authProbe: RequestSpec | undefined
expect: (ctx: ScenarioContext, state: unknown, result: CallResult) => Effect.Effect<void>
compare: Comparison
capture: CaptureMode
@@ -90,6 +97,7 @@ export type BuilderState<S> = {
project: ProjectOptions | undefined
seed: (ctx: ScenarioContext) => Effect.Effect<S>
request: (ctx: SeededContext<S>) => RequestSpec
authProbe: RequestSpec | undefined
capture: CaptureMode
mutates: boolean
reset: boolean