authorgravatar for mihneanob@gmail.comMihnea Peteu <mihneanob@gmail.com> 2026-08-15 15:01:29+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 12:01:29+00:00
log5ffbf3ce4a4e8d14f8f37fae304167ad6c1e5218
tree5fd06d580cc77a78b6c306bcd464d6b2936e5591
parent34a12bc33f5fba02b0b398e7dff35ca041a87dc7
signature Signed by PGP key B5690EEEBB952194

Make ClaudeTextGeneration tests hermetic on Windows (#4508)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com>

1 files changed, 70 insertions(+), 35 deletions(-)

apps/server/src/textGeneration/ClaudeTextGeneration.test.ts+70-35
...@@ -1,12 +1,13 @@...@@ -1,12 +1,13 @@
1import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts";
2import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
3import { it } from "@effect/vitest";2import { it } from "@effect/vitest";
3import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts";
4import { isHostWindows } from "@t3tools/shared/hostProcess";
5import { createModelSelection } from "@t3tools/shared/model";
4import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";7import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";9import * as Path from "effect/Path";
8import * as Schema from "effect/Schema";10import * as Schema from "effect/Schema";
9import { createModelSelection } from "@t3tools/shared/model";
10import { expect } from "vite-plus/test";11import { expect } from "vite-plus/test";
1112
12import * as ServerConfig from "../config.ts";13import * as ServerConfig from "../config.ts";
...@@ -23,47 +24,80 @@ function makeFakeClaudeBinary(dir: string) {...@@ -23,47 +24,80 @@ function makeFakeClaudeBinary(dir: string) {
23 return Effect.gen(function* () {24 return Effect.gen(function* () {
24 const fs = yield* FileSystem.FileSystem;25 const fs = yield* FileSystem.FileSystem;
25 const path = yield* Path.Path;26 const path = yield* Path.Path;
27 const isWindows = yield* isHostWindows;
26 const binDir = path.join(dir, "bin");28 const binDir = path.join(dir, "bin");
27 const claudePath = path.join(binDir, "claude");29 const stubPath = path.join(binDir, "claude-stub.mjs");
28 yield* fs.makeDirectory(binDir, { recursive: true });30 yield* fs.makeDirectory(binDir, { recursive: true });
2931
32 // The stub behaviour lives in Node rather than a `#!/bin/sh` script so the
33 // same implementation is usable on Windows, where a shebang file is not
34 // executable and would fall through to the real Claude CLI on PATH.
30 yield* fs.writeFileString(35 yield* fs.writeFileString(
31 claudePath,36 stubPath,
32 [37 [
33 "#!/bin/sh",38 'const args = process.argv.slice(2).join(" ");',
34 'args="$*"',39 "",
35 'stdin_content="$(cat)"',40 "function fail(message, code) {",
36 'if [ -n "$T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN" ]; then',41 ' process.stderr.write(message + "\\n");',
37 ' printf "%s" "$args" | grep -F -- "$T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN" >/dev/null || {',42 " process.exit(code);",
38 ' printf "%s\\n" "args missing expected content" >&2',43 "}",
39 " exit 2",44 "",
40 " }",45 'let stdinContent = "";',
41 "fi",46 "if (!process.stdin.isTTY) {",
42 'if [ -n "$T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN" ]; then',47 " const chunks = [];",
43 ' if printf "%s" "$args" | grep -F -- "$T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN" >/dev/null; then',48 " for await (const chunk of process.stdin) {",
44 ' printf "%s\\n" "args contained forbidden content" >&2',49 " chunks.push(chunk);",
45 " exit 3",
46 " fi",
47 "fi",
48 'if [ -n "$T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN" ]; then',
49 ' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN" >/dev/null || {',
50 ' printf "%s\\n" "stdin missing expected content" >&2',
51 " exit 4",
52 " }",50 " }",
53 "fi",51 ' stdinContent = Buffer.concat(chunks).toString("utf8");',
54 'if [ -n "$T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE" ] && [ "$CLAUDE_CONFIG_DIR" != "$T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE" ]; then',52 "}",
55 ' printf "%s\\n" "CLAUDE_CONFIG_DIR was $CLAUDE_CONFIG_DIR" >&2',53 "",
56 " exit 5",54 "const argsMustContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN;",
57 "fi",55 "if (argsMustContain && !args.includes(argsMustContain)) {",
58 'if [ -n "$T3_FAKE_CLAUDE_STDERR" ]; then',56 ' fail("args missing expected content", 2);',
59 ' printf "%s\\n" "$T3_FAKE_CLAUDE_STDERR" >&2',57 "}",
60 "fi",58 "",
61 'printf "%s" "$T3_FAKE_CLAUDE_OUTPUT"',59 "const argsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN;",
62 'exit "${T3_FAKE_CLAUDE_EXIT_CODE:-0}"',60 "if (argsMustNotContain && args.includes(argsMustNotContain)) {",
61 ' fail("args contained forbidden content", 3);',
62 "}",
63 "",
64 "const stdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN;",
65 "if (stdinMustContain && !stdinContent.includes(stdinMustContain)) {",
66 ' fail("stdin missing expected content", 4);',
67 "}",
68 "",
69 "const configDirMustBe = process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE;",
70 "if (configDirMustBe && process.env.CLAUDE_CONFIG_DIR !== configDirMustBe) {",
71 ' fail("CLAUDE_CONFIG_DIR was " + (process.env.CLAUDE_CONFIG_DIR ?? ""), 5);',
72 "}",
73 "",
74 "const stderrText = process.env.T3_FAKE_CLAUDE_STDERR;",
75 "if (stderrText) {",
76 ' process.stderr.write(stderrText + "\\n");',
77 "}",
78 "",
79 'process.stdout.write(process.env.T3_FAKE_CLAUDE_OUTPUT ?? "");',
80 "process.exitCode = Number(process.env.T3_FAKE_CLAUDE_EXIT_CODE ?? 0);",
63 "",81 "",
64 ].join("\n"),82 ].join("\n"),
65 );83 );
66 yield* fs.chmod(claudePath, 0o755);84
85 if (isWindows) {
86 // Windows resolves executables through PATHEXT, so the entry point has to
87 // carry a real extension. `resolveSpawnCommand` spawns `.cmd` via a shell.
88 yield* fs.writeFileString(
89 path.join(binDir, "claude.cmd"),
90 ["@echo off", 'node "%~dp0claude-stub.mjs" %*', "exit /b %ERRORLEVEL%", ""].join("\r\n"),
91 );
92 } else {
93 const claudePath = path.join(binDir, "claude");
94 yield* fs.writeFileString(
95 claudePath,
96 ["#!/bin/sh", 'exec node "$(dirname "$0")/claude-stub.mjs" "$@"', ""].join("\n"),
97 );
98 yield* fs.chmod(claudePath, 0o755);
99 }
100
67 return binDir;101 return binDir;
68 });102 });
69}103}
...@@ -85,6 +119,7 @@ function withFakeClaudeEnv<A, E, R>(...@@ -85,6 +119,7 @@ function withFakeClaudeEnv<A, E, R>(
85 const fs = yield* FileSystem.FileSystem;119 const fs = yield* FileSystem.FileSystem;
86 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-claude-text-" });120 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-claude-text-" });
87 const binDir = yield* makeFakeClaudeBinary(tempDir);121 const binDir = yield* makeFakeClaudeBinary(tempDir);
122 const pathDelimiter = (yield* isHostWindows) ? ";" : ":";
88 const previousPath = process.env.PATH;123 const previousPath = process.env.PATH;
89 const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT;124 const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT;
90 const previousExitCode = process.env.T3_FAKE_CLAUDE_EXIT_CODE;125 const previousExitCode = process.env.T3_FAKE_CLAUDE_EXIT_CODE;
...@@ -96,7 +131,7 @@ function withFakeClaudeEnv<A, E, R>(...@@ -96,7 +131,7 @@ function withFakeClaudeEnv<A, E, R>(
96131
97 yield* Effect.acquireRelease(132 yield* Effect.acquireRelease(
98 Effect.sync(() => {133 Effect.sync(() => {
99 process.env.PATH = `${binDir}:${previousPath ?? ""}`;134 process.env.PATH = `${binDir}${pathDelimiter}${previousPath ?? ""}`;
100 process.env.T3_FAKE_CLAUDE_OUTPUT = input.output;135 process.env.T3_FAKE_CLAUDE_OUTPUT = input.output;
101136
102 if (input.exitCode !== undefined) {137 if (input.exitCode !== undefined) {