command.test.ts 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  1. import { afterEach, describe, expect, test } from "bun:test"
  2. import path from "node:path"
  3. type Message = { readonly id?: number; readonly result?: unknown; readonly error?: unknown }
  4. const children: Bun.Subprocess[] = []
  5. afterEach(async () => {
  6. await Promise.all(
  7. children.splice(0).map(async (child) => {
  8. child.kill("SIGKILL")
  9. await child.exited
  10. }),
  11. )
  12. })
  13. describe("acp command", () => {
  14. test("is registered", async () => {
  15. const result = await cli(["--help"])
  16. expect(result.exitCode).toBe(0)
  17. expect(result.stdout).toContain("acp Start an Agent Client Protocol server")
  18. })
  19. test("initializes over ndjson and exits on stdin eof", async () => {
  20. const child = spawn()
  21. const stderr = new Response(child.stderr).text()
  22. await child.stdin.write(
  23. new TextEncoder().encode(
  24. JSON.stringify({
  25. jsonrpc: "2.0",
  26. id: 1,
  27. method: "initialize",
  28. params: {
  29. protocolVersion: 1,
  30. clientCapabilities: {},
  31. clientInfo: { name: "test", version: "1.0.0" },
  32. },
  33. }) + "\n",
  34. ),
  35. )
  36. await child.stdin.flush()
  37. const response = await readMessage(child.stdout)
  38. expect(response.id).toBe(1)
  39. expect(response.error).toBeUndefined()
  40. expect(response.result).toMatchObject({
  41. protocolVersion: 1,
  42. agentCapabilities: { loadSession: true },
  43. agentInfo: { name: "OpenCode" },
  44. })
  45. await child.stdin.end()
  46. const exitCode = await child.exited
  47. const errorOutput = await stderr
  48. if (exitCode !== 0) throw new Error(`ACP exited with ${exitCode}: ${errorOutput}`)
  49. children.splice(children.indexOf(child), 1)
  50. }, 30_000)
  51. })
  52. function spawn() {
  53. const child = Bun.spawn([process.execPath, "run", "src/index.ts", "acp"], {
  54. cwd: path.join(import.meta.dir, "../.."),
  55. stdin: "pipe",
  56. stdout: "pipe",
  57. stderr: "pipe",
  58. })
  59. children.push(child)
  60. return child
  61. }
  62. async function readMessage(stream: ReadableStream<Uint8Array>) {
  63. const reader = stream.getReader()
  64. const decoder = new TextDecoder()
  65. let output = ""
  66. while (true) {
  67. const result = await Promise.race([
  68. reader.read(),
  69. Bun.sleep(20_000).then(() => {
  70. throw new Error("timed out waiting for ACP response")
  71. }),
  72. ])
  73. if (result.done) throw new Error(`ACP exited before responding: ${output}`)
  74. output += decoder.decode(result.value, { stream: true })
  75. const newline = output.indexOf("\n")
  76. if (newline === -1) continue
  77. reader.releaseLock()
  78. const message: unknown = JSON.parse(output.slice(0, newline))
  79. if (!isMessage(message)) throw new Error(`invalid ACP response: ${output.slice(0, newline)}`)
  80. return message
  81. }
  82. }
  83. function isMessage(value: unknown): value is Message {
  84. return typeof value === "object" && value !== null
  85. }
  86. async function cli(args: string[]) {
  87. const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
  88. cwd: path.join(import.meta.dir, "../.."),
  89. stdout: "pipe",
  90. stderr: "pipe",
  91. })
  92. const [stdout, stderr, exitCode] = await Promise.all([
  93. new Response(child.stdout).text(),
  94. new Response(child.stderr).text(),
  95. child.exited,
  96. ])
  97. return { stdout, stderr, exitCode }
  98. }