installation.test.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. import { describe, expect, test } from "bun:test"
  2. import { Effect, Layer, Stream } from "effect"
  3. import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
  4. import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
  5. import { Installation } from "../../src/installation"
  6. import { InstallationChannel } from "@opencode-ai/core/installation/version"
  7. const encoder = new TextEncoder()
  8. function mockHttpClient(handler: (request: HttpClientRequest.HttpClientRequest) => Response) {
  9. const client = HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, handler(request))))
  10. return Layer.succeed(HttpClient.HttpClient, client)
  11. }
  12. function mockSpawner(handler: (cmd: string, args: readonly string[]) => string = () => "") {
  13. const spawner = ChildProcessSpawner.make((command) => {
  14. const std = ChildProcess.isStandardCommand(command) ? command : undefined
  15. const output = handler(std?.command ?? "", std?.args ?? [])
  16. return Effect.succeed(
  17. ChildProcessSpawner.makeHandle({
  18. pid: ChildProcessSpawner.ProcessId(0),
  19. exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
  20. isRunning: Effect.succeed(false),
  21. kill: () => Effect.void,
  22. stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any,
  23. stdout: output ? Stream.make(encoder.encode(output)) : Stream.empty,
  24. stderr: Stream.empty,
  25. all: Stream.empty,
  26. getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any,
  27. getOutputFd: () => Stream.empty,
  28. unref: Effect.succeed(Effect.void),
  29. }),
  30. )
  31. })
  32. return Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)
  33. }
  34. function jsonResponse(body: unknown) {
  35. return new Response(JSON.stringify(body), {
  36. status: 200,
  37. headers: { "content-type": "application/json" },
  38. })
  39. }
  40. function testLayer(
  41. httpHandler: (request: HttpClientRequest.HttpClientRequest) => Response,
  42. spawnHandler?: (cmd: string, args: readonly string[]) => string,
  43. ) {
  44. return Installation.layer.pipe(Layer.provide(mockHttpClient(httpHandler)), Layer.provide(mockSpawner(spawnHandler)))
  45. }
  46. describe("installation", () => {
  47. describe("latest", () => {
  48. test("reads release version from GitHub releases", async () => {
  49. const layer = testLayer(() => jsonResponse({ tag_name: "v1.2.3" }))
  50. const result = await Effect.runPromise(
  51. Installation.Service.use((svc) => svc.latest("unknown")).pipe(Effect.provide(layer)),
  52. )
  53. expect(result).toBe("1.2.3")
  54. })
  55. test("strips v prefix from GitHub release tag", async () => {
  56. const layer = testLayer(() => jsonResponse({ tag_name: "v4.0.0-beta.1" }))
  57. const result = await Effect.runPromise(
  58. Installation.Service.use((svc) => svc.latest("curl")).pipe(Effect.provide(layer)),
  59. )
  60. expect(result).toBe("4.0.0-beta.1")
  61. })
  62. test("reads npm versions via registry", async () => {
  63. const calls: string[] = []
  64. const layer = testLayer((request) => {
  65. calls.push(request.url)
  66. return jsonResponse({ version: "1.5.0" })
  67. })
  68. const result = await Effect.runPromise(
  69. Installation.Service.use((svc) => svc.latest("npm")).pipe(Effect.provide(layer)),
  70. )
  71. expect(result).toBe("1.5.0")
  72. expect(calls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`)
  73. })
  74. test("reads bun versions via registry", async () => {
  75. const calls: string[] = []
  76. const layer = testLayer((request) => {
  77. calls.push(request.url)
  78. return jsonResponse({ version: "1.6.0" })
  79. })
  80. const result = await Effect.runPromise(
  81. Installation.Service.use((svc) => svc.latest("bun")).pipe(Effect.provide(layer)),
  82. )
  83. expect(result).toBe("1.6.0")
  84. expect(calls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`)
  85. })
  86. test("reads pnpm versions via registry", async () => {
  87. const calls: string[] = []
  88. const layer = testLayer((request) => {
  89. calls.push(request.url)
  90. return jsonResponse({ version: "1.7.0" })
  91. })
  92. const result = await Effect.runPromise(
  93. Installation.Service.use((svc) => svc.latest("pnpm")).pipe(Effect.provide(layer)),
  94. )
  95. expect(result).toBe("1.7.0")
  96. expect(calls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`)
  97. })
  98. test("reads scoop manifest versions", async () => {
  99. const layer = testLayer(() => jsonResponse({ version: "2.3.4" }))
  100. const result = await Effect.runPromise(
  101. Installation.Service.use((svc) => svc.latest("scoop")).pipe(Effect.provide(layer)),
  102. )
  103. expect(result).toBe("2.3.4")
  104. })
  105. test("reads chocolatey feed versions", async () => {
  106. const layer = testLayer(() => jsonResponse({ d: { results: [{ Version: "3.4.5" }] } }))
  107. const result = await Effect.runPromise(
  108. Installation.Service.use((svc) => svc.latest("choco")).pipe(Effect.provide(layer)),
  109. )
  110. expect(result).toBe("3.4.5")
  111. })
  112. test("reads brew formulae API versions", async () => {
  113. const layer = testLayer(
  114. () => jsonResponse({ versions: { stable: "2.0.0" } }),
  115. (cmd, args) => {
  116. // getBrewFormula: return core formula (no tap)
  117. if (cmd === "brew" && args.includes("--formula") && args.includes("anomalyco/tap/opencode")) return ""
  118. if (cmd === "brew" && args.includes("--formula") && args.includes("opencode")) return "opencode"
  119. return ""
  120. },
  121. )
  122. const result = await Effect.runPromise(
  123. Installation.Service.use((svc) => svc.latest("brew")).pipe(Effect.provide(layer)),
  124. )
  125. expect(result).toBe("2.0.0")
  126. })
  127. test("reads brew tap info JSON via CLI", async () => {
  128. const brewInfoJson = JSON.stringify({
  129. formulae: [{ versions: { stable: "2.1.0" } }],
  130. })
  131. const layer = testLayer(
  132. () => jsonResponse({}), // HTTP not used for tap formula
  133. (cmd, args) => {
  134. if (cmd === "brew" && args.includes("anomalyco/tap/opencode") && args.includes("--formula")) return "opencode"
  135. if (cmd === "brew" && args.includes("--json=v2")) return brewInfoJson
  136. return ""
  137. },
  138. )
  139. const result = await Effect.runPromise(
  140. Installation.Service.use((svc) => svc.latest("brew")).pipe(Effect.provide(layer)),
  141. )
  142. expect(result).toBe("2.1.0")
  143. })
  144. })
  145. })