type-a-bin 0.1.0
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/LICENSE +21 -0
- package/README.md +680 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mock-bin-preload.d.ts +21 -0
- package/dist/mock-bin-preload.js +221 -0
- package/dist/mock-bin-windows.d.ts +10 -0
- package/dist/mock-bin-windows.js +182 -0
- package/dist/mock-bin.d.ts +64 -0
- package/dist/mock-bin.js +147 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SynthLuvr
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
# Type-A-Bin
|
|
2
|
+
|
|
3
|
+
> Mock any executable binary — for testing scripts that shell out.
|
|
4
|
+
|
|
5
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
Have a script or CLI that executes shell commands (`git`, `gh`,
|
|
9
|
+
`docker`, `kubectl`…)? Want to test that code without actually invoking
|
|
10
|
+
those commands — no network calls, no side effects, no real
|
|
11
|
+
dependencies? **Type-A-Bin** lets you mock any executable binary by
|
|
12
|
+
injecting a mock script into your `PATH`.
|
|
13
|
+
|
|
14
|
+
- **Intercept any command.** The moment your code shells out, the mock
|
|
15
|
+
takes over.
|
|
16
|
+
- **Total control over output and exit codes.** Return whatever stdout,
|
|
17
|
+
stderr, and exit status your tests need.
|
|
18
|
+
- **Conditional mocking.** Mock only the subcommands you care about and
|
|
19
|
+
let everything else fall through to the real binary.
|
|
20
|
+
- **Any interpreter.** Bash, Node, Python, Perl — if it has a shebang,
|
|
21
|
+
you can use it.
|
|
22
|
+
- **Cross-platform.** The same API works on Linux and Windows.
|
|
23
|
+
- **TypeScript-first.** Full type definitions and overloaded signatures.
|
|
24
|
+
- **Zero dependencies at runtime.** Pure Node.js standard library.
|
|
25
|
+
|
|
26
|
+
Type-A-Bin is a Node.js alternative to the npm packages
|
|
27
|
+
[mock-bin](https://github.com/stevemao/mock-bin) and
|
|
28
|
+
[mock-a-bin](https://github.com/levibostian/mock-a-bin). It began as a
|
|
29
|
+
Deno-to-Node.js migration of `mock-a-bin` and has since grown into a
|
|
30
|
+
fully typed, dependency-free library with richer features.
|
|
31
|
+
|
|
32
|
+
------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
## Table of Contents
|
|
35
|
+
|
|
36
|
+
- [Why does this exist?](#why-does-this-exist)
|
|
37
|
+
- [How it works](#how-it-works)
|
|
38
|
+
- [Installation](#installation)
|
|
39
|
+
- [Quick start](#quick-start)
|
|
40
|
+
- [Usage](#usage)
|
|
41
|
+
- [1. Output shorthand](#1-output-shorthand)
|
|
42
|
+
- [2. Full script](#2-full-script)
|
|
43
|
+
- [3. Script file](#3-script-file)
|
|
44
|
+
- [Conditional mocking](#conditional-mocking)
|
|
45
|
+
- [Pattern-based mocking](#pattern-based-mocking)
|
|
46
|
+
- [Script-based mocking with
|
|
47
|
+
`mock-a-bin-run-original`](#script-based-mocking-with-mock-a-bin-run-original)
|
|
48
|
+
- [Controlling exit codes](#controlling-exit-codes)
|
|
49
|
+
- [Passing environment variables](#passing-environment-variables)
|
|
50
|
+
- [Mocking multiple commands at
|
|
51
|
+
once](#mocking-multiple-commands-at-once)
|
|
52
|
+
- [Mocking with any interpreter](#mocking-with-any-interpreter)
|
|
53
|
+
- [Using with a test runner](#using-with-a-test-runner)
|
|
54
|
+
- [API reference](#api-reference)
|
|
55
|
+
- [`mockBin(...)`](#mockbin)
|
|
56
|
+
- [Types](#types)
|
|
57
|
+
- [Packages](#packages)
|
|
58
|
+
- [Prerequisites](#prerequisites)
|
|
59
|
+
- [Scripts](#scripts)
|
|
60
|
+
- [How it works under the hood](#how-it-works-under-the-hood)
|
|
61
|
+
- [Windows support](#windows-support)
|
|
62
|
+
- [Comparison with other tools](#comparison-with-other-tools)
|
|
63
|
+
- [Contributing](#contributing)
|
|
64
|
+
- [License](#license)
|
|
65
|
+
|
|
66
|
+
------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
## Why does this exist?
|
|
69
|
+
|
|
70
|
+
Testing code that shells out is hard. When a function calls
|
|
71
|
+
`execSync("gh pr list")`, your test depends on the real GitHub CLI, a
|
|
72
|
+
network connection, a logged-in account, and a real repository. That is
|
|
73
|
+
slow, fragile, and impossible to reproduce deterministically.
|
|
74
|
+
|
|
75
|
+
There are a few common workarounds, each with drawbacks:
|
|
76
|
+
|
|
77
|
+
| Approach | Problem |
|
|
78
|
+
|-----------------------|----------------------------------------------------------|
|
|
79
|
+
| Stub `child_process` | Couples tests to the module boundary; misses edge cases. |
|
|
80
|
+
| `nock` / HTTP mocking | Only works for HTTP, not arbitrary binaries. |
|
|
81
|
+
| Real binaries in CI | Slow, requires secrets, produces flaky tests. |
|
|
82
|
+
| **Type-A-Bin** | Mocks *any* binary at the `PATH` level — transparently. |
|
|
83
|
+
|
|
84
|
+
Type-A-Bin works by creating a temporary executable with the same name
|
|
85
|
+
as the target binary and prepending its directory to `PATH`. When your
|
|
86
|
+
code runs `gh`, the shell resolves it to your mock instead of the real
|
|
87
|
+
`gh`. Your application code doesn’t change at all — no dependency
|
|
88
|
+
injection, no wrappers, no monkey-patching. This is especially powerful
|
|
89
|
+
for testing CLIs and scripts that invoke other CLIs.
|
|
90
|
+
|
|
91
|
+
## How it works
|
|
92
|
+
|
|
93
|
+
Your test process.env.PATH
|
|
94
|
+
────────── ────────────────────────────────────────
|
|
95
|
+
mockBin("gh") /tmp/mock-bin-xK9/ ← prepended (mock)
|
|
96
|
+
│ /usr/local/bin ← original (real gh)
|
|
97
|
+
▼ /usr/bin
|
|
98
|
+
process calls ────────────────────────────────────────
|
|
99
|
+
"gh pr list" The shell finds the mock FIRST, runs it.
|
|
100
|
+
|
|
101
|
+
Every `mockBin()` call:
|
|
102
|
+
|
|
103
|
+
1. Creates a fresh temp directory.
|
|
104
|
+
2. Writes an executable mock script named after your binary into it.
|
|
105
|
+
3. Prepends that directory to `process.env.PATH` so the mock shadows
|
|
106
|
+
the real binary.
|
|
107
|
+
4. Returns a **cleanup function** that restores the original `PATH` and
|
|
108
|
+
deletes the temp directory.
|
|
109
|
+
|
|
110
|
+
## Installation
|
|
111
|
+
|
|
112
|
+
``` bash
|
|
113
|
+
pnpm add -D type-a-bin
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Or with npm/yarn:
|
|
117
|
+
|
|
118
|
+
``` bash
|
|
119
|
+
npm install -D type-a-bin
|
|
120
|
+
yarn add -D type-a-bin
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
> You can also install pre-built packages straight from GitHub via the
|
|
124
|
+
> `dist` branch, which is rebuilt automatically on every merge to
|
|
125
|
+
> `main`:
|
|
126
|
+
>
|
|
127
|
+
> ``` bash
|
|
128
|
+
> pnpm add -D github:SynthLuvr/type-a-bin#dist
|
|
129
|
+
> ```
|
|
130
|
+
|
|
131
|
+
## Quick start
|
|
132
|
+
|
|
133
|
+
``` ts
|
|
134
|
+
import { execFileSync } from "node:child_process";
|
|
135
|
+
import { mockBin } from "type-a-bin";
|
|
136
|
+
|
|
137
|
+
// Replace the 'gh' command with a mock that prints custom output
|
|
138
|
+
const cleanup = await mockBin("gh", "bash", 'echo "mocked output"');
|
|
139
|
+
|
|
140
|
+
// Any call to 'gh' now executes the mock script
|
|
141
|
+
const output = execFileSync("gh", ["pr", "list"], { encoding: "utf-8" });
|
|
142
|
+
console.log(output); // "mocked output\n"
|
|
143
|
+
|
|
144
|
+
cleanup(); // Restore the original PATH
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Usage
|
|
148
|
+
|
|
149
|
+
`mockBin` offers three calling conventions. Choose the one that fits how
|
|
150
|
+
much control you need.
|
|
151
|
+
|
|
152
|
+
### 1. Output shorthand
|
|
153
|
+
|
|
154
|
+
When you only need the mock to print static text, skip the interpreter
|
|
155
|
+
and pass the output string directly. The mock uses `bash` and `echo`s
|
|
156
|
+
the value:
|
|
157
|
+
|
|
158
|
+
``` ts
|
|
159
|
+
const cleanup = await mockBin("git", "Everything is up to date");
|
|
160
|
+
// $ git status → "Everything is up to date\n"
|
|
161
|
+
cleanup();
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
This is the simplest form — perfect for quick stubs where you just need
|
|
165
|
+
a predictable string back.
|
|
166
|
+
|
|
167
|
+
### 2. Full script
|
|
168
|
+
|
|
169
|
+
For dynamic behaviour — conditional logic, arguments, exit codes — pass
|
|
170
|
+
an interpreter (`shebang`) and arbitrary script `code`:
|
|
171
|
+
|
|
172
|
+
``` ts
|
|
173
|
+
const cleanup = await mockBin(
|
|
174
|
+
"gh",
|
|
175
|
+
"bash",
|
|
176
|
+
'echo "pr: $1 $2"',
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
// $ gh pr list → "pr: pr list\n"
|
|
180
|
+
cleanup();
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The `shebang` argument accepts either a bare interpreter name or a full
|
|
184
|
+
shebang line:
|
|
185
|
+
|
|
186
|
+
``` ts
|
|
187
|
+
// Bare interpreter — wrapped automatically in `#!/usr/bin/env …`
|
|
188
|
+
mockBin("git", "bash", 'echo "hi"');
|
|
189
|
+
|
|
190
|
+
// Full shebang line — used as-is
|
|
191
|
+
mockBin("git", "#!/usr/bin/env bash", 'echo "hi"');
|
|
192
|
+
|
|
193
|
+
// Absolute path
|
|
194
|
+
mockBin("git", "#!/bin/bash", 'echo "hi"');
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The mock receives all arguments the real binary would have received. In
|
|
198
|
+
a Bash script, access them via `$1`, `$2`, `"$@"`, etc.:
|
|
199
|
+
|
|
200
|
+
``` ts
|
|
201
|
+
await mockBin(
|
|
202
|
+
"docker",
|
|
203
|
+
"bash",
|
|
204
|
+
`
|
|
205
|
+
echo "Building image: $1"
|
|
206
|
+
echo "Args received: $@"
|
|
207
|
+
`,
|
|
208
|
+
);
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### 3. Script file
|
|
212
|
+
|
|
213
|
+
For larger or more complex mocks, point `mockBin` at a script file on
|
|
214
|
+
disk instead of inlining the code:
|
|
215
|
+
|
|
216
|
+
``` ts
|
|
217
|
+
const cleanup = await mockBin("dragon", "node --import tsx", {
|
|
218
|
+
file: "./src/tests/hoard-script.ts",
|
|
219
|
+
});
|
|
220
|
+
cleanup();
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
**Why a file?** When you inline script code as a string, the mock binary
|
|
224
|
+
is written to an *extensionless* temp file. Some tooling relies on file
|
|
225
|
+
extensions to decide what to do — most notably `node --import tsx`,
|
|
226
|
+
which only transforms `.ts`/`.tsx` files. By passing `{ file }`, the
|
|
227
|
+
original file keeps its real extension on disk, so extension-aware
|
|
228
|
+
loaders work correctly. Internally, Type-A-Bin writes a tiny `/bin/sh`
|
|
229
|
+
wrapper that `exec`s your file through the given interpreter, forwarding
|
|
230
|
+
all arguments.
|
|
231
|
+
|
|
232
|
+
This works with any language and any file extension:
|
|
233
|
+
|
|
234
|
+
``` ts
|
|
235
|
+
// A Python mock
|
|
236
|
+
await mockBin("mycli", "python3", {
|
|
237
|
+
file: "./tests/mocks/mycli.py",
|
|
238
|
+
});
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Conditional mocking
|
|
242
|
+
|
|
243
|
+
Often you want to mock only *some* invocations of a command and let
|
|
244
|
+
others run normally — for example, mock `git status` but use the real
|
|
245
|
+
`git log`. Type-A-Bin provides two complementary approaches.
|
|
246
|
+
|
|
247
|
+
#### Pattern-based mocking
|
|
248
|
+
|
|
249
|
+
Use a regex `pattern` to match the full command string. Only matching
|
|
250
|
+
commands are mocked; everything else passes through to the real binary
|
|
251
|
+
automatically:
|
|
252
|
+
|
|
253
|
+
``` ts
|
|
254
|
+
const cleanup = await mockBin(
|
|
255
|
+
{
|
|
256
|
+
binName: "gh",
|
|
257
|
+
pattern: "^gh pr (list|view)", // matches "gh pr list" and "gh pr view"
|
|
258
|
+
},
|
|
259
|
+
"bash",
|
|
260
|
+
'echo "mocked PR command"',
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
// These are mocked:
|
|
264
|
+
// $ gh pr list → "mocked PR command\n"
|
|
265
|
+
// $ gh pr view 123 → "mocked PR command\n"
|
|
266
|
+
|
|
267
|
+
// Everything else hits the real gh:
|
|
268
|
+
// $ gh auth status → (real output)
|
|
269
|
+
cleanup();
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
The pattern is matched against the full command including the binary
|
|
273
|
+
name and all arguments (e.g. `gh pr list`). An empty pattern (`""`)
|
|
274
|
+
mocks **every** invocation, just like passing no pattern at all — so you
|
|
275
|
+
can toggle the behaviour dynamically.
|
|
276
|
+
|
|
277
|
+
Pattern-based mocking composes with all three calling conventions,
|
|
278
|
+
including the output shorthand:
|
|
279
|
+
|
|
280
|
+
``` ts
|
|
281
|
+
const cleanup = await mockBin(
|
|
282
|
+
{ binName: "git", pattern: "^git status" },
|
|
283
|
+
"mocked status",
|
|
284
|
+
);
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
> **How patterns work internally:** the generated mock script builds the
|
|
288
|
+
> full command string (`"${binName} $*"`) and tests it with `grep -qE`.
|
|
289
|
+
> If it matches, the mock code runs; otherwise the real binary is
|
|
290
|
+
> invoked directly via `exec`.
|
|
291
|
+
|
|
292
|
+
#### Script-based mocking with `mock-a-bin-run-original`
|
|
293
|
+
|
|
294
|
+
For finer-grained, programmatic control, write the logic yourself in the
|
|
295
|
+
mock script and call the `mock-a-bin-run-original` helper to delegate
|
|
296
|
+
back to the real binary when you want to:
|
|
297
|
+
|
|
298
|
+
``` ts
|
|
299
|
+
const cleanup = await mockBin(
|
|
300
|
+
"git",
|
|
301
|
+
"bash",
|
|
302
|
+
`
|
|
303
|
+
if [ "$1" = "status" ]; then
|
|
304
|
+
echo "Everything is clean!"
|
|
305
|
+
else
|
|
306
|
+
mock-a-bin-run-original "$@"
|
|
307
|
+
fi
|
|
308
|
+
`,
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
// $ git status → "Everything is clean!\n" (mocked)
|
|
312
|
+
// $ git log --oneline → (real git output) (passed through)
|
|
313
|
+
cleanup();
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
Every `mockBin()` call creates a `mock-a-bin-run-original` executable in
|
|
317
|
+
the same temp directory (which is on the `PATH`). This helper restores
|
|
318
|
+
the original `PATH`, locates the real binary, and executes it with all
|
|
319
|
+
forwarded arguments. It works with **any** interpreter — bash, Node,
|
|
320
|
+
Python, etc.:
|
|
321
|
+
|
|
322
|
+
``` ts
|
|
323
|
+
// Conditional mocking from a Node mock
|
|
324
|
+
const cleanup = await mockBin("git", "node", `
|
|
325
|
+
const { spawnSync } = require("child_process");
|
|
326
|
+
if (process.argv[2] === "status") {
|
|
327
|
+
console.log("mocked from node");
|
|
328
|
+
} else {
|
|
329
|
+
const result = spawnSync("mock-a-bin-run-original", process.argv.slice(2), {
|
|
330
|
+
stdio: "inherit",
|
|
331
|
+
});
|
|
332
|
+
process.exit(result.status || 0);
|
|
333
|
+
}
|
|
334
|
+
`);
|
|
335
|
+
cleanup();
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
If the original binary doesn’t exist on the system, the helper prints an
|
|
339
|
+
error to stderr and exits with code `127` (the conventional “command not
|
|
340
|
+
found” exit status).
|
|
341
|
+
|
|
342
|
+
### Controlling exit codes
|
|
343
|
+
|
|
344
|
+
Your mock script controls the exit code exactly like any normal script.
|
|
345
|
+
In Bash, use `exit`:
|
|
346
|
+
|
|
347
|
+
``` ts
|
|
348
|
+
const cleanup = await mockBin("git", "bash", "exit 1");
|
|
349
|
+
|
|
350
|
+
const result = spawnSync("git", { encoding: "utf-8" });
|
|
351
|
+
console.log(result.status); // 1
|
|
352
|
+
|
|
353
|
+
cleanup();
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
This lets you simulate failures, permission errors, timeouts — anything
|
|
357
|
+
your application needs to handle.
|
|
358
|
+
|
|
359
|
+
### Passing environment variables
|
|
360
|
+
|
|
361
|
+
Environment variables from the calling process are passed through to
|
|
362
|
+
both the mock and the original binary. This is useful when you want your
|
|
363
|
+
test to inject configuration via `env`:
|
|
364
|
+
|
|
365
|
+
``` ts
|
|
366
|
+
const cleanup = await mockBin("env", "bash", 'mock-a-bin-run-original "$@"');
|
|
367
|
+
|
|
368
|
+
const result = spawnSync("env", {
|
|
369
|
+
encoding: "utf-8",
|
|
370
|
+
env: {
|
|
371
|
+
...process.env,
|
|
372
|
+
CUSTOM_TEST_VAR: "my-custom-value",
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
console.log(result.stdout); // includes "CUSTOM_TEST_VAR=my-custom-value"
|
|
377
|
+
cleanup();
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
### Mocking multiple commands at once
|
|
381
|
+
|
|
382
|
+
Call `mockBin` as many times as you need. Each call is independent and
|
|
383
|
+
returns its own cleanup function:
|
|
384
|
+
|
|
385
|
+
``` ts
|
|
386
|
+
const cleanupGit = await mockBin("git", "bash", 'echo "mocked git"');
|
|
387
|
+
const cleanupGh = await mockBin("gh", "bash", 'echo "mocked gh"');
|
|
388
|
+
|
|
389
|
+
// $ git status → "mocked git\n"
|
|
390
|
+
// $ gh pr list → "mocked gh\n"
|
|
391
|
+
|
|
392
|
+
cleanupGit();
|
|
393
|
+
cleanupGh();
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
### Mocking with any interpreter
|
|
397
|
+
|
|
398
|
+
Because mocks are real executable scripts with shebangs, you can use any
|
|
399
|
+
language available on your system:
|
|
400
|
+
|
|
401
|
+
``` ts
|
|
402
|
+
// Node.js
|
|
403
|
+
await mockBin("mytool", "node", 'console.log("from node")');
|
|
404
|
+
|
|
405
|
+
// Python
|
|
406
|
+
await mockBin("mytool", "python3", 'print("from python")');
|
|
407
|
+
|
|
408
|
+
// Perl
|
|
409
|
+
await mockBin("mytool", "perl", 'print "from perl\n";');
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
## Using with a test runner
|
|
413
|
+
|
|
414
|
+
Type-A-Bin is test-runner agnostic — it only touches `process.env.PATH`.
|
|
415
|
+
The recommended pattern is to set up mocks in a `beforeEach`/`beforeAll`
|
|
416
|
+
and clean up in `afterEach`/`afterAll` so each test starts with a fresh
|
|
417
|
+
`PATH`. Here’s an example using [Vitest](https://vitest.dev):
|
|
418
|
+
|
|
419
|
+
``` ts
|
|
420
|
+
import { execFileSync } from "node:child_process";
|
|
421
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
422
|
+
import { mockBin } from "type-a-bin";
|
|
423
|
+
|
|
424
|
+
describe("my CLI", () => {
|
|
425
|
+
let cleanup: () => void;
|
|
426
|
+
|
|
427
|
+
beforeEach(async () => {
|
|
428
|
+
cleanup = await mockBin("git", "bash", 'echo "On branch main"');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
afterEach(() => {
|
|
432
|
+
cleanup();
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("reads the current branch", () => {
|
|
436
|
+
const output = execFileSync("git", ["branch", "--show-current"], {
|
|
437
|
+
encoding: "utf-8",
|
|
438
|
+
});
|
|
439
|
+
expect(output.trim()).toBe("On branch main");
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
The same approach works with Jest, Mocha, Node’s built-in test runner,
|
|
445
|
+
or any other framework. The key is to always call the cleanup function
|
|
446
|
+
to restore the original `PATH` — otherwise mocks leak into subsequent
|
|
447
|
+
tests.
|
|
448
|
+
|
|
449
|
+
## API reference
|
|
450
|
+
|
|
451
|
+
### `mockBin`
|
|
452
|
+
|
|
453
|
+
Creates a mock executable and prepends it to `PATH`. Returns an async
|
|
454
|
+
**cleanup function** that restores the original `PATH` and removes the
|
|
455
|
+
temp directory.
|
|
456
|
+
|
|
457
|
+
The function is overloaded with three signatures:
|
|
458
|
+
|
|
459
|
+
#### Output shorthand
|
|
460
|
+
|
|
461
|
+
``` ts
|
|
462
|
+
mockBin(binNameOrConfig, output): Promise<MockBinCleanup>
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
| Parameter | Type | Description |
|
|
466
|
+
|-------------------|---------------------------|-----------------------------------------------------|
|
|
467
|
+
| `binNameOrConfig` | `string \| MockBinConfig` | Binary name, or a config with `binName` + `pattern` |
|
|
468
|
+
| `output` | `string` | The text the mock echoes (via `bash`) |
|
|
469
|
+
|
|
470
|
+
Mocks the binary so that every invocation prints `output`. The fastest
|
|
471
|
+
way to stub a command that just needs to return a string.
|
|
472
|
+
|
|
473
|
+
#### Full script
|
|
474
|
+
|
|
475
|
+
``` ts
|
|
476
|
+
mockBin(binNameOrConfig, shebang, code): Promise<MockBinCleanup>
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
| Parameter | Type | Description |
|
|
480
|
+
|-------------------|---------------------------|-----------------------------------------------------|
|
|
481
|
+
| `binNameOrConfig` | `string \| MockBinConfig` | Binary name, or a config with `binName` + `pattern` |
|
|
482
|
+
| `shebang` | `string` | Interpreter (e.g. `"bash"`, `"node"`) |
|
|
483
|
+
| `code` | `string` | The script body that runs when the mock is invoked |
|
|
484
|
+
|
|
485
|
+
Gives full control. The `shebang` accepts a bare interpreter name
|
|
486
|
+
(wrapped in `#!/usr/bin/env …` automatically) or a full shebang line.
|
|
487
|
+
|
|
488
|
+
#### Script file
|
|
489
|
+
|
|
490
|
+
``` ts
|
|
491
|
+
mockBin(binNameOrConfig, shebang, script): Promise<MockBinCleanup>
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
| Parameter | Type | Description |
|
|
495
|
+
|-------------------|---------------------------|-----------------------------------------------------|
|
|
496
|
+
| `binNameOrConfig` | `string \| MockBinConfig` | Binary name, or a config with `binName` + `pattern` |
|
|
497
|
+
| `shebang` | `string` | Interpreter used to run the file |
|
|
498
|
+
| `script` | `MockBinScriptFile` | `{ file: string }` pointing at a script on disk |
|
|
499
|
+
|
|
500
|
+
Runs a script file through the given interpreter, keeping the file’s
|
|
501
|
+
original extension so extension-aware loaders (e.g. `node --import tsx`)
|
|
502
|
+
work. Throws if the file does not exist.
|
|
503
|
+
|
|
504
|
+
### Types
|
|
505
|
+
|
|
506
|
+
``` ts
|
|
507
|
+
/** A cleanup function that restores the original PATH. */
|
|
508
|
+
type MockBinCleanup = () => void;
|
|
509
|
+
|
|
510
|
+
interface MockBinConfig {
|
|
511
|
+
/** The name of the binary to mock (e.g. "gh", "git"). */
|
|
512
|
+
binName: string;
|
|
513
|
+
/** Optional regex pattern. Only commands matching it are mocked. */
|
|
514
|
+
pattern?: string;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
interface MockBinScriptFile {
|
|
518
|
+
/**
|
|
519
|
+
* Script executed when the mock runs. The file keeps its real
|
|
520
|
+
* extension, so extension-aware loaders (e.g. `node --import tsx`)
|
|
521
|
+
* parse it — embedding the source inline fails because the mock
|
|
522
|
+
* binary is written to an extensionless temp file.
|
|
523
|
+
*/
|
|
524
|
+
file: string;
|
|
525
|
+
}
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
## Packages
|
|
529
|
+
|
|
530
|
+
This repository is a [pnpm](https://pnpm.io) workspace. The `type-a-bin`
|
|
531
|
+
library lives at the root; additional packages live under `packages/`:
|
|
532
|
+
|
|
533
|
+
- [`packages/bin-test`](packages/bin-test/) — a dragon CLI demo that
|
|
534
|
+
wraps a fictional `dragon` binary and tests it by mocking the binary
|
|
535
|
+
with `mockBin`. A great reference for how to consume the library in a
|
|
536
|
+
real package.
|
|
537
|
+
|
|
538
|
+
## Prerequisites
|
|
539
|
+
|
|
540
|
+
- [Node.js](https://nodejs.org) 26 and [pnpm](https://pnpm.io) (enforced
|
|
541
|
+
via `engines` in `package.json`)
|
|
542
|
+
- [pandoc](https://pandoc.org) ≥ 3.1 — only needed for Markdown
|
|
543
|
+
formatting/linting (`pnpm lint:md` / `pnpm format:md`), not for using
|
|
544
|
+
the library
|
|
545
|
+
- On Windows: [Git for Windows](https://gitforwindows.org/) — its bash
|
|
546
|
+
powers bash-interpreter mocks (node-interpreter mocks need nothing
|
|
547
|
+
beyond Node itself)
|
|
548
|
+
|
|
549
|
+
## Scripts
|
|
550
|
+
|
|
551
|
+
Run from the repository root:
|
|
552
|
+
|
|
553
|
+
| Script | Description |
|
|
554
|
+
|-------------------|-------------------------------------------------------|
|
|
555
|
+
| `pnpm build` | Build the library (and workspace packages) to `dist/` |
|
|
556
|
+
| `pnpm test` | Build, run unit tests, then run workspace tests |
|
|
557
|
+
| `pnpm lint` | Run all linters (Biome, oxlint, ast-grep, pandoc) |
|
|
558
|
+
| `pnpm format` | Run all formatters with auto-fix |
|
|
559
|
+
| `pnpm test:watch` | Run unit tests in watch mode |
|
|
560
|
+
|
|
561
|
+
## How it works under the hood
|
|
562
|
+
|
|
563
|
+
When you call `mockBin(...)` (on Linux/macOS — Windows follows the same
|
|
564
|
+
shape, see [Windows support](#windows-support)):
|
|
565
|
+
|
|
566
|
+
1. **Temp directory.** A fresh directory is created under the OS temp
|
|
567
|
+
folder (e.g. `/tmp/mock-bin-xxxx/`).
|
|
568
|
+
|
|
569
|
+
2. **Mock script.** An executable file named after your binary is
|
|
570
|
+
written into the temp dir. Its content depends on the calling
|
|
571
|
+
convention:
|
|
572
|
+
|
|
573
|
+
- **Output shorthand** → a `bash` script that echoes the string.
|
|
574
|
+
- **Full script** → the interpreter shebang + your code.
|
|
575
|
+
- **Script file** → a `/bin/sh` wrapper that `exec`s your file
|
|
576
|
+
through the interpreter, forwarding arguments.
|
|
577
|
+
|
|
578
|
+
3. **`mock-a-bin-run-original` helper.** A second executable is written
|
|
579
|
+
alongside the mock. It restores the original `PATH`, finds the real
|
|
580
|
+
binary with `command -v`, and `exec`s it with all forwarded
|
|
581
|
+
arguments. This is what powers [script-based conditional
|
|
582
|
+
mocking](#script-based-mocking-with-mock-a-bin-run-original).
|
|
583
|
+
|
|
584
|
+
4. **Pattern wrapper** *(only when a `pattern` is given)*. The mock
|
|
585
|
+
script is wrapped so that it builds the full command string, tests
|
|
586
|
+
it against the regex with `grep -qE`, and either runs the mock code
|
|
587
|
+
or `exec`s the real binary directly.
|
|
588
|
+
|
|
589
|
+
5. **PATH manipulation.** The temp directory is prepended to
|
|
590
|
+
`process.env.PATH`, so the mock shadows the real binary. The
|
|
591
|
+
original `PATH` is saved.
|
|
592
|
+
|
|
593
|
+
6. **Cleanup.** The returned function restores `process.env.PATH` to
|
|
594
|
+
its original value (or deletes it if it was unset) and recursively
|
|
595
|
+
removes the temp directory. Calling cleanup more than once is safe.
|
|
596
|
+
|
|
597
|
+
Type-A-Bin handles the platform-specific path separator (`:` on Unix,
|
|
598
|
+
`;` on Windows) automatically.
|
|
599
|
+
|
|
600
|
+
## Windows support
|
|
601
|
+
|
|
602
|
+
The same `mockBin` API works on Windows. Because Windows cannot execute
|
|
603
|
+
extensionless `#!` scripts — and Node refuses to spawn `.cmd`/`.bat`
|
|
604
|
+
shims without a shell — the implementation swaps the mechanism, not the
|
|
605
|
+
contract:
|
|
606
|
+
|
|
607
|
+
- Each mock binary is a **hard link of `node.exe`** named `<bin>.exe`
|
|
608
|
+
(copied if the temp directory is on another volume), prepended to
|
|
609
|
+
`PATH` exactly as on Linux.
|
|
610
|
+
- A small **preload** is registered through `NODE_OPTIONS --import` in
|
|
611
|
+
every spawned process. It detects that the process was started as a
|
|
612
|
+
mock shim (its first CLI argument is not a real file), then swaps the
|
|
613
|
+
entry for your mock script — argv, stdin, stdout, stderr, and exit
|
|
614
|
+
codes all pass through.
|
|
615
|
+
- Node-interpreter mocks (and `node --import tsx` script files) run
|
|
616
|
+
**in-process** through loader hooks; other interpreters (`bash`,
|
|
617
|
+
`python`, …) are resolved from `PATH` and spawned with your script and
|
|
618
|
+
the original arguments.
|
|
619
|
+
- The `mock-a-bin-run-original` helper, `pattern` conditionals, output
|
|
620
|
+
shorthand, script files, and cleanup contract all behave as on Linux.
|
|
621
|
+
Cleanup additionally restores `NODE_OPTIONS` and the internal mock
|
|
622
|
+
registry.
|
|
623
|
+
|
|
624
|
+
Requirements and behaviour notes:
|
|
625
|
+
|
|
626
|
+
- **Bash mocks need Git for Windows.** Bash-like interpreters prefer a
|
|
627
|
+
native `bash.exe` (Git Bash) over WSL’s launcher, which cannot run
|
|
628
|
+
Windows-path scripts; well-known Git install locations are probed as a
|
|
629
|
+
fallback.
|
|
630
|
+
- **The first CLI argument must be positional.** A shim *is* `node.exe`,
|
|
631
|
+
so a leading flag (`gh --version`) is consumed by Node’s own option
|
|
632
|
+
parser before the mock can intercept it. Prefer the subcommand form
|
|
633
|
+
(`gh version`), or wrap the flag behind a positional subcommand.
|
|
634
|
+
- **Pass-through needs a real `.exe`.** `mock-a-bin-run-original` and
|
|
635
|
+
pattern fall-through locate and spawn the original binary directly;
|
|
636
|
+
`.cmd`/`.bat`-only binaries (e.g. `npm.cmd`) cannot be spawned by Node
|
|
637
|
+
without a shell.
|
|
638
|
+
- **The output shorthand expands `$1`–`$9`, `$*`, and `$@`** from the
|
|
639
|
+
command line (matching bash `echo` for positional parameters); other
|
|
640
|
+
shell substitutions are printed literally.
|
|
641
|
+
- **Stacked mocks clean up last-in, first-out.** Like `PATH`, the mock
|
|
642
|
+
registry and `NODE_OPTIONS` are snapshot-restored, so call the cleanup
|
|
643
|
+
functions in reverse order of the `mockBin` calls for a full restore.
|
|
644
|
+
|
|
645
|
+
`NODE_OPTIONS` is process environment, not global state: it only affects
|
|
646
|
+
processes spawned while mocks are active, and the preload is inert for
|
|
647
|
+
any process that is not a mock shim.
|
|
648
|
+
|
|
649
|
+
## Comparison with other tools
|
|
650
|
+
|
|
651
|
+
| Feature | Type-A-Bin | [mock-bin](https://github.com/stevemao/mock-bin) | [mock-a-bin](https://github.com/levibostian/mock-a-bin) |
|
|
652
|
+
|------------------------------------|:----------:|:------------------------------------------------:|:-------------------------------------------------------:|
|
|
653
|
+
| Mocks any binary via `PATH` | ✅ | ✅ | ✅ |
|
|
654
|
+
| Runtime dependencies | 0 | several | several (Deno) |
|
|
655
|
+
| TypeScript types & overloads | ✅ | ❌ | partial |
|
|
656
|
+
| Output shorthand | ✅ | ❌ | ❌ |
|
|
657
|
+
| Script-file mode (keeps extension) | ✅ | ❌ | ❌ |
|
|
658
|
+
| Pattern-based conditional mocking | ✅ | ❌ | ❌ |
|
|
659
|
+
| `run-original` pass-through | ✅ | ❌ | ✅ |
|
|
660
|
+
| Cleanup function | ✅ | ✅ | ✅ |
|
|
661
|
+
| Runtime | Node.js | Node.js | Deno |
|
|
662
|
+
|
|
663
|
+
## Contributing
|
|
664
|
+
|
|
665
|
+
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for
|
|
666
|
+
the development setup, the coding conventions enforced by the toolchain,
|
|
667
|
+
and how to submit changes.
|
|
668
|
+
|
|
669
|
+
## License
|
|
670
|
+
|
|
671
|
+
[MIT](LICENSE) © SynthLuvr
|
|
672
|
+
|
|
673
|
+
## Special Thanks
|
|
674
|
+
|
|
675
|
+
This project began as a Deno-to-Node.js migration of
|
|
676
|
+
[mock-a-bin](https://github.com/levibostian/mock-a-bin) by [Levi
|
|
677
|
+
Bostian](https://github.com/levibostian), and it is also a Node.js
|
|
678
|
+
alternative to the npm package
|
|
679
|
+
[mock-bin](https://github.com/stevemao/mock-bin) by [Steve
|
|
680
|
+
Mao](https://github.com/stevemao). Both projects inspired Type-A-Bin.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { type MockBinCleanup, type MockBinConfig, type MockBinScriptFile, mockBin, } from "./mock-bin.js";
|