| 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as HttpClient from "effect/http/HttpClient"; |
| 4 | import * as HttpClientError from "effect/http/HttpClientError"; |
| 5 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 6 | |
| 7 | import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; |
| 8 | import { |
| 9 | classifyOpenCodeCliVersion, |
| 10 | makeOpenCodeRuntimeProbe, |
| 11 | probeOpenCodeRuntime, |
| 12 | } from "./versionProbe.ts"; |
| 13 | import { |
| 14 | OPENCODE_1_RESPONSES, |
| 15 | OPENCODE_2_RESPONSES, |
| 16 | replayOpenCodeServer, |
| 17 | } from "./probeResponses.fixture.ts"; |
| 18 | |
| 19 | const noBinary = { |
| 20 | runOpenCodeCommand: () => Effect.die("A configured server must not run the local binary"), |
| 21 | } as unknown as OpenCodeRuntime.OpenCodeRuntime["Service"]; |
| 22 | |
| 23 | const probeServer = (serverUrl: string, serverPassword: string, http: HttpClient.HttpClient) => |
| 24 | probeOpenCodeRuntime({ binaryPath: "opencode", serverUrl, serverPassword }).pipe( |
| 25 | Effect.provideService(HttpClient.HttpClient, http), |
| 26 | Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, noBinary), |
| 27 | ); |
| 28 | const SERVER_URL = "http://127.0.0.1:4096/"; |
| 29 | |
| 30 | describe("OpenCode version probe", () => { |
| 31 | it("classifies the recorded `opencode --version` output of both versions", () => { |
| 32 | assert.deepStrictEqual(classifyOpenCodeCliVersion("1.18.32\n"), { |
| 33 | generation: "v1", |
| 34 | version: "1.18.32", |
| 35 | }); |
| 36 | assert.deepStrictEqual(classifyOpenCodeCliVersion("opencode v2.0.18\n"), { |
| 37 | generation: "v2", |
| 38 | version: "2.0.18", |
| 39 | }); |
| 40 | assert.isUndefined(classifyOpenCodeCliVersion("opencode dev build\n")); |
| 41 | }); |
| 42 | |
| 43 | it.effect("finds 2.x at /api/info without falling through to its HTML /global/health", () => |
| 44 | Effect.gen(function* () { |
| 45 | const paths: Array<string> = []; |
| 46 | const probed = yield* probeServer( |
| 47 | SERVER_URL, |
| 48 | "pw", |
| 49 | replayOpenCodeServer(OPENCODE_2_RESPONSES, "pw", paths), |
| 50 | ); |
| 51 | assert.deepStrictEqual(probed, { generation: "v2", version: "2.0.18" }); |
| 52 | assert.deepStrictEqual(paths, ["/api/info"]); |
| 53 | }), |
| 54 | ); |
| 55 | |
| 56 | it.effect("skips the HTML 1.x serves at /api/info and finds it at /global/health", () => |
| 57 | Effect.gen(function* () { |
| 58 | const paths: Array<string> = []; |
| 59 | const probed = yield* probeServer( |
| 60 | SERVER_URL, |
| 61 | "pw", |
| 62 | replayOpenCodeServer(OPENCODE_1_RESPONSES, "pw", paths), |
| 63 | ); |
| 64 | assert.deepStrictEqual(probed, { generation: "v1", version: "1.18.32" }); |
| 65 | assert.deepStrictEqual(paths, ["/api/info", "/global/health"]); |
| 66 | }), |
| 67 | ); |
| 68 | |
| 69 | it.effect("keeps a server URL's path prefix and query when probing", () => |
| 70 | Effect.gen(function* () { |
| 71 | const urls: Array<string> = []; |
| 72 | const replay = replayOpenCodeServer(OPENCODE_1_RESPONSES, "pw"); |
| 73 | // A reverse proxy mounting OpenCode under /opencode, reached with a routing query. |
| 74 | const proxied = HttpClient.make((request, url) => { |
| 75 | urls.push(url.href); |
| 76 | return replay.execute( |
| 77 | HttpClientRequest.setUrl( |
| 78 | request, |
| 79 | `http://127.0.0.1:4096${url.pathname.replace(/^\/opencode/, "")}`, |
| 80 | ), |
| 81 | ); |
| 82 | }); |
| 83 | const probed = yield* probeServer("http://proxy:8080/opencode/?route=oc", "pw", proxied); |
| 84 | assert.strictEqual(probed.generation, "v1"); |
| 85 | assert.deepStrictEqual(urls, [ |
| 86 | "http://proxy:8080/opencode/api/info?route=oc", |
| 87 | "http://proxy:8080/opencode/global/health?route=oc", |
| 88 | ]); |
| 89 | }), |
| 90 | ); |
| 91 | |
| 92 | it.effect("reports a rejected password instead of guessing a version", () => |
| 93 | Effect.gen(function* () { |
| 94 | for (const responses of [OPENCODE_1_RESPONSES, OPENCODE_2_RESPONSES]) { |
| 95 | const paths: Array<string> = []; |
| 96 | const error = yield* Effect.flip( |
| 97 | probeServer(SERVER_URL, "wrong", replayOpenCodeServer(responses, "pw", paths)), |
| 98 | ); |
| 99 | assert.match(error.detail, /401 Unauthorized/); |
| 100 | assert.deepStrictEqual(paths, ["/api/info"]); |
| 101 | } |
| 102 | }), |
| 103 | ); |
| 104 | |
| 105 | it.effect("sends a non-ASCII password as UTF-8, as both versions expect", () => |
| 106 | Effect.gen(function* () { |
| 107 | for (const [responses, password, generation] of [ |
| 108 | [OPENCODE_1_RESPONSES, "pässwörd", "v1"], |
| 109 | [OPENCODE_2_RESPONSES, "pass€word", "v2"], |
| 110 | ] as const) { |
| 111 | const probed = yield* probeServer( |
| 112 | SERVER_URL, |
| 113 | password, |
| 114 | replayOpenCodeServer(responses, password), |
| 115 | ); |
| 116 | assert.strictEqual(probed.generation, generation); |
| 117 | } |
| 118 | }), |
| 119 | ); |
| 120 | |
| 121 | it.effect("does not take other JSON at /api/info for OpenCode 2", () => |
| 122 | Effect.gen(function* () { |
| 123 | const impostor = { |
| 124 | ...OPENCODE_1_RESPONSES, |
| 125 | "/api/info": { status: 200, contentType: "application/json", body: '{"version":"3.4.1"}' }, |
| 126 | "/global/health": OPENCODE_1_RESPONSES["/api/info"], |
| 127 | }; |
| 128 | const error = yield* Effect.flip( |
| 129 | probeServer(SERVER_URL, "pw", replayOpenCodeServer(impostor, "pw")), |
| 130 | ); |
| 131 | assert.match(error.detail, /did not identify itself as OpenCode/); |
| 132 | }), |
| 133 | ); |
| 134 | |
| 135 | it.effect("never puts the configured URL or its credentials in an error detail", () => |
| 136 | Effect.gen(function* () { |
| 137 | const secret = "userinfo-secret"; |
| 138 | const unreachable = HttpClient.make((request) => |
| 139 | Effect.die(`should not be reached for ${request.url}`), |
| 140 | ); |
| 141 | // A fetch failure that echoes the full request URL, as Node's does. |
| 142 | const refused = HttpClient.make((request) => |
| 143 | Effect.fail( |
| 144 | new HttpClientError.HttpClientError({ |
| 145 | reason: new HttpClientError.TransportError({ |
| 146 | request, |
| 147 | description: `connect ECONNREFUSED ${request.url}`, |
| 148 | }), |
| 149 | }), |
| 150 | ), |
| 151 | ); |
| 152 | for (const [serverUrl, http] of [ |
| 153 | [`127.0.0.1:4096?token=${secret}`, unreachable], |
| 154 | [`localhost:4096/${secret}`, unreachable], |
| 155 | [`http://user:${secret}@10.0.0.1:4096/?token=${secret}`, refused], |
| 156 | ] as const) { |
| 157 | const error = yield* Effect.flip(probeServer(serverUrl, secret, http)); |
| 158 | assert.notInclude(error.detail, secret, serverUrl); |
| 159 | assert.notInclude(error.detail, "10.0.0.1", serverUrl); |
| 160 | } |
| 161 | }), |
| 162 | ); |
| 163 | |
| 164 | it.effect("remembers a probed runtime but never a failure", () => |
| 165 | Effect.gen(function* () { |
| 166 | const outputs = ["", "opencode v2.0.18\n", "1.18.32\n"]; |
| 167 | let calls = 0; |
| 168 | const runtime = { |
| 169 | runOpenCodeCommand: () => { |
| 170 | const stdout = outputs[calls++]; |
| 171 | return stdout |
| 172 | ? Effect.succeed({ stdout, stderr: "", code: 0 }) |
| 173 | : Effect.fail( |
| 174 | new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "spawn", detail: "ENOENT" }), |
| 175 | ); |
| 176 | }, |
| 177 | } as unknown as OpenCodeRuntime.OpenCodeRuntime["Service"]; |
| 178 | const probe = yield* makeOpenCodeRuntimeProbe( |
| 179 | probeOpenCodeRuntime({ binaryPath: "opencode", serverUrl: "", serverPassword: "" }).pipe( |
| 180 | Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime), |
| 181 | Effect.provideService( |
| 182 | HttpClient.HttpClient, |
| 183 | HttpClient.make(() => Effect.die("A local binary must not be probed over HTTP")), |
| 184 | ), |
| 185 | ), |
| 186 | ); |
| 187 | |
| 188 | yield* Effect.flip(probe.get); |
| 189 | assert.isTrue((yield* probe.lastSuccess)._tag === "None"); |
| 190 | assert.strictEqual((yield* probe.get).generation, "v2"); |
| 191 | assert.strictEqual((yield* probe.get).generation, "v2"); |
| 192 | assert.strictEqual(calls, 2); |
| 193 | // A status refresh re-probes, so an in-place downgrade or upgrade re-routes. |
| 194 | assert.strictEqual((yield* probe.refresh).generation, "v1"); |
| 195 | assert.strictEqual((yield* probe.get).generation, "v1"); |
| 196 | assert.strictEqual(calls, 3); |
| 197 | }), |
| 198 | ); |
| 199 | }); |