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.
- package/README.md +289 -32
- 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 +2 -2
package/README.md
CHANGED
|
@@ -15,13 +15,17 @@ injecting a mock script into your `PATH`.
|
|
|
15
15
|
takes over.
|
|
16
16
|
- **Total control over output and exit codes.** Return whatever stdout,
|
|
17
17
|
stderr, and exit status your tests need.
|
|
18
|
+
- **Invocation recording.** Scripted mocks record every call — argv,
|
|
19
|
+
cwd, env, even stdin — on `mock.calls` for assertions.
|
|
18
20
|
- **Conditional mocking.** Mock only the subcommands you care about and
|
|
19
21
|
let everything else fall through to the real binary.
|
|
20
22
|
- **Any interpreter.** Bash, Node, Python, Perl — if it has a shebang,
|
|
21
23
|
you can use it.
|
|
22
24
|
- **Cross-platform.** The same API works on Linux and Windows.
|
|
23
25
|
- **TypeScript-first.** Full type definitions and overloaded signatures.
|
|
24
|
-
- **Zero
|
|
26
|
+
- **Zero npm runtime dependencies.** Pure Node.js standard library, plus
|
|
27
|
+
a bundled (checked-in, ~14 KB) Windows launcher for `mockBin` — no
|
|
28
|
+
native add-ons, nothing compiled at install time.
|
|
25
29
|
|
|
26
30
|
Type-A-Bin is a Node.js alternative to the npm packages
|
|
27
31
|
[mock-bin](https://github.com/stevemao/mock-bin) and
|
|
@@ -41,6 +45,7 @@ fully typed, dependency-free library with richer features.
|
|
|
41
45
|
- [1. Output shorthand](#1-output-shorthand)
|
|
42
46
|
- [2. Full script](#2-full-script)
|
|
43
47
|
- [3. Script file](#3-script-file)
|
|
48
|
+
- [4. Scripted behaviour](#4-scripted-behaviour)
|
|
44
49
|
- [Conditional mocking](#conditional-mocking)
|
|
45
50
|
- [Pattern-based mocking](#pattern-based-mocking)
|
|
46
51
|
- [Script-based mocking with
|
|
@@ -53,6 +58,8 @@ fully typed, dependency-free library with richer features.
|
|
|
53
58
|
- [Using with a test runner](#using-with-a-test-runner)
|
|
54
59
|
- [API reference](#api-reference)
|
|
55
60
|
- [`mockBin(...)`](#mockbin)
|
|
61
|
+
- [`withoutMocks(env)`](#withoutmocksenv)
|
|
62
|
+
- [`rmScratch(dir)`](#rmscratchdir)
|
|
56
63
|
- [Types](#types)
|
|
57
64
|
- [Packages](#packages)
|
|
58
65
|
- [Prerequisites](#prerequisites)
|
|
@@ -147,7 +154,7 @@ cleanup(); // Restore the original PATH
|
|
|
147
154
|
|
|
148
155
|
## Usage
|
|
149
156
|
|
|
150
|
-
`mockBin` offers
|
|
157
|
+
`mockBin` offers five calling conventions. Choose the one that fits how
|
|
151
158
|
much control you need.
|
|
152
159
|
|
|
153
160
|
### 1. Output shorthand
|
|
@@ -230,6 +237,32 @@ loaders work correctly. Internally, Type-A-Bin writes a tiny `/bin/sh`
|
|
|
230
237
|
wrapper that `exec`s your file through the given interpreter, forwarding
|
|
231
238
|
all arguments.
|
|
232
239
|
|
|
240
|
+
**Picking the interpreter for you.** Skip the interpreter and pass only
|
|
241
|
+
`{ file }` — the extension decides:
|
|
242
|
+
|
|
243
|
+
``` ts
|
|
244
|
+
const cleanup = await mockBin("dragon", {
|
|
245
|
+
file: "./src/tests/hoard-script.ts",
|
|
246
|
+
});
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
| Extension | Interpreter |
|
|
250
|
+
|----|----|
|
|
251
|
+
| `.ts` `.tsx` `.mts` `.cts` | `node --import <tsx>` — the tsx loader, resolved to an absolute file URL |
|
|
252
|
+
| `.js` `.mjs` `.cjs` | `node` |
|
|
253
|
+
| `.sh` | `bash` |
|
|
254
|
+
| anything else | throws — pass an interpreter explicitly |
|
|
255
|
+
|
|
256
|
+
The absolute loader URL matters: a mocked binary often runs with a
|
|
257
|
+
working directory outside your package (a temp dir, a fixture store),
|
|
258
|
+
where a bare `node --import tsx` cannot resolve `tsx` and the mock would
|
|
259
|
+
die on a module-not-found error. The shorthand resolves the loader once,
|
|
260
|
+
from the script’s own package first and Type-A-Bin’s installed location
|
|
261
|
+
second, and embeds the result. On Windows the trampoline bootstrap
|
|
262
|
+
imports the same URL before loading a `.ts` entry. When tsx is not
|
|
263
|
+
installed at all, Node’s native type stripping parses erasable
|
|
264
|
+
TypeScript instead.
|
|
265
|
+
|
|
233
266
|
This works with any language and any file extension:
|
|
234
267
|
|
|
235
268
|
``` ts
|
|
@@ -239,6 +272,60 @@ await mockBin("mycli", "python3", {
|
|
|
239
272
|
});
|
|
240
273
|
```
|
|
241
274
|
|
|
275
|
+
### 4. Scripted behaviour
|
|
276
|
+
|
|
277
|
+
For the most common testing need — stub a binary’s output, set its exit
|
|
278
|
+
code, and assert how it was invoked — skip the script entirely and pass
|
|
279
|
+
a behaviour object. The mock prints your lines, exits with your code,
|
|
280
|
+
and records every invocation:
|
|
281
|
+
|
|
282
|
+
``` ts
|
|
283
|
+
const mock = await mockBin("gh", {
|
|
284
|
+
stdout: ["#1 Fix login", "#2 Add docs"],
|
|
285
|
+
lineDelayMs: 50, // stream the lines one at a time
|
|
286
|
+
exitCode: 1,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Anywhere in your code under test:
|
|
290
|
+
// $ gh pr list → "#1 Fix login\n#2 Add docs\n", streamed, exit 1
|
|
291
|
+
|
|
292
|
+
expect(mock.calls[0]?.args).toEqual(["pr", "list"]);
|
|
293
|
+
expect(mock.calls[0]?.cwd).toBe(process.cwd());
|
|
294
|
+
expect(mock.calls[0]?.env.GH_TOKEN).toBe("secret");
|
|
295
|
+
|
|
296
|
+
mock(); // The handle is still the cleanup function
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Each call is recorded the moment the mock starts — before it sleeps,
|
|
300
|
+
streams, or answers — so `mock.calls` is ready to assert even while a
|
|
301
|
+
slow mock is still running. Reading stdin blocks until the caller closes
|
|
302
|
+
it, so it is opt-in: `record: { stdin: true }` captures the full input
|
|
303
|
+
as `call.stdin`, ideal for asserting the payload your code piped into
|
|
304
|
+
the binary.
|
|
305
|
+
|
|
306
|
+
The behaviour object scripts the whole response:
|
|
307
|
+
|
|
308
|
+
| Option | Type | Default | Description |
|
|
309
|
+
|----|----|----|----|
|
|
310
|
+
| `record` | `boolean \| { stdin?: boolean }` | `true` | Record invocations on `mock.calls` |
|
|
311
|
+
| `stdout` | `string \| readonly string[]` | — | Line(s) written to stdout, each followed by a newline |
|
|
312
|
+
| `stderr` | `string \| readonly string[]` | — | Line(s) written to stderr, after the stdout lines |
|
|
313
|
+
| `exitCode` | `number` | `0` | Exit code the mock finishes with |
|
|
314
|
+
| `delayMs` | `number` | `0` | Delay before the mock writes anything |
|
|
315
|
+
| `lineDelayMs` | `number` | `0` | Gap between stdout lines, so a tailing consumer sees them one at a time |
|
|
316
|
+
| `spawnChild` | `boolean \| { lifetimeMs?: number }` | — | Spawn a long-lived descendant, its pid recorded as `call.childPid` |
|
|
317
|
+
| `trapSignals` | `boolean \| { lifetimeMs?: number }` | — | Ignore SIGINT/SIGTERM, forcing a stop to escalate to SIGKILL |
|
|
318
|
+
|
|
319
|
+
The last two script lifecycle scenarios: `spawnChild` lets a test prove
|
|
320
|
+
a stop reaps the whole process tree rather than the mock alone;
|
|
321
|
+
`trapSignals` lets it prove a stop escalates to SIGKILL. Both keep the
|
|
322
|
+
mock (and any descendant) alive for a bounded `lifetimeMs` — 120s by
|
|
323
|
+
default — so a mock a test forgets to stop cannot outlive the suite.
|
|
324
|
+
|
|
325
|
+
Pattern-based mocking composes here too: matching invocations run the
|
|
326
|
+
behaviour, everything else falls through to the real binary and is not
|
|
327
|
+
recorded (see [Conditional mocking](#conditional-mocking)).
|
|
328
|
+
|
|
242
329
|
### Conditional mocking
|
|
243
330
|
|
|
244
331
|
Often you want to mock only *some* invocations of a command and let
|
|
@@ -275,7 +362,7 @@ name and all arguments (e.g. `gh pr list`). An empty pattern (`""`)
|
|
|
275
362
|
mocks **every** invocation, just like passing no pattern at all — so you
|
|
276
363
|
can toggle the behaviour dynamically.
|
|
277
364
|
|
|
278
|
-
Pattern-based mocking composes with all
|
|
365
|
+
Pattern-based mocking composes with all five calling conventions,
|
|
279
366
|
including the output shorthand:
|
|
280
367
|
|
|
281
368
|
``` ts
|
|
@@ -455,7 +542,24 @@ Creates a mock executable and prepends it to `PATH`. Returns an async
|
|
|
455
542
|
**cleanup function** that restores the original `PATH` and removes the
|
|
456
543
|
temp directory.
|
|
457
544
|
|
|
458
|
-
The function is overloaded with
|
|
545
|
+
The function is overloaded with five signatures:
|
|
546
|
+
|
|
547
|
+
#### Script file (interpreter inferred)
|
|
548
|
+
|
|
549
|
+
``` ts
|
|
550
|
+
mockBin(binNameOrConfig, script): Promise<MockBinCleanup>
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
| Parameter | Type | Description |
|
|
554
|
+
|----|----|----|
|
|
555
|
+
| `binNameOrConfig` | `string \| MockBinConfig` | Binary name, or a config with `binName` + `pattern` |
|
|
556
|
+
| `script` | `MockBinScriptFile` | `{ file: string }` pointing at a script on disk |
|
|
557
|
+
|
|
558
|
+
Runs a script file with the interpreter picked from its extension —
|
|
559
|
+
TypeScript through the tsx loader (resolved to an absolute URL, so the
|
|
560
|
+
mock works from any working directory), `.js` through node, `.sh`
|
|
561
|
+
through bash. Throws for extensions with no known interpreter (pass one
|
|
562
|
+
explicitly instead) or when the file does not exist.
|
|
459
563
|
|
|
460
564
|
#### Output shorthand
|
|
461
565
|
|
|
@@ -502,6 +606,66 @@ Runs a script file through the given interpreter, keeping the file’s
|
|
|
502
606
|
original extension so extension-aware loaders (e.g. `node --import tsx`)
|
|
503
607
|
work. Throws if the file does not exist.
|
|
504
608
|
|
|
609
|
+
#### Scripted behaviour
|
|
610
|
+
|
|
611
|
+
``` ts
|
|
612
|
+
mockBin(binNameOrConfig, behaviour): Promise<MockBinHandle>
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
| Parameter | Type | Description |
|
|
616
|
+
|----|----|----|
|
|
617
|
+
| `binNameOrConfig` | `string \| MockBinConfig` | Binary name, or a config with `binName` + `pattern` |
|
|
618
|
+
| `behaviour` | `MockBinBehaviour` | Object scripting output, exit code, timing and lifecycle |
|
|
619
|
+
|
|
620
|
+
Runs a mock scripted entirely by the `behaviour` object — no
|
|
621
|
+
interpreter, no script. Returns a `MockBinHandle`: the ordinary cleanup
|
|
622
|
+
function, extended with a `calls` property holding every recorded
|
|
623
|
+
invocation in call order. `calls` is read fresh on each access and keeps
|
|
624
|
+
serving the last snapshot after cleanup.
|
|
625
|
+
|
|
626
|
+
### `withoutMocks(env)`
|
|
627
|
+
|
|
628
|
+
Copies an environment without the mock registry, for spawns that must
|
|
629
|
+
not be intercepted. Inside a mock, a child spawned with the inherited
|
|
630
|
+
environment can re-enter the interception machinery — on Windows a spawn
|
|
631
|
+
through a legacy hard-link shim resolves `process.execPath` to the shim
|
|
632
|
+
itself. Passing `withoutMocks(process.env)` as the child’s `env` leaves
|
|
633
|
+
any preload inert (it finds no registry), and a trampoline-launched mock
|
|
634
|
+
handed such an environment forwards the invocation to the real binary
|
|
635
|
+
instead of the mock.
|
|
636
|
+
|
|
637
|
+
``` ts
|
|
638
|
+
import { spawn } from "node:child_process";
|
|
639
|
+
import { withoutMocks } from "type-a-bin";
|
|
640
|
+
|
|
641
|
+
const child = spawn(
|
|
642
|
+
process.execPath,
|
|
643
|
+
["-e", 'setTimeout(() => console.log("helper ran"), 1000)'],
|
|
644
|
+
{ env: withoutMocks(process.env) },
|
|
645
|
+
);
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
The registry variable’s name is exported as `MOCKS_VAR`, so callers that
|
|
649
|
+
need to read or strip the registry never hardcode it.
|
|
650
|
+
|
|
651
|
+
### `rmScratch(dir)`
|
|
652
|
+
|
|
653
|
+
Removes a scratch directory tree without ever failing a test: the
|
|
654
|
+
deletion is forced and retried (`maxRetries: 40`, `retryDelay: 250`),
|
|
655
|
+
because Windows — and busy filesystems generally — can transiently deny
|
|
656
|
+
deleting files a just-exited process still holds (shim executables,
|
|
657
|
+
SQLite WAL files). A removal that still fails after the retries logs a
|
|
658
|
+
warning instead of throwing: leaving a temp directory behind beats
|
|
659
|
+
failing a suite that passed.
|
|
660
|
+
|
|
661
|
+
``` ts
|
|
662
|
+
import { rmScratch } from "type-a-bin";
|
|
663
|
+
|
|
664
|
+
rmScratch(scratchDir); // never throws
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
`mockBin` cleanup uses this helper internally on every platform.
|
|
668
|
+
|
|
505
669
|
### Types
|
|
506
670
|
|
|
507
671
|
``` ts
|
|
@@ -524,6 +688,76 @@ interface MockBinScriptFile {
|
|
|
524
688
|
*/
|
|
525
689
|
file: string;
|
|
526
690
|
}
|
|
691
|
+
|
|
692
|
+
/** A cleanup function, carrying the calls a scripted mock recorded. */
|
|
693
|
+
type MockBinHandle = MockBinCleanup & {
|
|
694
|
+
/** Every recorded invocation, in call order. */
|
|
695
|
+
readonly calls: MockBinCall[];
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
/** One recorded invocation of a scripted mock. */
|
|
699
|
+
interface MockBinCall {
|
|
700
|
+
/** Arguments the mock was invoked with, excluding the binary name. */
|
|
701
|
+
args: string[];
|
|
702
|
+
/** Working directory the mock ran in. */
|
|
703
|
+
cwd: string;
|
|
704
|
+
/** Environment the mock ran with. */
|
|
705
|
+
env: Record<string, string>;
|
|
706
|
+
/** Process id of the mock itself. */
|
|
707
|
+
pid: number;
|
|
708
|
+
/** Stdin, when the behaviour recorded it. */
|
|
709
|
+
stdin?: string;
|
|
710
|
+
/** Pid of the descendant, when the behaviour spawned one. */
|
|
711
|
+
childPid?: number;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** How long a spawned descendant or a signal-trapped mock stays alive. */
|
|
715
|
+
interface MockBinLifetimeOptions {
|
|
716
|
+
/** Lifetime in milliseconds. Default 120000. */
|
|
717
|
+
lifetimeMs?: number;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** Extra recording knobs for a scripted mock. */
|
|
721
|
+
interface MockBinRecordOptions {
|
|
722
|
+
/**
|
|
723
|
+
* Read stdin to end-of-file and record it as `call.stdin`. Off by
|
|
724
|
+
* default: a mock that drains stdin waits for the caller to close it.
|
|
725
|
+
*/
|
|
726
|
+
stdin?: boolean;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** Scripts a mock's output, exit code, timing and lifecycle. */
|
|
730
|
+
interface MockBinBehaviour {
|
|
731
|
+
/**
|
|
732
|
+
* Record every invocation for `handle.calls`. On by default; pass
|
|
733
|
+
* `false` to skip recording, or `{ stdin: true }` to capture stdin
|
|
734
|
+
* as well.
|
|
735
|
+
*/
|
|
736
|
+
record?: boolean | MockBinRecordOptions;
|
|
737
|
+
/** Line(s) written to stdout, each followed by a newline. */
|
|
738
|
+
stdout?: string | readonly string[];
|
|
739
|
+
/** Line(s) written to stderr, after the stdout lines. */
|
|
740
|
+
stderr?: string | readonly string[];
|
|
741
|
+
/** Exit code the mock finishes with. Default 0. */
|
|
742
|
+
exitCode?: number;
|
|
743
|
+
/** Delay before the mock writes anything, in milliseconds. */
|
|
744
|
+
delayMs?: number;
|
|
745
|
+
/**
|
|
746
|
+
* Gap between stdout lines, in milliseconds, so a consumer tailing
|
|
747
|
+
* the stream sees them arrive one at a time instead of one burst.
|
|
748
|
+
*/
|
|
749
|
+
lineDelayMs?: number;
|
|
750
|
+
/**
|
|
751
|
+
* Spawn a long-lived descendant and record its pid as `call.childPid`,
|
|
752
|
+
* so a test can prove a stop reaps the whole process tree.
|
|
753
|
+
*/
|
|
754
|
+
spawnChild?: boolean | MockBinLifetimeOptions;
|
|
755
|
+
/**
|
|
756
|
+
* Ignore SIGINT and SIGTERM, so stopping the mock has to escalate to
|
|
757
|
+
* SIGKILL.
|
|
758
|
+
*/
|
|
759
|
+
trapSignals?: boolean | MockBinLifetimeOptions;
|
|
760
|
+
}
|
|
527
761
|
```
|
|
528
762
|
|
|
529
763
|
## Packages
|
|
@@ -546,7 +780,10 @@ library lives at the root; additional packages live under `packages/`:
|
|
|
546
780
|
library
|
|
547
781
|
- On Windows: [Git for Windows](https://gitforwindows.org/) — its bash
|
|
548
782
|
powers bash-interpreter mocks (node-interpreter mocks need nothing
|
|
549
|
-
beyond Node itself)
|
|
783
|
+
beyond Node itself). The `mockBin` launcher itself is bundled in the
|
|
784
|
+
package (`dist/native/win32`), rebuilt and checksum-verified from
|
|
785
|
+
`native/trampoline.c` in CI — no compiler or download is needed at
|
|
786
|
+
install time.
|
|
550
787
|
|
|
551
788
|
## Scripts
|
|
552
789
|
|
|
@@ -639,22 +876,42 @@ extensionless `#!` scripts — and Node refuses to spawn `.cmd`/`.bat`
|
|
|
639
876
|
shims without a shell — the implementation swaps the mechanism, not the
|
|
640
877
|
contract:
|
|
641
878
|
|
|
642
|
-
- Each mock binary is a **
|
|
643
|
-
(
|
|
644
|
-
`
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
`
|
|
653
|
-
|
|
654
|
-
- The
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
879
|
+
- Each mock binary is a **copy of a tiny trampoline launcher**
|
|
880
|
+
(`type-a-bin-trampoline.exe`, built from
|
|
881
|
+
[`native/trampoline.c`](native/trampoline.c)) named `<bin>.exe`,
|
|
882
|
+
prepended to `PATH` exactly as on Linux.
|
|
883
|
+
- The trampoline starts the real Node executable with a small
|
|
884
|
+
**bootstrap script** as Node’s script argument, passing the invoked
|
|
885
|
+
mock executable and the caller’s original arguments after it. A real
|
|
886
|
+
script occupies Node’s first positional slot, so every original
|
|
887
|
+
argument — including leading flags (`gh --version`), embedded quotes,
|
|
888
|
+
line breaks, trailing backslashes, and Unicode — arrives in the mock’s
|
|
889
|
+
`process.argv` exactly as the caller passed it. No shell and no Node
|
|
890
|
+
option parser sits in between.
|
|
891
|
+
- The bootstrap dispatches through the same runtime the library uses
|
|
892
|
+
everywhere: node-interpreter mocks (and TypeScript script files,
|
|
893
|
+
loaded through the tsx loader URL) run **in-process** with
|
|
894
|
+
`process.argv` rewritten to `[node, entry, ...args]`; other
|
|
895
|
+
interpreters (`bash`, `python`, …) are resolved from `PATH` and
|
|
896
|
+
spawned with your script and the original arguments.
|
|
897
|
+
- The launcher inherits stdin/stdout/stderr, the environment, and the
|
|
898
|
+
working directory, propagates the mock’s exit code, and starts the
|
|
899
|
+
mock inside a Windows Job Object — killing the process you spawned
|
|
900
|
+
reaps the mock and its descendants, while a mock that finishes
|
|
901
|
+
normally releases descendants it deliberately left behind.
|
|
902
|
+
- Inside a mock, `process.execPath` is the real Node executable, and
|
|
903
|
+
unrelated Node children spawned during the test no longer carry a
|
|
904
|
+
preload: nothing is injected into `NODE_OPTIONS`.
|
|
905
|
+
- The `mock-a-bin-run-original` helper, `pattern` conditionals (both
|
|
906
|
+
flag-first), output shorthand, script files, and cleanup contract all
|
|
907
|
+
behave as on Linux. Cleanup removes the launcher, bootstrap, and
|
|
908
|
+
scripts with the same retry semantics as before.
|
|
909
|
+
|
|
910
|
+
A legacy mechanism — a hard link of `node.exe` plus a `NODE_OPTIONS`
|
|
911
|
+
preload that redirects the shim’s entry — ships as a temporary escape
|
|
912
|
+
hatch: set `TYPE_A_BIN_DISABLE_TRAMPOLINE=1` to force it while the
|
|
913
|
+
launcher rollout is validated. It cannot support flag-first CLIs,
|
|
914
|
+
because Node parses a leading option before the preload runs.
|
|
658
915
|
|
|
659
916
|
Requirements and behaviour notes:
|
|
660
917
|
|
|
@@ -662,31 +919,31 @@ Requirements and behaviour notes:
|
|
|
662
919
|
native `bash.exe` (Git Bash) over WSL’s launcher, which cannot run
|
|
663
920
|
Windows-path scripts; well-known Git install locations are probed as a
|
|
664
921
|
fallback.
|
|
665
|
-
- **The first CLI argument must be positional.** A shim *is* `node.exe`,
|
|
666
|
-
so a leading flag (`gh --version`) is consumed by Node’s own option
|
|
667
|
-
parser before the mock can intercept it. Prefer the subcommand form
|
|
668
|
-
(`gh version`), or wrap the flag behind a positional subcommand.
|
|
669
922
|
- **Pass-through needs a real `.exe`.** `mock-a-bin-run-original` and
|
|
670
923
|
pattern fall-through locate and spawn the original binary directly;
|
|
671
924
|
`.cmd`/`.bat`-only binaries (e.g. `npm.cmd`) cannot be spawned by Node
|
|
672
|
-
without a shell.
|
|
925
|
+
without a shell. (That limitation is about the *real* binary; the
|
|
926
|
+
mock’s own argv is always forwarded verbatim.)
|
|
673
927
|
- **The output shorthand expands `$1`–`$9`, `$*`, and `$@`** from the
|
|
674
928
|
command line (matching bash `echo` for positional parameters); other
|
|
675
929
|
shell substitutions are printed literally.
|
|
676
930
|
- **Stacked mocks clean up last-in, first-out.** Like `PATH`, the mock
|
|
677
|
-
registry
|
|
678
|
-
|
|
931
|
+
registry is snapshot-restored, so call the cleanup functions in
|
|
932
|
+
reverse order of the `mockBin` calls for a full restore.
|
|
679
933
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
934
|
+
The mock registry (`TYPE_A_BIN_MOCKS`) and the launcher’s Node
|
|
935
|
+
executable (`TYPE_A_BIN_NODE_EXE`) are process environment, not global
|
|
936
|
+
state: they only affect processes spawned while mocks are active.
|
|
937
|
+
Children spawned from inside a mock escape interception on their own —
|
|
938
|
+
[`withoutMocks(process.env)`](#withoutmocksenv) remains for spawns that
|
|
939
|
+
must reach the real binary behind a mock.
|
|
683
940
|
|
|
684
941
|
## Comparison with other tools
|
|
685
942
|
|
|
686
943
|
| Feature | Type-A-Bin | [mock-bin](https://github.com/stevemao/mock-bin) | [mock-a-bin](https://github.com/levibostian/mock-a-bin) |
|
|
687
944
|
|----|:--:|:--:|:--:|
|
|
688
945
|
| Mocks any binary via `PATH` | ✅ | ✅ | ✅ |
|
|
689
|
-
| Runtime dependencies | 0 | several | several (Deno) |
|
|
946
|
+
| Runtime dependencies | 0 npm (bundled Windows launcher) | several | several (Deno) |
|
|
690
947
|
| TypeScript types & overloads | ✅ | ❌ | partial |
|
|
691
948
|
| Output shorthand | ✅ | ❌ | ❌ |
|
|
692
949
|
| Script-file mode (keeps extension) | ✅ | ❌ | ❌ |
|
package/dist/index.d.ts
CHANGED
|
@@ -1 +1,4 @@
|
|
|
1
1
|
export { type MockBinCleanup, type MockBinConfig, type MockBinScriptFile, mockBin, } from "./mock-bin.js";
|
|
2
|
+
export { type MockBinBehaviour, type MockBinCall, type MockBinHandle, type MockBinLifetimeOptions, type MockBinRecordOptions, } from "./mock-bin-behaviour.js";
|
|
3
|
+
export { MOCKS_VAR, withoutMocks } from "./mock-bin-env.js";
|
|
4
|
+
export { rmScratch } from "./rm-scratch.js";
|
package/dist/index.js
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
interface MockBehaviourScript {
|
|
2
|
+
/** Binary name, as the pattern matches it against the command line. */
|
|
3
|
+
binName: string;
|
|
4
|
+
stdout: string[];
|
|
5
|
+
stderr: string[];
|
|
6
|
+
exitCode: number;
|
|
7
|
+
delayMs: number;
|
|
8
|
+
lineDelayMs: number;
|
|
9
|
+
recordStdin: boolean;
|
|
10
|
+
/** Regex source; only matching commands run the behaviour. */
|
|
11
|
+
pattern?: string;
|
|
12
|
+
/** Directory that receives one JSON record per invocation. */
|
|
13
|
+
recordDir?: string;
|
|
14
|
+
/** Life of the spawned descendant; absent means spawn none. */
|
|
15
|
+
spawnChildMs?: number;
|
|
16
|
+
/** How long trapped signals keep the mock alive; absent traps none. */
|
|
17
|
+
trapSignalsMs?: number;
|
|
18
|
+
}
|
|
19
|
+
/** Runs one invocation of a mock built from a scripted behaviour. */
|
|
20
|
+
declare const runMockBehaviour: (script: MockBehaviourScript) => Promise<void>;
|
|
21
|
+
export { type MockBehaviourScript, runMockBehaviour };
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { closeSync, openSync, renameSync, statSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
// Runs inside the mocked binary's own process — spawned on POSIX, in
|
|
6
|
+
// the shim on Windows — so it must stay free of runtime imports beyond
|
|
7
|
+
// node's own modules. mock-bin-behaviour compiles a MockBinBehaviour
|
|
8
|
+
// into the script below and the mock hands it straight back here.
|
|
9
|
+
// The registry env var must stay in sync with mock-bin-env.
|
|
10
|
+
const MOCKS_VAR = "TYPE_A_BIN_MOCKS";
|
|
11
|
+
const WINDOWS_EXTENSIONS = ["", ".exe", ".cmd", ".bat", ".com"];
|
|
12
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
13
|
+
const isFile = (candidate) => {
|
|
14
|
+
try {
|
|
15
|
+
return statSync(candidate).isFile();
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
/** The PATH without the mock temp dirs, so the real binary wins. */
|
|
22
|
+
const realBinary = (binName) => {
|
|
23
|
+
const dirs = (process.env.PATH ?? "")
|
|
24
|
+
.split(path.delimiter)
|
|
25
|
+
.filter((dir) => dir !== "" && !dir.includes("mock-bin-"));
|
|
26
|
+
const extensions = process.platform === "win32" ? WINDOWS_EXTENSIONS : [""];
|
|
27
|
+
for (const dir of dirs)
|
|
28
|
+
for (const extension of extensions) {
|
|
29
|
+
const candidate = path.join(dir, `${binName}${extension}`);
|
|
30
|
+
if (isFile(candidate))
|
|
31
|
+
return candidate;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Pattern miss: hand the invocation to the binary the mock shadows.
|
|
37
|
+
* The behaviour tests the pattern itself rather than leaning on the
|
|
38
|
+
* wrappers the other conventions use, because those are shell scripts
|
|
39
|
+
* and this mock is a node one on both platforms.
|
|
40
|
+
*/
|
|
41
|
+
const runRealBinary = (binName, args) => {
|
|
42
|
+
const real = realBinary(binName);
|
|
43
|
+
if (real === null) {
|
|
44
|
+
process.stderr.write(`Error: Real binary '${binName}' not found in PATH\n`);
|
|
45
|
+
process.exitCode = 127;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// The real binary must not be re-intercepted by the Windows preload.
|
|
49
|
+
const env = { ...process.env };
|
|
50
|
+
delete env[MOCKS_VAR];
|
|
51
|
+
const result = spawnSync(real, args, { stdio: "inherit", env });
|
|
52
|
+
process.exitCode = result.status ?? 127;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Claims the next free record slot. Creating the file exclusively makes
|
|
56
|
+
* the number a race-free ticket, so concurrent invocations are recorded
|
|
57
|
+
* in the order they started.
|
|
58
|
+
*/
|
|
59
|
+
const reserveRecord = (recordDir) => {
|
|
60
|
+
for (let index = 0;; index += 1) {
|
|
61
|
+
const file = path.join(recordDir, `${index}.json`);
|
|
62
|
+
try {
|
|
63
|
+
closeSync(openSync(file, "wx"));
|
|
64
|
+
return file;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error.code !== "EEXIST")
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
/** Publishes a record by rename, so a reader never sees half of one. */
|
|
73
|
+
const writeRecord = (file, call) => {
|
|
74
|
+
const pending = `${file}.pending`;
|
|
75
|
+
writeFileSync(pending, JSON.stringify(call));
|
|
76
|
+
renameSync(pending, file);
|
|
77
|
+
};
|
|
78
|
+
const readStdin = async () => {
|
|
79
|
+
const chunks = [];
|
|
80
|
+
for await (const chunk of process.stdin)
|
|
81
|
+
chunks.push(Buffer.from(chunk));
|
|
82
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Spawns a descendant that outlives the mock's own work, so a test can
|
|
86
|
+
* prove a stop reaps the process tree rather than the mock alone.
|
|
87
|
+
*/
|
|
88
|
+
const spawnChild = (lifetimeMs) => {
|
|
89
|
+
// A Windows mock binary runs behind the type-a-bin trampoline
|
|
90
|
+
// launcher, which starts it inside a Job Object killed when the
|
|
91
|
+
// launcher dies — so a descendant detached from its console stays in
|
|
92
|
+
// the job: a killed mock takes it down with the tree, while a
|
|
93
|
+
// normally completed mock has the launcher clear the kill-on-close
|
|
94
|
+
// flag and lets it outlive the invocation. Dropping the mock registry
|
|
95
|
+
// from its env keeps any legacy NODE_OPTIONS preload inert there.
|
|
96
|
+
const env = { ...process.env };
|
|
97
|
+
delete env[MOCKS_VAR];
|
|
98
|
+
const child = spawn(process.execPath, ["-e", `setTimeout(() => {}, ${lifetimeMs})`], {
|
|
99
|
+
stdio: "ignore",
|
|
100
|
+
env,
|
|
101
|
+
// POSIX detaching would start a new session, moving the child out
|
|
102
|
+
// of the mock's process group and out of a group signal's reach.
|
|
103
|
+
// Windows is the opposite: a spawned child sits in a job that is
|
|
104
|
+
// killed when the mock exits, so only there must it detach to
|
|
105
|
+
// outlive the mock.
|
|
106
|
+
...(process.platform === "win32" ? { detached: true } : {}),
|
|
107
|
+
});
|
|
108
|
+
// The child stays in the mock's process group, so a stop that signals
|
|
109
|
+
// the group reaps it; unref keeps it from holding the mock open, and
|
|
110
|
+
// its bounded life keeps a missed descendant from outliving the suite.
|
|
111
|
+
child.unref();
|
|
112
|
+
return child.pid;
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* Stop-escalation tests need a mock that ignores the graceful signals:
|
|
116
|
+
* no-op handlers swallow them, so the process survives until the
|
|
117
|
+
* ladder's SIGKILL rung lands.
|
|
118
|
+
*/
|
|
119
|
+
const trapSignals = (lifetimeMs) => {
|
|
120
|
+
process.on("SIGINT", () => undefined);
|
|
121
|
+
process.on("SIGTERM", () => undefined);
|
|
122
|
+
// Signal listeners do not hold node's event loop open, so a bounded
|
|
123
|
+
// timer keeps the mock running for the stop ladder to work against.
|
|
124
|
+
setTimeout(() => undefined, lifetimeMs);
|
|
125
|
+
};
|
|
126
|
+
/** Runs one invocation of a mock built from a scripted behaviour. */
|
|
127
|
+
const runMockBehaviour = async (script) => {
|
|
128
|
+
const args = process.argv.slice(2);
|
|
129
|
+
const command = [script.binName, ...args].join(" ");
|
|
130
|
+
if (script.pattern !== undefined && !new RegExp(script.pattern).test(command))
|
|
131
|
+
return runRealBinary(script.binName, args);
|
|
132
|
+
if (script.trapSignalsMs !== undefined)
|
|
133
|
+
trapSignals(script.trapSignalsMs);
|
|
134
|
+
// The slot is claimed before anything can block and published as soon
|
|
135
|
+
// as the invocation is fully described, so a test can read the call
|
|
136
|
+
// while a slow or streaming mock is still running.
|
|
137
|
+
const record = script.recordDir === undefined
|
|
138
|
+
? undefined
|
|
139
|
+
: reserveRecord(script.recordDir);
|
|
140
|
+
const childPid = script.spawnChildMs === undefined
|
|
141
|
+
? undefined
|
|
142
|
+
: spawnChild(script.spawnChildMs);
|
|
143
|
+
const stdin = script.recordStdin ? await readStdin() : undefined;
|
|
144
|
+
if (record !== undefined)
|
|
145
|
+
writeRecord(record, {
|
|
146
|
+
args,
|
|
147
|
+
cwd: process.cwd(),
|
|
148
|
+
env: { ...process.env },
|
|
149
|
+
pid: process.pid,
|
|
150
|
+
...(stdin === undefined ? {} : { stdin }),
|
|
151
|
+
...(childPid === undefined ? {} : { childPid }),
|
|
152
|
+
});
|
|
153
|
+
if (script.delayMs > 0)
|
|
154
|
+
await sleep(script.delayMs);
|
|
155
|
+
for (const [index, line] of script.stdout.entries()) {
|
|
156
|
+
if (index > 0 && script.lineDelayMs > 0)
|
|
157
|
+
await sleep(script.lineDelayMs);
|
|
158
|
+
process.stdout.write(`${line}\n`);
|
|
159
|
+
}
|
|
160
|
+
for (const line of script.stderr)
|
|
161
|
+
process.stderr.write(`${line}\n`);
|
|
162
|
+
process.exitCode = script.exitCode;
|
|
163
|
+
};
|
|
164
|
+
export { runMockBehaviour };
|