| 1 | // @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off - synchronous fixture writer used from plain and Effect tests alike. |
| 2 | import * as NodeFS from "node:fs"; |
| 3 | import * as NodePath from "node:path"; |
| 4 | |
| 5 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 6 | |
| 7 | export interface FakeCliOptions { |
| 8 | /** Directory the launcher and its stub are written into. */ |
| 9 | readonly directory: string; |
| 10 | /** Command name as callers spawn it, e.g. `codex`. */ |
| 11 | readonly name: string; |
| 12 | /** ES module source the launcher runs with `node`. `process.argv.slice(2)` carries the CLI args. */ |
| 13 | readonly source: string; |
| 14 | /** Environment set for the stub before it runs, on top of the inherited environment. */ |
| 15 | readonly env?: Readonly<Record<string, string>>; |
| 16 | /** |
| 17 | * Platform the launcher is shaped for. Effect callers should pass the value |
| 18 | * they read from `HostProcess.Platform` so an injected override is honoured; |
| 19 | * defaults to the real host for plain tests. |
| 20 | */ |
| 21 | readonly platform?: NodeJS.Platform; |
| 22 | } |
| 23 | |
| 24 | /** |
| 25 | * Writes a fake CLI whose behaviour lives in a Node stub. On posix the |
| 26 | * launcher is a `#!/bin/sh` script; on Windows it is a `.cmd` shim, since a |
| 27 | * shebang file is not executable there and `resolveSpawnCommand` routes |
| 28 | * `.cmd` through a shell. Returns the launcher path to hand to the code under |
| 29 | * test, which on Windows carries the `.cmd` extension. |
| 30 | */ |
| 31 | export function writeFakeCli(options: FakeCliOptions): string { |
| 32 | NodeFS.mkdirSync(options.directory, { recursive: true }); |
| 33 | const stubPath = NodePath.join(options.directory, `${options.name}-stub.mjs`); |
| 34 | // The environment rides in a JSON sidecar the stub applies to itself, since |
| 35 | // neither sh nor cmd.exe can quote every value (newlines, quotes, `%`) safely. |
| 36 | const envPath = NodePath.join(options.directory, `${options.name}-env.json`); |
| 37 | NodeFS.writeFileSync(envPath, JSON.stringify(options.env ?? {}), "utf8"); |
| 38 | NodeFS.writeFileSync( |
| 39 | stubPath, |
| 40 | [ |
| 41 | 'import { readFileSync as readFakeCliEnv } from "node:fs";', |
| 42 | `Object.assign(process.env, JSON.parse(readFakeCliEnv(${JSON.stringify(envPath)}, "utf8")));`, |
| 43 | options.source, |
| 44 | ].join("\n"), |
| 45 | "utf8", |
| 46 | ); |
| 47 | |
| 48 | if ((options.platform ?? HostProcess.Platform.defaultValue()) === "win32") { |
| 49 | const launcherPath = NodePath.join(options.directory, `${options.name}.cmd`); |
| 50 | NodeFS.writeFileSync( |
| 51 | launcherPath, |
| 52 | ["@echo off", `node "%~dp0${options.name}-stub.mjs" %*`, "exit /b %ERRORLEVEL%", ""].join( |
| 53 | "\r\n", |
| 54 | ), |
| 55 | "utf8", |
| 56 | ); |
| 57 | return launcherPath; |
| 58 | } |
| 59 | |
| 60 | const launcherPath = NodePath.join(options.directory, options.name); |
| 61 | NodeFS.writeFileSync( |
| 62 | launcherPath, |
| 63 | ["#!/bin/sh", `exec node "$(dirname "$0")/${options.name}-stub.mjs" "$@"`, ""].join("\n"), |
| 64 | "utf8", |
| 65 | ); |
| 66 | NodeFS.chmodSync(launcherPath, 0o755); |
| 67 | return launcherPath; |
| 68 | } |
| 69 | |
| 70 | /** |
| 71 | * Stub source that becomes `scriptPath`: after optionally requiring a leading |
| 72 | * argv prefix, it imports the script into its own process so the stub is the |
| 73 | * agent. Nothing sits between the code under test and the mock, so a kill or |
| 74 | * a closed stdin reaches it directly and its exit log is faithful on every |
| 75 | * host. The script must not read `process.argv`; the mock agents are driven |
| 76 | * by environment and stdin. |
| 77 | */ |
| 78 | export function execScriptSource(options: { |
| 79 | readonly scriptPath: string; |
| 80 | readonly expectedArgs?: ReadonlyArray<string>; |
| 81 | /** Tab-separated argv appended here per invocation, for launch-flag assertions. */ |
| 82 | readonly argvLogPath?: string; |
| 83 | /** Wait before handing over, for tests that race a slow startup. */ |
| 84 | readonly delayMs?: number; |
| 85 | }): string { |
| 86 | return [ |
| 87 | 'import { appendFileSync } from "node:fs";', |
| 88 | 'import { pathToFileURL } from "node:url";', |
| 89 | "const args = process.argv.slice(2);", |
| 90 | ...(options.argvLogPath === undefined |
| 91 | ? [] |
| 92 | : [ |
| 93 | `appendFileSync(${JSON.stringify(options.argvLogPath)}, args.join(${JSON.stringify("\t")}) + ${JSON.stringify("\n")});`, |
| 94 | ]), |
| 95 | ...(options.delayMs === undefined |
| 96 | ? [] |
| 97 | : [`Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ${options.delayMs});`]), |
| 98 | `const expected = ${JSON.stringify(options.expectedArgs ?? [])};`, |
| 99 | "if (expected.some((value, index) => args[index] !== value)) {", |
| 100 | ' process.stderr.write(`unexpected args: ${args.join(" ")}\\n`);', |
| 101 | " process.exit(11);", |
| 102 | "}", |
| 103 | `await import(pathToFileURL(${JSON.stringify(options.scriptPath)}).href);`, |
| 104 | "", |
| 105 | ].join("\n"); |
| 106 | } |