type-a-bin 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
1
+ import type { MockBinCleanup } from "./mock-bin.js";
2
+ interface MockBinRecordOptions {
3
+ /**
4
+ * Read stdin to end-of-file and record it as `call.stdin`. Off by
5
+ * default: a mock that drains stdin waits for the caller to close it.
6
+ */
7
+ stdin?: boolean;
8
+ }
9
+ interface MockBinLifetimeOptions {
10
+ /** How long to stay alive before exiting, in ms. Default 120000. */
11
+ lifetimeMs?: number;
12
+ }
13
+ interface MockBinBehaviour {
14
+ /**
15
+ * Record every invocation for `handle.calls`. On by default; pass
16
+ * `false` to skip recording, or `{ stdin: true }` to capture stdin
17
+ * as well.
18
+ */
19
+ record?: boolean | MockBinRecordOptions;
20
+ /** Line(s) written to stdout, each followed by a newline. */
21
+ stdout?: string | readonly string[];
22
+ /** Line(s) written to stderr, after the stdout lines. */
23
+ stderr?: string | readonly string[];
24
+ /** Exit code the mock finishes with. Default 0. */
25
+ exitCode?: number;
26
+ /** Delay before the mock writes anything, in milliseconds. */
27
+ delayMs?: number;
28
+ /**
29
+ * Gap between stdout lines, in milliseconds, so a consumer tailing
30
+ * the stream sees them arrive one at a time instead of in one burst.
31
+ */
32
+ lineDelayMs?: number;
33
+ /**
34
+ * Spawn a long-lived descendant in the mock's process group and
35
+ * record its pid as `call.childPid`, so a test can prove a stop reaps
36
+ * the whole process tree rather than the mock alone.
37
+ */
38
+ spawnChild?: boolean | MockBinLifetimeOptions;
39
+ /**
40
+ * Ignore SIGINT and SIGTERM, so stopping the mock has to escalate to
41
+ * SIGKILL. The mock then runs until it is killed, or until its
42
+ * lifetime runs out — the bound keeps a mock a test forgets to stop
43
+ * from outliving the suite.
44
+ */
45
+ trapSignals?: boolean | MockBinLifetimeOptions;
46
+ }
47
+ interface MockBinCall {
48
+ /** Arguments the mock was invoked with, excluding the binary name. */
49
+ args: string[];
50
+ /** Working directory the mock ran in. */
51
+ cwd: string;
52
+ /** Environment the mock ran with. */
53
+ env: Record<string, string>;
54
+ /** Process id of the mock itself. */
55
+ pid: number;
56
+ /** Stdin, when the behaviour recorded it. */
57
+ stdin?: string;
58
+ /** Pid of the descendant, when the behaviour spawned one. */
59
+ childPid?: number;
60
+ }
61
+ /**
62
+ * The cleanup function every `mockBin` call returns, carrying the
63
+ * invocations a scripted behaviour recorded. `calls` is read fresh on
64
+ * every access — a still-running mock shows up as soon as it has been
65
+ * recorded — and keeps serving the last reading after cleanup.
66
+ */
67
+ type MockBinHandle = MockBinCleanup & {
68
+ readonly calls: MockBinCall[];
69
+ };
70
+ /**
71
+ * Compiles a behaviour into the mock's script, creating the directory
72
+ * its invocations are recorded into when recording is on.
73
+ */
74
+ declare const prepareBehaviour: (binName: string, pattern: string | undefined, behaviour: MockBinBehaviour) => Promise<{
75
+ code: string;
76
+ recordDir: string | undefined;
77
+ }>;
78
+ /**
79
+ * Turns a cleanup function into the handle a scripted behaviour
80
+ * returns. Cleanup snapshots the recorded calls before removing the
81
+ * record directory, so assertions still read after teardown.
82
+ */
83
+ declare const withCalls: (cleanup: MockBinCleanup, recordDir: string | undefined) => MockBinHandle;
84
+ export { type MockBinBehaviour, type MockBinCall, type MockBinHandle, type MockBinLifetimeOptions, type MockBinRecordOptions, prepareBehaviour, withCalls, };
@@ -0,0 +1,109 @@
1
+ import { readdirSync, readFileSync, rmSync } from "node:fs";
2
+ import { mkdtemp } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+ // Test-side half of the scripted-behaviour convention: it compiles a
7
+ // MockBinBehaviour into the tiny node mock that mock-bin installs, and
8
+ // reads back the invocations that mock recorded. The behaviour itself
9
+ // runs in the mocked binary's process — see mock-bin-behaviour-runtime.
10
+ /** Default life of a mock kept alive on purpose, and of its child. */
11
+ const LIFETIME_MS = 120_000;
12
+ // The runtime twin ships next to this module: the .js build in the
13
+ // published package, the .ts source when tests run from source (node
14
+ // strips the types in the spawned mock process).
15
+ const ownPath = fileURLToPath(import.meta.url);
16
+ const runtimeUrl = pathToFileURL(path.join(path.dirname(ownPath), `mock-bin-behaviour-runtime${path.extname(ownPath)}`)).href;
17
+ const toLines = (value) => {
18
+ if (value === undefined)
19
+ return [];
20
+ return typeof value === "string" ? [value] : [...value];
21
+ };
22
+ const toLifetimeMs = (option) => {
23
+ if (option === undefined || option === false)
24
+ return undefined;
25
+ if (option === true)
26
+ return LIFETIME_MS;
27
+ return option.lifetimeMs ?? LIFETIME_MS;
28
+ };
29
+ /**
30
+ * Builds the mock script: a bootstrap that imports the runtime twin by
31
+ * absolute URL and hands it the scripted behaviour. Keeping the logic
32
+ * in a real module — rather than generating it as source — leaves it
33
+ * type-checked and linted, and the dynamic import runs the same from
34
+ * the extensionless POSIX mock and the Windows `.cjs` shim entry, both
35
+ * of which node parses as CommonJS.
36
+ */
37
+ const bootstrapCode = (script) => `import(${JSON.stringify(runtimeUrl)}).then((runtime) =>\n` +
38
+ ` runtime.runMockBehaviour(${JSON.stringify(script)}),\n);\n`;
39
+ /**
40
+ * Compiles a behaviour into the mock's script, creating the directory
41
+ * its invocations are recorded into when recording is on.
42
+ */
43
+ const prepareBehaviour = async (binName, pattern, behaviour) => {
44
+ const record = behaviour.record ?? true;
45
+ const recordDir = record === false
46
+ ? undefined
47
+ : await mkdtemp(path.join(tmpdir(), "type-a-bin-calls-"));
48
+ const spawnChildMs = toLifetimeMs(behaviour.spawnChild);
49
+ const trapSignalsMs = toLifetimeMs(behaviour.trapSignals);
50
+ const script = {
51
+ binName,
52
+ stdout: toLines(behaviour.stdout),
53
+ stderr: toLines(behaviour.stderr),
54
+ exitCode: behaviour.exitCode ?? 0,
55
+ delayMs: behaviour.delayMs ?? 0,
56
+ lineDelayMs: behaviour.lineDelayMs ?? 0,
57
+ recordStdin: typeof record === "object" && record.stdin === true,
58
+ ...(pattern === undefined ? {} : { pattern }),
59
+ ...(recordDir === undefined ? {} : { recordDir }),
60
+ ...(spawnChildMs === undefined ? {} : { spawnChildMs }),
61
+ ...(trapSignalsMs === undefined ? {} : { trapSignalsMs }),
62
+ };
63
+ return { code: bootstrapCode(script), recordDir };
64
+ };
65
+ /**
66
+ * Reads the recorded invocations in the order the mock was called: the
67
+ * runtime numbers each record as it claims a slot. A claimed-but-empty
68
+ * slot is skipped, and the `.json` suffix filters out records still
69
+ * being written to their `.pending` sidecar.
70
+ */
71
+ const readCalls = (recordDir) => readdirSync(recordDir)
72
+ .filter((name) => name.endsWith(".json"))
73
+ .sort((left, right) => Number.parseInt(left, 10) - Number.parseInt(right, 10))
74
+ .flatMap((name) => {
75
+ const content = readFileSync(path.join(recordDir, name), "utf-8");
76
+ return content === "" ? [] : [JSON.parse(content)];
77
+ });
78
+ /**
79
+ * Turns a cleanup function into the handle a scripted behaviour
80
+ * returns. Cleanup snapshots the recorded calls before removing the
81
+ * record directory, so assertions still read after teardown.
82
+ */
83
+ const withCalls = (cleanup, recordDir) => {
84
+ let snapshot;
85
+ const handle = () => {
86
+ if (snapshot === undefined && recordDir !== undefined)
87
+ snapshot = readCalls(recordDir);
88
+ cleanup();
89
+ if (recordDir !== undefined)
90
+ // Windows can transiently deny deleting a file a just-exited mock
91
+ // still holds, so the removal retries like mock-bin's own.
92
+ rmSync(recordDir, {
93
+ recursive: true,
94
+ force: true,
95
+ maxRetries: 40,
96
+ retryDelay: 250,
97
+ });
98
+ };
99
+ Object.defineProperty(handle, "calls", {
100
+ enumerable: true,
101
+ get: () => {
102
+ if (snapshot !== undefined)
103
+ return snapshot;
104
+ return recordDir === undefined ? [] : readCalls(recordDir);
105
+ },
106
+ });
107
+ return handle;
108
+ };
109
+ export { prepareBehaviour, withCalls, };
@@ -0,0 +1,22 @@
1
+ declare const MOCKS_VAR = "TYPE_A_BIN_MOCKS";
2
+ /**
3
+ * Copies an environment without the mock registry.
4
+ *
5
+ * A child spawned from inside a mock inherits the registry, and on
6
+ * Windows a spawn through the shim executable (`process.execPath` *is*
7
+ * the shim there) would be redirected back into a mock. Passing
8
+ * `withoutMocks(process.env)` as the child's `env` leaves the preload
9
+ * loaded but inert: it finds no registry and lets the child run
10
+ * untouched.
11
+ *
12
+ * With the Windows trampoline launcher, mocks already run inside a real
13
+ * Node executable, so ordinary helper spawns escape interception on
14
+ * their own; the copy remains for children spawned through a legacy
15
+ * hard-link shim, and for trampoline spawns that must reach the real
16
+ * binary instead of the mock.
17
+ *
18
+ * @param env - The environment to copy, usually `process.env`
19
+ * @returns A copy of `env` with the mock registry variable removed
20
+ */
21
+ declare const withoutMocks: (env: NodeJS.ProcessEnv) => NodeJS.ProcessEnv;
22
+ export { MOCKS_VAR, withoutMocks };
@@ -0,0 +1,31 @@
1
+ const MOCKS_VAR = "TYPE_A_BIN_MOCKS";
2
+ // The mock registry travels from mockBin to the Windows dispatch in
3
+ // this env var. mock-bin-runtime keeps its own copy of the name — it
4
+ // runs inside child processes, as a compiled module or a type-stripped
5
+ // source file, and cannot import library code — so the two must stay
6
+ // in sync.
7
+ /**
8
+ * Copies an environment without the mock registry.
9
+ *
10
+ * A child spawned from inside a mock inherits the registry, and on
11
+ * Windows a spawn through the shim executable (`process.execPath` *is*
12
+ * the shim there) would be redirected back into a mock. Passing
13
+ * `withoutMocks(process.env)` as the child's `env` leaves the preload
14
+ * loaded but inert: it finds no registry and lets the child run
15
+ * untouched.
16
+ *
17
+ * With the Windows trampoline launcher, mocks already run inside a real
18
+ * Node executable, so ordinary helper spawns escape interception on
19
+ * their own; the copy remains for children spawned through a legacy
20
+ * hard-link shim, and for trampoline spawns that must reach the real
21
+ * binary instead of the mock.
22
+ *
23
+ * @param env - The environment to copy, usually `process.env`
24
+ * @returns A copy of `env` with the mock registry variable removed
25
+ */
26
+ const withoutMocks = (env) => {
27
+ const childEnv = { ...env };
28
+ delete childEnv[MOCKS_VAR];
29
+ return childEnv;
30
+ };
31
+ export { MOCKS_VAR, withoutMocks };
@@ -1,21 +1 @@
1
- interface MockTarget {
2
- /** "node" redirects the main entry in-process; "spawn" runs a script. */
3
- kind: "node" | "spawn";
4
- /** Absolute path of the mock script to run. */
5
- entry: string;
6
- /** Interpreter name for "spawn" targets (e.g. "bash", "python"). */
7
- interpreter?: string;
8
- /** Regex source; only matching commands run the mock. */
9
- pattern?: string;
10
- /** PATH snapshot from before this mock was installed. */
11
- originalPath?: string;
12
- }
13
- interface RunOriginalTarget {
14
- binName: string;
15
- originalPath: string;
16
- }
17
- interface MocksEnv {
18
- targets?: Record<string, MockTarget>;
19
- runOriginal?: RunOriginalTarget;
20
- }
21
- export type { MocksEnv, MockTarget };
1
+ export type { MocksEnv, MockTarget, RunOriginalTarget, } from "./mock-bin-runtime.js";
@@ -1,221 +1,28 @@
1
- import { spawnSync } from "node:child_process";
2
- import { statSync } from "node:fs";
3
- import Module, { createRequire, registerHooks } from "node:module";
4
- import { basename, delimiter, extname, join, relative } from "node:path";
1
+ import path from "node:path";
5
2
  import process from "node:process";
6
3
  import { fileURLToPath, pathToFileURL } from "node:url";
7
4
  import { threadId } from "node:worker_threads";
8
- // Loaded through `NODE_OPTIONS --import` into every Node process a test
9
- // spawns while Windows mocks are active. A mock binary is a hard link of
10
- // node.exe renamed <bin>.exe, so the command line ("status", "--porcelain",
11
- // ...) is not a real module. This preload swaps the shim's main entry for
12
- // the mock script named in TYPE_A_BIN_MOCKS argv, stdin, stdout, stderr,
13
- // and exit codes all pass through unchanged.
5
+ // Fallback Windows entry point, loaded through `NODE_OPTIONS --import`
6
+ // into every Node process spawned while mocks are active. A legacy shim
7
+ // is a hard link of node.exe renamed <bin>.exe, so the command line
8
+ // ("status", "--porcelain", ...) is not a real module; the dispatch in
9
+ // mock-bin-runtime swaps the shim's main entry for the mock script
10
+ // named in TYPE_A_BIN_MOCKS argv, stdin, stdout, stderr, and exit
11
+ // codes all pass through unchanged. The default Windows mechanism is
12
+ // the argv-preserving trampoline launcher (see mock-bin-windows); this
13
+ // preload remains for the escape hatch that forces the old hardlinks
14
+ // while the launcher rolls out.
15
+ //
16
+ // The dispatch lives in mock-bin-runtime — its sibling module — and is
17
+ // loaded here dynamically by absolute URL so the same code runs from
18
+ // the compiled .js in the published package and the .ts source under
19
+ // node's native type stripping (spawned processes cannot rely on the
20
+ // .js → .ts specifier rewriting a bundler or test runner provides).
21
+ // Only a shim's own main thread may intercept: loaders such as tsx
22
+ // spawn workers whose entry-point load must pass through untouched,
23
+ // and processes without the registry are not mocks at all.
14
24
  const MOCKS_VAR = "TYPE_A_BIN_MOCKS";
15
- const HELPER_NAME = "mock-a-bin-run-original";
16
- const PATH_EXTENSIONS = ["", ".exe", ".cmd", ".bat", ".com"];
17
- const TS_EXTENSIONS = [".cts", ".mts", ".ts", ".tsx"];
18
- const BASH_LIKE_INTERPRETERS = ["bash", "dash", "ksh", "sh", "zsh"];
19
- const readMocks = () => {
20
- try {
21
- return JSON.parse(process.env[MOCKS_VAR] ?? "{}");
22
- }
23
- catch {
24
- return {};
25
- }
26
- };
27
- const mocks = readMocks();
28
- const invokedName = basename(process.argv[0] ?? "", extname(process.argv[0] ?? ""));
29
- // Node rewrites the CLI entry in argv[1] to an absolute path before
30
- // preloads run, losing the argument as the caller typed it. Recover it
31
- // relative to the working directory when possible, so mocks, patterns,
32
- // and spawned interpreters see "pr" instead of "C:\repo\pr".
33
- const denormalizeEntry = (entry) => {
34
- const relativePath = relative(process.cwd(), entry);
35
- if (relativePath === "" || relativePath.startsWith(".."))
36
- return entry;
37
- return relativePath;
38
- };
39
- // The shim's whole command line after the exe are CLI arguments: unlike
40
- // a Node script there is no "entry" consuming the first positional.
41
- const cliArgs = process.argv.length === 1
42
- ? []
43
- : [denormalizeEntry(process.argv[1] ?? ""), ...process.argv.slice(2)];
44
- const writeError = (message) => {
45
- process.stderr.write(`${message}\n`);
46
- };
47
- // Lookup failures exit 127, matching the POSIX mock scripts.
48
- const fail = (message) => {
49
- writeError(message);
50
- process.exit(127);
51
- };
52
- const isFile = (candidate) => {
53
- try {
54
- return statSync(candidate).isFile();
55
- }
56
- catch {
57
- return false;
58
- }
59
- };
60
- const searchPathDirs = (originalPath) => (originalPath ?? process.env.PATH ?? "")
61
- .split(delimiter)
62
- .filter((dir) => dir !== "" && !dir.includes("mock-bin-"));
63
- const pathCandidates = (name, dirs) => {
64
- const candidates = [];
65
- for (const dir of dirs)
66
- for (const extension of PATH_EXTENSIONS) {
67
- const candidate = join(dir, `${name}${extension}`);
68
- if (isFile(candidate))
69
- candidates.push(candidate);
70
- }
71
- return candidates;
72
- };
73
- const findExecutable = (name, dirs) => pathCandidates(name, dirs)[0] ?? null;
74
- const spawnRealAndExit = (command, args) => {
75
- // The real binary must not be re-intercepted by this preload.
76
- delete process.env[MOCKS_VAR];
77
- const result = spawnSync(command, args, { stdio: "inherit" });
78
- if (result.error !== undefined)
79
- writeError(`Error: '${command}' failed to start: ${String(result.error)}`);
80
- process.exit(result.status ?? 127);
81
- };
82
- const runOriginalCommand = (spec) => {
83
- if (spec === undefined)
84
- return fail(`Error: ${HELPER_NAME} used outside a mockBin context`);
85
- const real = findExecutable(spec.binName, searchPathDirs(spec.originalPath));
86
- if (real === null)
87
- return fail(`Error: Original '${spec.binName}' command not found in PATH`);
88
- return spawnRealAndExit(real, cliArgs);
89
- };
90
- const runRealBinary = (target) => {
91
- const real = findExecutable(invokedName, searchPathDirs(target.originalPath));
92
- if (real === null)
93
- return fail(`Error: Real binary '${invokedName}' not found in PATH`);
94
- return spawnRealAndExit(real, cliArgs);
95
- };
96
- // WSL's bash launcher lives in the Windows system directories; it cannot
97
- // run Windows-path scripts, so bash-like interpreters prefer a native
98
- // shell (e.g. Git for Windows) and fall back to well-known Git installs.
99
- const isWslLauncher = (candidate) => {
100
- const lower = candidate.toLowerCase();
101
- return (lower.includes("\\windows\\system32\\") || lower.includes("\\windowsapps\\"));
102
- };
103
- const gitBashCandidates = () => {
104
- const roots = [
105
- process.env.ProgramFiles ?? "",
106
- process.env["ProgramFiles(x86)"] ?? "",
107
- join(process.env.LOCALAPPDATA ?? "", "Programs"),
108
- ];
109
- const locations = [];
110
- for (const root of roots)
111
- if (root !== "") {
112
- locations.push(join(root, "Git", "bin", "bash.exe"));
113
- locations.push(join(root, "Git", "usr", "bin", "bash.exe"));
114
- }
115
- return locations;
116
- };
117
- const resolveInterpreter = (interpreter) => {
118
- const dirs = (process.env.PATH ?? "")
119
- .split(delimiter)
120
- .filter((dir) => dir !== "");
121
- const candidates = pathCandidates(interpreter, dirs);
122
- if (BASH_LIKE_INTERPRETERS.includes(interpreter)) {
123
- const native = candidates.find((candidate) => !isWslLauncher(candidate));
124
- if (native !== undefined)
125
- return native;
126
- for (const location of gitBashCandidates())
127
- if (isFile(location))
128
- return location;
129
- }
130
- return candidates[0] ?? null;
131
- };
132
- const runInterpreterAndExit = (interpreter, entry) => {
133
- const interpreterPath = resolveInterpreter(interpreter);
134
- if (interpreterPath === null)
135
- return fail(`Error: Interpreter '${interpreter}' not found in PATH`);
136
- const result = spawnSync(interpreterPath, [entry, ...cliArgs], {
137
- stdio: "inherit",
138
- });
139
- if (result.error !== undefined)
140
- writeError(`Error: Interpreter '${interpreter}' failed: ${String(result.error)}`);
141
- process.exit(result.status ?? 127);
142
- };
143
- const asPath = (specifier) => specifier.startsWith("file:") ? fileURLToPath(specifier) : specifier;
144
- // tsx registers its CommonJS hooks from a separate entry point; load it
145
- // lazily so non-TypeScript mocks never require the tsx package.
146
- const loadTsxCommonJs = (entry) => {
147
- if (!TS_EXTENSIONS.includes(extname(entry).toLowerCase()))
148
- return;
149
- try {
150
- createRequire(import.meta.url)("tsx/cjs");
151
- }
152
- catch {
153
- // tsx is not installed: node's native type stripping applies instead.
154
- }
155
- };
156
- const redirectNodeEntry = (entry) => {
157
- // Node normalizes the CLI entry to an absolute path in argv[1] before
158
- // preloads run. Capture it, then reposition argv so the mock sees the
159
- // CLI arguments at process.argv.slice(2) like a real Node CLI script.
160
- const originalEntry = process.argv[1] ?? "";
161
- process.argv.length = 1;
162
- process.argv.push(entry, ...cliArgs);
163
- // ESM main entry: resolve hooks see the main module with no parent URL,
164
- // so redirect that one resolution to the mock script. The tsx loader
165
- // (registered before this preload in NODE_OPTIONS) transforms .ts
166
- // entries as part of the same resolve chain.
167
- registerHooks({
168
- resolve: (specifier, context, nextResolve) => {
169
- if (context.parentURL == null && asPath(specifier) === originalEntry)
170
- return nextResolve(pathToFileURL(entry).href, context);
171
- return nextResolve(specifier, context);
172
- },
173
- });
174
- // CommonJS main entry: Module._load receives the CLI entry with isMain
175
- // set — load the mock through the CommonJS loader instead.
176
- const moduleApi = Module;
177
- const originalLoad = moduleApi._load;
178
- moduleApi._load = (request, parent, isMain) => {
179
- if (isMain && request === originalEntry) {
180
- moduleApi._load = originalLoad;
181
- loadTsxCommonJs(entry);
182
- return originalLoad(entry, null, true);
183
- }
184
- return originalLoad(request, parent, isMain);
185
- };
186
- };
187
- // A spawn with no CLI arguments leaves node without an entry (the REPL
188
- // would start), so the mock module is imported directly instead.
189
- const runEntryDirectly = async (entry) => {
190
- process.argv.length = 1;
191
- process.argv.push(entry, ...cliArgs);
192
- await import(pathToFileURL(entry).href);
193
- process.exit(process.exitCode);
194
- };
195
- const intercept = async () => {
196
- if (invokedName === HELPER_NAME)
197
- return runOriginalCommand(mocks.runOriginal);
198
- const target = mocks.targets?.[invokedName];
199
- if (target === undefined)
200
- return;
201
- // A process whose CLI entry is a real file is not a shim: mock scripts
202
- // spawned through process.execPath (tsx's esbuild service, `node -e`
203
- // helpers) inherit the shim exe's name, but their entry exists on disk
204
- // while a shim's "subcommand" entry never does.
205
- if (isFile(process.argv[1] ?? ""))
206
- return;
207
- const commandLine = `${invokedName} ${cliArgs.join(" ")}`;
208
- const mocked = target.pattern === undefined ||
209
- new RegExp(target.pattern).test(commandLine);
210
- if (!mocked)
211
- return runRealBinary(target);
212
- if (target.kind !== "node")
213
- return runInterpreterAndExit(target.interpreter ?? "bash", target.entry);
214
- if (process.argv[1] === undefined)
215
- return runEntryDirectly(target.entry);
216
- return redirectNodeEntry(target.entry);
217
- };
218
- // Only a shim's own main thread may intercept: loaders such as tsx spawn
219
- // workers whose entry-point load must pass through untouched.
25
+ const ownPath = fileURLToPath(import.meta.url);
26
+ const runtimeUrl = pathToFileURL(path.join(path.dirname(ownPath), `mock-bin-runtime${path.extname(ownPath)}`)).href;
220
27
  if (process.env[MOCKS_VAR] !== undefined && threadId === 0)
221
- await intercept();
28
+ await (await import(runtimeUrl)).interceptShim();
@@ -0,0 +1,41 @@
1
+ interface MockTarget {
2
+ /** "node" redirects the main entry in-process; "spawn" runs a script. */
3
+ kind: "node" | "spawn";
4
+ /** Absolute path of the mock script to run. */
5
+ entry: string;
6
+ /** Interpreter name for "spawn" targets (e.g. "bash", "python"). */
7
+ interpreter?: string;
8
+ /** Regex source; only matching commands run the mock. */
9
+ pattern?: string;
10
+ /** PATH snapshot from before this mock was installed. */
11
+ originalPath?: string;
12
+ /**
13
+ * Absolute file URL of the tsx loader for TypeScript entries. The
14
+ * trampoline bootstrap imports it before the entry so `.ts` mocks
15
+ * load through tsx without a NODE_OPTIONS preload.
16
+ */
17
+ tsxImportUrl?: string;
18
+ }
19
+ interface RunOriginalTarget {
20
+ binName: string;
21
+ originalPath: string;
22
+ }
23
+ interface MocksEnv {
24
+ targets?: Record<string, MockTarget>;
25
+ runOriginal?: RunOriginalTarget;
26
+ }
27
+ /**
28
+ * Trampoline entry point. The native launcher starts Node as
29
+ * `[node, mock-bin-trampoline.cjs, <mock>.exe, ...originalArgs]`, so the
30
+ * invoked binary is the path in argv[2] and every argument after it is
31
+ * the caller's original argv — Node's option parser never sees it.
32
+ */
33
+ declare const runTrampoline: () => Promise<void>;
34
+ /**
35
+ * Shim entry point for the NODE_OPTIONS preload: the process itself is
36
+ * a hard link of node.exe named after the mocked binary, so the main
37
+ * entry is redirected in-process once the registry recognizes the
38
+ * invocation. Kept as the fallback behind the trampoline rollout.
39
+ */
40
+ declare const interceptShim: () => Promise<void>;
41
+ export { interceptShim, type MocksEnv, type MockTarget, type RunOriginalTarget, runTrampoline, };