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 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 dependencies at runtime.** Pure Node.js standard library.
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 three calling conventions. Choose the one that fits how
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 three calling conventions,
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 three signatures:
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 **hard link of `node.exe`** named `<bin>.exe`
643
- (copied if the temp directory is on another volume), prepended to
644
- `PATH` exactly as on Linux.
645
- - A small **preload** is registered through `NODE_OPTIONS --import` in
646
- every spawned process. It detects that the process was started as a
647
- mock shim (its first CLI argument is not a real file), then swaps the
648
- entry for your mock script argv, stdin, stdout, stderr, and exit
649
- codes all pass through.
650
- - Node-interpreter mocks (and `node --import tsx` script files) run
651
- **in-process** through loader hooks; other interpreters (`bash`,
652
- `python`, …) are resolved from `PATH` and spawned with your script and
653
- the original arguments.
654
- - The `mock-a-bin-run-original` helper, `pattern` conditionals, output
655
- shorthand, script files, and cleanup contract all behave as on Linux.
656
- Cleanup additionally restores `NODE_OPTIONS` and the internal mock
657
- registry.
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 and `NODE_OPTIONS` are snapshot-restored, so call the cleanup
678
- functions in reverse order of the `mockBin` calls for a full restore.
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
- `NODE_OPTIONS` is process environment, not global state: it only affects
681
- processes spawned while mocks are active, and the preload is inert for
682
- any process that is not a mock shim.
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
@@ -1 +1,3 @@
1
1
  export { mockBin, } from "./mock-bin.js";
2
+ export { MOCKS_VAR, withoutMocks } from "./mock-bin-env.js";
3
+ export { rmScratch } from "./rm-scratch.js";
@@ -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 };