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.
@@ -1,3 +1,4 @@
1
+ import type { MockBinBehaviour, MockBinHandle } from "./mock-bin-behaviour.js";
1
2
  type MockBinCleanup = () => void;
2
3
  interface MockBinConfig {
3
4
  /** The name of the binary to mock (e.g., "gh", "git") */
@@ -21,16 +22,25 @@ interface MockBinScriptFile {
21
22
  * original command, enabling conditional mocking where some subcommands
22
23
  * are mocked while others pass through to the real binary.
23
24
  *
24
- * There are three calling conventions:
25
+ * There are five calling conventions:
25
26
  *
26
27
  * 1. **Output shorthand** — pass the plain text the mock should print.
27
28
  * The interpreter defaults to `bash` and the output is echoed.
28
29
  * 2. **Full script** — pass an interpreter (`shebang`) and arbitrary
29
30
  * script `code` to run when the mock binary is invoked.
30
- * 3. **Script file** — pass an interpreter (`shebang`) and a
31
+ * 3. **Script-file shorthand** — pass only a `{ file }` object and the
32
+ * interpreter is picked from the file's extension: TypeScript runs
33
+ * through the tsx loader (resolved to an absolute URL so the mock
34
+ * works from any working directory), `.js` through node, and `.sh`
35
+ * through bash.
36
+ * 4. **Script file** — pass an interpreter (`shebang`) and a
31
37
  * `{ file }` object pointing at a script on disk. The file keeps its
32
38
  * own extension, so extension-aware loaders work (e.g.
33
39
  * `node --import tsx` with a `.ts` file).
40
+ * 5. **Scripted behaviour** — pass a `MockBinBehaviour` object instead
41
+ * of a script. The mock records every invocation on the returned
42
+ * handle's `calls`, and the object scripts the output, exit code and
43
+ * timing without writing a script at all.
34
44
  *
35
45
  * @param binNameOrConfig - Binary name or a config object with `binName`
36
46
  * and an optional `pattern`
@@ -52,13 +62,31 @@ interface MockBinScriptFile {
52
62
  *
53
63
  * @example
54
64
  * ```ts
55
- * // Script file (keeps its extension, so tsx transforms it)
65
+ * // Script-file shorthand (the extension picks the interpreter)
66
+ * const cleanup = await mockBin("dragon", {
67
+ * file: "./src/tests/hoard-script.ts", // → node --import <absolute tsx>
68
+ * })
69
+ * ```
70
+ *
71
+ * @example
72
+ * ```ts
73
+ * // Script file with an explicit interpreter (keeps its extension)
56
74
  * const cleanup = await mockBin("dragon", "node --import tsx", {
57
75
  * file: "./src/tests/hoard-script.ts",
58
76
  * })
59
77
  * ```
78
+ *
79
+ * @example
80
+ * ```ts
81
+ * // Scripted behaviour, with the invocations recorded
82
+ * const mock = await mockBin("gh", { stdout: ["#1", "#2"], exitCode: 1 })
83
+ * expect(mock.calls[0]?.args).toEqual(["pr", "list"])
84
+ * mock() // The handle is the cleanup function
85
+ * ```
60
86
  */
61
87
  declare function mockBin(binNameOrConfig: string | MockBinConfig, output: string): Promise<MockBinCleanup>;
88
+ declare function mockBin(binNameOrConfig: string | MockBinConfig, script: MockBinScriptFile): Promise<MockBinCleanup>;
89
+ declare function mockBin(binNameOrConfig: string | MockBinConfig, behaviour: MockBinBehaviour): Promise<MockBinHandle>;
62
90
  declare function mockBin(binNameOrConfig: string | MockBinConfig, shebang: string, code: string): Promise<MockBinCleanup>;
63
91
  declare function mockBin(binNameOrConfig: string | MockBinConfig, shebang: string, script: MockBinScriptFile): Promise<MockBinCleanup>;
64
92
  export { type MockBinCleanup, type MockBinConfig, type MockBinScriptFile, mockBin, };
package/dist/mock-bin.js CHANGED
@@ -1,8 +1,10 @@
1
- import { rmSync } from "node:fs";
2
1
  import { chmod, mkdtemp, stat, writeFile } from "node:fs/promises";
3
2
  import { tmpdir } from "node:os";
4
3
  import path from "node:path";
4
+ import { prepareBehaviour, withCalls } from "./mock-bin-behaviour.js";
5
+ import { isTypeScriptFile, resolveTsxImportUrl } from "./mock-bin-tsx.js";
5
6
  import { mockBinWindows } from "./mock-bin-windows.js";
7
+ import { rmScratch } from "./rm-scratch.js";
6
8
  /**
7
9
  * Finds the path to a binary within the given PATH directories.
8
10
  *
@@ -28,16 +30,20 @@ const findBinaryInPath = async (binName, pathDirs) => {
28
30
  };
29
31
  /** Wraps a bare interpreter in a `#!/usr/bin/env …` shebang line. */
30
32
  const toShebangLine = (interpreter) => interpreter.startsWith("#!") ? interpreter : `#!/usr/bin/env ${interpreter}`;
33
+ const resolveExistingFile = async (file) => {
34
+ const resolvedFile = path.resolve(file);
35
+ const stats = await stat(resolvedFile).catch(() => null);
36
+ if (!stats?.isFile())
37
+ throw new Error(`mockBin: script file not found: ${file}`);
38
+ return resolvedFile;
39
+ };
31
40
  /**
32
41
  * Validates the script file exists, then builds an `exec` wrapper that
33
42
  * delegates to it through the given interpreter. The file keeps its real
34
43
  * extension so extension-aware loaders (e.g. tsx) parse it.
35
44
  */
36
45
  const resolveScriptFile = async (shebang, { file }) => {
37
- const resolvedFile = path.resolve(file);
38
- const stats = await stat(resolvedFile).catch(() => null);
39
- if (!stats?.isFile())
40
- throw new Error(`mockBin: script file not found: ${file}`);
46
+ const resolvedFile = await resolveExistingFile(file);
41
47
  // Accept either a bare interpreter ("node --import tsx") or a full
42
48
  // shebang line ("#!/usr/bin/env node"); strip "#!" for the exec line.
43
49
  const interpreter = shebang.startsWith("#!")
@@ -48,6 +54,33 @@ const resolveScriptFile = async (shebang, { file }) => {
48
54
  body: `exec ${interpreter} "${resolvedFile}" "$@"`,
49
55
  };
50
56
  };
57
+ /** Discriminates the script-file shorthand from a behaviour object. */
58
+ const isScriptFile = (value) => typeof value === "object" &&
59
+ "file" in value &&
60
+ typeof value.file === "string";
61
+ /**
62
+ * Picks the interpreter for the script-file shorthand from the file's
63
+ * extension: TypeScript runs through the tsx loader resolved to an
64
+ * absolute file URL — so the mock works from any working directory —
65
+ * `.js` through node, `.sh` through bash. Anything else must name its
66
+ * interpreter explicitly.
67
+ */
68
+ const scriptFileInterpreter = async (script) => {
69
+ const resolvedFile = await resolveExistingFile(script.file);
70
+ if (isTypeScriptFile(resolvedFile)) {
71
+ const tsxImportUrl = resolveTsxImportUrl(resolvedFile);
72
+ // Without tsx, node's native type stripping still parses erasable
73
+ // TypeScript.
74
+ return tsxImportUrl === null ? "node" : `node --import ${tsxImportUrl}`;
75
+ }
76
+ const extension = path.extname(resolvedFile).toLowerCase();
77
+ if ([".cjs", ".js", ".mjs"].includes(extension))
78
+ return "node";
79
+ if (extension === ".sh")
80
+ return "bash";
81
+ throw new Error(`mockBin: no interpreter known for '${extension || "no extension"}' script files; pass one explicitly, e.g. ` +
82
+ `mockBin(bin, "node", { file })`);
83
+ };
51
84
  /**
52
85
  * Resolves the shebang and body for inline code or the output shorthand.
53
86
  * With no `code`, `shebangOrOutput` is echoed via bash.
@@ -62,6 +95,17 @@ async function mockBin(binNameOrConfig, shebangOrOutput, codeOrScript) {
62
95
  ? { binName: binNameOrConfig }
63
96
  : binNameOrConfig;
64
97
  const { binName, pattern } = config;
98
+ // Script-file shorthand: pick the interpreter, then take the explicit
99
+ // interpreter + script-file path.
100
+ if (isScriptFile(shebangOrOutput))
101
+ return mockBin(config, await scriptFileInterpreter(shebangOrOutput), shebangOrOutput);
102
+ // A scripted behaviour compiles to a node mock that carries its own
103
+ // pattern check, so it installs through the ordinary inline-code path
104
+ // on both platforms and only the call recorder is layered on top.
105
+ if (typeof shebangOrOutput === "object") {
106
+ const { code, recordDir } = await prepareBehaviour(binName, pattern, shebangOrOutput);
107
+ return withCalls(await mockBin(binName, "node", code), recordDir);
108
+ }
65
109
  // Windows needs a real .exe on the PATH plus a NODE_OPTIONS preload
66
110
  // that redirects the shim's entry to the mock script; everything else
67
111
  // (pattern handling, cleanup contract) behaves the same.
@@ -134,13 +178,7 @@ fi
134
178
  process.env.PATH = originalPath;
135
179
  else
136
180
  delete process.env.PATH;
137
- try {
138
- rmSync(tempDir, { recursive: true });
139
- }
140
- catch (error) {
141
- // Ignore cleanup errors - temp dir will be cleaned up eventually
142
- console.warn(`Warning: Failed to remove mock-bin temp directory ${tempDir}: ${String(error)}`);
143
- }
181
+ rmScratch(tempDir);
144
182
  };
145
183
  }
146
184
  export { mockBin, };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Removes a scratch directory tree without ever throwing. Deletion is
3
+ * retried because Windows — and busy filesystems generally — can
4
+ * transiently deny removing files a just-exited process still holds;
5
+ * a removal that still fails warns, since an unclean temp directory
6
+ * beats failing a suite that passed.
7
+ *
8
+ * @param dir - Directory to remove; missing paths are fine (`force`)
9
+ */
10
+ declare const rmScratch: (dir: string) => void;
11
+ export { rmScratch };
@@ -0,0 +1,25 @@
1
+ import { rmSync } from "node:fs";
2
+ const RM_OPTIONS = {
3
+ recursive: true,
4
+ force: true,
5
+ maxRetries: 40,
6
+ retryDelay: 250,
7
+ };
8
+ /**
9
+ * Removes a scratch directory tree without ever throwing. Deletion is
10
+ * retried because Windows — and busy filesystems generally — can
11
+ * transiently deny removing files a just-exited process still holds;
12
+ * a removal that still fails warns, since an unclean temp directory
13
+ * beats failing a suite that passed.
14
+ *
15
+ * @param dir - Directory to remove; missing paths are fine (`force`)
16
+ */
17
+ const rmScratch = (dir) => {
18
+ try {
19
+ rmSync(dir, RM_OPTIONS);
20
+ }
21
+ catch (err) {
22
+ console.error(`warning: could not remove ${dir}: ${String(err)}`);
23
+ }
24
+ };
25
+ export { rmScratch };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "type-a-bin",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Mock any executable binary for testing in Node.js",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,7 +24,7 @@
24
24
  "node": "==26"
25
25
  },
26
26
  "scripts": {
27
- "build:lib": "del-cli dist && tsc && del-cli dist/tests",
27
+ "build:lib": "del-cli dist && tsc && del-cli dist/tests && node scripts/copy-native.mjs",
28
28
  "build": "pnpm build:lib && pnpm -r build",
29
29
  "format": "ts-canon format",
30
30
  "lint": "ts-canon lint",