1/**
2 * Live check of the OpenCode 2 server lifecycle against a real binary:
3 *
4 * OPENCODE2_BIN=/path/to/opencode vp test run src/provider/opencode2/OpenCode2Server.live.test.ts
5 *
6 * No model is called. The server runs with isolated HOME and XDG directories.
7 */
8import * as NodeServices from "@effect/platform-node/NodeServices";
9import { AbsolutePath, Location } from "@opencode/client/effect";
10import { assert, it } from "@effect/vitest";
11import * as Context from "effect/Context";
12import * as Effect from "effect/Effect";
13import * as Exit from "effect/Exit";
14import * as FileSystem from "effect/FileSystem";
15import * as Filter from "effect/Filter";
16import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";
18import * as Path from "effect/Path";
19import * as Redacted from "effect/Redacted";
20import * as Scope from "effect/Scope";
21import * as Stream from "effect/Stream";
22import * as FetchHttpClient from "effect/http/FetchHttpClient";
23import * as ChildProcess from "effect/process/ChildProcess";
24import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
25import { describe } from "vite-plus/test";
26
27import * as OpenCodeRuntime from "../OpenCodeRuntime.ts";
28import * as OpenCodeServerLedger from "../OpenCodeServerLedger.ts";
29import * as OpenCode2Client from "./OpenCode2Client.ts";
30import * as OpenCode2Server from "./OpenCode2Server.ts";
31
32const binaryPath = process.env.OPENCODE2_BIN;
33
34// A negative pid probes the whole process group, which T3 stops as one unit.
35const isAlive = (pid: number) => {
36 try {
37 process.kill(pid, 0);
38 return true;
39 } catch {
40 return false;
41 }
42};
43
44/**
45 * Stops a server the test spawned itself: SIGTERM, then SIGKILL if it is still
46 * alive after five seconds. Keyed on the PID captured at spawn, never on a
47 * pattern. Servers T3 spawns are stopped by T3, through the instance scope.
48 */
49const stopByPid = (pid: number) =>
50 Effect.gen(function* () {
51 const signal = (name: NodeJS.Signals) => {
52 try {
53 process.kill(pid, name);
54 } catch {
55 // Already exited.
56 }
57 };
58 signal("SIGTERM");
59 for (let attempt = 0; attempt < 50 && isAlive(pid); attempt++) {
60 yield* Effect.sleep("100 millis");
61 }
62 if (isAlive(pid)) signal("SIGKILL");
63 });
64
65/**
66 * Starts `opencode serve` the way a user would run it themselves. It is stopped
67 * by its PID when the calling scope closes, whether or not the test passed.
68 */
69const startExternalServer = Effect.fn("OpenCode2ServerLive.startExternalServer")(function* (input: {
70 readonly environment: NodeJS.ProcessEnv;
71 readonly directory: string;
72}) {
73 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
74 const child = yield* spawner.spawn(
75 ChildProcess.make(binaryPath!, ["serve", "--hostname=127.0.0.1", "--port=0"], {
76 cwd: input.directory,
77 env: input.environment,
78 }),
79 );
80 const pid = Number(child.pid);
81 yield* Effect.addFinalizer(() => stopByPid(pid));
82 const url = yield* child.stdout.pipe(
83 Stream.decodeText(),
84 Stream.splitLines,
85 Stream.filterMap(
86 Filter.fromPredicateOption((line: string) =>
87 Option.fromUndefinedOr(/server listening on\s+(https?:\/\/\S+)/i.exec(line)?.[1]),
88 ),
89 ),
90 Stream.runHead,
91 Effect.flatMap(Effect.fromOption),
92 Effect.timeout("30 seconds"),
93 );
94 return { url, pid };
95});
96
97describe.runIf(binaryPath !== undefined)("OpenCode2Server live", () => {
98 it.live(
99 "spawns one authenticated server per instance and stops it by PID",
100 () =>
101 Effect.gen(function* () {
102 const fs = yield* FileSystem.FileSystem;
103 const path = yield* Path.Path;
104 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode2-server-live-" });
105 const first = path.join(root, "first");
106 const second = path.join(root, "second");
107 yield* fs.makeDirectory(first);
108 yield* fs.makeDirectory(second);
109 const environment = {
110 PATH: process.env.PATH,
111 HOME: root,
112 XDG_CONFIG_HOME: path.join(root, "config"),
113 XDG_DATA_HOME: path.join(root, "data"),
114 XDG_STATE_HOME: path.join(root, "state"),
115 XDG_CACHE_HOME: path.join(root, "cache"),
116 // Ignored: the T3 password is the only one the server sees.
117 OPENCODE_SERVER_PASSWORD: "ambient-password",
118 };
119
120 // The instance owns its server: building the layer spawns nothing, and
121 // closing its scope stops whatever it spawned. The scope is released
122 // even when an assertion below fails; it is closed early on purpose.
123 const instanceScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
124 Scope.close(scope, Exit.void),
125 );
126 const server = Context.get(
127 yield* Layer.buildWithScope(
128 OpenCode2Server.layer({
129 binaryPath: binaryPath!,
130 serverUrl: "",
131 serverPassword: "",
132 directory: first,
133 environment,
134 }),
135 instanceScope,
136 ),
137 OpenCode2Server.OpenCode2Server,
138 );
139
140 const spawned = yield* server.withConnection((connection) =>
141 Effect.gen(function* () {
142 const info = yield* connection.client.server.info();
143 const session = yield* connection.client.session.create({
144 title: "first location",
145 location: Location.PublicRef.make({ directory: AbsolutePath.make(first) }),
146 });
147 return { ...connection, pid: info.pid, session };
148 }),
149 );
150 assert.strictEqual(spawned.version, "2.0.18");
151 assert.isFalse(spawned.external);
152 assert.isTrue(isAlive(spawned.pid));
153
154 // A second location is served by the same process.
155 const secondLocation = yield* server.withConnection((connection) =>
156 Effect.gen(function* () {
157 const info = yield* connection.client.server.info();
158 const session = yield* connection.client.session.create({
159 title: "second location",
160 location: Location.PublicRef.make({ directory: AbsolutePath.make(second) }),
161 });
162 return { url: connection.url, pid: info.pid, session };
163 }),
164 );
165 assert.strictEqual(secondLocation.url, spawned.url);
166 assert.strictEqual(secondLocation.pid, spawned.pid);
167 assert.strictEqual(secondLocation.session.location.directory, second);
168 assert.strictEqual(spawned.session.location.directory, first);
169
170 // External URL: the configured password works, a wrong one is a 401.
171 const external = yield* OpenCode2Server.make({
172 binaryPath: binaryPath!,
173 serverUrl: spawned.url,
174 serverPassword: "wrong-password",
175 directory: first,
176 environment,
177 });
178 const rejected = yield* external.withConnection(() => Effect.void).pipe(Effect.flip);
179 assert.include(rejected.detail, "rejected the server password");
180 const ambient = yield* OpenCode2Server.make({
181 binaryPath: binaryPath!,
182 serverUrl: spawned.url,
183 serverPassword: environment.OPENCODE_SERVER_PASSWORD,
184 directory: first,
185 environment,
186 });
187 const ambientRejected = yield* ambient.withConnection(() => Effect.void).pipe(Effect.flip);
188 assert.include(ambientRejected.detail, "rejected the server password");
189
190 // An external server whose password is not ASCII: OpenCode decodes Basic
191 // credentials as UTF-8, so the configured password must be sent that way.
192 const utf8Password = "pässwörd€";
193 const externalServer = yield* startExternalServer({
194 environment: OpenCode2Server.serverEnvironment(environment, Redacted.make(utf8Password)),
195 directory: second,
196 });
197 const utf8 = yield* OpenCode2Server.make({
198 binaryPath: binaryPath!,
199 serverUrl: externalServer.url,
200 serverPassword: utf8Password,
201 directory: second,
202 environment,
203 });
204 const utf8Connection = yield* utf8.withConnection((connection) =>
205 Effect.succeed(connection),
206 );
207 assert.strictEqual(utf8Connection.version, "2.0.18");
208 assert.isTrue(utf8Connection.external);
209 const asciiOnly = yield* OpenCode2Server.make({
210 binaryPath: binaryPath!,
211 serverUrl: externalServer.url,
212 serverPassword: "passwrd",
213 directory: second,
214 environment,
215 });
216 const asciiRejected = yield* asciiOnly.withConnection(() => Effect.void).pipe(Effect.flip);
217 assert.include(asciiRejected.detail, "rejected the server password");
218
219 // Closing the instance stops the server it spawned.
220 yield* Scope.close(instanceScope, Exit.void);
221 for (let attempt = 0; attempt < 50 && isAlive(-spawned.pid); attempt++) {
222 yield* Effect.sleep("100 millis");
223 }
224 assert.isFalse(isAlive(spawned.pid), `opencode serve ${spawned.pid} outlived its instance`);
225 assert.isFalse(isAlive(-spawned.pid), `a process in group ${spawned.pid} outlived it`);
226 const unreachable = yield* external.withConnection(() => Effect.void).pipe(Effect.flip);
227 assert.include(unreachable.detail, "Could not reach the OpenCode server");
228 }).pipe(
229 Effect.scoped,
230 Effect.provide(
231 Layer.mergeAll(
232 OpenCode2Client.layer,
233 OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)),
234 ).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer))),
235 ),
236 ),
237 60_000,
238 );
239});