Skip to content

Commit 4fe14ab

Browse files
authored
test: cover HttpApi instance context middleware (#25032)
1 parent 9052e8a commit 4fe14ab

3 files changed

Lines changed: 190 additions & 0 deletions

File tree

.opencode/skills/effect/SKILL.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,11 @@ Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3
2828
- In tests, prefer the repo's existing Effect test helpers and live tests for filesystem, git, child process, locks, or timing behavior.
2929
- Do not introduce `any`, non-null assertions, unchecked casts, or older Effect APIs just to satisfy types.
3030
- Do not answer from memory. Verify against `.opencode/references/effect-smol` or nearby code first.
31+
32+
## Testing Patterns
33+
34+
- Use `testEffect(...)` from `packages/opencode/test/lib/effect.ts` for tests that exercise Effect services, layers, runtime context, scoped resources, or platform integrations.
35+
- Use `it.live(...)` for filesystem, git repositories, HTTP servers, sockets, child processes, locks, real time, and other live platform behavior.
36+
- Run tests from package directories such as `packages/opencode`; never run package tests from the repo root.
37+
- Prefer explicit test layers over ad hoc managed runtimes. Keep dependency provisioning visible in the test file.
38+
- Use scoped fixtures and finalizers for resources that must be cleaned up, including temporary directories, flags, databases, fibers, servers, and global state.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Server Test Guide
2+
3+
Use these patterns for server and HttpApi middleware tests in this directory.
4+
5+
- Prefer focused middleware tests with tiny fake routes over full API route trees when testing routing, context, proxying, or middleware policy.
6+
- Use `testEffect(...)` with `NodeHttpServer.layerTest` for the primary in-test server and make relative `HttpClient` requests against it.
7+
- Use `HttpRouter.add(...)` probe routes that expose the context under test, such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`.
8+
- Compose middleware in the same order as production when testing interactions, for example `instanceRouterMiddleware.combine(workspaceRouterMiddleware)`.
9+
- For secondary upstream servers, build Effect `NodeHttpServer.layer(...)` into the current test scope with `Layer.build(...)` so the listener stays alive until the test scope exits.
10+
- Avoid `Bun.serve` when testing Effect HTTP middleware. Keep the test in the Effect HTTP stack unless the production path being tested is Bun-specific.
11+
- For WebSocket paths, use `Socket.makeWebSocket(...)` from the test client and assert protocol forwarding or frame relay when relevant.
12+
- Use scoped test layers for flags, database reset, and other global mutable state. Restore flags and reset state in finalizers.
13+
- Use `tmpdirScoped({ git: true })` plus `Project.use.fromDirectory(dir)` for project-backed requests.
14+
- If a test needs persisted state without matching runtime state, keep direct database setup inside a narrowly named helper that explains that state.
15+
- Add comments for non-obvious test topology, especially tests involving both the local test server and a fake upstream server.
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
2+
import { Flag } from "@opencode-ai/core/flag/flag"
3+
import { describe, expect } from "bun:test"
4+
import { Effect, Layer } from "effect"
5+
import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http"
6+
import * as Socket from "effect/unstable/socket/Socket"
7+
import { mkdir } from "node:fs/promises"
8+
import path from "node:path"
9+
import { registerAdaptor } from "../../src/control-plane/adaptors"
10+
import type { WorkspaceAdaptor } from "../../src/control-plane/types"
11+
import { Workspace } from "../../src/control-plane/workspace"
12+
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
13+
import { Instance } from "../../src/project/instance"
14+
import { Project } from "../../src/project/project"
15+
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
16+
import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
17+
import { resetDatabase } from "../fixture/db"
18+
import { tmpdirScoped } from "../fixture/fixture"
19+
import { testEffect } from "../lib/effect"
20+
21+
const testStateLayer = Layer.effectDiscard(
22+
Effect.gen(function* () {
23+
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
24+
yield* Effect.promise(() => resetDatabase())
25+
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
26+
yield* Effect.addFinalizer(() =>
27+
Effect.promise(async () => {
28+
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
29+
await Instance.disposeAll()
30+
await resetDatabase()
31+
}),
32+
)
33+
}),
34+
)
35+
36+
const it = testEffect(
37+
Layer.mergeAll(testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, Project.defaultLayer),
38+
)
39+
40+
const instanceContextTestLayer = instanceRouterMiddleware
41+
.combine(workspaceRouterMiddleware)
42+
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
43+
44+
const localAdaptor = (directory: string): WorkspaceAdaptor => ({
45+
name: "Local Test",
46+
description: "Create a local test workspace",
47+
configure: (info) => ({ ...info, name: "local-test", directory }),
48+
create: async () => {
49+
await mkdir(directory, { recursive: true })
50+
},
51+
async remove() {},
52+
target: () => ({ type: "local" as const, directory }),
53+
})
54+
55+
const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) =>
56+
Effect.acquireRelease(
57+
Effect.promise(async () => {
58+
registerAdaptor(input.projectID, input.type, localAdaptor(input.directory))
59+
return Workspace.create({
60+
type: input.type,
61+
branch: null,
62+
extra: null,
63+
projectID: input.projectID,
64+
})
65+
}),
66+
(workspace) => Effect.promise(() => Workspace.remove(workspace.id)).pipe(Effect.ignore),
67+
)
68+
69+
const probeInstanceContext = Effect.gen(function* () {
70+
const instance = yield* InstanceRef
71+
const workspaceID = yield* WorkspaceRef
72+
return yield* HttpServerResponse.json({
73+
directory: instance?.directory,
74+
worktree: instance?.worktree,
75+
projectID: instance?.project.id,
76+
workspaceID,
77+
})
78+
})
79+
80+
const serveProbe = (probePath: HttpRouter.PathInput = "/probe") =>
81+
HttpRouter.add("GET", probePath, probeInstanceContext).pipe(
82+
Layer.provide(instanceContextTestLayer),
83+
HttpRouter.serve,
84+
Layer.build,
85+
)
86+
87+
describe("HttpApi instance context middleware", () => {
88+
it.live("provides instance context from the routed directory", () =>
89+
Effect.gen(function* () {
90+
const dir = yield* tmpdirScoped({ git: true })
91+
const project = yield* Project.use.fromDirectory(dir)
92+
yield* serveProbe()
93+
94+
const response = yield* HttpClient.get(`/probe?directory=${encodeURIComponent(dir)}`)
95+
96+
expect(response.status).toBe(200)
97+
expect(yield* response.json).toEqual({
98+
directory: dir,
99+
worktree: dir,
100+
projectID: project.project.id,
101+
})
102+
}),
103+
)
104+
105+
it.live("falls back to the raw directory when URI decoding fails", () =>
106+
Effect.gen(function* () {
107+
yield* serveProbe()
108+
109+
const response = yield* HttpClient.get("/probe?directory=%25E0%25A4%25A")
110+
111+
expect(response.status).toBe(200)
112+
expect(yield* response.json).toMatchObject({
113+
directory: path.join(process.cwd(), "%E0%A4%A"),
114+
})
115+
}),
116+
)
117+
118+
it.live("provides selected workspace id on control-plane routes", () =>
119+
Effect.gen(function* () {
120+
const dir = yield* tmpdirScoped({ git: true })
121+
const project = yield* Project.use.fromDirectory(dir)
122+
const workspaceDir = path.join(dir, ".workspace-local")
123+
const workspace = yield* createLocalWorkspace({
124+
projectID: project.project.id,
125+
type: "instance-context-workspace-ref",
126+
directory: workspaceDir,
127+
})
128+
yield* serveProbe("/session")
129+
130+
const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe(
131+
HttpClientRequest.setHeader("x-opencode-directory", dir),
132+
HttpClient.execute,
133+
)
134+
135+
expect(response.status).toBe(200)
136+
expect(yield* response.json).toMatchObject({
137+
directory: dir,
138+
workspaceID: workspace.id,
139+
})
140+
}),
141+
)
142+
143+
it.live("uses workspace routing output instead of raw directory hints", () =>
144+
Effect.gen(function* () {
145+
const dir = yield* tmpdirScoped({ git: true })
146+
const project = yield* Project.use.fromDirectory(dir)
147+
const workspaceDir = path.join(dir, ".workspace-local")
148+
const workspace = yield* createLocalWorkspace({
149+
projectID: project.project.id,
150+
type: "instance-context-routing-output",
151+
directory: workspaceDir,
152+
})
153+
yield* serveProbe()
154+
155+
const response = yield* HttpClientRequest.get(`/probe?workspace=${workspace.id}`).pipe(
156+
HttpClientRequest.setHeader("x-opencode-directory", dir),
157+
HttpClient.execute,
158+
)
159+
160+
expect(response.status).toBe(200)
161+
expect(yield* response.json).toMatchObject({
162+
directory: workspaceDir,
163+
workspaceID: workspace.id,
164+
})
165+
}),
166+
)
167+
})

0 commit comments

Comments
 (0)