diff --git a/.opencode/skills/effect/SKILL.md b/.opencode/skills/effect/SKILL.md index 78216ab01c..3a44fa88dc 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 0000000000..bed2b52695 --- /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 0000000000..74b1ecdeba --- /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, + }) + }), + ) +})