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 +21 -0
- package/README.md +415 -0
- package/bats-executor.js +103 -0
- package/bats-generator.js +114 -0
- package/coverage-reporter.js +319 -0
- package/index.d.ts +655 -0
- package/index.js +9 -0
- package/matchers.js +310 -0
- package/package.json +68 -0
- package/plugin.js +208 -0
- package/runtime.d.ts +82 -0
- package/runtime.js +172 -0
- package/schema.js +75 -0
- package/setup.d.ts +1 -0
- package/setup.js +12 -0
- package/tsdoc-metadata.json +11 -0
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)
|
package/bats-executor.js
ADDED
|
@@ -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 };
|