| 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 | */ |
| 8 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 9 | import { AbsolutePath, Location } from "@opencode/client/effect"; |
| 10 | import { assert, it } from "@effect/vitest"; |
| 11 | import * as Context from "effect/Context"; |
| 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Exit from "effect/Exit"; |
| 14 | import * as FileSystem from "effect/FileSystem"; |
| 15 | import * as Filter from "effect/Filter"; |
| 16 | import * as Layer from "effect/Layer"; |
| 17 | import * as Option from "effect/Option"; |
| 18 | import * as Path from "effect/Path"; |
| 19 | import * as Redacted from "effect/Redacted"; |
| 20 | import * as Scope from "effect/Scope"; |
| 21 | import * as Stream from "effect/Stream"; |
| 22 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; |
| 23 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 24 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 25 | import { describe } from "vite-plus/test"; |
| 26 | |
| 27 | import * as OpenCodeRuntime from "../OpenCodeRuntime.ts"; |
| 28 | import * as OpenCodeServerLedger from "../OpenCodeServerLedger.ts"; |
| 29 | import * as OpenCode2Client from "./OpenCode2Client.ts"; |
| 30 | import * as OpenCode2Server from "./OpenCode2Server.ts"; |
| 31 | |
| 32 | const binaryPath = process.env.OPENCODE2_BIN; |
| 33 | |
| 34 | // A negative pid probes the whole process group, which T3 stops as one unit. |
| 35 | const 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 | */ |
| 49 | const 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 | */ |
| 69 | const 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 | |
| 97 | describe.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 | }); |