run-process.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. // Subprocess test harness for the `opencode run` CLI.
  2. //
  3. // This is the missing test tier: every other `cli/run/*.test.ts` is a unit
  4. // test of an extracted helper. Nothing actually exercises the `RunCommand`
  5. // handler end-to-end. Bugs that span argv parsing → server boot → SDK call →
  6. // event consumption → exit code (like the original /event race or the
  7. // non-interactive hang #27371) are invisible to in-process tests.
  8. //
  9. // The harness uses opencode's built-in test affordances to spawn the real CLI
  10. // hermetically:
  11. // - OPENCODE_CONFIG_CONTENT : provider config inline, no files to find
  12. // - OPENCODE_TEST_HOME : pins os.homedir() → tmpdir
  13. // - OPENCODE_DISABLE_PROJECT_CONFIG : skip walking up for opencode.json
  14. // - OPENCODE_PURE : skip external plugin discovery + install
  15. // - OPENCODE_DISABLE_AUTOUPDATE / AUTOCOMPACT / MODELS_FETCH : no background work
  16. //
  17. // Plus HOME / XDG_* pointing at the tmpdir for belt-and-suspenders isolation.
  18. //
  19. // The custom `test` provider points at a TestLLMServer running in the same
  20. // process at a random port. The CLI subprocess talks to it over real HTTP.
  21. import type { TestOptions } from "bun:test"
  22. import * as Scope from "effect/Scope"
  23. import { Effect } from "effect"
  24. import path from "node:path"
  25. import fs from "node:fs/promises"
  26. import os from "node:os"
  27. import { Process } from "@/util/process"
  28. import { TestLLMServer } from "./llm-server"
  29. import { testProviderConfig } from "./test-provider"
  30. import { it } from "./effect"
  31. const opencodeRoot = path.resolve(import.meta.dir, "../../")
  32. const cliEntry = path.join(opencodeRoot, "src/index.ts")
  33. export const testModelID = "test/test-model"
  34. function isolatedEnv(home: string, configJson: string): Record<string, string> {
  35. return {
  36. OPENCODE_TEST_HOME: home,
  37. HOME: home,
  38. XDG_CONFIG_HOME: path.join(home, ".config"),
  39. XDG_DATA_HOME: path.join(home, ".local/share"),
  40. XDG_STATE_HOME: path.join(home, ".local/state"),
  41. XDG_CACHE_HOME: path.join(home, ".cache"),
  42. OPENCODE_CONFIG_CONTENT: configJson,
  43. OPENCODE_DISABLE_PROJECT_CONFIG: "1",
  44. OPENCODE_PURE: "1",
  45. OPENCODE_DISABLE_AUTOUPDATE: "1",
  46. OPENCODE_DISABLE_AUTOCOMPACT: "1",
  47. OPENCODE_DISABLE_MODELS_FETCH: "1",
  48. OPENCODE_AUTH_CONTENT: "{}",
  49. }
  50. }
  51. export type RunResult = {
  52. readonly exitCode: number
  53. readonly stdout: string
  54. readonly stderr: string
  55. readonly durationMs: number
  56. }
  57. type SpawnOpts = { readonly timeoutMs?: number; readonly env?: Record<string, string> }
  58. // A `RunOpts` is the typed equivalent of constructing argv for `opencode run`.
  59. // New flags should land here so tests stay grep-able and refactor-safe.
  60. export type RunOpts = SpawnOpts & {
  61. readonly model?: string
  62. readonly agent?: string
  63. readonly format?: "default" | "json"
  64. readonly command?: string
  65. readonly printLogs?: boolean
  66. readonly extraArgs?: string[]
  67. }
  68. export type OpencodeCli = {
  69. // High-level: run a single prompt against the test model.
  70. readonly run: (message: string, opts?: RunOpts) => Effect.Effect<RunResult>
  71. // Escape hatch: any CLI invocation with full control over argv.
  72. readonly spawn: (args: string[], opts?: SpawnOpts) => Effect.Effect<RunResult>
  73. // Convenience assertion. Dumps captured stderr/stdout on mismatch so CI
  74. // failures are debuggable without re-running locally.
  75. readonly expectExit: (result: RunResult, expected: number, label?: string) => void
  76. // Parse `--format json` stdout into one event object per non-empty line.
  77. // The CLI writes `JSON.stringify({ type, sessionID, ... }) + EOL` for each
  78. // event (see src/cli/cmd/run.ts `emit`). Throws if any line is malformed
  79. // so tests fail loudly rather than silently skipping data.
  80. readonly parseJsonEvents: (stdout: string) => Array<Record<string, unknown>>
  81. }
  82. export type RunFixture = {
  83. readonly llm: TestLLMServer["Service"]
  84. readonly home: string
  85. readonly opencode: OpencodeCli
  86. }
  87. // `withRunFixture(fn)` provisions a TestLLMServer + tmpdir + spawn helper and
  88. // invokes fn. Cleans up the tmpdir on scope exit.
  89. //
  90. // Note on the R channel: TestLLMServer.layer is provided internally so the
  91. // caller doesn't need to wire it up. The fixture's lifetime is tied to the
  92. // surrounding Scope.
  93. export function withRunFixture<A, E>(
  94. fn: (input: RunFixture) => Effect.Effect<A, E>,
  95. ): Effect.Effect<A, E | unknown, Scope.Scope> {
  96. return Effect.gen(function* () {
  97. const llm = yield* TestLLMServer
  98. const home = path.join(os.tmpdir(), "oc-run-" + Math.random().toString(36).slice(2))
  99. yield* Effect.promise(() => fs.mkdir(home, { recursive: true }))
  100. yield* Effect.addFinalizer(() =>
  101. Effect.promise(() => fs.rm(home, { recursive: true, force: true }).catch(() => undefined)),
  102. )
  103. const configJson = JSON.stringify(testProviderConfig(llm.url))
  104. const env = isolatedEnv(home, configJson)
  105. const spawn = (args: string[], opts?: SpawnOpts): Effect.Effect<RunResult> =>
  106. Effect.promise(async () => {
  107. const start = Date.now()
  108. // Process.run pipes stdout/stderr by default and returns them as Buffers.
  109. const result = await Process.run(["bun", "run", "--conditions=browser", cliEntry, ...args], {
  110. cwd: home,
  111. timeout: opts?.timeoutMs ?? 30_000,
  112. env: { ...process.env, ...env, ...opts?.env },
  113. nothrow: true,
  114. })
  115. return {
  116. exitCode: result.code,
  117. stdout: result.stdout.toString(),
  118. stderr: result.stderr.toString(),
  119. durationMs: Date.now() - start,
  120. }
  121. })
  122. const run = (message: string, opts?: RunOpts): Effect.Effect<RunResult> => {
  123. const argv: string[] = ["run"]
  124. if (opts?.printLogs) argv.push("--print-logs")
  125. argv.push("--model", opts?.model ?? testModelID)
  126. if (opts?.agent) argv.push("--agent", opts.agent)
  127. if (opts?.format) argv.push("--format", opts.format)
  128. if (opts?.command) argv.push("--command", opts.command)
  129. if (opts?.extraArgs) argv.push(...opts.extraArgs)
  130. argv.push(message)
  131. return spawn(argv, opts)
  132. }
  133. const opencode: OpencodeCli = { run, spawn, expectExit, parseJsonEvents }
  134. return yield* fn({ llm, home, opencode })
  135. }).pipe(Effect.provide(TestLLMServer.layer))
  136. }
  137. function parseJsonEvents(stdout: string): Array<Record<string, unknown>> {
  138. return stdout
  139. .split("\n")
  140. .map((line) => line.trim())
  141. .filter((line) => line.length > 0)
  142. .map((line) => JSON.parse(line) as Record<string, unknown>)
  143. }
  144. // Convenience for the common assertion pattern. Dumps stderr/stdout when
  145. // the exit code doesn't match — saves debugging time on CI failures.
  146. function expectExit(result: RunResult, expected: number, label = "opencode") {
  147. if (result.exitCode === expected) return
  148. const tail = (s: string, n: number) => (s.length > n ? "..." + s.slice(-n) : s)
  149. // eslint-disable-next-line no-console
  150. console.error(`[${label}] expected exit ${expected}, got ${result.exitCode} after ${result.durationMs}ms`)
  151. // eslint-disable-next-line no-console
  152. console.error(`[${label}] stderr (last 2000):\n${tail(result.stderr, 2000)}`)
  153. // eslint-disable-next-line no-console
  154. console.error(`[${label}] stdout (last 500):\n${tail(result.stdout, 500)}`)
  155. throw new Error(`${label}: expected exit ${expected}, got ${result.exitCode}`)
  156. }
  157. // `runIt.live(name, fixture => effect)` is the same as
  158. // `it.live(name, () => withRunFixture(fixture))` — one fewer nesting level at
  159. // every call site. Use this for any test that needs the opencode CLI fixture.
  160. //
  161. // Only `.live` is exposed because subprocess tests must run against the real
  162. // clock — a TestClock-paused environment can't drive a child process. If you
  163. // need `.only` or `.skip`, fall back to `it.live` + `withRunFixture` directly.
  164. export const runIt = {
  165. live: <A, E>(name: string, body: (input: RunFixture) => Effect.Effect<A, E>, opts?: number | TestOptions) =>
  166. it.live(name, () => withRunFixture(body), opts),
  167. }