service-smoke.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  1. #!/usr/bin/env bun
  2. import { Service } from "@opencode-ai/client/effect/service"
  3. import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
  4. import { Schema } from "effect"
  5. import fs from "node:fs/promises"
  6. import os from "node:os"
  7. import path from "node:path"
  8. const target = `cli-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`
  9. const directory = path.join(import.meta.dir, "..", "dist", target, "bin")
  10. const binary = path.join(directory, `opencode2${process.platform === "win32" ? ".exe" : ""}`)
  11. if (!(await Bun.file(binary).exists())) throw new Error(`Missing compiled CLI in ${directory}`)
  12. const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-"))
  13. const env = {
  14. ...process.env,
  15. HOME: root,
  16. USERPROFILE: root,
  17. OPENCODE_DB: path.join(root, "opencode.db"),
  18. OPENCODE_TEST_HOME: root,
  19. XDG_CACHE_HOME: path.join(root, "cache"),
  20. XDG_CONFIG_HOME: path.join(root, "config"),
  21. XDG_DATA_HOME: path.join(root, "data"),
  22. XDG_STATE_HOME: path.join(root, "state"),
  23. }
  24. const processes: Array<ReturnType<typeof Bun.spawn>> = []
  25. const errors: Array<Promise<string>> = []
  26. let failure: unknown
  27. try {
  28. spawnService()
  29. spawnService()
  30. const registration = await waitForRegistration()
  31. const info = await Schema.decodeUnknownPromise(Service.Info)(await Bun.file(registration).json())
  32. if (info.id === undefined || info.password === undefined) throw new Error("Registration is missing service identity")
  33. const credential = btoa(`opencode:${info.password}`)
  34. const headers = { authorization: "Basic " + credential }
  35. const token = encodeURIComponent(credential)
  36. const health = await waitForReady(info.url, headers)
  37. if (health.pid !== info.pid) throw new Error("Health process does not match registration")
  38. const tokenHealth = await fetch(
  39. new URL(`/api/health?auth_token=${token}`, info.url),
  40. { signal: AbortSignal.timeout(5_000) },
  41. )
  42. if (tokenHealth.status !== 200) throw new Error("Compiled service rejected query authentication")
  43. const tokenOpenApi = await fetch(
  44. new URL(`/openapi.json?auth_token=${token}`, info.url),
  45. { signal: AbortSignal.timeout(5_000) },
  46. )
  47. if (tokenOpenApi.status !== 200) throw new Error("Compiled application rejected query authentication")
  48. const unauthorizedHealth = await fetch(new URL("/api/health", info.url), {
  49. signal: AbortSignal.timeout(5_000),
  50. })
  51. if (unauthorizedHealth.status !== 401) throw new Error("Compiled service exposed health without authentication")
  52. const unauthorizedOpenApi = await fetch(new URL("/openapi.json", info.url), {
  53. signal: AbortSignal.timeout(5_000),
  54. })
  55. if (unauthorizedOpenApi.status !== 401) throw new Error("Compiled service exposed application routes without authentication")
  56. const unauthorizedStop = await fetch(new URL("/api/service/stop", info.url), {
  57. method: "POST",
  58. headers: { "content-type": "application/json" },
  59. body: JSON.stringify({ instanceID: info.id }),
  60. signal: AbortSignal.timeout(5_000),
  61. })
  62. if (unauthorizedStop.status !== 401) throw new Error("Compiled service accepted unauthenticated stop")
  63. const winner = processes.find((process) => process.pid === info.pid)
  64. const loser = processes.find((process) => process.pid !== info.pid)
  65. if (!winner || !loser) throw new Error("Compiled contenders did not elect one registered owner")
  66. if (!(await exitsWithin(loser, 10_000))) throw new Error("Losing compiled contender did not exit")
  67. const stopped = await Schema.decodeUnknownPromise(ServiceStatus.StopResponse)(
  68. await fetch(new URL("/api/service/stop", info.url), {
  69. method: "POST",
  70. headers: { ...headers, "content-type": "application/json" },
  71. body: JSON.stringify({ instanceID: info.id }),
  72. signal: AbortSignal.timeout(5_000),
  73. }).then((response) => response.json()),
  74. )
  75. if (!stopped.accepted) throw new Error("Compiled service rejected exact-instance stop")
  76. if (!(await exitsWithin(winner, 10_000))) throw new Error("Compiled service did not stop")
  77. for (let attempt = 0; attempt < 200 && (await Bun.file(registration).exists()); attempt++) await Bun.sleep(25)
  78. if (await Bun.file(registration).exists()) throw new Error("Compiled service registration was not removed")
  79. } catch (cause) {
  80. failure = cause
  81. } finally {
  82. processes.forEach((process) => process.kill())
  83. await Promise.all(processes.map((process) => process.exited))
  84. }
  85. const output = await Promise.all(errors)
  86. await fs.rm(root, { recursive: true, force: true })
  87. if (failure)
  88. throw new Error(output.filter(Boolean).join("\n") || "Compiled service lifecycle smoke test failed", {
  89. cause: failure,
  90. })
  91. function spawnService() {
  92. const process = Bun.spawn([binary, "serve", "--service"], { env, stdout: "ignore", stderr: "pipe" })
  93. processes.push(process)
  94. errors.push(new Response(process.stderr).text())
  95. return process
  96. }
  97. async function waitForRegistration() {
  98. const directory = path.join(root, "state", "opencode")
  99. for (let attempt = 0; attempt < 400; attempt++) {
  100. const files = await fs.readdir(directory).catch(() => [])
  101. const file = files.find(
  102. (file) => file === "service.json" || (file.startsWith("service-") && file.endsWith(".json")),
  103. )
  104. if (file) return path.join(directory, file)
  105. await Bun.sleep(25)
  106. }
  107. throw new Error("Compiled service did not publish registration")
  108. }
  109. async function waitForReady(url: string, headers: HeadersInit) {
  110. const deadline = Date.now() + 20_000
  111. while (Date.now() < deadline) {
  112. const response = await fetch(new URL("/api/health", url), {
  113. headers,
  114. signal: AbortSignal.timeout(1_000),
  115. }).catch(() => undefined)
  116. if (response?.ok) return Schema.decodeUnknownPromise(ServiceStatus.Health)(await response.json())
  117. await Bun.sleep(25)
  118. }
  119. throw new Error("Compiled service did not become ready")
  120. }
  121. function exitsWithin(process: Bun.Subprocess, milliseconds: number) {
  122. return Promise.race([process.exited.then(() => true), Bun.sleep(milliseconds).then(() => false)])
  123. }