1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as HttpClient from "effect/http/HttpClient";
4import * as HttpClientError from "effect/http/HttpClientError";
5import * as HttpClientRequest from "effect/http/HttpClientRequest";
6
7import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
8import {
9 classifyOpenCodeCliVersion,
10 makeOpenCodeRuntimeProbe,
11 probeOpenCodeRuntime,
12} from "./versionProbe.ts";
13import {
14 OPENCODE_1_RESPONSES,
15 OPENCODE_2_RESPONSES,
16 replayOpenCodeServer,
17} from "./probeResponses.fixture.ts";
18
19const noBinary = {
20 runOpenCodeCommand: () => Effect.die("A configured server must not run the local binary"),
21} as unknown as OpenCodeRuntime.OpenCodeRuntime["Service"];
22
23const 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 );
28const SERVER_URL = "http://127.0.0.1:4096/";
29
30describe("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});