cli-process.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535
  1. // Subprocess test harness for the kirincode 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. // - KIRINCODE_CONFIG_CONTENT : provider config inline, no files to find
  10. // - KIRINCODE_TEST_HOME : pins os.homedir() → tmpdir
  11. // - KIRINCODE_DISABLE_PROJECT_CONFIG : skip walking up for kirincode.json
  12. // - KIRINCODE_PURE : skip external plugin discovery + install
  13. // - KIRINCODE_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 KirinCodeCli.
  20. import { test, type TestOptions } from "bun:test"
  21. import { FSUtil } from "@kirincode-ai/core/fs-util"
  22. import { AppNodeBuilder } from "@kirincode-ai/core/effect/app-node-builder"
  23. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  24. import { AppProcess } from "@kirincode-ai/core/process"
  25. import { Deferred, Duration, Effect, Layer, Queue, Schedule, Scope, Stream } from "effect"
  26. import { FetchHttpClient, HttpClient } from "effect/unstable/http"
  27. import { ChildProcess } from "effect/unstable/process"
  28. import path from "node:path"
  29. import { TestLLMServer } from "./llm-server"
  30. import { testProviderConfig } from "./test-provider"
  31. import { it } from "./effect"
  32. const kirincodeRoot = path.resolve(import.meta.dir, "../../")
  33. const cliEntry = path.join(opencodeRoot, "src/index.ts")
  34. export const testModelID = "test/test-model"
  35. // Wrap a Bun subprocess pipe (or any ReadableStream<Uint8Array>) as a Stream.
  36. // Centralizes the `evaluate` + `onError` boilerplate and tags errors with the
  37. // stream name so a stderr/stdout failure is greppable in logs.
  38. function fromBunStream(name: string, get: () => ReadableStream<Uint8Array>) {
  39. return Stream.fromReadableStream({
  40. evaluate: get,
  41. onError: (cause) => new Error(`${name} stream error: ${String(cause)}`),
  42. })
  43. }
  44. // Long-lived processes (serve, acp) all want the same stderr drain: read every
  45. // chunk, push to a tail buffer, swallow stream errors (the child closing the
  46. // pipe is normal). `log: true` surfaces a real protocol error to logs so a
  47. // regression doesn't silently disappear.
  48. function forkStderrDrain(stream: ReadableStream<Uint8Array>, into: string[]) {
  49. return Effect.forkScoped(
  50. fromBunStream("stderr", () => stream).pipe(
  51. Stream.decodeText(),
  52. Stream.runForEach((chunk) => Effect.sync(() => into.push(chunk))),
  53. Effect.ignore({ log: true }),
  54. ),
  55. )
  56. }
  57. function isolatedEnv(home: string, configJson: string): Record<string, string> {
  58. return {
  59. KIRINCODE_TEST_HOME: home,
  60. HOME: home,
  61. XDG_CONFIG_HOME: path.join(home, ".config"),
  62. XDG_DATA_HOME: path.join(home, ".local/share"),
  63. XDG_STATE_HOME: path.join(home, ".local/state"),
  64. XDG_CACHE_HOME: path.join(home, ".cache"),
  65. KIRINCODE_CONFIG_CONTENT: configJson,
  66. KIRINCODE_DISABLE_PROJECT_CONFIG: "1",
  67. KIRINCODE_PURE: "1",
  68. KIRINCODE_DISABLE_AUTOUPDATE: "1",
  69. KIRINCODE_DISABLE_AUTOCOMPACT: "1",
  70. KIRINCODE_DISABLE_MODELS_FETCH: "1",
  71. KIRINCODE_AUTH_CONTENT: "{}",
  72. }
  73. }
  74. export type RunResult = {
  75. readonly exitCode: number
  76. readonly stdout: string
  77. readonly stderr: string
  78. readonly durationMs: number
  79. }
  80. export type RunHandle = {
  81. readonly interrupt: () => void
  82. readonly result: Effect.Effect<RunResult>
  83. }
  84. export type SpawnOpts = { readonly timeoutMs?: number; readonly env?: Record<string, string> }
  85. // Typed equivalent of constructing argv for `opencode run`. New flags should
  86. // land here so tests stay grep-able and refactor-safe.
  87. export type RunOpts = SpawnOpts & {
  88. readonly model?: string
  89. readonly agent?: string
  90. readonly format?: "default" | "json"
  91. readonly command?: string
  92. readonly printLogs?: boolean
  93. readonly permission?: Record<string, "ask" | "allow" | "deny">
  94. readonly extraArgs?: string[]
  95. }
  96. // `opencode serve` is a long-lived process — it never exits on its own.
  97. // `serve(opts)` therefore returns a handle inside the caller's Scope: the
  98. // subprocess is killed when the scope closes (test end), and the URL the
  99. // server actually bound to (port 0 means OS-assigned) is parsed off stdout.
  100. export type ServeOpts = SpawnOpts & {
  101. readonly port?: number
  102. readonly hostname?: string
  103. readonly extraArgs?: string[]
  104. // How long to wait for the "listening on http://..." line before failing.
  105. // Default 15s — startup is dominated by bun's transpile + plugin init, not
  106. // the actual listen() call.
  107. readonly readyTimeoutMs?: number
  108. }
  109. export type ServeHandle = {
  110. // Full URL the server is bound to, e.g. "http://127.0.0.1:54321". Use this
  111. // as the base for HTTP requests in tests — never assume the port.
  112. readonly url: string
  113. readonly hostname: string
  114. readonly port: number
  115. // Sends SIGTERM. The scope finalizer also calls this, so tests rarely need
  116. // to invoke it directly — useful for tests that assert exit behavior.
  117. readonly kill: () => void
  118. // Resolves with the exit code once the process exits. Bun returns a number.
  119. readonly exited: Promise<number>
  120. }
  121. // `opencode acp` speaks newline-delimited JSON-RPC over stdin/stdout. It is
  122. // long-lived and exits cleanly when stdin is closed. The handle exposes the
  123. // duplex stream as send/receive rather than raw pipes so tests don't have to
  124. // reimplement framing on every call site.
  125. export type AcpOpts = SpawnOpts & {
  126. readonly cwd?: string
  127. readonly extraArgs?: string[]
  128. }
  129. export type AcpHandle = {
  130. // Writes a single JSON-RPC message to the child's stdin as one ndjson line.
  131. readonly send: (msg: object) => Effect.Effect<void>
  132. // Resolves with the next parsed JSON-RPC line from the child's stdout.
  133. // Lines are buffered in a queue so multiple receives in a row won't drop
  134. // anything. Pair with `Effect.timeout` if a test wants a deadline.
  135. readonly receive: Effect.Effect<unknown>
  136. // Closes stdin. ACP exits cleanly on stdin EOF; the scope finalizer also
  137. // calls this, so tests only need it when asserting exit behavior.
  138. readonly close: () => void
  139. readonly exited: Promise<number>
  140. }
  141. export type KirinCodeCli = {
  142. // High-level: run a single prompt against the test model. Short-lived.
  143. readonly run: (message: string, opts?: RunOpts) => Effect.Effect<RunResult>
  144. readonly startRun: (message: string, opts?: RunOpts) => Effect.Effect<RunHandle, never, Scope.Scope>
  145. // Spawn `opencode serve` and wait until it's listening. Long-lived: the
  146. // returned handle is killed when the caller's Scope closes. Fails if the
  147. // listening line doesn't appear within `readyTimeoutMs`.
  148. readonly serve: (opts?: ServeOpts) => Effect.Effect<ServeHandle, Error, Scope.Scope>
  149. // Spawn `opencode acp` and return a duplex JSON-RPC handle. Long-lived:
  150. // the subprocess exits on stdin close, which the scope finalizer triggers.
  151. readonly acp: (opts?: AcpOpts) => Effect.Effect<AcpHandle, Error, Scope.Scope>
  152. // Escape hatch: any CLI invocation with full control over argv. Used to test
  153. // commands that don't yet have a typed builder.
  154. readonly spawn: (args: string[], opts?: SpawnOpts) => Effect.Effect<RunResult>
  155. // Convenience assertion. Dumps captured stderr/stdout on mismatch so CI
  156. // failures are debuggable without re-running locally.
  157. readonly expectExit: (result: RunResult, expected: number, label?: string) => void
  158. // Parse `--format json` stdout into one event object per non-empty line.
  159. // The CLI writes `JSON.stringify({ type, sessionID, ... }) + EOL` for each
  160. // event (see src/cli/cmd/run.ts `emit`). Throws on a malformed line so
  161. // tests fail loudly rather than silently skipping data.
  162. readonly parseJsonEvents: (stdout: string) => Array<Record<string, unknown>>
  163. }
  164. export type CliFixture = {
  165. readonly llm: TestLLMServer["Service"]
  166. readonly home: string
  167. readonly kirincode: KirinCodeCli
  168. }
  169. // Provisions a TestLLMServer + tmpdir + spawn helper and invokes fn. Cleans
  170. // up the tmpdir on scope exit. TestLLMServer.layer is provided internally so
  171. // the caller doesn't need to wire it up — the fixture's lifetime is tied to
  172. // the surrounding Scope.
  173. export function withCliFixture<A, E>(
  174. fn: (input: CliFixture) => Effect.Effect<A, E, Scope.Scope | HttpClient.HttpClient>,
  175. ): Effect.Effect<A, E | unknown, Scope.Scope> {
  176. return Effect.gen(function* () {
  177. const llm = yield* TestLLMServer
  178. const fs = yield* FSUtil.Service
  179. const appProc = yield* AppProcess.Service
  180. const home = yield* fs.makeTempDirectory({ prefix: "oc-cli-" })
  181. yield* Effect.addFinalizer(() =>
  182. fs
  183. .remove(home, { recursive: true })
  184. .pipe(Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(20)))), Effect.ignore),
  185. )
  186. const configJson = JSON.stringify(testProviderConfig(llm.url))
  187. const env = isolatedEnv(home, configJson)
  188. const spawn = Effect.fn("opencode.spawn")(function* (args: string[], opts?: SpawnOpts) {
  189. const start = Date.now()
  190. const timeoutMs = opts?.timeoutMs ?? 30_000
  191. // stdin: "ignore" so the child doesn't see a piped stdin and block
  192. // on `Bun.stdin.text()` (see src/cli/cmd/run.ts — non-TTY stdin is
  193. // consumed as the prompt). The old Process.run wrapper defaulted to
  194. // ignore; ChildProcess.make defaults to pipe, so we set it explicitly.
  195. const command = ChildProcess.make("bun", ["run", "--conditions=browser", cliEntry, ...args], {
  196. cwd: home,
  197. env: { ...env, ...opts?.env },
  198. extendEnv: true,
  199. stdin: "ignore",
  200. })
  201. // Pass timeout to appProc.run rather than wrapping with
  202. // Effect.timeoutOrElse externally: AppProcess.run is itself scoped, so
  203. // its built-in timeout triggers the acquireRelease kill finalizer
  204. // inside cross-spawn-spawner *before* surfacing the AppProcessError —
  205. // guaranteeing the child is dead by the time the test continues.
  206. // External timeoutOrElse interrupts the run fiber but races the
  207. // scope close, which can leak the child past the test boundary.
  208. //
  209. // Catch AppProcessError (timeout OR spawn failure) and synthesize a
  210. // non-zero result so the test sees it via the usual `expectExit`
  211. // path rather than as an unhandled Effect failure.
  212. const result = yield* appProc.run(command, { timeout: Duration.millis(timeoutMs) }).pipe(
  213. Effect.catchTag("AppProcessError", (err) =>
  214. Effect.succeed({
  215. command: err.command,
  216. exitCode: err.exitCode ?? -1,
  217. stdout: Buffer.alloc(0),
  218. stderr: Buffer.from((err.stderr ?? String(err.cause ?? err.message)) + "\n"),
  219. stdoutTruncated: false,
  220. stderrTruncated: false,
  221. } satisfies AppProcess.RunResult),
  222. ),
  223. )
  224. return {
  225. exitCode: result.exitCode,
  226. stdout: normalizeLines(result.stdout.toString()),
  227. stderr: normalizeLines(result.stderr.toString()),
  228. durationMs: Date.now() - start,
  229. }
  230. })
  231. const runArgs = (message: string, opts?: RunOpts) => {
  232. const argv: string[] = ["run"]
  233. if (opts?.printLogs) argv.push("--print-logs")
  234. argv.push("--model", opts?.model ?? testModelID)
  235. if (opts?.agent) argv.push("--agent", opts.agent)
  236. if (opts?.format) argv.push("--format", opts.format)
  237. if (opts?.command) argv.push("--command", opts.command)
  238. if (opts?.extraArgs) argv.push(...opts.extraArgs)
  239. argv.push(message)
  240. return argv
  241. }
  242. const runOpts = (opts?: RunOpts): SpawnOpts | undefined => {
  243. if (!opts?.permission) return opts
  244. return {
  245. ...opts,
  246. env: {
  247. ...opts.env,
  248. KIRINCODE_CONFIG_CONTENT: JSON.stringify({
  249. ...testProviderConfig(llm.url),
  250. permission: opts.permission,
  251. }),
  252. },
  253. }
  254. }
  255. const run = (message: string, opts?: RunOpts): Effect.Effect<RunResult> => {
  256. return spawn(runArgs(message, opts), runOpts(opts))
  257. }
  258. const startRun = Effect.fn("opencode.startRun")(function* (message: string, opts?: RunOpts) {
  259. const start = Date.now()
  260. const options = runOpts(opts)
  261. const proc = yield* Effect.acquireRelease(
  262. Effect.sync(() =>
  263. Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...runArgs(message, opts)], {
  264. cwd: home,
  265. env: { ...process.env, ...env, ...options?.env },
  266. stdin: "ignore",
  267. stdout: "pipe",
  268. stderr: "pipe",
  269. }),
  270. ),
  271. (child) =>
  272. Effect.promise(() => {
  273. child.kill()
  274. return child.exited
  275. }).pipe(Effect.ignore),
  276. )
  277. const stdout = new Response(proc.stdout).text()
  278. const stderr = new Response(proc.stderr).text()
  279. return {
  280. interrupt: () => proc.kill("SIGINT"),
  281. result: Effect.promise(async () => ({
  282. exitCode: await proc.exited,
  283. stdout: normalizeLines(await stdout),
  284. stderr: normalizeLines(await stderr),
  285. durationMs: Date.now() - start,
  286. })),
  287. } satisfies RunHandle
  288. })
  289. const serve = Effect.fn("opencode.serve")(function* (opts?: ServeOpts) {
  290. const argv = ["serve"]
  291. // Default port 0 — let the OS pick a free port, parse the actual one
  292. // off stdout. Hard-coded ports flake under parallel tests.
  293. argv.push("--port", String(opts?.port ?? 0))
  294. if (opts?.hostname) argv.push("--hostname", opts.hostname)
  295. if (opts?.extraArgs) argv.push(...opts.extraArgs)
  296. // Acquire the subprocess; release sends SIGTERM and awaits exit on
  297. // scope close. Wrapped in Effect.ignore so a flaky kill doesn't surface
  298. // as a finalizer error during test teardown.
  299. const proc = yield* Effect.acquireRelease(
  300. Effect.sync(() =>
  301. Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], {
  302. cwd: home,
  303. env: { ...process.env, ...env, ...opts?.env },
  304. stdout: "pipe",
  305. stderr: "pipe",
  306. }),
  307. ),
  308. (p) =>
  309. Effect.promise(() => {
  310. p.kill()
  311. return p.exited
  312. }).pipe(Effect.ignore),
  313. )
  314. // Tail buffer so timeout failures can include stderr context. The fork
  315. // also keeps the OS pipe buffer from filling and wedging the child.
  316. const stderrChunks: string[] = []
  317. yield* forkStderrDrain(proc.stderr, stderrChunks)
  318. // Watch stdout line-by-line for the listening sentinel. Format
  319. // (see src/cli/cmd/serve.ts):
  320. // "kirincode server listening on http://<host>:<port>"
  321. const readyRe = /listening on (http:\/\/([^\s:]+):(\d+))/
  322. const readyDeferred = yield* Deferred.make<{ url: string; hostname: string; port: number }>()
  323. yield* Effect.forkScoped(
  324. fromBunStream("stdout", () => proc.stdout).pipe(
  325. Stream.decodeText(),
  326. Stream.splitLines,
  327. Stream.runForEach((line) => {
  328. const m = line.match(readyRe)
  329. return m ? Deferred.succeed(readyDeferred, { url: m[1], hostname: m[2], port: Number(m[3]) }) : Effect.void
  330. }),
  331. Effect.ignore({ log: true }),
  332. ),
  333. )
  334. const readyTimeoutMs = opts?.readyTimeoutMs ?? 15_000
  335. const match = yield* Deferred.await(readyDeferred).pipe(
  336. Effect.timeoutOrElse({
  337. duration: Duration.millis(readyTimeoutMs),
  338. orElse: () =>
  339. Effect.fail(
  340. new Error(
  341. `opencode serve did not become ready within ${readyTimeoutMs}ms\n` +
  342. `stderr (last 2000):\n${stderrChunks.join("").slice(-2000)}`,
  343. ),
  344. ),
  345. }),
  346. )
  347. return {
  348. url: match.url,
  349. hostname: match.hostname,
  350. port: match.port,
  351. kill: () => {
  352. proc.kill()
  353. },
  354. exited: proc.exited as Promise<number>,
  355. } satisfies ServeHandle
  356. })
  357. const acp = Effect.fn("opencode.acp")(function* (opts?: AcpOpts) {
  358. const argv = ["acp"]
  359. if (opts?.cwd) argv.push("--cwd", opts.cwd)
  360. if (opts?.extraArgs) argv.push(...opts.extraArgs)
  361. // Acquire the subprocess. Release ends stdin (clean shutdown — ACP exits
  362. // on stdin EOF) and falls back to SIGTERM if it doesn't exit promptly.
  363. // Either way we await proc.exited so the test scope doesn't leak.
  364. const proc = yield* Effect.acquireRelease(
  365. Effect.sync(() =>
  366. Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], {
  367. cwd: opts?.cwd ?? home,
  368. env: { ...process.env, ...env, ...opts?.env },
  369. stdin: "pipe",
  370. stdout: "pipe",
  371. stderr: "pipe",
  372. }),
  373. ),
  374. (p) =>
  375. // Graceful shutdown: close stdin (ACP exits on EOF), give it a
  376. // window to exit, then SIGTERM. The Effect.timeoutOrElse expresses
  377. // exactly that race without raw setTimeout or Promise.race.
  378. Effect.gen(function* () {
  379. yield* Effect.sync(() => p.stdin.end())
  380. yield* Effect.promise(() => p.exited).pipe(
  381. Effect.timeoutOrElse({
  382. duration: Duration.seconds(2),
  383. orElse: () =>
  384. Effect.sync(() => {
  385. p.kill()
  386. }),
  387. }),
  388. )
  389. yield* Effect.promise(() => p.exited)
  390. }).pipe(Effect.ignore),
  391. )
  392. const stderrChunks: string[] = []
  393. yield* forkStderrDrain(proc.stderr, stderrChunks)
  394. // Each ndjson line becomes one queue entry. JSON.parse failures are
  395. // surfaced as the raw string so a malformed protocol message doesn't
  396. // silently wedge the test in `receive`.
  397. const responses = yield* Queue.unbounded<unknown>()
  398. yield* Effect.forkScoped(
  399. fromBunStream("stdout", () => proc.stdout).pipe(
  400. Stream.decodeText(),
  401. Stream.splitLines,
  402. Stream.runForEach((line) => {
  403. if (line.length === 0) return Effect.void
  404. let parsed: unknown
  405. try {
  406. parsed = JSON.parse(line)
  407. } catch {
  408. parsed = { _rawLine: line }
  409. }
  410. return Queue.offer(responses, parsed)
  411. }),
  412. Effect.ignore({ log: true }),
  413. ),
  414. )
  415. return {
  416. // `proc.stdin.write` returns `number | Promise<number>`. The promise
  417. // form is the backpressure signal — if we don't await it, rapid
  418. // successive sends can interleave under pipe-buffer-full conditions
  419. // and corrupt the ndjson framing.
  420. send: (msg: object) =>
  421. Effect.promise(async () => {
  422. const ret = proc.stdin.write(JSON.stringify(msg) + "\n")
  423. if (typeof ret !== "number") await ret
  424. }),
  425. receive: Queue.take(responses),
  426. // proc.stdin.end() is idempotent in Bun; no try/catch needed.
  427. close: () => proc.stdin.end(),
  428. exited: proc.exited as Promise<number>,
  429. } satisfies AcpHandle
  430. })
  431. const kirincode: KirinCodeCli = { run, startRun, serve, acp, spawn, expectExit, parseJsonEvents }
  432. return yield* fn({ llm, home, kirincode })
  433. // FetchHttpClient is provided so test bodies can `yield* HttpClient.HttpClient`
  434. // and hit endpoints on `opencode.serve()` without rolling their own fetch.
  435. }).pipe(
  436. Effect.provide(
  437. Layer.mergeAll(
  438. TestLLMServer.layer,
  439. FetchHttpClient.layer,
  440. AppNodeBuilder.build(LayerNode.group([FSUtil.node, AppProcess.node])),
  441. ),
  442. ),
  443. )
  444. }
  445. function parseJsonEvents(stdout: string): Array<Record<string, unknown>> {
  446. return stdout
  447. .split("\n")
  448. .map((line) => line.trim())
  449. .filter((line) => line.length > 0)
  450. .map((line) => JSON.parse(line) as Record<string, unknown>)
  451. }
  452. function normalizeLines(value: string) {
  453. return value.replaceAll("\r\n", "\n")
  454. }
  455. // Convenience for the common assertion pattern. Dumps stderr/stdout when
  456. // the exit code doesn't match — saves debugging time on CI failures.
  457. function expectExit(result: RunResult, expected: number, label = "kirincode") {
  458. if (result.exitCode === expected) return
  459. const tail = (s: string, n: number) => (s.length > n ? "..." + s.slice(-n) : s)
  460. // eslint-disable-next-line no-console
  461. console.error(`[${label}] expected exit ${expected}, got ${result.exitCode} after ${result.durationMs}ms`)
  462. // eslint-disable-next-line no-console
  463. console.error(`[${label}] stderr (last 2000):\n${tail(result.stderr, 2000)}`)
  464. // eslint-disable-next-line no-console
  465. console.error(`[${label}] stdout (last 500):\n${tail(result.stdout, 500)}`)
  466. throw new Error(`${label}: expected exit ${expected}, got ${result.exitCode}`)
  467. }
  468. // `cliIt.live(name, fixture => effect)` is the same as
  469. // `it.live(name, () => withCliFixture(fixture))` — one fewer nesting level at
  470. // every call site. Use this for any test that needs the kirincode CLI fixture.
  471. //
  472. // Subprocess tests must run against the real clock — a TestClock-paused
  473. // environment can't drive a child process. If you need `.only` or `.skip`, fall
  474. // back to `it.live` + `withCliFixture` directly.
  475. // Body's R is `Scope.Scope | never` so tests can yield* scope-requiring
  476. // resources (e.g. `opencode.serve`) without an extra `Effect.scoped` wrapper —
  477. // `withCliFixture`'s outer scope is the natural lifetime.
  478. export const cliIt = {
  479. live: <A, E>(
  480. name: string,
  481. body: (input: CliFixture) => Effect.Effect<A, E, Scope.Scope | HttpClient.HttpClient>,
  482. opts?: number | TestOptions,
  483. ) => it.live(name, () => withCliFixture(body), opts),
  484. concurrent: <A, E>(
  485. name: string,
  486. body: (input: CliFixture) => Effect.Effect<A, E, Scope.Scope | HttpClient.HttpClient>,
  487. opts?: number | TestOptions,
  488. ) =>
  489. (process.platform === "win32" ? test : test.concurrent)(
  490. name,
  491. () => Effect.runPromise(Effect.scoped(withCliFixture(body))),
  492. opts,
  493. ),
  494. }