vitest-bats 0.0.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 C. Spencer Beggs
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,415 @@
1
+ # vitest-bats
2
+
3
+ A [Vitest](https://vitest.dev/) plugin for testing bash scripts with
4
+ [BATS](https://github.com/bats-core/bats-core) and collecting
5
+ [kcov](https://github.com/SimonKagstrom/kcov) coverage, merged into Vitest's
6
+ standard v8 coverage report.
7
+
8
+ ## Features
9
+
10
+ - **Single plugin setup** -- `BatsPlugin()` handles dependency detection,
11
+ reporter injection, environment configuration, and matcher registration
12
+ - **Native `.sh` imports** -- `import script from "./hello.sh"` returns a
13
+ builder. Configure with `env()` / `flags()` / `mock()`, terminate with
14
+ `run(...args)` or `exec(shellExpr)`.
15
+ - **23 `expect.extend` matchers** -- `toSucceed`, `toContainOutput`,
16
+ `toHaveJsonValue`, `toMatchSchema`, `toHaveInvoked`, and more.
17
+ Auto-registered via setup file -- no user setup required.
18
+ - **Schema validation** -- `toMatchSchema` accepts any
19
+ [Standard Schema](https://github.com/standard-schema/standard-schema)
20
+ validator (Zod, Valibot, Arktype, Effect Schema). `toMatchJsonSchema`
21
+ validates raw JSON Schema via Ajv.
22
+ - **Self-contained command mocking** -- `.mock("git", { "remote get-url *":
23
+ "echo https://example.com" })` embeds shim binaries that record calls and
24
+ emit pre-recorded responses. No `bats-mock` runtime dependency.
25
+ - **Unified coverage** -- Shell script coverage from kcov is merged into the v8
26
+ Istanbul CoverageMap and reported alongside TypeScript coverage.
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ npm install --save-dev vitest-bats vitest
32
+ ```
33
+
34
+ `vitest-bats` requires `vitest >= 4.1.0` and BATS `>= 1.5` (for the
35
+ `run --separate-stderr` form used to capture stderr separately).
36
+
37
+ ### System Dependencies
38
+
39
+ These must be installed on the host (or in your Docker container):
40
+
41
+ | Dependency | Required | Install (macOS) | Install (Linux) |
42
+ | --- | --- | --- | --- |
43
+ | bats (>= 1.5) | Yes | `brew install bats-core` | `apt-get install bats` |
44
+ | bats-support | Yes | `brew install bats-support` | `apt-get install bats-support` |
45
+ | bats-assert | Yes | `brew install bats-assert` | `apt-get install bats-assert` |
46
+ | bats-mock | Yes | `brew install bats-mock` | `apt-get install bats-file` |
47
+ | jq | Yes | `brew install jq` | `apt-get install jq` |
48
+ | kcov | Coverage only | `brew install kcov` | `apt-get install kcov` |
49
+
50
+ `bats-assert` and `bats-mock` are still detected at startup for compatibility
51
+ warnings; the runtime no longer loads them. Mocks are self-contained, and
52
+ assertions happen in TypeScript via the matchers below.
53
+
54
+ ## Quick Start
55
+
56
+ ### 1. Configure Vitest
57
+
58
+ ```typescript
59
+ // vitest.config.ts
60
+ import { defineConfig } from "vitest/config";
61
+ import { BatsPlugin } from "vitest-bats";
62
+
63
+ export default defineConfig({
64
+ plugins: [BatsPlugin()],
65
+ test: {
66
+ include: ["__test__/**/*.test.ts"],
67
+ coverage: {
68
+ provider: "v8",
69
+ },
70
+ },
71
+ });
72
+ ```
73
+
74
+ The plugin auto-injects `vitest-bats/setup` into `test.setupFiles`. Do not
75
+ add it manually -- it registers all matchers and a `beforeEach` builder
76
+ reset.
77
+
78
+ ### 2. Add the `.sh` module type
79
+
80
+ In `tsconfig.json`, add `vitest-bats` to `types` so TypeScript understands
81
+ `.sh` imports:
82
+
83
+ ```json
84
+ {
85
+ "compilerOptions": {
86
+ "types": ["vitest-bats"]
87
+ }
88
+ }
89
+ ```
90
+
91
+ ### 3. Write Tests
92
+
93
+ ```typescript
94
+ // __test__/hello.test.ts
95
+ import { describe, expect, test } from "vitest";
96
+ import hello from "../scripts/hello.sh";
97
+
98
+ describe("hello.sh", () => {
99
+ test("outputs default greeting", () => {
100
+ const result = hello.run();
101
+ expect(result).toSucceed();
102
+ expect(result).toContainOutput("Hello World");
103
+ });
104
+
105
+ test("greets by name", () => {
106
+ const result = hello.run("--name", "Alice");
107
+ expect(result).toSucceed();
108
+ expect(result).toContainOutput("Hello Alice");
109
+ });
110
+
111
+ test("outputs JSON with --json flag", () => {
112
+ const result = hello.run("--json");
113
+ expect(result).toSucceed();
114
+ expect(result).toHaveJsonValue("greeting", "Hello World");
115
+ });
116
+
117
+ test("rejects unknown arguments", () => {
118
+ const result = hello.run("--invalid");
119
+ expect(result).toFail();
120
+ expect(result).toContainStderr("Unknown option");
121
+ });
122
+ });
123
+ ```
124
+
125
+ `run()` returns a `BatsResult` synchronously. `await` is harmless but
126
+ unnecessary -- `BatsResult` is not thenable.
127
+
128
+ ### 4. Run Tests
129
+
130
+ ```bash
131
+ vitest run
132
+ vitest run --coverage # With coverage (requires kcov on Linux)
133
+ ```
134
+
135
+ ## API Reference
136
+
137
+ ### `BatsPlugin(options?)`
138
+
139
+ Vitest plugin. Add to the `plugins` array in `vitest.config.ts`.
140
+
141
+ ```typescript
142
+ BatsPlugin({
143
+ coverage: "auto", // "auto" | true | false (default: "auto")
144
+ deps: "error", // "error" | "warn" (default: "error")
145
+ logLevel: "errors-only", // "verbose" | "debug" | "errors-only"
146
+ links: "auto", // "auto" | "default" | "hte"
147
+ });
148
+ ```
149
+
150
+ Coverage modes:
151
+
152
+ | Value | Behavior |
153
+ | --- | --- |
154
+ | `"auto"` (default) | Shell coverage on when kcov is available and platform is not macOS |
155
+ | `true` | Require kcov on a non-macOS platform; throw otherwise |
156
+ | `false` | Always exclude shell scripts from coverage |
157
+
158
+ `deps`:
159
+
160
+ - `"error"` (default) -- throw on missing required dependencies
161
+ - `"warn"` -- log warnings; pure-TS unit tests still run, integration
162
+ tests using `.sh` imports fail individually
163
+
164
+ ### Importing Scripts
165
+
166
+ `.sh` imports return a `ScriptBuilder` instance:
167
+
168
+ ```typescript
169
+ import script from "../scripts/hello.sh";
170
+ // script: ScriptBuilder
171
+ ```
172
+
173
+ The plugin's Vite transform (`resolveId` + `load`) intercepts the import and
174
+ emits a virtual module that constructs a `ScriptBuilder` bound to the
175
+ script's absolute path.
176
+
177
+ ### `ScriptBuilder`
178
+
179
+ Configuration methods accumulate state and return `this` for chaining.
180
+ Termination methods (`run` / `exec`) consume the state, execute the script,
181
+ and return a `BatsResult`. Builder state is reset after every termination
182
+ (via a `finally` block) and again before each test (via the auto-injected
183
+ `beforeEach`).
184
+
185
+ #### Configuration
186
+
187
+ | Method | Purpose |
188
+ | --- | --- |
189
+ | `env(vars)` | Set environment variables for the next run. Multiple calls accumulate. |
190
+ | `flags(value)` | Append a fixed flag string to the script invocation. Replaces prior `flags()`. |
191
+ | `mock(cmd, responses?)` | Register a recorder shim for `cmd`. Patterns map to bash response expressions. |
192
+
193
+ `mock()` patterns are converted to bash globs: literal segments are
194
+ double-quoted while `* ? [ ]` stay active. Responses are evaluated by `eval`
195
+ so they can include any bash expression.
196
+
197
+ ```typescript
198
+ const result = script
199
+ .env({ NAME: "Alice" })
200
+ .flags("--upper")
201
+ .mock("git", { "remote get-url *": "echo https://example.com" })
202
+ .run();
203
+ ```
204
+
205
+ If no pattern matches at runtime, the shim emits an error to stderr and
206
+ exits 1. Mocks accumulate across `mock()` calls; passing an empty
207
+ `responses` map records calls without producing output.
208
+
209
+ #### Termination
210
+
211
+ | Method | Generated invocation |
212
+ | --- | --- |
213
+ | `run(...args)` | `"$SCRIPT" arg0 arg1 ...` (kcov-wrapped when coverage is on) |
214
+ | `exec(shellExpr)` | `bash -c '<expr>'` -- `$SCRIPT` is exported. Not kcov-wrapped. |
215
+
216
+ `exec()` is the escape hatch for pipelines and shell expressions:
217
+
218
+ ```typescript
219
+ const result = script.exec('"$SCRIPT" --name Pipeline | tr a-z A-Z');
220
+ ```
221
+
222
+ ### `BatsResult`
223
+
224
+ Returned by `run()` / `exec()`. Plain class, not thenable.
225
+
226
+ ```typescript
227
+ class BatsResult {
228
+ readonly status: number;
229
+ readonly output: string;
230
+ readonly stderr: string;
231
+ readonly lines: string[]; // output split on "\n"
232
+ readonly stderr_lines: string[]; // stderr split on "\n"
233
+ readonly calls: Record<string, MockCall[]>;
234
+ json<T = unknown>(): T; // Lazy parse, cached
235
+ }
236
+ ```
237
+
238
+ `result.json<T>()` lazily parses `output` as JSON, caching the value on
239
+ first call. Throws with a descriptive message if the output is not valid
240
+ JSON.
241
+
242
+ ### Matchers
243
+
244
+ 23 matchers are registered automatically. All operate on a `BatsResult`.
245
+
246
+ #### Status
247
+
248
+ | Matcher | Asserts |
249
+ | --- | --- |
250
+ | `toSucceed()` | `status === 0` |
251
+ | `toFail(code?)` | `status !== 0` (or `status === code` if given) |
252
+
253
+ #### Output (stdout)
254
+
255
+ | Matcher | Asserts |
256
+ | --- | --- |
257
+ | `toHaveOutput(text)` | `output === text` |
258
+ | `toContainOutput(text)` | `output.includes(text)` |
259
+ | `toMatchOutput(pattern)` | RegExp test against `output` |
260
+ | `toHaveEmptyOutput()` | `output === ""` |
261
+
262
+ #### Stderr
263
+
264
+ | Matcher | Asserts |
265
+ | --- | --- |
266
+ | `toHaveStderr(text)` | `stderr === text` |
267
+ | `toContainStderr(text)` | `stderr.includes(text)` |
268
+ | `toMatchStderr(pattern)` | RegExp test against `stderr` |
269
+
270
+ #### Lines
271
+
272
+ | Matcher | Asserts |
273
+ | --- | --- |
274
+ | `toHaveLine(index, text)` | `lines[index] === text` |
275
+ | `toHaveLineContaining(text, index?)` | Line at `index` contains `text`, or any line if index omitted |
276
+ | `toHaveLineMatching(pattern, index?)` | Same shape, RegExp |
277
+ | `toHaveLineCount(n)` | `lines.length === n` |
278
+
279
+ #### JSON
280
+
281
+ | Matcher | Asserts |
282
+ | --- | --- |
283
+ | `toOutputJson()` | `result.json()` doesn't throw |
284
+ | `toEqualJson(expected)` | Parsed JSON deep-equals `expected` |
285
+ | `toMatchJson(partial)` | `partial` is a deep subset of parsed JSON |
286
+ | `toHaveJsonValue(path, expected)` | Path exists and deep-equals `expected` |
287
+ | `toHaveJsonPath(path)` | Path exists |
288
+
289
+ Path syntax: dots for keys, brackets for array indices
290
+ (e.g., `"users[0].name"`).
291
+
292
+ #### Schema
293
+
294
+ | Matcher | Asserts |
295
+ | --- | --- |
296
+ | `toMatchSchema(schema)` | Standard Schema OR raw JSON Schema validates the parsed JSON |
297
+ | `toMatchJsonSchema(schema)` | Raw JSON Schema validates the parsed JSON |
298
+
299
+ `toMatchSchema` auto-detects Standard Schema (Zod, Valibot, Arktype, Effect
300
+ Schema) by the `~standard` property and falls through to Ajv for plain
301
+ objects. Compiled validators are cached.
302
+
303
+ ```typescript
304
+ import { z } from "zod";
305
+
306
+ const HookSchema = z.object({
307
+ decision: z.enum(["approve", "block"]),
308
+ reason: z.string(),
309
+ });
310
+
311
+ test("hook.sh emits a valid response", () => {
312
+ const result = hook.run();
313
+ expect(result).toSucceed();
314
+ expect(result).toMatchSchema(HookSchema);
315
+ });
316
+ ```
317
+
318
+ #### Invocation (mock calls)
319
+
320
+ | Matcher | Asserts |
321
+ | --- | --- |
322
+ | `toHaveInvoked(cmd, opts?)` | `cmd` was invoked at least once; if `opts.args` given, at least one call matches |
323
+ | `toHaveInvokedTimes(cmd, n)` | `result.calls[cmd].length === n` |
324
+ | `toHaveInvokedExactly(cmd, calls)` | `result.calls[cmd]` deep-equals the given list (order-sensitive) |
325
+
326
+ ```typescript
327
+ const result = widget
328
+ .mock("widget-cli", {
329
+ init: "echo initialized",
330
+ "build --target=*": "echo built",
331
+ "ship *": "echo shipped",
332
+ })
333
+ .run();
334
+
335
+ expect(result).toHaveInvokedTimes("widget-cli", 3);
336
+ expect(result).toHaveInvokedExactly("widget-cli", [
337
+ { args: ["init"] },
338
+ { args: ["build", "--target=foo"] },
339
+ { args: ["ship", "release with spaces"] },
340
+ ]);
341
+ ```
342
+
343
+ Strict-count matchers can be flaky when kcov instrumentation invokes
344
+ binaries you have mocked (e.g., `git rev-parse`). For reliable
345
+ strict-count assertions, mock binaries kcov never touches.
346
+
347
+ ## Coverage
348
+
349
+ ### How It Works
350
+
351
+ 1. Vitest runs TypeScript test files.
352
+ 2. Each `script.run(...)` call generates a self-contained `.bats` file in a
353
+ per-pid temp dir and executes it via `bats --tap`.
354
+ 3. When coverage is enabled and kcov is available, the generated `.bats`
355
+ wraps `"$SCRIPT"` with kcov.
356
+ 4. `BatsCoverageReporter.onCoverage()` parses each script's
357
+ `cobertura.xml`, converts to Istanbul format, and merges into the v8
358
+ CoverageMap.
359
+ 5. Vitest reports unified coverage (TypeScript + shell scripts).
360
+
361
+ ### Platform Support
362
+
363
+ | Platform | Tests | Coverage |
364
+ | --- | --- | --- |
365
+ | Linux | Full support | Full support |
366
+ | macOS | Full support | Not supported (SIP blocks ptrace) |
367
+ | Docker on macOS | Full support | Full support |
368
+
369
+ When kcov is unavailable, the reporter still emits coverage entries for
370
+ every `.sh` script registered during the Vite transform: synthetic at the
371
+ configured threshold percentage when thresholds are set, otherwise
372
+ zero-coverage from source-line analysis. Shell scripts therefore always
373
+ appear in the coverage table, even on macOS.
374
+
375
+ ## Entry Points
376
+
377
+ | Path | Purpose |
378
+ | --- | --- |
379
+ | `vitest-bats` | `BatsPlugin`, `BatsCoverageReporter`, `ScriptBuilder`, `BatsResult`, `batsMatchers`, types |
380
+ | `vitest-bats/runtime` | `ScriptBuilder`, `BatsResult`, `resetAllBuilders`. Imported by virtual `.sh` modules. |
381
+ | `vitest-bats/setup` | Auto-injected setup file. Registers matchers and the `beforeEach` reset hook. |
382
+
383
+ The previous `vitest-bats/runner` entry has been removed -- execution now
384
+ happens inline inside `ScriptBuilder.run/exec`.
385
+
386
+ ## Troubleshooting
387
+
388
+ ### Coverage Shows 0% on macOS
389
+
390
+ Expected. kcov cannot instrument bash scripts on macOS due to SIP. Use the
391
+ included Docker setup, the dev container, or run in CI on Linux.
392
+
393
+ ### "Missing required dependencies" Error
394
+
395
+ `BatsPlugin` checks for system dependencies at startup. Install the missing
396
+ tools listed in the error message, or pass `deps: "warn"` to allow pure-TS
397
+ tests to run.
398
+
399
+ ### "BATS version 1.4 is too old"
400
+
401
+ `run --separate-stderr` requires BATS 1.5+. Upgrade your BATS install.
402
+
403
+ ## Related
404
+
405
+ - [Vitest](https://vitest.dev/) -- Test framework
406
+ - [BATS](https://github.com/bats-core/bats-core) -- Bash Automated Testing
407
+ System
408
+ - [kcov](https://github.com/SimonKagstrom/kcov) -- Code coverage for shell
409
+ scripts
410
+ - [Standard Schema](https://github.com/standard-schema/standard-schema) --
411
+ Schema validator interoperability spec
412
+
413
+ ## License
414
+
415
+ [MIT](LICENSE)
@@ -0,0 +1,103 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+
6
+ //#region src/bats-executor.ts
7
+ let cleanupRegistered = false;
8
+ let batsFileDirCache = null;
9
+ function getBatsFileDir() {
10
+ if (batsFileDirCache) return batsFileDirCache;
11
+ const dir = join(tmpdir(), `vitest-bats-${process.pid}`);
12
+ mkdirSync(dir, { recursive: true });
13
+ batsFileDirCache = dir;
14
+ if (!cleanupRegistered) {
15
+ cleanupRegistered = true;
16
+ process.on("exit", () => {
17
+ try {
18
+ if (batsFileDirCache) rmSync(batsFileDirCache, {
19
+ recursive: true,
20
+ force: true
21
+ });
22
+ } catch {}
23
+ });
24
+ }
25
+ return dir;
26
+ }
27
+ function decodeB64(b64) {
28
+ return Buffer.from(b64, "base64").toString("utf-8");
29
+ }
30
+ /**
31
+ * Parses the base64-encoded result payload written by the generated BATS test file.
32
+ * @internal
33
+ */
34
+ function parseExecutionResult(recorderDir) {
35
+ const resultPath = join(recorderDir, "result.txt");
36
+ if (!existsSync(resultPath)) throw new Error(`vitest-bats: expected result file not found at ${resultPath}. BATS likely crashed before the test body completed.`);
37
+ const raw = readFileSync(resultPath, "utf-8");
38
+ let status = null;
39
+ let outputB64 = null;
40
+ let stderrB64 = null;
41
+ for (const line of raw.split("\n")) if (line.startsWith("status:")) {
42
+ const n = Number(line.slice(7));
43
+ if (!Number.isNaN(n)) status = n;
44
+ } else if (line.startsWith("output_b64:")) outputB64 = line.slice(11);
45
+ else if (line.startsWith("stderr_b64:")) stderrB64 = line.slice(11);
46
+ if (status === null) throw new Error(`vitest-bats: malformed result file at ${resultPath}: missing 'status:' line. Contents:\n${raw}`);
47
+ if (outputB64 === null || stderrB64 === null) throw new Error(`vitest-bats: malformed result file at ${resultPath}: missing output_b64 or stderr_b64. Contents:\n${raw}`);
48
+ const calls = {};
49
+ const callsPath = join(recorderDir, "calls.jsonl");
50
+ if (existsSync(callsPath)) {
51
+ const callsRaw = readFileSync(callsPath, "utf-8");
52
+ for (const [i, line] of callsRaw.split("\n").entries()) {
53
+ if (line.trim() === "") continue;
54
+ let parsed;
55
+ try {
56
+ parsed = JSON.parse(line);
57
+ } catch (err) {
58
+ const msg = err instanceof Error ? err.message : String(err);
59
+ throw new Error(`vitest-bats: failed to parse calls.jsonl line ${i + 1}: ${msg}\n line: ${line}`);
60
+ }
61
+ if (!parsed || typeof parsed !== "object" || !("cmd" in parsed) || !("args" in parsed)) throw new Error(`vitest-bats: calls.jsonl line ${i + 1} missing cmd or args: ${line}`);
62
+ const { cmd, args } = parsed;
63
+ if (typeof cmd !== "string" || !Array.isArray(args)) throw new Error(`vitest-bats: calls.jsonl line ${i + 1} has wrong types: ${line}`);
64
+ const argsStr = args.map(String);
65
+ if (!calls[cmd]) calls[cmd] = [];
66
+ calls[cmd].push({ args: argsStr });
67
+ }
68
+ }
69
+ return {
70
+ status,
71
+ output: decodeB64(outputB64),
72
+ stderr: decodeB64(stderrB64),
73
+ calls
74
+ };
75
+ }
76
+ /**
77
+ * Spawns `bats` against a generated `.bats` file and parses the recorded result.
78
+ * @internal
79
+ */
80
+ function executeBats(opts) {
81
+ mkdirSync(opts.recorderDir, { recursive: true });
82
+ const batsFileDir = getBatsFileDir();
83
+ const batsFile = resolve(batsFileDir, `${Date.now()}-${Math.random().toString(36).slice(2)}.bats`);
84
+ writeFileSync(batsFile, opts.batsContent, { mode: 493 });
85
+ const result = spawnSync(opts.batsPath, ["--tap", batsFile], {
86
+ encoding: "utf-8",
87
+ stdio: [
88
+ "pipe",
89
+ "pipe",
90
+ "pipe"
91
+ ],
92
+ timeout: opts.timeoutMs ?? 6e4,
93
+ env: {
94
+ ...process.env,
95
+ VBATS_RECORDER: opts.recorderDir
96
+ }
97
+ });
98
+ if (result.error) throw new Error(`vitest-bats: failed to spawn bats at ${opts.batsPath}: ${result.error.message}`);
99
+ return parseExecutionResult(opts.recorderDir);
100
+ }
101
+
102
+ //#endregion
103
+ export { executeBats, parseExecutionResult };
@@ -0,0 +1,114 @@
1
+ import { dirname } from "node:path";
2
+
3
+ //#region src/bats-generator.ts
4
+ function shellQuote(s) {
5
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
6
+ }
7
+ function singleQuoteForBash(s) {
8
+ return `'${s.replace(/'/g, `'"'"'`)}'`;
9
+ }
10
+ function patternToBashGlob(pattern) {
11
+ let result = "";
12
+ let inLiteral = false;
13
+ for (const ch of pattern) if (ch === "*" || ch === "?" || ch === "[" || ch === "]") {
14
+ if (inLiteral) {
15
+ result += "\"";
16
+ inLiteral = false;
17
+ }
18
+ result += ch;
19
+ } else {
20
+ if (!inLiteral) {
21
+ result += "\"";
22
+ inLiteral = true;
23
+ }
24
+ if (ch === "\\" || ch === "\"" || ch === "$" || ch === "`") result += `\\${ch}`;
25
+ else result += ch;
26
+ }
27
+ if (inLiteral) result += "\"";
28
+ return result;
29
+ }
30
+ function buildRecorderShim(stub, indent) {
31
+ const lines = [
32
+ `${indent}cat > "$VBATS_RECORDER/bin/${stub.cmd}" <<__VBATS_EOF__`,
33
+ `#!/usr/bin/env bash`,
34
+ `jq -nc --arg cmd "${stub.cmd}" --args '{cmd: \\$cmd, args: \\$ARGS.positional}' -- "\\$@" \\\\`,
35
+ ` >> "$VBATS_RECORDER/calls.jsonl"`
36
+ ];
37
+ const entries = Object.entries(stub.responses);
38
+ if (entries.length === 0) lines.push(`exit 0`);
39
+ else {
40
+ const arms = entries.map(([pattern, response], i) => {
41
+ return [
42
+ `${i === 0 ? "if" : "elif"} [[ "\\$*" == ${patternToBashGlob(pattern)} ]]; then`,
43
+ ` eval ${singleQuoteForBash(response)}`,
44
+ ` exit \\$?`
45
+ ];
46
+ });
47
+ for (const arm of arms) for (const line of arm) lines.push(line);
48
+ lines.push(`else`);
49
+ lines.push(` echo "vitest-bats: no mock pattern matched for '${stub.cmd} \\$*'" >&2`);
50
+ lines.push(` exit 1`);
51
+ lines.push(`fi`);
52
+ }
53
+ lines.push(`__VBATS_EOF__`);
54
+ lines.push(`${indent}chmod +x "$VBATS_RECORDER/bin/${stub.cmd}"`);
55
+ return lines;
56
+ }
57
+ function buildRunCommand(scriptPath, args, flags, mode, kcov) {
58
+ const wrapWithKcov = (target) => {
59
+ if (!kcov) return target;
60
+ const includePattern = dirname(scriptPath);
61
+ return [
62
+ shellQuote(kcov.kcovPath),
63
+ "--skip-solibs",
64
+ `--include-pattern=${singleQuoteForBash(includePattern)}`,
65
+ `--exclude-line="^#!/,^set -euo pipefail,^set -eo pipefail"`,
66
+ `"$KCOV_OUT"`,
67
+ target
68
+ ].join(" ");
69
+ };
70
+ if (mode.kind === "shell") return `bash -c ${singleQuoteForBash(mode.expression)}`;
71
+ const quotedArgs = args.map(shellQuote).join(" ");
72
+ const flagsSuffix = flags ? ` ${flags}` : "";
73
+ return wrapWithKcov(`"$SCRIPT"${quotedArgs ? ` ${quotedArgs}` : ""}${flagsSuffix}`);
74
+ }
75
+ /**
76
+ * Renders a `.bats` file body for a single `ScriptBuilder` invocation: setup (deps,
77
+ * env, recorder, mock shims, kcov), the `run` command, and the base64 result capture.
78
+ * @internal
79
+ */
80
+ function generateBatsFile(input) {
81
+ const { scriptPath, args, env, flags, stubs, recorderDir, deps, mode, kcov } = input;
82
+ const lines = [];
83
+ lines.push(`#!${deps.batsPath}`);
84
+ lines.push("");
85
+ lines.push("setup() {");
86
+ lines.push(` load '${deps.batsSupportPath}/load.bash'`);
87
+ lines.push(` export SCRIPT=${singleQuoteForBash(scriptPath)}`);
88
+ lines.push(` export VBATS_RECORDER=${singleQuoteForBash(recorderDir)}`);
89
+ lines.push(" mkdir -p \"$VBATS_RECORDER/bin\"");
90
+ if (kcov) {
91
+ lines.push(` export KCOV_OUT=${singleQuoteForBash(kcov.outputDir)}`);
92
+ lines.push(" mkdir -p \"$KCOV_OUT\"");
93
+ }
94
+ for (const stub of stubs) for (const shimLine of buildRecorderShim(stub, " ")) lines.push(shimLine);
95
+ if (stubs.length > 0) lines.push(" PATH=\"$VBATS_RECORDER/bin:$PATH\"");
96
+ lines.push("}");
97
+ lines.push("");
98
+ lines.push(`@test "_run_" {`);
99
+ const envEntries = Object.entries(env);
100
+ const envPrefix = envEntries.length > 0 ? `${envEntries.map(([k, v]) => `${k}=${shellQuote(v)}`).join(" ")} ` : "";
101
+ const target = buildRunCommand(scriptPath, args, flags, mode, kcov);
102
+ lines.push(` ${envPrefix}run --separate-stderr ${target}`);
103
+ lines.push(" {");
104
+ lines.push(" echo \"status:$status\"");
105
+ lines.push(" echo \"output_b64:$(printf '%s' \"$output\" | base64 | tr -d '\\n')\"");
106
+ lines.push(" echo \"stderr_b64:$(printf '%s' \"$stderr\" | base64 | tr -d '\\n')\"");
107
+ lines.push(" } > \"$VBATS_RECORDER/result.txt\"");
108
+ lines.push("}");
109
+ lines.push("");
110
+ return lines.join("\n");
111
+ }
112
+
113
+ //#endregion
114
+ export { generateBatsFile };