Skip to content
Source
Sign inSign up

snapshotProbe.test.ts

210 lines6.9 KB

1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";
3import type { ModelCapabilities } from "@t3tools/contracts";
4import * as HostProcess from "@t3tools/shared/HostProcess";
5import { createModelCapabilities } from "@t3tools/shared/model";
6import * as Effect from "effect/Effect";
7import * as Option from "effect/Option";
8import * as Sink from "effect/Sink";
9import * as Stream from "effect/Stream";
10import * as ChildProcess from "effect/process/ChildProcess";
11import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
12​
13import {
14 isCommandMissingCause,
15 resolveProviderProbeTimeoutMs,
16 parseGenericCliVersion,
17 providerModelsFromSettings,
18 spawnAndCollect,
19} from "./snapshotProbe.ts";
20​
21const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
22 optionDescriptors: [
23 {
24 id: "variant",
25 label: "Reasoning",
26 type: "select",
27 options: [{ id: "medium", label: "Medium", isDefault: true }],
28 currentValue: "medium",
29 },
30 {
31 id: "agent",
32 label: "Agent",
33 type: "select",
34 options: [{ id: "build", label: "Build", isDefault: true }],
35 currentValue: "build",
36 },
37 ],
38});
39​
40describe("providerModelsFromSettings", () => {
41 it("keeps an entry's own name and capabilities over the driver default", () => {
42 const capabilities = createModelCapabilities({
43 optionDescriptors: [{ id: "fastMode", label: "Fast Mode", type: "boolean" }],
44 });
45 const models = providerModelsFromSettings(
46 [],
47 ["bare", { slug: "named", name: "Named", capabilities }],
48 OPENCODE_CUSTOM_MODEL_CAPABILITIES,
49 );
50​
51 expect(models).toEqual([
52 {
53 slug: "bare",
54 name: "bare",
55 isCustom: true,
56 capabilities: OPENCODE_CUSTOM_MODEL_CAPABILITIES,
57 },
58 { slug: "named", name: "Named", isCustom: true, capabilities },
59 ]);
60 });
61​
62 it("renames a built-in model when a named entry uses its slug", () => {
63 const capabilities = createModelCapabilities({ optionDescriptors: [] });
64 const models = providerModelsFromSettings(
65 [
66 {
67 slug: "gpt-5.5",
68 name: "GPT-5.5",
69 shortName: "5.5",
70 isCustom: false,
71 capabilities,
72 },
73 ],
74 [{ slug: "gpt-5.5", name: "Team GPT" }],
75 capabilities,
76 );
77​
78 expect(models).toEqual([{ slug: "gpt-5.5", name: "Team GPT", isCustom: false, capabilities }]);
79 });
80​
81 it("preserves a custom slug that collides with a provider alias", () => {
82 const capabilities = createModelCapabilities({ optionDescriptors: [] });
83 const models = providerModelsFromSettings(
84 [
85 {
86 slug: "claude-opus-4-8",
87 name: "Claude Opus 4.8",
88 isCustom: false,
89 capabilities,
90 },
91 ],
92 [" opus "],
93 capabilities,
94 );
95​
96 expect(models.map((model) => model.slug)).toEqual(["claude-opus-4-8", "opus"]);
97 expect(models[1]?.isCustom).toBe(true);
98 });
99});
100​
101describe("parseGenericCliVersion", () => {
102 it("parses a v-prefixed version", () => {
103 expect(parseGenericCliVersion("opencode v2.0.3")).toBe("2.0.3");
104 expect(parseGenericCliVersion("v22.19.0")).toBe("22.19.0");
105 });
106​
107 it("parses a version embedded in other output", () => {
108 expect(parseGenericCliVersion("codex-cli 0.53.0 (build abc)")).toBe("0.53.0");
109 });
110​
111 it("ignores versions glued to other word characters", () => {
112 expect(parseGenericCliVersion("build2.0.3artifact")).toBeNull();
113 });
114});
115​
116describe("ProviderCommandNotFoundError", () => {
117 it.effect("retains safe failed-command diagnostics without process output", () => {
118 const stderr = "'codex' is not recognized: secret-token-value";
119 const spawner = ChildProcessSpawner.make(() =>
120 Effect.succeed(
121 ChildProcessSpawner.makeHandle({
122 pid: ChildProcessSpawner.ProcessId(1),
123 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(9009)),
124 isRunning: Effect.succeed(false),
125 kill: () => Effect.void,
126 unref: Effect.succeed(Effect.void),
127 stdin: Sink.drain,
128 stdout: Stream.empty,
129 stderr: Stream.encodeText(Stream.make(stderr)),
130 all: Stream.empty,
131 getInputFd: () => Sink.drain,
132 getOutputFd: () => Stream.empty,
133 }),
134 ),
135 );
136 return Effect.gen(function* () {
137 const error = yield* spawnAndCollect(
138 "C:\\tools\\codex.cmd",
139 ChildProcess.make("codex", ["--version"]),
140 ).pipe(
141 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
142 Effect.provideService(HostProcess.Platform, "win32"),
143 Effect.flip,
144 );
145​
146 if (error._tag !== "ProviderCommandNotFoundError") {
147 throw new Error(`Unexpected error: ${error._tag}`);
148 }
149​
150 expect(error.binaryPath).toBe("C:\\tools\\codex.cmd");
151 expect(error.exitCode).toBe(9009);
152 expect(error.stdoutLength).toBe(0);
153 expect(error.stderrLength).toBe(stderr.length);
154 expect(error.message).toBe(
155 "Provider command C:\\tools\\codex.cmd was not found (exit code 9009).",
156 );
157 expect(isCommandMissingCause(error)).toBe(true);
158 expect(error).not.toHaveProperty("stdout");
159 expect(error).not.toHaveProperty("stderr");
160 expect(error.message).not.toContain("secret-token-value");
161 });
162 });
163});
164​
165describe("resolveProviderProbeTimeoutMs", () => {
166 it.each([
167 "codex",
168 "claude",
169 "cursor",
170 "antigravity",
171 "grok",
172 "kiro",
173 "fx",
174 "pi",
175 "muse",
176 "opencode",
177 "acp_registry",
178 ])("uses only %s's readiness override", (provider) => {
179 const key = `T3CODE_${provider.toUpperCase()}_PROBE_TIMEOUT_MS`;
180 expect(resolveProviderProbeTimeoutMs(provider, 25000, { [key]: "60000" })).toBe(60000);
181 expect(resolveProviderProbeTimeoutMs(provider, 25000, { [key]: "1000" })).toBe(25000);
182 expect(
183 resolveProviderProbeTimeoutMs(provider, 25000, { T3CODE_OTHER_PROBE_TIMEOUT_MS: "60000" }),
184 ).toBe(25000);
185 });
186 it.each([undefined, "", " ", "bad", "0", "-1", "1.5", "Infinity", "2147483648"])(
187 "retains the phase default for invalid value %s",
188 (value) => {
189 expect(
190 resolveProviderProbeTimeoutMs("claude", 25000, { T3CODE_CLAUDE_PROBE_TIMEOUT_MS: value }),
191 ).toBe(25000);
192 },
193 );
194});
195​
196describe("spawnAndCollect", () => {
197 // regression: #35262 - without forceKillAfter a timed-out probe whose CLI ignores
198 // SIGTERM waits for it to exit, so the caller's timeout never returns.
199 it.live.skipIf(HostProcess.Platform.defaultValue() === "win32")(
200 "lets a timed-out probe return when the CLI ignores SIGTERM",
201 () =>
202 Effect.gen(function* () {
203 const result = yield* spawnAndCollect(
204 "sh",
205 ChildProcess.make("sh", ["-c", 'trap "" TERM; sleep 30']),
206 ).pipe(Effect.timeoutOption("200 millis"));
207 expect(Option.isNone(result)).toBe(true);
208 }).pipe(Effect.provide(NodeServices.layer)),
209 );
210});