service-smoke.ts 6.0 KB

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