mini.test.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. import { describe, expect, test } from "bun:test"
  2. import { ClientError, OpenCode } from "@opencode-ai/client/promise"
  3. import { OPENCODE_VERSION } from "../src/version"
  4. import path from "node:path"
  5. import { createMiniConnection, mergeInput as mergeInteractiveInput, resolveMiniTarget } from "../src/mini"
  6. import { mergeInput as mergeNonInteractiveInput, parseRunModel } from "../src/run/run"
  7. import { parseSessionTargetModel } from "../src/session-target"
  8. async function cli(args: string[]) {
  9. const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
  10. cwd: path.join(import.meta.dir, ".."),
  11. stdout: "pipe",
  12. stderr: "pipe",
  13. })
  14. const [stdout, stderr, exitCode] = await Promise.all([
  15. new Response(child.stdout).text(),
  16. new Response(child.stderr).text(),
  17. child.exited,
  18. ])
  19. return { stdout, stderr, exitCode }
  20. }
  21. describe("mini command", () => {
  22. test("uses piped stdin as the initial prompt", () => {
  23. expect(mergeInteractiveInput("from stdin", undefined)).toBe("from stdin")
  24. expect(mergeInteractiveInput("from stdin", "from flag")).toBe("from stdin\nfrom flag")
  25. })
  26. test("constructs a fresh authenticated client for a replacement endpoint", async () => {
  27. const authorization: Array<string | null> = []
  28. const initial = Bun.serve({
  29. port: 0,
  30. fetch() {
  31. return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
  32. },
  33. })
  34. const replacement = Bun.serve({
  35. port: 0,
  36. fetch(request) {
  37. authorization.push(request.headers.get("authorization"))
  38. return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
  39. },
  40. })
  41. const controller = new AbortController()
  42. let signal: AbortSignal | undefined
  43. try {
  44. const connection = createMiniConnection({
  45. endpoint: { url: initial.url.toString() },
  46. reconnect: async (next) => {
  47. signal = next
  48. return {
  49. url: replacement.url.toString(),
  50. auth: { type: "basic", username: "replacement", password: "secret" },
  51. }
  52. },
  53. })
  54. const client = await connection.reconnect?.(controller.signal)
  55. if (!client) throw new Error("Expected a replacement client")
  56. await client.health.get()
  57. expect(client).not.toBe(connection.sdk)
  58. expect(signal).toBe(controller.signal)
  59. expect(authorization).toEqual([`Basic ${btoa("replacement:secret")}`])
  60. expect(createMiniConnection({ endpoint: { url: initial.url.toString() } }).reconnect).toBeUndefined()
  61. } finally {
  62. initial.stop(true)
  63. replacement.stop(true)
  64. }
  65. })
  66. test("re-resolves a managed target when the endpoint moves before transport construction", async () => {
  67. const initial = OpenCode.make({ baseUrl: "https://initial.opencode.test" })
  68. const replacement = OpenCode.make({ baseUrl: "https://replacement.opencode.test" })
  69. const controller = new AbortController()
  70. const seen: (typeof initial)[] = []
  71. let reconnects = 0
  72. const result = await resolveMiniTarget({
  73. sdk: initial,
  74. reconnect: async (signal) => {
  75. expect(signal).toBe(controller.signal)
  76. reconnects++
  77. if (reconnects === 1) throw new Error("service still moving")
  78. return replacement
  79. },
  80. signal: controller.signal,
  81. resolve: async (sdk) => {
  82. seen.push(sdk)
  83. if (sdk === initial) throw new ClientError("Transport")
  84. return "ses-replacement"
  85. },
  86. })
  87. expect(seen).toEqual([initial, replacement])
  88. expect(reconnects).toBe(2)
  89. expect(result).toEqual({ sdk: replacement, value: "ses-replacement" })
  90. })
  91. test("merges non-interactive argument and stdin input", () => {
  92. expect(mergeNonInteractiveInput("from args", "from stdin")).toBe("from args\nfrom stdin")
  93. expect(mergeNonInteractiveInput(undefined, "from stdin")).toBe("from stdin")
  94. })
  95. test("parses model variants from the model reference", () => {
  96. expect(JSON.stringify(parseRunModel("openrouter/openai/gpt-5#high"))).toBe(
  97. JSON.stringify({ model: { providerID: "openrouter", modelID: "openai/gpt-5" }, variant: "high" }),
  98. )
  99. expect(parseSessionTargetModel("openrouter/openai/gpt-5#high")).toEqual({
  100. providerID: "openrouter",
  101. id: "openai/gpt-5",
  102. variant: "high",
  103. })
  104. })
  105. test("is registered in the preview CLI", async () => {
  106. const result = await cli(["--help"])
  107. expect(result.exitCode).toBe(0)
  108. expect(result.stdout).toContain("mini Start the minimal interactive interface")
  109. expect(result.stdout).toContain("run Run OpenCode with a message")
  110. })
  111. test("exposes run without legacy attach or command modes", async () => {
  112. const result = await cli(["run", "--help"])
  113. expect(result.exitCode).toBe(0)
  114. expect(result.stdout).toContain("--server string")
  115. expect(result.stdout).not.toContain("--variant")
  116. expect(result.stdout).not.toContain("--attach")
  117. expect(result.stdout).not.toContain("--command")
  118. })
  119. test("keeps option-like prompt text after the argument separator", async () => {
  120. const result = await cli(["run", "--server", "http://127.0.0.1:1", "--", "--foo"])
  121. expect(result.exitCode).toBe(1)
  122. expect(result.stderr).not.toContain("You must provide a message")
  123. })
  124. test("preserves a run failure exit code", async () => {
  125. let modelRequests = 0
  126. const server = Bun.serve({
  127. port: 0,
  128. fetch(request) {
  129. const url = new URL(request.url)
  130. if (url.pathname === "/api/health")
  131. return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
  132. if (url.pathname === "/api/location")
  133. return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
  134. if (url.pathname === "/api/model") {
  135. modelRequests++
  136. return Response.json({
  137. location: { directory: process.cwd(), project: { id: "global", directory: process.cwd() } },
  138. data: modelRequests === 1 ? [{ id: "missing", providerID: "definitely" }] : [],
  139. })
  140. }
  141. return new Response(undefined, { status: 404 })
  142. },
  143. })
  144. try {
  145. const result = await cli(["run", "--server", server.url.toString(), "--model", "definitely/missing", "hi"])
  146. expect(result.exitCode).toBe(1)
  147. expect(result.stderr).toContain("Model unavailable: definitely/missing")
  148. } finally {
  149. server.stop(true)
  150. }
  151. })
  152. test("reports pre-admission errors as JSON", async () => {
  153. const server = Bun.serve({
  154. port: 0,
  155. fetch(request) {
  156. if (new URL(request.url).pathname === "/api/session") return new Response("boom", { status: 500 })
  157. return Response.json({ healthy: true, version: "incompatible", pid: process.pid })
  158. },
  159. })
  160. try {
  161. const result = await cli(["run", "--format", "json", "--server", server.url.toString(), "hi"])
  162. expect(result.exitCode).toBe(1)
  163. expect(JSON.parse(result.stdout)).toMatchObject({
  164. type: "error",
  165. sessionID: "",
  166. error: { type: "unknown", message: "UnexpectedStatus" },
  167. })
  168. } finally {
  169. server.stop(true)
  170. }
  171. })
  172. test("uses the shared V2 server option instead of an attach command", async () => {
  173. const result = await cli(["mini", "--help"])
  174. expect(result.exitCode).toBe(0)
  175. expect(result.stdout).toContain("--server string")
  176. expect(result.stdout).not.toContain("SUBCOMMANDS")
  177. })
  178. test("routes local and explicit-server invocations into mini", async () => {
  179. for (const args of [["mini"], ["mini", "--server", "http://127.0.0.1:1"]]) {
  180. const result = await cli(args)
  181. expect(result.exitCode).toBe(1)
  182. expect(result.stderr).toContain("opencode mini requires a TTY stdout")
  183. }
  184. })
  185. })