refactor(httpapi-exercise): migrate query-schema-drift scenarios into harness

Move the 8 standalone scenarios from
packages/opencode/test/server/httpapi-query-schema-drift.test.ts into
the route-coverage exerciser as `.viaSdk(...)` scenarios. Each one
exercises a routing-aware GET through the real SDK client wired to the
in-process router, so the SDK's auto-injected `?directory=...` /
`?workspace=...` query params hit the same typed schemas as in production.

Routes now covered by `.viaSdk(...)` scenarios in the exerciser:
  /session                          (session.list.via_sdk)
  /session/{sessionID}/message      (session.messages.via_sdk)
  /find/file                        (find.files.via_sdk)
  /find                             (find.text.via_sdk)
  /file/content                     (file.read.via_sdk)
  /experimental/session             (experimental.session.list.via_sdk)
  /experimental/tool                (experimental.tool.list.via_sdk)
  /vcs/diff                         (vcs.diff.via_sdk)

Standalone httpapi-query-schema-drift.test.ts deleted — its coverage
now lives in the harness alongside every other route test, single
source of truth.

149/149 scenarios pass after migration (139 existing + 2 from
proof-of-concept #26604 + 8 migrated here).
This commit is contained in:
Kit Langton
2026-05-09 20:27:14 -04:00
parent a4ead918f5
commit f4cb3170d4
3 changed files with 44 additions and 134 deletions

View File

@@ -0,0 +1,3 @@
{
"$schema": "https://opencode.ai/config.json"
}

View File

@@ -1260,6 +1260,47 @@ const scenarios: Scenario[] = [
.probe({ path: "/global/upgrade", body: { target: 1 } })
.at(() => ({ path: "/global/upgrade", body: { target: 1 } }))
.status(400),
// ─── SDK routing-params drift coverage (replaces httpapi-query-schema-drift.test.ts) ───
// Each routing-aware GET endpoint runs through the real SDK client, which
// auto-injects `?directory=...&workspace=...`. Pre-#26581 these all 4xx'd
// because the typed query schemas didn't accept the SDK's injected fields.
// Any future endpoint added under WorkspaceRoutingMiddleware that forgets
// WorkspaceRoutingQueryFields will fail its `.viaSdk(...)` scenario on
// first run.
http.protected
.get("/session", "session.list.via_sdk")
.viaSdk((sdk) => sdk.session.list({ roots: true }, { throwOnError: true }))
.json(200, array, "status"),
http.protected
.get("/session/{sessionID}/message", "session.messages.via_sdk")
.at((ctx) => ({ path: route("/session/{sessionID}/message", { sessionID: "ses_via_sdk_drift" }), headers: ctx.headers() }))
.viaSdk((sdk) => sdk.session.messages({ sessionID: "ses_via_sdk_drift", limit: 80 }))
.status(404, undefined, "status"),
http.protected
.get("/find/file", "find.files.via_sdk")
.viaSdk((sdk) => sdk.find.files({ query: "foo" }, { throwOnError: true }))
.json(200, array, "status"),
http.protected
.get("/find", "find.text.via_sdk")
.viaSdk((sdk) => sdk.find.text({ pattern: "foo" }, { throwOnError: true }))
.json(200, array, "status"),
http.protected
.get("/file/content", "file.read.via_sdk")
.viaSdk((sdk) => sdk.file.read({ path: "foo" }, { throwOnError: true }))
.json(200, undefined, "status"),
http.protected
.get("/experimental/session", "experimental.session.list.via_sdk")
.viaSdk((sdk) => sdk.experimental.session.list({}, { throwOnError: true }))
.json(200, array, "status"),
http.protected
.get("/experimental/tool", "experimental.tool.list.via_sdk")
.viaSdk((sdk) => sdk.tool.list({ provider: "anthropic", model: "claude" }, { throwOnError: true }))
.json(200, array, "status"),
http.protected
.get("/vcs/diff", "vcs.diff.via_sdk")
.viaSdk((sdk) => sdk.vcs.diff({ mode: "git" }, { throwOnError: true }))
.json(200, array, "status"),
]
const llmScenarios = new Set([

View File

@@ -1,134 +0,0 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Server } from "../../src/server/server"
import { SessionID } from "../../src/session/schema"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { it } from "../lib/effect"
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
function app() {
return Server.Default().app
}
function request(url: string, init?: RequestInit) {
return Effect.promise(async () => app().request(url, init))
}
function withTmp<A, E, R>(
options: Parameters<typeof tmpdir>[0],
fn: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>,
) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir(options)),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap(fn))
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
await disposeAllInstances()
await resetDatabase()
})
// Regression for the "OpenAPI advertises ?directory&workspace, runtime
// rejects them" drift class. Each affected route must accept both params
// without 400.
describe("httpapi query schema drift", () => {
const routingParams = (dir: string) =>
`directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent("ws_test")}`
const expectNotSchemaRejection = (status: number, url: string) => {
expect(status, `route ${url} 400'd, query schema is missing routing fields`).not.toBe(400)
}
it.live(
"session list accepts directory and workspace",
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const url = `/session?${routingParams(tmp.path)}`
const response = yield* request(url)
expectNotSchemaRejection(response.status, url)
}),
),
)
it.live(
"session messages accepts directory and workspace",
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const url = `/session/${SessionID.descending()}/message?limit=80&${routingParams(tmp.path)}`
const response = yield* request(url)
expectNotSchemaRejection(response.status, url)
}),
),
)
it.live(
"file find/file accepts directory and workspace",
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const url = `/find/file?query=foo&${routingParams(tmp.path)}`
const response = yield* request(url)
expectNotSchemaRejection(response.status, url)
}),
),
)
it.live(
"file find/text accepts directory and workspace",
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const url = `/find?pattern=foo&${routingParams(tmp.path)}`
const response = yield* request(url)
expectNotSchemaRejection(response.status, url)
}),
),
)
it.live(
"file read accepts directory and workspace",
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const url = `/file?path=foo&${routingParams(tmp.path)}`
const response = yield* request(url)
expectNotSchemaRejection(response.status, url)
}),
),
)
it.live(
"experimental session list accepts directory and workspace",
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const url = `/experimental/session?${routingParams(tmp.path)}`
const response = yield* request(url)
expectNotSchemaRejection(response.status, url)
}),
),
)
it.live(
"experimental tool list accepts directory and workspace",
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const url = `/experimental/tool?provider=anthropic&model=claude&${routingParams(tmp.path)}`
const response = yield* request(url)
expectNotSchemaRejection(response.status, url)
}),
),
)
it.live(
"vcs diff accepts directory and workspace",
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const url = `/vcs/diff?mode=working&${routingParams(tmp.path)}`
const response = yield* request(url)
expectNotSchemaRejection(response.status, url)
}),
),
)
})