cli-process.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  1. // Subprocess test harness for the opencode CLI. Spawns the real binary against
  2. // a TestLLMServer running in-process at a random port, with full env isolation.
  3. //
  4. // This is the missing test tier: in-process tests can't catch bugs that span
  5. // argv parsing → server boot → SDK call → event consumption → exit code (like
  6. // the original /event race or #27371's invalid-model hang).
  7. //
  8. // Configuration flows through opencode's built-in test affordances:
  9. // - OPENCODE_CONFIG_CONTENT : provider config inline, no files to find
  10. // - OPENCODE_TEST_HOME : pins os.homedir() → tmpdir
  11. // - OPENCODE_DISABLE_PROJECT_CONFIG : skip walking up for opencode.json
  12. // - OPENCODE_PURE : skip external plugin discovery + install
  13. // - OPENCODE_DISABLE_AUTOUPDATE / AUTOCOMPACT / MODELS_FETCH : no background work
  14. // Plus HOME / XDG_* pointing at the tmpdir for belt-and-suspenders isolation.
  15. //
  16. // Today only `opencode.run` is fully wired. The shape supports adding more
  17. // builders (`opencode.serve(opts)`, `opencode.acp(opts)`, `opencode.auth(...)`)
  18. // without changing the fixture. Long-lived commands like `serve` will need a
  19. // different return shape — see the TODO at the bottom of OpencodeCli.
  20. import type { TestOptions } from "bun:test"
  21. import { Deferred, Duration, Effect, Layer, Queue, Scope, Stream } from "effect"
  22. import { FetchHttpClient, HttpClient } from "effect/unstable/http"
  23. import path from "node:path"
  24. import fs from "node:fs/promises"
  25. import os from "node:os"
  26. import { Process } from "@/util/process"
  27. import { TestLLMServer } from "./llm-server"
  28. import { testProviderConfig } from "./test-provider"
  29. import { it } from "./effect"
  30. const opencodeRoot = path.resolve(import.meta.dir, "../../")
  31. const cliEntry = path.join(opencodeRoot, "src/index.ts")
  32. export const testModelID = "test/test-model"
  33. // Wrap a Bun subprocess pipe (or any ReadableStream<Uint8Array>) as a Stream.
  34. // Centralizes the `evaluate` + `onError` boilerplate and tags errors with the
  35. // stream name so a stderr/stdout failure is greppable in logs.
  36. function fromBunStream(name: string, get: () => ReadableStream<Uint8Array>) {
  37. return Stream.fromReadableStream({
  38. evaluate: get,
  39. onError: (cause) => new Error(`${name} stream error: ${String(cause)}`),
  40. })
  41. }
  42. // Long-lived processes (serve, acp) all want the same stderr drain: read every
  43. // chunk, push to a tail buffer, swallow stream errors (the child closing the
  44. // pipe is normal). `log: true` surfaces a real protocol error to logs so a
  45. // regression doesn't silently disappear.
  46. function forkStderrDrain(stream: ReadableStream<Uint8Array>, into: string[]) {
  47. return Effect.forkScoped(
  48. fromBunStream("stderr", () => stream).pipe(
  49. Stream.decodeText(),
  50. Stream.runForEach((chunk) => Effect.sync(() => into.push(chunk))),
  51. Effect.ignore({ log: true }),
  52. ),
  53. )
  54. }
  55. function isolatedEnv(home: string, configJson: string): Record<string, string> {
  56. return {
  57. OPENCODE_TEST_HOME: home,
  58. HOME: home,
  59. XDG_CONFIG_HOME: path.join(home, ".config"),
  60. XDG_DATA_HOME: path.join(home, ".local/share"),
  61. XDG_STATE_HOME: path.join(home, ".local/state"),
  62. XDG_CACHE_HOME: path.join(home, ".cache"),
  63. OPENCODE_CONFIG_CONTENT: configJson,
  64. OPENCODE_DISABLE_PROJECT_CONFIG: "1",
  65. OPENCODE_PURE: "1",
  66. OPENCODE_DISABLE_AUTOUPDATE: "1",
  67. OPENCODE_DISABLE_AUTOCOMPACT: "1",
  68. OPENCODE_DISABLE_MODELS_FETCH: "1",
  69. OPENCODE_AUTH_CONTENT: "{}",
  70. }
  71. }
  72. export type RunResult = {
  73. readonly exitCode: number
  74. readonly stdout: string
  75. readonly stderr: string
  76. readonly durationMs: number
  77. }
  78. export type SpawnOpts = { readonly timeoutMs?: number; readonly env?: Record<string, string> }
  79. // Typed equivalent of constructing argv for `opencode run`. New flags should
  80. // land here so tests stay grep-able and refactor-safe.
  81. export type RunOpts = SpawnOpts & {
  82. readonly model?: string
  83. readonly agent?: string
  84. readonly format?: "default" | "json"
  85. readonly command?: string
  86. readonly printLogs?: boolean
  87. readonly extraArgs?: string[]
  88. }
  89. // `opencode serve` is a long-lived process — it never exits on its own.
  90. // `serve(opts)` therefore returns a handle inside the caller's Scope: the
  91. // subprocess is killed when the scope closes (test end), and the URL the
  92. // server actually bound to (port 0 means OS-assigned) is parsed off stdout.
  93. export type ServeOpts = SpawnOpts & {
  94. readonly port?: number
  95. readonly hostname?: string
  96. readonly extraArgs?: string[]
  97. // How long to wait for the "listening on http://..." line before failing.
  98. // Default 15s — startup is dominated by bun's transpile + plugin init, not
  99. // the actual listen() call.
  100. readonly readyTimeoutMs?: number
  101. }
  102. export type ServeHandle = {
  103. // Full URL the server is bound to, e.g. "http://127.0.0.1:54321". Use this
  104. // as the base for HTTP requests in tests — never assume the port.
  105. readonly url: string
  106. readonly hostname: string
  107. readonly port: number
  108. // Sends SIGTERM. The scope finalizer also calls this, so tests rarely need
  109. // to invoke it directly — useful for tests that assert exit behavior.
  110. readonly kill: () => void
  111. // Resolves with the exit code once the process exits. Bun returns a number.
  112. readonly exited: Promise<number>
  113. }
  114. // `opencode acp` speaks newline-delimited JSON-RPC over stdin/stdout. It is
  115. // long-lived and exits cleanly when stdin is closed. The handle exposes the
  116. // duplex stream as send/receive rather than raw pipes so tests don't have to
  117. // reimplement framing on every call site.
  118. export type AcpOpts = SpawnOpts & {
  119. readonly cwd?: string
  120. readonly extraArgs?: string[]
  121. }
  122. export type AcpHandle = {
  123. // Writes a single JSON-RPC message to the child's stdin as one ndjson line.
  124. readonly send: (msg: object) => Effect.Effect<void>
  125. // Resolves with the next parsed JSON-RPC line from the child's stdout.
  126. // Lines are buffered in a queue so multiple receives in a row won't drop
  127. // anything. Pair with `Effect.timeout` if a test wants a deadline.
  128. readonly receive: Effect.Effect<unknown>
  129. // Closes stdin. ACP exits cleanly on stdin EOF; the scope finalizer also
  130. // calls this, so tests only need it when asserting exit behavior.
  131. readonly close: () => void
  132. readonly exited: Promise<number>
  133. }
  134. export type OpencodeCli = {
  135. // High-level: run a single prompt against the test model. Short-lived.
  136. readonly run: (message: string, opts?: RunOpts) => Effect.Effect<RunResult>
  137. // Spawn `opencode serve` and wait until it's listening. Long-lived: the
  138. // returned handle is killed when the caller's Scope closes. Fails if the
  139. // listening line doesn't appear within `readyTimeoutMs`.
  140. readonly serve: (opts?: ServeOpts) => Effect.Effect<ServeHandle, Error, Scope.Scope>
  141. // Spawn `opencode acp` and return a duplex JSON-RPC handle. Long-lived:
  142. // the subprocess exits on stdin close, which the scope finalizer triggers.
  143. readonly acp: (opts?: AcpOpts) => Effect.Effect<AcpHandle, Error, Scope.Scope>
  144. // Escape hatch: any CLI invocation with full control over argv. Used to test
  145. // commands that don't yet have a typed builder.
  146. readonly spawn: (args: string[], opts?: SpawnOpts) => Effect.Effect<RunResult>
  147. // Convenience assertion. Dumps captured stderr/stdout on mismatch so CI
  148. // failures are debuggable without re-running locally.
  149. readonly expectExit: (result: RunResult, expected: number, label?: string) => void
  150. // Parse `--format json` stdout into one event object per non-empty line.
  151. // The CLI writes `JSON.stringify({ type, sessionID, ... }) + EOL` for each
  152. // event (see src/cli/cmd/run.ts `emit`). Throws on a malformed line so
  153. // tests fail loudly rather than silently skipping data.
  154. readonly parseJsonEvents: (stdout: string) => Array<Record<string, unknown>>
  155. }
  156. export type CliFixture = {
  157. readonly llm: TestLLMServer["Service"]
  158. readonly home: string
  159. readonly opencode: OpencodeCli
  160. }
  161. // Provisions a TestLLMServer + tmpdir + spawn helper and invokes fn. Cleans
  162. // up the tmpdir on scope exit. TestLLMServer.layer is provided internally so
  163. // the caller doesn't need to wire it up — the fixture's lifetime is tied to
  164. // the surrounding Scope.
  165. export function withCliFixture<A, E>(
  166. fn: (input: CliFixture) => Effect.Effect<A, E, Scope.Scope | HttpClient.HttpClient>,
  167. ): Effect.Effect<A, E | unknown, Scope.Scope> {
  168. return Effect.gen(function* () {
  169. const llm = yield* TestLLMServer
  170. const home = path.join(os.tmpdir(), "oc-cli-" + Math.random().toString(36).slice(2))
  171. yield* Effect.promise(() => fs.mkdir(home, { recursive: true }))
  172. yield* Effect.addFinalizer(() =>
  173. Effect.promise(() => fs.rm(home, { recursive: true, force: true }).catch(() => undefined)),
  174. )
  175. const configJson = JSON.stringify(testProviderConfig(llm.url))
  176. const env = isolatedEnv(home, configJson)
  177. const spawn = (args: string[], opts?: SpawnOpts): Effect.Effect<RunResult> =>
  178. Effect.promise(async () => {
  179. const start = Date.now()
  180. // Process.run pipes stdout/stderr by default and returns them as Buffers.
  181. const result = await Process.run(["bun", "run", "--conditions=browser", cliEntry, ...args], {
  182. cwd: home,
  183. timeout: opts?.timeoutMs ?? 30_000,
  184. env: { ...process.env, ...env, ...opts?.env },
  185. nothrow: true,
  186. })
  187. return {
  188. exitCode: result.code,
  189. stdout: result.stdout.toString(),
  190. stderr: result.stderr.toString(),
  191. durationMs: Date.now() - start,
  192. }
  193. })
  194. const run = (message: string, opts?: RunOpts): Effect.Effect<RunResult> => {
  195. const argv: string[] = ["run"]
  196. if (opts?.printLogs) argv.push("--print-logs")
  197. argv.push("--model", opts?.model ?? testModelID)
  198. if (opts?.agent) argv.push("--agent", opts.agent)
  199. if (opts?.format) argv.push("--format", opts.format)
  200. if (opts?.command) argv.push("--command", opts.command)
  201. if (opts?.extraArgs) argv.push(...opts.extraArgs)
  202. argv.push(message)
  203. return spawn(argv, opts)
  204. }
  205. const serve = Effect.fn("opencode.serve")(function* (opts?: ServeOpts) {
  206. const argv = ["serve"]
  207. // Default port 0 — let the OS pick a free port, parse the actual one
  208. // off stdout. Hard-coded ports flake under parallel tests.
  209. argv.push("--port", String(opts?.port ?? 0))
  210. if (opts?.hostname) argv.push("--hostname", opts.hostname)
  211. if (opts?.extraArgs) argv.push(...opts.extraArgs)
  212. // Acquire the subprocess; release sends SIGTERM and awaits exit on
  213. // scope close. Wrapped in Effect.ignore so a flaky kill doesn't surface
  214. // as a finalizer error during test teardown.
  215. const proc = yield* Effect.acquireRelease(
  216. Effect.sync(() =>
  217. Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], {
  218. cwd: home,
  219. env: { ...process.env, ...env, ...opts?.env },
  220. stdout: "pipe",
  221. stderr: "pipe",
  222. }),
  223. ),
  224. (p) =>
  225. Effect.promise(() => {
  226. p.kill()
  227. return p.exited
  228. }).pipe(Effect.ignore),
  229. )
  230. // Tail buffer so timeout failures can include stderr context. The fork
  231. // also keeps the OS pipe buffer from filling and wedging the child.
  232. const stderrChunks: string[] = []
  233. yield* forkStderrDrain(proc.stderr, stderrChunks)
  234. // Watch stdout line-by-line for the listening sentinel. Format
  235. // (see src/cli/cmd/serve.ts):
  236. // "opencode server listening on http://<host>:<port>"
  237. const readyRe = /listening on (http:\/\/([^\s:]+):(\d+))/
  238. const readyDeferred = yield* Deferred.make<{ url: string; hostname: string; port: number }>()
  239. yield* Effect.forkScoped(
  240. fromBunStream("stdout", () => proc.stdout).pipe(
  241. Stream.decodeText(),
  242. Stream.splitLines,
  243. Stream.runForEach((line) => {
  244. const m = line.match(readyRe)
  245. return m ? Deferred.succeed(readyDeferred, { url: m[1], hostname: m[2], port: Number(m[3]) }) : Effect.void
  246. }),
  247. Effect.ignore({ log: true }),
  248. ),
  249. )
  250. const readyTimeoutMs = opts?.readyTimeoutMs ?? 15_000
  251. const match = yield* Deferred.await(readyDeferred).pipe(
  252. Effect.timeoutOrElse({
  253. duration: Duration.millis(readyTimeoutMs),
  254. orElse: () =>
  255. Effect.fail(
  256. new Error(
  257. `opencode serve did not become ready within ${readyTimeoutMs}ms\n` +
  258. `stderr (last 2000):\n${stderrChunks.join("").slice(-2000)}`,
  259. ),
  260. ),
  261. }),
  262. )
  263. return {
  264. url: match.url,
  265. hostname: match.hostname,
  266. port: match.port,
  267. kill: () => {
  268. proc.kill()
  269. },
  270. exited: proc.exited as Promise<number>,
  271. } satisfies ServeHandle
  272. })
  273. const acp = Effect.fn("opencode.acp")(function* (opts?: AcpOpts) {
  274. const argv = ["acp"]
  275. if (opts?.cwd) argv.push("--cwd", opts.cwd)
  276. if (opts?.extraArgs) argv.push(...opts.extraArgs)
  277. // Acquire the subprocess. Release ends stdin (clean shutdown — ACP exits
  278. // on stdin EOF) and falls back to SIGTERM if it doesn't exit promptly.
  279. // Either way we await proc.exited so the test scope doesn't leak.
  280. const proc = yield* Effect.acquireRelease(
  281. Effect.sync(() =>
  282. Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], {
  283. cwd: opts?.cwd ?? home,
  284. env: { ...process.env, ...env, ...opts?.env },
  285. stdin: "pipe",
  286. stdout: "pipe",
  287. stderr: "pipe",
  288. }),
  289. ),
  290. (p) =>
  291. // Graceful shutdown: close stdin (ACP exits on EOF), give it a
  292. // window to exit, then SIGTERM. The Effect.timeoutOrElse expresses
  293. // exactly that race without raw setTimeout or Promise.race.
  294. Effect.gen(function* () {
  295. yield* Effect.sync(() => p.stdin.end())
  296. yield* Effect.promise(() => p.exited).pipe(
  297. Effect.timeoutOrElse({
  298. duration: Duration.seconds(2),
  299. orElse: () =>
  300. Effect.sync(() => {
  301. p.kill()
  302. }),
  303. }),
  304. )
  305. yield* Effect.promise(() => p.exited)
  306. }).pipe(Effect.ignore),
  307. )
  308. const stderrChunks: string[] = []
  309. yield* forkStderrDrain(proc.stderr, stderrChunks)
  310. // Each ndjson line becomes one queue entry. JSON.parse failures are
  311. // surfaced as the raw string so a malformed protocol message doesn't
  312. // silently wedge the test in `receive`.
  313. const responses = yield* Queue.unbounded<unknown>()
  314. yield* Effect.forkScoped(
  315. fromBunStream("stdout", () => proc.stdout).pipe(
  316. Stream.decodeText(),
  317. Stream.splitLines,
  318. Stream.runForEach((line) => {
  319. if (line.length === 0) return Effect.void
  320. let parsed: unknown
  321. try {
  322. parsed = JSON.parse(line)
  323. } catch {
  324. parsed = { _rawLine: line }
  325. }
  326. return Queue.offer(responses, parsed)
  327. }),
  328. Effect.ignore({ log: true }),
  329. ),
  330. )
  331. return {
  332. // `proc.stdin.write` returns `number | Promise<number>`. The promise
  333. // form is the backpressure signal — if we don't await it, rapid
  334. // successive sends can interleave under pipe-buffer-full conditions
  335. // and corrupt the ndjson framing.
  336. send: (msg: object) =>
  337. Effect.promise(async () => {
  338. const ret = proc.stdin.write(JSON.stringify(msg) + "\n")
  339. if (typeof ret !== "number") await ret
  340. }),
  341. receive: Queue.take(responses),
  342. // proc.stdin.end() is idempotent in Bun; no try/catch needed.
  343. close: () => proc.stdin.end(),
  344. exited: proc.exited as Promise<number>,
  345. } satisfies AcpHandle
  346. })
  347. const opencode: OpencodeCli = { run, serve, acp, spawn, expectExit, parseJsonEvents }
  348. return yield* fn({ llm, home, opencode })
  349. // FetchHttpClient is provided so test bodies can `yield* HttpClient.HttpClient`
  350. // and hit endpoints on `opencode.serve()` without rolling their own fetch.
  351. }).pipe(Effect.provide(Layer.mergeAll(TestLLMServer.layer, FetchHttpClient.layer)))
  352. }
  353. function parseJsonEvents(stdout: string): Array<Record<string, unknown>> {
  354. return stdout
  355. .split("\n")
  356. .map((line) => line.trim())
  357. .filter((line) => line.length > 0)
  358. .map((line) => JSON.parse(line) as Record<string, unknown>)
  359. }
  360. // Convenience for the common assertion pattern. Dumps stderr/stdout when
  361. // the exit code doesn't match — saves debugging time on CI failures.
  362. function expectExit(result: RunResult, expected: number, label = "opencode") {
  363. if (result.exitCode === expected) return
  364. const tail = (s: string, n: number) => (s.length > n ? "..." + s.slice(-n) : s)
  365. // eslint-disable-next-line no-console
  366. console.error(`[${label}] expected exit ${expected}, got ${result.exitCode} after ${result.durationMs}ms`)
  367. // eslint-disable-next-line no-console
  368. console.error(`[${label}] stderr (last 2000):\n${tail(result.stderr, 2000)}`)
  369. // eslint-disable-next-line no-console
  370. console.error(`[${label}] stdout (last 500):\n${tail(result.stdout, 500)}`)
  371. throw new Error(`${label}: expected exit ${expected}, got ${result.exitCode}`)
  372. }
  373. // `cliIt.live(name, fixture => effect)` is the same as
  374. // `it.live(name, () => withCliFixture(fixture))` — one fewer nesting level at
  375. // every call site. Use this for any test that needs the opencode CLI fixture.
  376. //
  377. // Only `.live` is exposed because subprocess tests must run against the real
  378. // clock — a TestClock-paused environment can't drive a child process. If you
  379. // need `.only` or `.skip`, fall back to `it.live` + `withCliFixture` directly.
  380. // Body's R is `Scope.Scope | never` so tests can yield* scope-requiring
  381. // resources (e.g. `opencode.serve`) without an extra `Effect.scoped` wrapper —
  382. // `withCliFixture`'s outer scope is the natural lifetime.
  383. export const cliIt = {
  384. live: <A, E>(
  385. name: string,
  386. body: (input: CliFixture) => Effect.Effect<A, E, Scope.Scope | HttpClient.HttpClient>,
  387. opts?: number | TestOptions,
  388. ) => it.live(name, () => withCliFixture(body), opts),
  389. }