diff --git a/packages/opencode/specs/property-based-tui-testing.md b/packages/opencode/specs/property-based-tui-testing.md index a728b403b7b..c089778a4e2 100644 --- a/packages/opencode/specs/property-based-tui-testing.md +++ b/packages/opencode/specs/property-based-tui-testing.md @@ -196,6 +196,8 @@ Implementation shape: - Add simulation control state under `packages/opencode/src/testing/simulation/service.ts`. - Add HTTP routes under a simulation-gated path like `/experimental/simulation/*`. - Keep the route inaccessible unless simulation mode is explicitly enabled. +- First pass uses a raw route wrapper at `packages/opencode/src/server/routes/instance/httpapi/simulation.ts` to avoid SDK regeneration while the API shape is still moving. +- Current control service can reset state, seed filesystem files, register static network responses, and return a snapshot. - Register/configure a local mock provider/model through the normal provider path. - The mock model reads scripts from simulation control state. - No JSON-in-prompt fallback. @@ -230,10 +232,10 @@ Keep the old useful rule: step `0` runs before tool results, step `N` runs after Todos: -- [ ] Define simulation mode activation flag/env. -- [ ] Add simulation control state and reset semantics. -- [ ] Add gated simulation endpoints. -- [ ] Decide raw route vs typed HttpApi route. If typed, regenerate JS SDK. +- [x] Define simulation mode activation flag/env. +- [x] Add simulation control state and reset semantics. +- [x] Add gated simulation endpoints for reset, filesystem seed, network register, and snapshot. +- [x] Decide raw route vs typed HttpApi route. Raw route for first pass; no SDK regeneration yet. - [ ] Implement mock provider/model on the normal provider path. - [ ] Port the useful stream chunk behavior from the old branch to the current AI SDK interface. - [ ] Make missing scripts fail with a typed simulation error. diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 29b8be82d68..d23f720a2ce 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -64,6 +64,7 @@ import { Workspace } from "@/control-plane/workspace" import { SimulationFileSystem } from "@/testing/simulation/filesystem" import { SimulationNetwork } from "@/testing/simulation/network" import { SimulationNetworkRoutes } from "@/testing/simulation/network-routes" +import { Simulation } from "@/testing/simulation/service" import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors" import { serveUIEffect } from "@/server/shared/ui" import { ServerAuth } from "@/server/auth" @@ -92,6 +93,7 @@ import { workspaceHandlers } from "./handlers/workspace" import { instanceContextLayer, instanceRouterMiddleware } from "./middleware/instance-context" import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/workspace-routing" import { disposeMiddleware } from "./lifecycle" +import { simulationRoute } from "./simulation" import { memoMap } from "@opencode-ai/core/effect/memo-map" import { compressionLayer } from "./middleware/compression" import { corsVaryFix } from "./middleware/cors-vary" @@ -305,6 +307,7 @@ export function createSimulatedRoutes(corsOptions?: CorsOptions): ReturnType(effect: Effect.Effect) { + return effect.pipe( + Effect.map((result) => HttpServerResponse.jsonUnsafe(result)), + Effect.catch((error) => Effect.succeed(HttpServerResponse.jsonUnsafe({ error: String(error) }, { status: 400 }))), + ) +} + +export const simulationRoute = HttpRouter.use((router) => + Effect.gen(function* () { + const simulation = yield* Simulation.Service + + yield* router.add("POST", "/experimental/simulation/reset", () => + json(simulation.reset().pipe(Effect.as(ok))), + ) + + yield* router.add("POST", "/experimental/simulation/filesystem/seed", () => + json( + Effect.gen(function* () { + const input = yield* HttpServerRequest.schemaBodyJson(Simulation.FilesystemSeedInput) + return yield* simulation.seedFilesystem(input) + }), + ), + ) + + yield* router.add("POST", "/experimental/simulation/network/register", () => + json( + Effect.gen(function* () { + const input = yield* HttpServerRequest.schemaBodyJson(Simulation.NetworkRegisterInput) + return yield* simulation.registerNetwork(input) + }), + ), + ) + + yield* router.add("POST", "/experimental/simulation/llm/enqueue", () => + json(Effect.succeed({ ok: false, skipped: "mock provider scripts are not implemented yet" })), + ) + + yield* router.add("GET", "/experimental/simulation/snapshot", () => json(simulation.snapshot())) + }), +) + +export * as SimulationHttpRoute from "./simulation" diff --git a/packages/opencode/src/testing/simulation/network.ts b/packages/opencode/src/testing/simulation/network.ts index 2e04e314e03..c75d3d89023 100644 --- a/packages/opencode/src/testing/simulation/network.ts +++ b/packages/opencode/src/testing/simulation/network.ts @@ -71,10 +71,20 @@ export interface Options { } interface State { + readonly initialEntries: readonly ResponseEntry[] readonly entries: readonly ResponseEntry[] readonly allowLoopback: boolean } +export interface Snapshot { + readonly allowLoopback: boolean + readonly routes: ReadonlyArray<{ + readonly kind: ResponseEntry["kind"] + readonly matcher: string + readonly method?: string | readonly string[] + }> +} + export class SimulationNetworkError extends Schema.TaggedErrorClass()( "SimulationNetworkError", { @@ -86,6 +96,8 @@ export class SimulationNetworkError extends Schema.TaggedErrorClass Effect.Effect + readonly reset: () => Effect.Effect + readonly snapshot: () => Effect.Effect readonly handle: (request: RequestInfo) => Effect.Effect } @@ -108,6 +120,12 @@ function matches(matcher: Matcher, request: RequestInfo) { return matchesUrl(matcher.url, request) } +function matcherLabel(matcher: Matcher) { + if (typeof matcher.url === "string") return matcher.url + if (matcher.url instanceof RegExp) return matcher.url.source + return "" +} + function isLoopback(url: URL) { return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" } @@ -239,6 +257,7 @@ function toHttpClientError(request: Parameters({ + initialEntries: options.entries ?? [], entries: options.entries ?? [], allowLoopback: options.allowLoopback ?? true, }) @@ -247,6 +266,22 @@ export function make(options: Options = {}) { yield* Ref.update(state, (current) => ({ ...current, entries: [...current.entries, entry] })) }) + const reset = Effect.fn("SimulationNetwork.reset")(function* () { + yield* Ref.update(state, (current) => ({ ...current, entries: current.initialEntries })) + }) + + const snapshot = Effect.fn("SimulationNetwork.snapshot")(function* () { + const current = yield* Ref.get(state) + return { + allowLoopback: current.allowLoopback, + routes: current.entries.map((entry) => ({ + kind: entry.kind, + matcher: matcherLabel(entry.matcher), + ...(entry.matcher.method === undefined ? {} : { method: entry.matcher.method }), + })), + } satisfies Snapshot + }) + const handle = Effect.fn("SimulationNetwork.handle")(function* (request: RequestInfo) { const current = yield* Ref.get(state) const entry = current.entries.find((entry) => matches(entry.matcher, request)) @@ -261,7 +296,7 @@ export function make(options: Options = {}) { }) }) - return Service.of({ register, handle }) + return Service.of({ register, reset, snapshot, handle }) }) } diff --git a/packages/opencode/src/testing/simulation/service.ts b/packages/opencode/src/testing/simulation/service.ts new file mode 100644 index 00000000000..dff1e0bb20d --- /dev/null +++ b/packages/opencode/src/testing/simulation/service.ts @@ -0,0 +1,124 @@ +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Context, Effect, Layer, Ref, Schema } from "effect" +import path from "path" +import { SimulationNetwork } from "./network" + +export const FileContent = Schema.Union([ + Schema.String, + Schema.Struct({ encoding: Schema.Literal("base64"), data: Schema.String }), +]) + +export const FilesystemSeedInput = Schema.Struct({ + files: Schema.Record(Schema.String, FileContent), +}) + +export const NetworkRegisterInput = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("json"), + url: Schema.String, + method: Schema.optional(Schema.String), + status: Schema.optional(Schema.Number), + headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), + body: Schema.Json, + }), + Schema.Struct({ + kind: Schema.Literal("text"), + url: Schema.String, + method: Schema.optional(Schema.String), + status: Schema.optional(Schema.Number), + headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), + body: Schema.String, + }), + Schema.Struct({ + kind: Schema.Literal("status"), + url: Schema.String, + method: Schema.optional(Schema.String), + status: Schema.Number, + headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), + }), +]) + +type FilePath = string + +interface State { + readonly files: readonly FilePath[] + readonly networkRegistrations: readonly string[] +} + +export interface Interface { + readonly reset: () => Effect.Effect + readonly seedFilesystem: (input: typeof FilesystemSeedInput.Type) => Effect.Effect<{ files: string[] }, unknown> + readonly registerNetwork: (input: typeof NetworkRegisterInput.Type) => Effect.Effect<{ registered: string }, unknown> + readonly snapshot: () => Effect.Effect<{ + files: readonly string[] + networkRegistrations: readonly string[] + network: SimulationNetwork.Snapshot + }> +} + +export class Service extends Context.Service()("@opencode/Simulation") {} + +function fileContent(content: typeof FileContent.Type) { + if (typeof content === "string") return content + return Uint8Array.from(Buffer.from(content.data, "base64")) +} + +function matcher(input: typeof NetworkRegisterInput.Type) { + return input.method ? { method: input.method, url: input.url } : input.url +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const network = yield* SimulationNetwork.Service + const state = yield* Ref.make({ files: [], networkRegistrations: [] }) + + const reset = Effect.fn("Simulation.reset")(function* () { + yield* network.reset() + yield* Ref.set(state, { files: [], networkRegistrations: [] }) + }) + + const seedFilesystem = Effect.fn("Simulation.seedFilesystem")(function* (input: typeof FilesystemSeedInput.Type) { + const files = Object.keys(input.files) + yield* Effect.forEach( + Object.entries(input.files), + ([file, content]) => fs.writeWithDirs(path.isAbsolute(file) ? file : path.join("/opencode", file), fileContent(content)), + ) + yield* Ref.update(state, (current) => ({ ...current, files: [...current.files, ...files] })) + return { files } + }) + + const registerNetwork = Effect.fn("Simulation.registerNetwork")(function* (input: typeof NetworkRegisterInput.Type) { + switch (input.kind) { + case "json": + yield* network.register( + SimulationNetwork.json(matcher(input), input.body, { status: input.status, headers: input.headers }), + ) + break + case "text": + yield* network.register( + SimulationNetwork.text(matcher(input), input.body, { status: input.status, headers: input.headers }), + ) + break + case "status": + yield* network.register(SimulationNetwork.status(matcher(input), input.status, { headers: input.headers })) + break + } + yield* Ref.update(state, (current) => ({ + ...current, + networkRegistrations: [...current.networkRegistrations, `${input.method ?? "*"} ${input.url}`], + })) + return { registered: input.url } + }) + + const snapshot = Effect.fn("Simulation.snapshot")(function* () { + const current = yield* Ref.get(state) + return { ...current, network: yield* network.snapshot() } + }) + + return Service.of({ reset, seedFilesystem, registerNetwork, snapshot }) + }), +) + +export * as Simulation from "./service" diff --git a/packages/opencode/test/testing/simulation/service.test.ts b/packages/opencode/test/testing/simulation/service.test.ts new file mode 100644 index 00000000000..573affb6f39 --- /dev/null +++ b/packages/opencode/test/testing/simulation/service.test.ts @@ -0,0 +1,67 @@ +import { describe, expect } from "bun:test" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Effect, Layer } from "effect" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" +import { SimulationFileSystem } from "../../../src/testing/simulation/filesystem" +import { SimulationNetwork } from "../../../src/testing/simulation/network" +import { Simulation } from "../../../src/testing/simulation/service" +import { testEffect } from "../../lib/effect" + +const fsLayer = SimulationFileSystem.layer({ root: "/opencode" }) +const networkLayer = SimulationNetwork.layer({ allowLoopback: false }) +const simulationLayer = Simulation.layer.pipe(Layer.provide(fsLayer), Layer.provide(networkLayer)) +const it = testEffect(Layer.mergeAll(fsLayer, networkLayer, simulationLayer)) + +describe("Simulation", () => { + it.effect("seeds files into the simulated filesystem", () => + Effect.gen(function* () { + const simulation = yield* Simulation.Service + const fs = yield* AppFileSystem.Service + + expect(yield* simulation.seedFilesystem({ files: { "opencode.json": "{}" } })).toEqual({ + files: ["opencode.json"], + }) + expect(yield* fs.readFileString("/opencode/opencode.json")).toBe("{}") + }), + ) + + it.effect("registers network responses through control state", () => + Effect.gen(function* () { + const simulation = yield* Simulation.Service + const http = yield* HttpClient.HttpClient + + expect( + yield* simulation.registerNetwork({ + kind: "json", + method: "GET", + url: "https://example.com/data", + body: { ok: true }, + }), + ).toEqual({ registered: "https://example.com/data" }) + + const response = yield* http.execute(HttpClientRequest.get("https://example.com/data")) + expect(yield* response.json).toEqual({ ok: true }) + }), + ) + + it.effect("snapshots and resets simulation state", () => + Effect.gen(function* () { + const simulation = yield* Simulation.Service + const http = yield* HttpClient.HttpClient + + yield* simulation.seedFilesystem({ files: { "README.md": "hello" } }) + yield* simulation.registerNetwork({ kind: "text", url: "https://example.com/page", body: "hello" }) + + const snapshot = yield* simulation.snapshot() + expect(snapshot.files).toEqual(["README.md"]) + expect(snapshot.networkRegistrations).toEqual(["* https://example.com/page"]) + expect(snapshot.network.routes.some((route) => route.matcher === "https://example.com/page")).toBe(true) + + yield* simulation.reset() + + expect((yield* simulation.snapshot()).files).toEqual([]) + const exit = yield* http.execute(HttpClientRequest.get("https://example.com/page")).pipe(Effect.exit) + expect(exit._tag).toBe("Failure") + }), + ) +})