test(server): run fake provider CLIs through a Node stub on every host#9567

merged1
windows-tests/symlinks
windows-tests/fake-cli
openedSep 4, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 3d ago
3 unresolved threads Β· Jump to file
0 / 12 viewed12 files changedLine totals unavailable

apps/server/src/testUtils/fakeCli.ts Β· Earlier revision 339ef035 Β· right 99

Original diff
@@ -0,0 +1,106 @@
+// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off - synchronous fixture writer used from plain and Effect tests alike.
+import * as NodeFS from "node:fs";
+import * as NodePath from "node:path";
+
+import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
+
+const windowsHost = HostProcessPlatform.defaultValue() === "win32";
+
+export interface FakeCliOptions {
+  /** Directory the launcher and its stub are written into. */
+  readonly directory: string;
+  /** Command name as callers spawn it, e.g. `codex`. */
+  readonly name: string;
+  /** ES module source the launcher runs with `node`. `process.argv.slice(2)` carries the CLI args. */
+  readonly source: string;
+  /** Environment set for the stub before it runs, on top of the inherited environment. */
+  readonly env?: Readonly<Record<string, string>>;
+}
+
+/**
+ * Writes a fake CLI whose behaviour lives in a Node stub. On posix the
+ * launcher is a `#!/bin/sh` script; on Windows it is a `.cmd` shim, since a
+ * shebang file is not executable there and `resolveSpawnCommand` routes
+ * `.cmd` through a shell. Returns the launcher path to hand to the code under
+ * test, which on Windows carries the `.cmd` extension.
+ */
+export function writeFakeCli(options: FakeCliOptions): string {
+  NodeFS.mkdirSync(options.directory, { recursive: true });
+  const stubPath = NodePath.join(options.directory, `${options.name}-stub.mjs`);
+  // The environment rides in a JSON sidecar the stub applies to itself, since
+  // neither sh nor cmd.exe can quote every value (newlines, quotes, `%`) safely.
+  const envPath = NodePath.join(options.directory, `${options.name}-env.json`);
+  NodeFS.writeFileSync(envPath, JSON.stringify(options.env ?? {}), "utf8");
+  NodeFS.writeFileSync(
+    stubPath,
+    [
+      'import { readFileSync as readFakeCliEnv } from "node:fs";',
+      `Object.assign(process.env, JSON.parse(readFakeCliEnv(${JSON.stringify(envPath)}, "utf8")));`,
+      options.source,
+    ].join("\n"),
+    "utf8",
+  );
+
+  if (windowsHost) {
+    const launcherPath = NodePath.join(options.directory, `${options.name}.cmd`);
+    NodeFS.writeFileSync(
+      launcherPath,
+      ["@echo off", `node "%~dp0${options.name}-stub.mjs" %*`, "exit /b %ERRORLEVEL%", ""].join(
+        "\r\n",
+      ),
+      "utf8",
+    );
+    return launcherPath;
+  }
+
+  const launcherPath = NodePath.join(options.directory, options.name);
+  NodeFS.writeFileSync(
+    launcherPath,
+    ["#!/bin/sh", `exec node "$(dirname "$0")/${options.name}-stub.mjs" "$@"`, ""].join("\n"),
+    "utf8",
+  );
+  NodeFS.chmodSync(launcherPath, 0o755);
+  return launcherPath;
+}
+
+/**
+ * Stub source that hands the process over to `scriptPath` (run with the
+ * current Node) after optionally requiring a leading argv prefix. Stdio is
+ * inherited so protocol traffic such as ACP JSON-RPC passes straight through,
+ * and the child's exit code and termination are mirrored back.
+ */
+export function execScriptSource(options: {
+  readonly scriptPath: string;
+  readonly expectedArgs?: ReadonlyArray<string>;
+  readonly forwardArgs?: boolean;
+  /** Tab-separated argv appended here per invocation, for launch-flag assertions. */
+  readonly argvLogPath?: string;
+  /** Wait before handing over, for tests that race a slow startup. */
+  readonly delayMs?: number;
+}): string {
+  return [
+    'import { spawnSync } from "node:child_process";',
+    'import { appendFileSync } from "node:fs";',
+    "const args = process.argv.slice(2);",
+    ...(options.argvLogPath === undefined
+      ? []
+      : [
+          `appendFileSync(${JSON.stringify(options.argvLogPath)}, args.join(${JSON.stringify("\t")}) + ${JSON.stringify("\n")});`,
+        ]),
+    ...(options.delayMs === undefined
+      ? []
+      : [`Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ${options.delayMs});`]),
+    `const expected = ${JSON.stringify(options.expectedArgs ?? [])};`,
+    "if (expected.some((value, index) => args[index] !== value)) {",
+    '  process.stderr.write(`unexpected args: ${args.join(" ")}\\n`);',
+    "  process.exit(11);",
+    "}",
+    `const forwarded = ${options.forwardArgs === false ? "[]" : "args"};`,
+    `const result = spawnSync(process.execPath, [${JSON.stringify(options.scriptPath)}, ...forwarded], {`,
M
macroscopeappBotSep 4, 2026

🟑 Medium testUtils/fakeCli.ts:99

When the ACP session terminates the stub with SIGTERM, the scriptPath process remains running, so tests waiting for the mock agent's SIGTERM log time out and leave a child process behind. Because spawnSync only waits for the child and does not forward termination signals to it, handle SIGTERM/SIGINT and relay them to the spawned script, or use an exec-style handoff for long-lived scripts.

No longer relevant as of e439d44d643ad25f9d69b65812ece4e9807aaaa6

J
Julius MarmingeSep 4, 2026

Superseded: the stub now imports the mock in-process rather than spawning it, so there is no intermediate process to relay signals through.

apps/server/src/provider/testFixtures/codexCollabMockPeer.cmd Β· Earlier revision e439d44d Β· right 5–5

Original diff
@@ -0,0 +1,7 @@
+@echo off
+rem Wrapper so CodexSessionRuntime can spawn the mock peer on Windows: the
+rem runtime always passes "app-server" as the first argument; drop it and
+rem run the .mjs peer with node.
+shift

apps/server/src/provider/testFixtures/codexCollabMockPeer.cmd Β· right 5–5

M
macroscopeappBotSep 4, 2026

🟠 High testFixtures/codexCollabMockPeer.cmd:5

Every Windows CodexCollab spawn fails before the mock starts because %~dp0 resolves from the shifted app-server argument, so Node looks for codexCollabMockPeer.mjs in the working directory (such as /tmp) instead of the fixture directory. shift shifts %1 into %0; use shift /1 so the batch-file path remains available.

suggestion
1shift /1
πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/server/src/provider/testFixtures/codexCollabMockPeer.cmd around line 5:
2
3Every Windows CodexCollab spawn fails before the mock starts because `%~dp0` resolves from the shifted `app-server` argument, so Node looks for `codexCollabMockPeer.mjs` in the working directory (such as `/tmp`) instead of the fixture directory. `shift` shifts `%1` into `%0`; use `shift /1` so the batch-file path remains available.
J
Julius MarmingeSep 4, 2026

Right β€” shift moved app-server into %0. Now shift /1.

apps/server/src/testUtils/fakeCli.ts Β· Earlier revision 339ef035 Β· right 7

Original diff
@@ -0,0 +1,106 @@
+// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off - synchronous fixture writer used from plain and Effect tests alike.
+import * as NodeFS from "node:fs";
+import * as NodePath from "node:path";
+
+import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
+
+const windowsHost = HostProcessPlatform.defaultValue() === "win32";
M
macroscopeappBotSep 4, 2026

This freezes a service-derived value into a module global at import time: HostProcessPlatform is a Context.Reference, so reading defaultValue() here bypasses the environment and any test that provides an override (e.g. the yield* isHostWindows read this helper replaces in ClaudeTextGeneration.test.ts) no longer affects which launcher shape gets written.

Since the helper is intentionally synchronous, consider resolving the platform per call instead of once per module β€” e.g. an optional platform on FakeCliOptions that defaults to HostProcessPlatform.defaultValue() inside writeFakeCli, letting Effect callers pass the platform they acquired from the environment.

suggestion
1

Posted via Macroscope β€” Effect Service Conventions

J
Julius MarmingeSep 4, 2026

Agreed. writeFakeCli now takes an optional platform and only falls back to HostProcessPlatform.defaultValue() when a plain test omits it; the Effect callers pass what they read from the environment.