1import type { OpencodeClient } from "@opencode-ai/sdk/v2";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { it as effectIt } from "@effect/vitest";
4import * as HostProcess from "@t3tools/shared/HostProcess";
5import * as Effect from "effect/Effect";
6import * as Fiber from "effect/Fiber";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Path from "effect/Path";
10import * as TestClock from "effect/testing/TestClock";
11import * as FetchHttpClient from "effect/http/FetchHttpClient";
12import * as HttpClient from "effect/http/HttpClient";
13import { describe, expect, it } from "vite-plus/test";
14
15import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
16import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts";
17
18describe("resolveOpenCodeConfigContent", () => {
19 it("prefers the caller environment over the inherited environment", () => {
20 expect(
21 OpenCodeRuntime.resolveOpenCodeConfigContent(
22 { OPENCODE_CONFIG_CONTENT: '{"source":"caller"}' },
23 { OPENCODE_CONFIG_CONTENT: '{"source":"process"}' },
24 ),
25 ).toBe('{"source":"caller"}');
26 });
27
28 it("falls back to the inherited environment and then an empty config", () => {
29 expect(
30 OpenCodeRuntime.resolveOpenCodeConfigContent(undefined, {
31 OPENCODE_CONFIG_CONTENT: '{"source":"process"}',
32 }),
33 ).toBe('{"source":"process"}');
34 expect(OpenCodeRuntime.resolveOpenCodeConfigContent(undefined, {})).toBe("{}");
35 });
36});
37
38describe("resolveOpenCodeServerPassword", () => {
39 it("uses the local environment password when settings do not provide one", () => {
40 expect(
41 OpenCodeRuntime.resolveOpenCodeServerPassword(
42 { external: false, environment: { OPENCODE_SERVER_PASSWORD: " env password " } },
43 {},
44 ),
45 ).toBe(" env password ");
46 });
47
48 it("uses the settings password for a local server", () => {
49 expect(
50 OpenCodeRuntime.resolveOpenCodeServerPassword(
51 { external: false, serverPassword: " settings password " },
52 {},
53 ),
54 ).toBe(" settings password ");
55 });
56
57 it("uses the settings password when local settings and environment differ", () => {
58 expect(
59 OpenCodeRuntime.resolveOpenCodeServerPassword(
60 {
61 external: false,
62 serverPassword: "settings-password",
63 environment: { OPENCODE_SERVER_PASSWORD: "environment-password" },
64 },
65 {},
66 ),
67 ).toBe("settings-password");
68 });
69
70 it("does not send an inherited local password to an external server", () => {
71 expect(
72 OpenCodeRuntime.resolveOpenCodeServerPassword(
73 { external: true, environment: { OPENCODE_SERVER_PASSWORD: "local-secret" } },
74 { OPENCODE_SERVER_PASSWORD: "inherited-secret" },
75 ),
76 ).toBeUndefined();
77 });
78});
79
80function makeHealthClient(
81 result: (options?: { readonly signal?: AbortSignal }) => Promise<unknown>,
82): OpencodeClient {
83 return {
84 global: {
85 health: result,
86 },
87 } as unknown as OpencodeClient;
88}
89
90describe("verifyOpenCodeServerVersion", () => {
91 effectIt.effect("accepts a supported server version", () =>
92 Effect.gen(function* () {
93 const version = yield* OpenCodeRuntime.verifyOpenCodeServerVersion(
94 makeHealthClient(() => Promise.resolve({ data: { healthy: true, version: "1.14.19" } })),
95 );
96 expect(version).toBe("1.14.19");
97 }),
98 );
99
100 effectIt.effect("rejects a server below the supported version", () =>
101 Effect.gen(function* () {
102 const error = yield* OpenCodeRuntime.verifyOpenCodeServerVersion(
103 makeHealthClient(() => Promise.resolve({ data: { healthy: true, version: "1.14.18" } })),
104 ).pipe(Effect.flip);
105 expect(error).toBeInstanceOf(OpenCodeRuntime.OpenCodeRuntimeError);
106 expect(error.detail).toContain("v1.14.18 is too old");
107 }),
108 );
109
110 for (const data of [
111 { healthy: true },
112 { healthy: true, version: "not-a-version" },
113 { healthy: false, version: "1.14.19" },
114 ]) {
115 effectIt.effect(`rejects an invalid health response: ${JSON.stringify(data)}`, () =>
116 Effect.gen(function* () {
117 const error = yield* OpenCodeRuntime.verifyOpenCodeServerVersion(
118 makeHealthClient(() => Promise.resolve({ data })),
119 ).pipe(Effect.flip);
120 expect(error).toBeInstanceOf(OpenCodeRuntime.OpenCodeRuntimeError);
121 expect(error.detail).toContain("requires OpenCode v1.14.19 or newer");
122 }),
123 );
124 }
125
126 effectIt.effect("preserves an unauthorized health error", () =>
127 Effect.gen(function* () {
128 const error = yield* OpenCodeRuntime.verifyOpenCodeServerVersion(
129 makeHealthClient(() =>
130 Promise.reject({ response: { status: 401 }, error: { message: "Unauthorized" } }),
131 ),
132 ).pipe(Effect.flip);
133 expect(error).toBeInstanceOf(OpenCodeRuntime.OpenCodeRuntimeError);
134 expect(error.detail).toContain("status=401");
135 expect(error.detail).toContain("Unauthorized");
136 }),
137 );
138
139 effectIt.effect("aborts a health request when the version check times out", () =>
140 Effect.gen(function* () {
141 let requestSignal: AbortSignal | undefined;
142 const checkFiber = yield* OpenCodeRuntime.verifyOpenCodeServerVersion(
143 makeHealthClient((options) => {
144 requestSignal = options?.signal;
145 return new Promise(() => undefined);
146 }),
147 ).pipe(Effect.flip, Effect.forkChild);
148
149 yield* Effect.yieldNow;
150 expect(requestSignal).toBeDefined();
151 yield* TestClock.adjust("6 seconds");
152
153 const error = yield* Fiber.join(checkFiber);
154 expect(error.detail).toBe("Timed out while checking the OpenCode server version.");
155 expect(requestSignal?.aborted).toBe(true);
156 }).pipe(Effect.provide(TestClock.layer())),
157 );
158});
159
160describe("OpenCode server output", () => {
161 effectIt.live(
162 "drains stdout and stderr after startup so server requests can finish",
163 () =>
164 Effect.gen(function* () {
165 const fs = yield* FileSystem.FileSystem;
166 const path = yield* Path.Path;
167 const environment = yield* HostProcess.Environment;
168 const executablePath = yield* HostProcess.ExecutablePath;
169 const platform = yield* HostProcess.Platform;
170 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-output-" });
171 const isWindows = platform === "win32";
172 const binaryPath = path.join(tempDir, isWindows ? "opencode.cmd" : "opencode");
173 const scriptPath = path.join(tempDir, "opencode.mjs");
174
175 yield* fs.writeFileString(
176 scriptPath,
177 `import { createServer } from "node:http";
178const writeOutput = (stream) => new Promise((resolve, reject) => {
179 stream.write("x".repeat(2 * 1024 * 1024), (error) => error ? reject(error) : resolve());
180});
181const server = createServer(async (request, response) => {
182 if (request.url.startsWith("/global/health")) {
183 response.setHeader("Content-Type", "application/json");
184 response.end(JSON.stringify({ healthy: true, version: "1.14.19" }));
185 return;
186 }
187 await Promise.all([writeOutput(process.stdout), writeOutput(process.stderr)]);
188 response.end("drained");
189});
190server.listen(0, "127.0.0.1", () => {
191 process.stdout.write("opencode server listening on http://127.0.0.1:" + server.address().port + "\\n");
192});
193`,
194 );
195 yield* fs.writeFileString(
196 binaryPath,
197 [
198 ...(isWindows ? ["@echo off"] : ["#!/bin/sh"]),
199 isWindows
200 ? '"%T3_TEST_NODE_BINARY%" "%T3_TEST_OPENCODE_SCRIPT%" %*'
201 : 'exec "$T3_TEST_NODE_BINARY" "$T3_TEST_OPENCODE_SCRIPT" "$@"',
202 "",
203 ].join("\n"),
204 );
205 if (!isWindows) {
206 yield* fs.chmod(binaryPath, 0o755);
207 }
208
209 const runtime = yield* OpenCodeRuntime.OpenCodeRuntime;
210 const server = yield* runtime.startOpenCodeServerProcess({
211 binaryPath,
212 directory: tempDir,
213 port: 0,
214 environment: {
215 ...environment,
216 T3_TEST_NODE_BINARY: executablePath,
217 T3_TEST_OPENCODE_SCRIPT: scriptPath,
218 },
219 });
220 const response = yield* HttpClient.get(`${server.url}/output`);
221
222 expect(yield* response.text).toBe("drained");
223 expect(yield* server.isRunning).toBe(true);
224 }).pipe(
225 Effect.scoped,
226 Effect.provide([
227 OpenCodeRuntime.layer.pipe(
228 Layer.provide(OpenCodeServerLedger.layerTest),
229 Layer.provideMerge(NodeServices.layer),
230 ),
231 FetchHttpClient.layer,
232 ]),
233 ),
234 10_000,
235 );
236});