Procházet zdrojové kódy

test(cli): subprocess integration tests for opencode acp (#28265)

Kit Langton před 3 měsíci
rodič
revize
cb15b3ad84

+ 70 - 0
packages/opencode/test/cli/acp/acp-process.test.ts

@@ -0,0 +1,70 @@
+// Subprocess integration tests for `opencode acp`. ACP is a JSON-RPC
+// protocol spoken over stdin/stdout (not HTTP) — see src/acp/README.md.
+// This is the only test tier that exercises the full pipe of bun startup →
+// server boot → ACP agent init → stdio framing → graceful shutdown.
+import { describe, expect } from "bun:test"
+import { Duration, Effect } from "effect"
+import { cliIt } from "../../lib/cli-process"
+
+describe("opencode acp (subprocess)", () => {
+  // Smoke test: send the `initialize` request from src/acp/README.md and
+  // assert the response advertises the same protocol version and a non-empty
+  // capabilities block. If this fails, every other ACP test will too — start
+  // debugging here.
+  cliIt.live(
+    "responds to initialize with protocolVersion 1 and capabilities",
+    ({ opencode }) =>
+      Effect.gen(function* () {
+        const acp = yield* opencode.acp()
+
+        yield* acp.send({
+          jsonrpc: "2.0",
+          id: 1,
+          method: "initialize",
+          params: { protocolVersion: 1 },
+        })
+
+        // Tight deadline — the response should arrive within a few seconds
+        // once startup completes. A hang means the agent never finished init,
+        // which is a real regression and not a tuning issue.
+        const response = (yield* acp.receive.pipe(Effect.timeout(Duration.seconds(10)))) as {
+          jsonrpc: string
+          id: number
+          result?: { protocolVersion: number; agentCapabilities: Record<string, unknown> }
+          error?: unknown
+        }
+
+        expect(response.jsonrpc).toBe("2.0")
+        expect(response.id).toBe(1)
+        expect(response.error).toBeUndefined()
+        expect(response.result?.protocolVersion).toBe(1)
+        expect(response.result?.agentCapabilities).toBeDefined()
+      }),
+    60_000,
+  )
+
+  // Lock in the scope-close kill path. ACP's clean shutdown is "EOF on stdin"
+  // — if a future refactor breaks the stdin-end branch in the handler, the
+  // process would only exit on SIGTERM fallback (2s in the harness). This
+  // test passing within the inner-scope assertion proves the EOF path works.
+  cliIt.live(
+    "exits cleanly when stdin is closed (scope close)",
+    ({ opencode }) =>
+      Effect.gen(function* () {
+        const exitedPromise = yield* Effect.scoped(
+          Effect.gen(function* () {
+            const acp = yield* opencode.acp()
+            // Capture the Promise — scope-close fires the finalizer which
+            // ends stdin, and ACP should exit gracefully.
+            return acp.exited
+          }),
+        )
+
+        const code = yield* Effect.promise(() => exitedPromise)
+        // Bun returns a number for normal exit. Anything goes for SIGTERM,
+        // but we still require resolution within the test timeout.
+        expect(typeof code === "number" || code === null).toBe(true)
+      }),
+    60_000,
+  )
+})

+ 118 - 2
packages/opencode/test/lib/cli-process.ts

@@ -18,7 +18,7 @@
 // without changing the fixture. Long-lived commands like `serve` will need a
 // without changing the fixture. Long-lived commands like `serve` will need a
 // different return shape — see the TODO at the bottom of OpencodeCli.
 // different return shape — see the TODO at the bottom of OpencodeCli.
 import type { TestOptions } from "bun:test"
 import type { TestOptions } from "bun:test"
-import { Deferred, Duration, Effect, Layer, Scope, Stream } from "effect"
+import { Deferred, Duration, Effect, Layer, Queue, Scope, Stream } from "effect"
 import { FetchHttpClient, HttpClient } from "effect/unstable/http"
 import { FetchHttpClient, HttpClient } from "effect/unstable/http"
 import path from "node:path"
 import path from "node:path"
 import fs from "node:fs/promises"
 import fs from "node:fs/promises"
@@ -98,6 +98,28 @@ export type ServeHandle = {
   readonly exited: Promise<number>
   readonly exited: Promise<number>
 }
 }
 
 
+// `opencode acp` speaks newline-delimited JSON-RPC over stdin/stdout. It is
+// long-lived and exits cleanly when stdin is closed. The handle exposes the
+// duplex stream as send/receive rather than raw pipes so tests don't have to
+// reimplement framing on every call site.
+export type AcpOpts = SpawnOpts & {
+  readonly cwd?: string
+  readonly extraArgs?: string[]
+}
+
+export type AcpHandle = {
+  // Writes a single JSON-RPC message to the child's stdin as one ndjson line.
+  readonly send: (msg: object) => Effect.Effect<void>
+  // Resolves with the next parsed JSON-RPC line from the child's stdout.
+  // Lines are buffered in a queue so multiple receives in a row won't drop
+  // anything. Pair with `Effect.timeout` if a test wants a deadline.
+  readonly receive: Effect.Effect<unknown>
+  // Closes stdin. ACP exits cleanly on stdin EOF; the scope finalizer also
+  // calls this, so tests only need it when asserting exit behavior.
+  readonly close: () => void
+  readonly exited: Promise<number>
+}
+
 export type OpencodeCli = {
 export type OpencodeCli = {
   // High-level: run a single prompt against the test model. Short-lived.
   // High-level: run a single prompt against the test model. Short-lived.
   readonly run: (message: string, opts?: RunOpts) => Effect.Effect<RunResult>
   readonly run: (message: string, opts?: RunOpts) => Effect.Effect<RunResult>
@@ -105,6 +127,9 @@ export type OpencodeCli = {
   // returned handle is killed when the caller's Scope closes. Fails if the
   // returned handle is killed when the caller's Scope closes. Fails if the
   // listening line doesn't appear within `readyTimeoutMs`.
   // listening line doesn't appear within `readyTimeoutMs`.
   readonly serve: (opts?: ServeOpts) => Effect.Effect<ServeHandle, Error, Scope.Scope>
   readonly serve: (opts?: ServeOpts) => Effect.Effect<ServeHandle, Error, Scope.Scope>
+  // Spawn `opencode acp` and return a duplex JSON-RPC handle. Long-lived:
+  // the subprocess exits on stdin close, which the scope finalizer triggers.
+  readonly acp: (opts?: AcpOpts) => Effect.Effect<AcpHandle, Error, Scope.Scope>
   // Escape hatch: any CLI invocation with full control over argv. Used to test
   // Escape hatch: any CLI invocation with full control over argv. Used to test
   // commands that don't yet have a typed builder.
   // commands that don't yet have a typed builder.
   readonly spawn: (args: string[], opts?: SpawnOpts) => Effect.Effect<RunResult>
   readonly spawn: (args: string[], opts?: SpawnOpts) => Effect.Effect<RunResult>
@@ -260,7 +285,98 @@ export function withCliFixture<A, E>(
       } satisfies ServeHandle
       } satisfies ServeHandle
     })
     })
 
 
-    const opencode: OpencodeCli = { run, serve, spawn, expectExit, parseJsonEvents }
+    const acp = Effect.fn("opencode.acp")(function* (opts?: AcpOpts) {
+      const argv = ["acp"]
+      if (opts?.cwd) argv.push("--cwd", opts.cwd)
+      if (opts?.extraArgs) argv.push(...opts.extraArgs)
+
+      // Acquire the subprocess. Release ends stdin (clean shutdown — ACP exits
+      // on stdin EOF) and falls back to SIGTERM if it doesn't exit promptly.
+      // Either way we await proc.exited so the test scope doesn't leak.
+      const proc = yield* Effect.acquireRelease(
+        Effect.sync(() =>
+          Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], {
+            cwd: opts?.cwd ?? home,
+            env: { ...process.env, ...env, ...opts?.env },
+            stdin: "pipe",
+            stdout: "pipe",
+            stderr: "pipe",
+          }),
+        ),
+        (p) =>
+          // Graceful shutdown: close stdin (ACP exits on EOF), give it a
+          // window to exit, then SIGTERM. The Effect.timeoutOrElse expresses
+          // exactly that race without raw setTimeout or Promise.race.
+          Effect.gen(function* () {
+            yield* Effect.sync(() => p.stdin.end())
+            yield* Effect.promise(() => p.exited).pipe(
+              Effect.timeoutOrElse({
+                duration: Duration.seconds(2),
+                orElse: () =>
+                  Effect.sync(() => {
+                    p.kill()
+                  }),
+              }),
+            )
+            yield* Effect.promise(() => p.exited)
+          }).pipe(Effect.ignore),
+      )
+
+      const stderrChunks: string[] = []
+      yield* Effect.forkScoped(
+        Stream.fromReadableStream({
+          evaluate: () => proc.stderr,
+          onError: () => new Error("stderr stream error"),
+        }).pipe(
+          Stream.decodeText(),
+          Stream.runForEach((chunk) => Effect.sync(() => stderrChunks.push(chunk))),
+          Effect.ignore,
+        ),
+      )
+
+      // Each ndjson line becomes one queue entry. JSON.parse failures are
+      // surfaced as the raw string so a malformed protocol message doesn't
+      // silently wedge the test in `receive`.
+      const responses = yield* Queue.unbounded<unknown>()
+      yield* Effect.forkScoped(
+        Stream.fromReadableStream({
+          evaluate: () => proc.stdout,
+          onError: () => new Error("stdout stream error"),
+        }).pipe(
+          Stream.decodeText(),
+          Stream.splitLines,
+          Stream.runForEach((line) => {
+            if (line.length === 0) return Effect.void
+            let parsed: unknown
+            try {
+              parsed = JSON.parse(line)
+            } catch {
+              parsed = { _rawLine: line }
+            }
+            return Queue.offer(responses, parsed)
+          }),
+          Effect.ignore,
+        ),
+      )
+
+      return {
+        send: (msg: object) =>
+          Effect.sync(() => {
+            proc.stdin.write(JSON.stringify(msg) + "\n")
+          }),
+        receive: Queue.take(responses),
+        close: () => {
+          try {
+            proc.stdin.end()
+          } catch {
+            // already closed
+          }
+        },
+        exited: proc.exited as Promise<number>,
+      } satisfies AcpHandle
+    })
+
+    const opencode: OpencodeCli = { run, serve, acp, spawn, expectExit, parseJsonEvents }
 
 
     return yield* fn({ llm, home, opencode })
     return yield* fn({ llm, home, opencode })
     // FetchHttpClient is provided so test bodies can `yield* HttpClient.HttpClient`
     // FetchHttpClient is provided so test bodies can `yield* HttpClient.HttpClient`