installation.test.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. import { describe, expect } from "bun:test"
  2. import { makeGlobalNode } from "@kirincode-ai/core/effect/app-node"
  3. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  4. import { httpClient } from "@kirincode-ai/core/effect/app-node-platform"
  5. import { Effect, Layer, Stream } from "effect"
  6. import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
  7. import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
  8. import { Installation } from "../../src/installation"
  9. import { InstallationChannel } from "@kirincode-ai/core/installation/version"
  10. import { CrossSpawnSpawner } from "@kirincode-ai/core/cross-spawn-spawner"
  11. import { testEffect } from "../lib/effect"
  12. const encoder = new TextEncoder()
  13. function mockHttpClient(handler: (request: HttpClientRequest.HttpClientRequest) => Response) {
  14. const client = HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, handler(request))))
  15. return Layer.succeed(HttpClient.HttpClient, client)
  16. }
  17. function mockSpawner(
  18. handler: (cmd: string, args: readonly string[]) => string | { code: number; stdout?: string; stderr?: string } = () =>
  19. "",
  20. ) {
  21. const spawner = ChildProcessSpawner.make((command) => {
  22. const std = ChildProcess.isStandardCommand(command) ? command : undefined
  23. const result = handler(std?.command ?? "", std?.args ?? [])
  24. const output = typeof result === "string" ? { code: 0, stdout: result, stderr: "" } : result
  25. return Effect.succeed(
  26. ChildProcessSpawner.makeHandle({
  27. pid: ChildProcessSpawner.ProcessId(0),
  28. exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(output.code)),
  29. isRunning: Effect.succeed(false),
  30. kill: () => Effect.void,
  31. stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any,
  32. stdout: output.stdout ? Stream.make(encoder.encode(output.stdout)) : Stream.empty,
  33. stderr: output.stderr ? Stream.make(encoder.encode(output.stderr)) : Stream.empty,
  34. all: Stream.empty,
  35. getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any,
  36. getOutputFd: () => Stream.empty,
  37. unref: Effect.succeed(Effect.void),
  38. }),
  39. )
  40. })
  41. return Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)
  42. }
  43. function jsonResponse(body: unknown) {
  44. return new Response(JSON.stringify(body), {
  45. status: 200,
  46. headers: { "content-type": "application/json" },
  47. })
  48. }
  49. function testLayer(
  50. httpHandler: (request: HttpClientRequest.HttpClientRequest) => Response,
  51. spawnHandler?: (cmd: string, args: readonly string[]) => string | { code: number; stdout?: string; stderr?: string },
  52. ) {
  53. const spawnerNode = makeGlobalNode({
  54. service: ChildProcessSpawner.ChildProcessSpawner,
  55. layer: mockSpawner(spawnHandler),
  56. deps: [],
  57. })
  58. return LayerNode.compile(Installation.node, [
  59. [httpClient, mockHttpClient(httpHandler)],
  60. [CrossSpawnSpawner.node, spawnerNode],
  61. ])
  62. }
  63. describe("installation", () => {
  64. describe("latest", () => {
  65. testEffect(testLayer(() => jsonResponse({ tag_name: "v1.2.3" }))).effect(
  66. "reads release version from GitHub releases",
  67. () =>
  68. Effect.gen(function* () {
  69. const result = yield* Installation.use.latest("unknown")
  70. expect(result).toBe("1.2.3")
  71. }),
  72. )
  73. testEffect(testLayer(() => jsonResponse({ tag_name: "v4.0.0-beta.1" }))).effect(
  74. "strips v prefix from GitHub release tag",
  75. () =>
  76. Effect.gen(function* () {
  77. const result = yield* Installation.use.latest("curl")
  78. expect(result).toBe("4.0.0-beta.1")
  79. }),
  80. )
  81. const npmCalls: string[] = []
  82. testEffect(
  83. testLayer((request) => {
  84. npmCalls.push(request.url)
  85. return jsonResponse({ version: "1.5.0" })
  86. }),
  87. ).effect("reads npm versions via registry", () =>
  88. Effect.gen(function* () {
  89. const result = yield* Installation.use.latest("npm")
  90. expect(result).toBe("1.5.0")
  91. expect(npmCalls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`)
  92. }),
  93. )
  94. const bunCalls: string[] = []
  95. testEffect(
  96. testLayer((request) => {
  97. bunCalls.push(request.url)
  98. return jsonResponse({ version: "1.6.0" })
  99. }),
  100. ).effect("reads bun versions via registry", () =>
  101. Effect.gen(function* () {
  102. const result = yield* Installation.use.latest("bun")
  103. expect(result).toBe("1.6.0")
  104. expect(bunCalls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`)
  105. }),
  106. )
  107. const pnpmCalls: string[] = []
  108. testEffect(
  109. testLayer((request) => {
  110. pnpmCalls.push(request.url)
  111. return jsonResponse({ version: "1.7.0" })
  112. }),
  113. ).effect("reads pnpm versions via registry", () =>
  114. Effect.gen(function* () {
  115. const result = yield* Installation.use.latest("pnpm")
  116. expect(result).toBe("1.7.0")
  117. expect(pnpmCalls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`)
  118. }),
  119. )
  120. testEffect(testLayer(() => jsonResponse({ version: "2.3.4" }))).effect("reads scoop manifest versions", () =>
  121. Effect.gen(function* () {
  122. const result = yield* Installation.use.latest("scoop")
  123. expect(result).toBe("2.3.4")
  124. }),
  125. )
  126. testEffect(testLayer(() => jsonResponse({ d: { results: [{ Version: "3.4.5" }] } }))).effect(
  127. "reads chocolatey feed versions",
  128. () =>
  129. Effect.gen(function* () {
  130. const result = yield* Installation.use.latest("choco")
  131. expect(result).toBe("3.4.5")
  132. }),
  133. )
  134. testEffect(
  135. testLayer(
  136. () => jsonResponse({ versions: { stable: "2.0.0" } }),
  137. (cmd, args) => {
  138. // getBrewFormula: return core formula (no tap)
  139. if (cmd === "brew" && args.includes("--formula") && args.includes("anomalyco/tap/opencode")) return ""
  140. if (cmd === "brew" && args.includes("--formula") && args.includes("kirincode")) return "kirincode"
  141. return ""
  142. },
  143. ),
  144. ).effect("reads brew formulae API versions", () =>
  145. Effect.gen(function* () {
  146. const result = yield* Installation.use.latest("brew")
  147. expect(result).toBe("2.0.0")
  148. }),
  149. )
  150. const brewInfoJson = JSON.stringify({
  151. formulae: [{ versions: { stable: "2.1.0" } }],
  152. })
  153. testEffect(
  154. testLayer(
  155. () => jsonResponse({}), // HTTP not used for tap formula
  156. (cmd, args) => {
  157. if (cmd === "brew" && args.includes("anomalyco/tap/opencode") && args.includes("--formula")) return "kirincode"
  158. if (cmd === "brew" && args.includes("--json=v2")) return brewInfoJson
  159. return ""
  160. },
  161. ),
  162. ).effect("reads brew tap info JSON via CLI", () =>
  163. Effect.gen(function* () {
  164. const result = yield* Installation.use.latest("brew")
  165. expect(result).toBe("2.1.0")
  166. }),
  167. )
  168. })
  169. describe("upgrade", () => {
  170. testEffect(
  171. testLayer(
  172. () => jsonResponse({}),
  173. (cmd) => {
  174. if (cmd === "npm") return { code: 1, stderr: "token=secret command output" }
  175. return ""
  176. },
  177. ),
  178. ).effect("returns sanitized typed errors for failed package upgrades", () =>
  179. Effect.gen(function* () {
  180. const error = yield* Effect.flip(Installation.use.upgrade("npm", "9.9.9"))
  181. expect(error).toBeInstanceOf(Installation.UpgradeFailedError)
  182. expect(error.stderr).toBe("Upgrade failed for npm (exit code 1).")
  183. expect(error.message).toBe(error.stderr)
  184. expect(error.stderr).not.toContain("secret")
  185. expect(error.stderr).not.toContain("command output")
  186. }),
  187. )
  188. testEffect(
  189. testLayer(
  190. () => new Response("install script with token=secret", { status: 200 }),
  191. (cmd, args) => {
  192. if (cmd === "bash" && args[0] === "--version") return "GNU bash"
  193. if (cmd === "bash" || cmd === "sh") return { code: 1, stderr: "script output with token=secret" }
  194. return ""
  195. },
  196. ),
  197. ).effect("returns sanitized typed errors when the curl install script fails", () =>
  198. Effect.gen(function* () {
  199. const error = yield* Effect.flip(Installation.use.upgrade("curl", "9.9.9"))
  200. expect(error).toBeInstanceOf(Installation.UpgradeFailedError)
  201. expect(error.stderr).toBe("Upgrade failed for curl (exit code 1).")
  202. expect(error.message).toBe(error.stderr)
  203. expect(error.stderr).not.toContain("secret")
  204. expect(error.stderr).not.toContain("script output")
  205. }),
  206. )
  207. testEffect(
  208. testLayer(
  209. () => new Response("install script", { status: 200 }),
  210. (cmd, args) => {
  211. if (cmd === "bash" && args[0] === "--version") return { code: 1, stderr: "missing" }
  212. if (cmd === "bash") return { code: 1, stderr: "should not execute installer with bash" }
  213. if (cmd === "sh") return "ok"
  214. return ""
  215. },
  216. ),
  217. ).effect("falls back to sh when bash is unavailable during curl upgrade", () =>
  218. Effect.gen(function* () {
  219. yield* Installation.use.upgrade("curl", "9.9.9")
  220. }),
  221. )
  222. })
  223. })