Showing the changes to apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts (1 of 52 files). Show all
Unified · Split
| @@ -0,0 +1,259 @@ | |||
| 1 | import type { ChildProcess } from "node:child_process"; | ||
| 2 | |||
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 4 | import { it } from "@effect/vitest"; | ||
| 5 | import { Duration, Effect, Layer } from "effect"; | ||
| 6 | import { TestClock } from "effect/testing"; | ||
| 7 | import { beforeEach, expect, vi } from "vitest"; | ||
| 8 | |||
| 9 | import { ServerConfig } from "../../config.ts"; | ||
| 10 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 11 | import { TextGeneration } from "../Services/TextGeneration.ts"; | ||
| 12 | import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts"; | ||
| 13 | |||
| 14 | const runtimeMock = vi.hoisted(() => { | ||
| 15 | const state = { | ||
| 16 | startCalls: [] as string[], | ||
| 17 | promptUrls: [] as string[], | ||
| 18 | authHeaders: [] as Array<string | null>, | ||
| 19 | closeCalls: [] as string[], | ||
| 20 | promptResult: undefined as { data?: { info?: { structured?: unknown } } } | undefined, | ||
| 21 | }; | ||
| 22 | |||
| 23 | return { | ||
| 24 | state, | ||
| 25 | reset() { | ||
| 26 | state.startCalls.length = 0; | ||
| 27 | state.promptUrls.length = 0; | ||
| 28 | state.authHeaders.length = 0; | ||
| 29 | state.closeCalls.length = 0; | ||
| 30 | state.promptResult = undefined; | ||
| 31 | }, | ||
| 32 | }; | ||
| 33 | }); | ||
| 34 | |||
| 35 | vi.mock("../../provider/opencodeRuntime.ts", async () => { | ||
| 36 | const actual = await vi.importActual<typeof import("../../provider/opencodeRuntime.ts")>( | ||
| 37 | "../../provider/opencodeRuntime.ts", | ||
| 38 | ); | ||
| 39 | |||
| 40 | return { | ||
| 41 | ...actual, | ||
| 42 | startOpenCodeServerProcess: vi.fn(async ({ binaryPath }: { binaryPath: string }) => { | ||
| 43 | const index = runtimeMock.state.startCalls.length + 1; | ||
| 44 | const url = `http://127.0.0.1:${4_300 + index}`; | ||
| 45 | runtimeMock.state.startCalls.push(binaryPath); | ||
| 46 | return { | ||
| 47 | url, | ||
| 48 | process: {} as ChildProcess, | ||
| 49 | close: () => { | ||
| 50 | runtimeMock.state.closeCalls.push(url); | ||
| 51 | }, | ||
| 52 | }; | ||
| 53 | }), | ||
| 54 | createOpenCodeSdkClient: vi.fn( | ||
| 55 | ({ baseUrl, serverPassword }: { baseUrl: string; serverPassword?: string }) => ({ | ||
| 56 | session: { | ||
| 57 | create: vi.fn(async () => ({ data: { id: `${baseUrl}/session` } })), | ||
| 58 | prompt: vi.fn(async () => { | ||
| 59 | runtimeMock.state.promptUrls.push(baseUrl); | ||
| 60 | runtimeMock.state.authHeaders.push( | ||
| 61 | serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, | ||
| 62 | ); | ||
| 63 | return ( | ||
| 64 | runtimeMock.state.promptResult ?? { | ||
| 65 | data: { | ||
| 66 | info: { | ||
| 67 | structured: { | ||
| 68 | subject: "Improve OpenCode reuse", | ||
| 69 | body: "Reuse one server for the full action.", | ||
| 70 | }, | ||
| 71 | }, | ||
| 72 | }, | ||
| 73 | } | ||
| 74 | ); | ||
| 75 | }), | ||
| 76 | }, | ||
| 77 | }), | ||
| 78 | ), | ||
| 79 | }; | ||
| 80 | }); | ||
| 81 | |||
| 82 | const DEFAULT_TEST_MODEL_SELECTION = { | ||
| 83 | provider: "opencode" as const, | ||
| 84 | model: "openai/gpt-5", | ||
| 85 | }; | ||
| 86 | |||
| 87 | const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; | ||
| 88 | |||
| 89 | const OpenCodeTextGenerationTestLayer = OpenCodeTextGenerationLive.pipe( | ||
| 90 | Layer.provideMerge( | ||
| 91 | ServerSettingsService.layerTest({ | ||
| 92 | providers: { | ||
| 93 | opencode: { | ||
| 94 | binaryPath: "fake-opencode", | ||
| 95 | }, | ||
| 96 | }, | ||
| 97 | }), | ||
| 98 | ), | ||
| 99 | Layer.provideMerge( | ||
| 100 | ServerConfig.layerTest(process.cwd(), { | ||
| 101 | prefix: "t3code-opencode-text-generation-test-", | ||
| 102 | }), | ||
| 103 | ), | ||
| 104 | Layer.provideMerge(NodeServices.layer), | ||
| 105 | ); | ||
| 106 | |||
| 107 | const OpenCodeTextGenerationExistingServerTestLayer = OpenCodeTextGenerationLive.pipe( | ||
| 108 | Layer.provideMerge( | ||
| 109 | ServerSettingsService.layerTest({ | ||
| 110 | providers: { | ||
| 111 | opencode: { | ||
| 112 | binaryPath: "fake-opencode", | ||
| 113 | serverUrl: "http://127.0.0.1:9999", | ||
| 114 | serverPassword: "secret-password", | ||
| 115 | }, | ||
| 116 | }, | ||
| 117 | }), | ||
| 118 | ), | ||
| 119 | Layer.provideMerge( | ||
| 120 | ServerConfig.layerTest(process.cwd(), { | ||
| 121 | prefix: "t3code-opencode-text-generation-existing-server-test-", | ||
| 122 | }), | ||
| 123 | ), | ||
| 124 | Layer.provideMerge(NodeServices.layer), | ||
| 125 | ); | ||
| 126 | |||
| 127 | beforeEach(() => { | ||
| 128 | runtimeMock.reset(); | ||
| 129 | }); | ||
| 130 | |||
| 131 | const advanceIdleClock = Effect.gen(function* () { | ||
| 132 | yield* Effect.yieldNow; | ||
| 133 | yield* TestClock.adjust(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS + 1)); | ||
| 134 | yield* Effect.yieldNow; | ||
| 135 | }); | ||
| 136 | |||
| 137 | it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) => { | ||
| 138 | it.effect("reuses a warm server across back-to-back requests and closes it after idling", () => | ||
| 139 | Effect.gen(function* () { | ||
| 140 | const textGeneration = yield* TextGeneration; | ||
| 141 | |||
| 142 | yield* textGeneration.generateCommitMessage({ | ||
| 143 | cwd: process.cwd(), | ||
| 144 | branch: "feature/opencode-reuse", | ||
| 145 | stagedSummary: "M README.md", | ||
| 146 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 147 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 148 | }); | ||
| 149 | yield* textGeneration.generateCommitMessage({ | ||
| 150 | cwd: process.cwd(), | ||
| 151 | branch: "feature/opencode-reuse", | ||
| 152 | stagedSummary: "M README.md", | ||
| 153 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 154 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 155 | }); | ||
| 156 | |||
| 157 | expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]); | ||
| 158 | expect(runtimeMock.state.promptUrls).toEqual([ | ||
| 159 | "http://127.0.0.1:4301", | ||
| 160 | "http://127.0.0.1:4301", | ||
| 161 | ]); | ||
| 162 | expect(runtimeMock.state.closeCalls).toEqual([]); | ||
| 163 | |||
| 164 | yield* advanceIdleClock; | ||
| 165 | |||
| 166 | expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); | ||
| 167 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 168 | ); | ||
| 169 | |||
| 170 | it.effect("starts a new server after the warm server idles out", () => | ||
| 171 | Effect.gen(function* () { | ||
| 172 | const textGeneration = yield* TextGeneration; | ||
| 173 | |||
| 174 | yield* textGeneration.generateCommitMessage({ | ||
| 175 | cwd: process.cwd(), | ||
| 176 | branch: "feature/opencode-reuse", | ||
| 177 | stagedSummary: "M README.md", | ||
| 178 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 179 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 180 | }); | ||
| 181 | |||
| 182 | yield* advanceIdleClock; | ||
| 183 | |||
| 184 | yield* textGeneration.generateCommitMessage({ | ||
| 185 | cwd: process.cwd(), | ||
| 186 | branch: "feature/opencode-reuse", | ||
| 187 | stagedSummary: "M README.md", | ||
| 188 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 189 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 190 | }); | ||
| 191 | |||
| 192 | expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]); | ||
| 193 | expect(runtimeMock.state.promptUrls).toEqual([ | ||
| 194 | "http://127.0.0.1:4301", | ||
| 195 | "http://127.0.0.1:4302", | ||
| 196 | ]); | ||
| 197 | expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); | ||
| 198 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 199 | ); | ||
| 200 | |||
| 201 | it.effect("returns a typed missing-output error when OpenCode omits info.structured", () => | ||
| 202 | Effect.gen(function* () { | ||
| 203 | runtimeMock.state.promptResult = { data: {} }; | ||
| 204 | const textGeneration = yield* TextGeneration; | ||
| 205 | |||
| 206 | const error = yield* textGeneration | ||
| 207 | .generateCommitMessage({ | ||
| 208 | cwd: process.cwd(), | ||
| 209 | branch: "feature/opencode-reuse", | ||
| 210 | stagedSummary: "M README.md", | ||
| 211 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 212 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 213 | }) | ||
| 214 | .pipe(Effect.flip); | ||
| 215 | |||
| 216 | expect(error.message).toContain("OpenCode returned no structured output."); | ||
| 217 | }), | ||
| 218 | ); | ||
| 219 | }); | ||
| 220 | |||
| 221 | it.layer(OpenCodeTextGenerationExistingServerTestLayer)( | ||
| 222 | "OpenCodeTextGenerationLive with configured server URL", | ||
| 223 | (it) => { | ||
| 224 | it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () => | ||
| 225 | Effect.gen(function* () { | ||
| 226 | const textGeneration = yield* TextGeneration; | ||
| 227 | |||
| 228 | yield* textGeneration.generateCommitMessage({ | ||
| 229 | cwd: process.cwd(), | ||
| 230 | branch: "feature/opencode-reuse", | ||
| 231 | stagedSummary: "M README.md", | ||
| 232 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 233 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 234 | }); | ||
| 235 | yield* textGeneration.generateCommitMessage({ | ||
| 236 | cwd: process.cwd(), | ||
| 237 | branch: "feature/opencode-reuse", | ||
| 238 | stagedSummary: "M README.md", | ||
| 239 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 240 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 241 | }); | ||
| 242 | |||
| 243 | expect(runtimeMock.state.startCalls).toEqual([]); | ||
| 244 | expect(runtimeMock.state.promptUrls).toEqual([ | ||
| 245 | "http://127.0.0.1:9999", | ||
| 246 | "http://127.0.0.1:9999", | ||
| 247 | ]); | ||
| 248 | expect(runtimeMock.state.authHeaders).toEqual([ | ||
| 249 | `Basic ${btoa("opencode:secret-password")}`, | ||
| 250 | `Basic ${btoa("opencode:secret-password")}`, | ||
| 251 | ]); | ||
| 252 | |||
| 253 | yield* advanceIdleClock; | ||
| 254 | |||
| 255 | expect(runtimeMock.state.closeCalls).toEqual([]); | ||
| 256 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 257 | ); | ||
| 258 | }, | ||
| 259 | ); | ||