type-a-bin 0.1.1 → 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.
- package/README.md +301 -40
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/mock-bin-behaviour-runtime.d.ts +21 -0
- package/dist/mock-bin-behaviour-runtime.js +164 -0
- package/dist/mock-bin-behaviour.d.ts +84 -0
- package/dist/mock-bin-behaviour.js +109 -0
- package/dist/mock-bin-env.d.ts +22 -0
- package/dist/mock-bin-env.js +31 -0
- package/dist/mock-bin-preload.d.ts +1 -21
- package/dist/mock-bin-preload.js +23 -216
- package/dist/mock-bin-runtime.d.ts +41 -0
- package/dist/mock-bin-runtime.js +293 -0
- package/dist/mock-bin-tsx.d.ts +4 -0
- package/dist/mock-bin-tsx.js +30 -0
- package/dist/mock-bin-windows.d.ts +6 -4
- package/dist/mock-bin-windows.js +137 -67
- package/dist/mock-bin.d.ts +31 -3
- package/dist/mock-bin.js +50 -12
- package/dist/native/win32/arm64/type-a-bin-trampoline.exe +0 -0
- package/dist/native/win32/x64/type-a-bin-trampoline.exe +0 -0
- package/dist/rm-scratch.d.ts +11 -0
- package/dist/rm-scratch.js +25 -0
- package/package.json +5 -26
|
@@ -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
|
-
|
|
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";
|
package/dist/mock-bin-preload.js
CHANGED
|
@@ -1,221 +1,28 @@
|
|
|
1
|
-
import
|
|
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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// node.exe renamed <bin>.exe, so the command line
|
|
11
|
-
// ...) is not a real module
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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
|
|
16
|
-
const
|
|
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
|
|
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, };
|