summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.opencode/skills/effect/SKILL.md8
-rw-r--r--packages/opencode/test/server/AGENTS.md15
-rw-r--r--packages/opencode/test/server/httpapi-instance-context.test.ts167
3 files changed, 190 insertions, 0 deletions
diff --git a/.opencode/skills/effect/SKILL.md b/.opencode/skills/effect/SKILL.md
index 78216ab01..3a44fa88d 100644
--- a/.opencode/skills/effect/SKILL.md
+++ b/.opencode/skills/effect/SKILL.md
@@ -28,3 +28,11 @@ Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3
- In tests, prefer the repo's existing Effect test helpers and live tests for filesystem, git, child process, locks, or timing behavior.
- Do not introduce `any`, non-null assertions, unchecked casts, or older Effect APIs just to satisfy types.
- Do not answer from memory. Verify against `.opencode/references/effect-smol` or nearby code first.
+
+## Testing Patterns
+
+- Use `testEffect(...)` from `packages/opencode/test/lib/effect.ts` for tests that exercise Effect services, layers, runtime context, scoped resources, or platform integrations.
+- Use `it.live(...)` for filesystem, git repositories, HTTP servers, sockets, child processes, locks, real time, and other live platform behavior.
+- Run tests from package directories such as `packages/opencode`; never run package tests from the repo root.
+- Prefer explicit test layers over ad hoc managed runtimes. Keep dependency provisioning visible in the test file.
+- Use scoped fixtures and finalizers for resources that must be cleaned up, including temporary directories, flags, databases, fibers, servers, and global state.
diff --git a/packages/opencode/test/server/AGENTS.md b/packages/opencode/test/server/AGENTS.md
new file mode 100644
index 000000000..bed2b5269
--- /dev/null
+++ b/packages/opencode/test/server/AGENTS.md
@@ -0,0 +1,15 @@
+# Server Test Guide
+
+Use these patterns for server and HttpApi middleware tests in this directory.
+
+- Prefer focused middleware tests with tiny fake routes over full API route trees when testing routing, context, proxying, or middleware policy.
+- Use `testEffect(...)` with `NodeHttpServer.layerTest` for the primary in-test server and make relative `HttpClient` requests against it.
+- Use `HttpRouter.add(...)` probe routes that expose the context under test, such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`.
+- Compose middleware in the same order as production when testing interactions, for example `instanceRouterMiddleware.combine(workspaceRouterMiddleware)`.
+- 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.
+- 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.
+- For WebSocket paths, use `Socket.makeWebSocket(...)` from the test client and assert protocol forwarding or frame relay when relevant.
+- Use scoped test layers for flags, database reset, and other global mutable state. Restore flags and reset state in finalizers.
+- Use `tmpdirScoped({ git: true })` plus `Project.use.fromDirectory(dir)` for project-backed requests.
+- If a test needs persisted state without matching runtime state, keep direct database setup inside a narrowly named helper that explains that state.
+- Add comments for non-obvious test topology, especially tests involving both the local test server and a fake upstream server.
diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts
new file mode 100644
index 000000000..74b1ecdeb
--- /dev/null
+++ b/packages/opencode/test/server/httpapi-instance-context.test.ts
@@ -0,0 +1,167 @@
+import { NodeHttpServer, NodeServices } from "@effect/platform-node"
+import { Flag } from "@opencode-ai/core/flag/flag"
+import { describe, expect } from "bun:test"
+import { Effect, Layer } from "effect"
+import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http"
+import * as Socket from "effect/unstable/socket/Socket"
+import { mkdir } from "node:fs/promises"
+import path from "node:path"
+import { registerAdaptor } from "../../src/control-plane/adaptors"
+import type { WorkspaceAdaptor } from "../../src/control-plane/types"
+import { Workspace } from "../../src/control-plane/workspace"
+import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
+import { Instance } from "../../src/project/instance"
+import { Project } from "../../src/project/project"
+import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
+import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
+import { resetDatabase } from "../fixture/db"
+import { tmpdirScoped } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
+
+const testStateLayer = Layer.effectDiscard(
+ Effect.gen(function* () {
+ const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
+ yield* Effect.promise(() => resetDatabase())
+ Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
+ yield* Effect.addFinalizer(() =>
+ Effect.promise(async () => {
+ Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
+ await Instance.disposeAll()
+ await resetDatabase()
+ }),
+ )
+ }),
+)
+
+const it = testEffect(
+ Layer.mergeAll(testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, Project.defaultLayer),
+)
+
+const instanceContextTestLayer = instanceRouterMiddleware
+ .combine(workspaceRouterMiddleware)
+ .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
+
+const localAdaptor = (directory: string): WorkspaceAdaptor => ({
+ name: "Local Test",
+ description: "Create a local test workspace",
+ configure: (info) => ({ ...info, name: "local-test", directory }),
+ create: async () => {
+ await mkdir(directory, { recursive: true })
+ },
+ async remove() {},
+ target: () => ({ type: "local" as const, directory }),
+})
+
+const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) =>
+ Effect.acquireRelease(
+ Effect.promise(async () => {
+ registerAdaptor(input.projectID, input.type, localAdaptor(input.directory))
+ return Workspace.create({
+ type: input.type,
+ branch: null,
+ extra: null,
+ projectID: input.projectID,
+ })
+ }),
+ (workspace) => Effect.promise(() => Workspace.remove(workspace.id)).pipe(Effect.ignore),
+ )
+
+const probeInstanceContext = Effect.gen(function* () {
+ const instance = yield* InstanceRef
+ const workspaceID = yield* WorkspaceRef
+ return yield* HttpServerResponse.json({
+ directory: instance?.directory,
+ worktree: instance?.worktree,
+ projectID: instance?.project.id,
+ workspaceID,
+ })
+})
+
+const serveProbe = (probePath: HttpRouter.PathInput = "/probe") =>
+ HttpRouter.add("GET", probePath, probeInstanceContext).pipe(
+ Layer.provide(instanceContextTestLayer),
+ HttpRouter.serve,
+ Layer.build,
+ )
+
+describe("HttpApi instance context middleware", () => {
+ it.live("provides instance context from the routed directory", () =>
+ Effect.gen(function* () {
+ const dir = yield* tmpdirScoped({ git: true })
+ const project = yield* Project.use.fromDirectory(dir)
+ yield* serveProbe()
+
+ const response = yield* HttpClient.get(`/probe?directory=${encodeURIComponent(dir)}`)
+
+ expect(response.status).toBe(200)
+ expect(yield* response.json).toEqual({
+ directory: dir,
+ worktree: dir,
+ projectID: project.project.id,
+ })
+ }),
+ )
+
+ it.live("falls back to the raw directory when URI decoding fails", () =>
+ Effect.gen(function* () {
+ yield* serveProbe()
+
+ const response = yield* HttpClient.get("/probe?directory=%25E0%25A4%25A")
+
+ expect(response.status).toBe(200)
+ expect(yield* response.json).toMatchObject({
+ directory: path.join(process.cwd(), "%E0%A4%A"),
+ })
+ }),
+ )
+
+ it.live("provides selected workspace id on control-plane routes", () =>
+ Effect.gen(function* () {
+ const dir = yield* tmpdirScoped({ git: true })
+ const project = yield* Project.use.fromDirectory(dir)
+ const workspaceDir = path.join(dir, ".workspace-local")
+ const workspace = yield* createLocalWorkspace({
+ projectID: project.project.id,
+ type: "instance-context-workspace-ref",
+ directory: workspaceDir,
+ })
+ yield* serveProbe("/session")
+
+ const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe(
+ HttpClientRequest.setHeader("x-opencode-directory", dir),
+ HttpClient.execute,
+ )
+
+ expect(response.status).toBe(200)
+ expect(yield* response.json).toMatchObject({
+ directory: dir,
+ workspaceID: workspace.id,
+ })
+ }),
+ )
+
+ it.live("uses workspace routing output instead of raw directory hints", () =>
+ Effect.gen(function* () {
+ const dir = yield* tmpdirScoped({ git: true })
+ const project = yield* Project.use.fromDirectory(dir)
+ const workspaceDir = path.join(dir, ".workspace-local")
+ const workspace = yield* createLocalWorkspace({
+ projectID: project.project.id,
+ type: "instance-context-routing-output",
+ directory: workspaceDir,
+ })
+ yield* serveProbe()
+
+ const response = yield* HttpClientRequest.get(`/probe?workspace=${workspace.id}`).pipe(
+ HttpClientRequest.setHeader("x-opencode-directory", dir),
+ HttpClient.execute,
+ )
+
+ expect(response.status).toBe(200)
+ expect(yield* response.json).toMatchObject({
+ directory: workspaceDir,
+ workspaceID: workspace.id,
+ })
+ }),
+ )
+})