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/matchers.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { BATS_RESULT_BRAND } from "./runtime.js";
|
|
2
|
+
import { validate } from "./schema.js";
|
|
3
|
+
|
|
4
|
+
//#region src/matchers.ts
|
|
5
|
+
function ensureBatsResult(received) {
|
|
6
|
+
if (received !== null && typeof received === "object" && received[BATS_RESULT_BRAND] === true) return received;
|
|
7
|
+
throw new Error("vitest-bats matcher: expected received value to be a BatsResult (the value returned by hello.run() or hello.exec())");
|
|
8
|
+
}
|
|
9
|
+
function ctx(r) {
|
|
10
|
+
const stderr = r.stderr.length > 200 ? `${r.stderr.slice(0, 200)}…` : r.stderr;
|
|
11
|
+
const tail = stderr ? `\n stderr: ${stderr}` : "";
|
|
12
|
+
return `\n status: ${r.status}${tail}`;
|
|
13
|
+
}
|
|
14
|
+
function getPath(obj, path) {
|
|
15
|
+
const parts = [];
|
|
16
|
+
for (const m of path.matchAll(/([^.[\]]+)|\[(\d+)\]/g)) if (m[1] !== void 0) parts.push(m[1]);
|
|
17
|
+
else if (m[2] !== void 0) parts.push(Number(m[2]));
|
|
18
|
+
let current = obj;
|
|
19
|
+
for (const part of parts) {
|
|
20
|
+
if (current === null || current === void 0 || typeof current !== "object") return {
|
|
21
|
+
found: false,
|
|
22
|
+
value: void 0
|
|
23
|
+
};
|
|
24
|
+
if (typeof part === "number") {
|
|
25
|
+
if (!Array.isArray(current) || part >= current.length) return {
|
|
26
|
+
found: false,
|
|
27
|
+
value: void 0
|
|
28
|
+
};
|
|
29
|
+
current = current[part];
|
|
30
|
+
} else {
|
|
31
|
+
if (!(part in current)) return {
|
|
32
|
+
found: false,
|
|
33
|
+
value: void 0
|
|
34
|
+
};
|
|
35
|
+
current = current[part];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
found: true,
|
|
40
|
+
value: current
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function deepEqual(a, b) {
|
|
44
|
+
if (Object.is(a, b)) return true;
|
|
45
|
+
if (typeof a !== typeof b) return false;
|
|
46
|
+
if (a === null || b === null) return false;
|
|
47
|
+
if (Array.isArray(a)) {
|
|
48
|
+
if (!Array.isArray(b) || a.length !== b.length) return false;
|
|
49
|
+
return a.every((x, i) => deepEqual(x, b[i]));
|
|
50
|
+
}
|
|
51
|
+
if (typeof a === "object" && typeof b === "object") {
|
|
52
|
+
const aKeys = Object.keys(a);
|
|
53
|
+
const bKeys = Object.keys(b);
|
|
54
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
55
|
+
return aKeys.every((k) => deepEqual(a[k], b[k]));
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
function partialMatch(actual, expected) {
|
|
60
|
+
if (expected === null || typeof expected !== "object") return deepEqual(actual, expected);
|
|
61
|
+
if (Array.isArray(expected)) {
|
|
62
|
+
if (!Array.isArray(actual)) return false;
|
|
63
|
+
return expected.every((e, i) => partialMatch(actual[i], e));
|
|
64
|
+
}
|
|
65
|
+
if (typeof actual !== "object" || actual === null) return false;
|
|
66
|
+
return Object.keys(expected).every((k) => partialMatch(actual[k], expected[k]));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The 23 `expect.extend` matchers registered by `vitest-bats/setup` (auto-injected
|
|
70
|
+
* by {@link BatsPlugin}). Cover status, stdout/stderr, lines, mock calls, JSON
|
|
71
|
+
* values, and schema validation against a `BatsResult`.
|
|
72
|
+
* @public
|
|
73
|
+
*/
|
|
74
|
+
const batsMatchers = {
|
|
75
|
+
toSucceed(received) {
|
|
76
|
+
const r = ensureBatsResult(received);
|
|
77
|
+
return {
|
|
78
|
+
pass: r.status === 0,
|
|
79
|
+
message: () => r.status === 0 ? `expected script not to succeed, but it did${ctx(r)}` : `expected script to succeed${ctx(r)}`
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
toFail(received, code) {
|
|
83
|
+
const r = ensureBatsResult(received);
|
|
84
|
+
const pass = code === void 0 ? r.status !== 0 : r.status === code;
|
|
85
|
+
return {
|
|
86
|
+
pass,
|
|
87
|
+
message: () => code === void 0 ? `expected script ${pass ? "not " : ""}to fail${ctx(r)}` : `expected script to fail with code ${code}${ctx(r)}`
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
toHaveOutput(received, text) {
|
|
91
|
+
const r = ensureBatsResult(received);
|
|
92
|
+
return {
|
|
93
|
+
pass: r.output === text,
|
|
94
|
+
message: () => `expected output to be ${JSON.stringify(text)}, got ${JSON.stringify(r.output)}${ctx(r)}`
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
toContainOutput(received, text) {
|
|
98
|
+
const r = ensureBatsResult(received);
|
|
99
|
+
return {
|
|
100
|
+
pass: r.output.includes(text),
|
|
101
|
+
message: () => `expected output to contain ${JSON.stringify(text)}\n output: ${JSON.stringify(r.output)}${ctx(r)}`
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
toMatchOutput(received, pattern) {
|
|
105
|
+
const r = ensureBatsResult(received);
|
|
106
|
+
const re = pattern instanceof RegExp ? pattern : new RegExp(pattern);
|
|
107
|
+
return {
|
|
108
|
+
pass: re.test(r.output),
|
|
109
|
+
message: () => `expected output to match ${re}\n output: ${JSON.stringify(r.output)}${ctx(r)}`
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
toHaveEmptyOutput(received) {
|
|
113
|
+
const r = ensureBatsResult(received);
|
|
114
|
+
return {
|
|
115
|
+
pass: r.output === "",
|
|
116
|
+
message: () => `expected output to be empty\n output: ${JSON.stringify(r.output)}${ctx(r)}`
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
toHaveStderr(received, text) {
|
|
120
|
+
const r = ensureBatsResult(received);
|
|
121
|
+
return {
|
|
122
|
+
pass: r.stderr === text,
|
|
123
|
+
message: () => `expected stderr to be ${JSON.stringify(text)}, got ${JSON.stringify(r.stderr)}`
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
toContainStderr(received, text) {
|
|
127
|
+
const r = ensureBatsResult(received);
|
|
128
|
+
return {
|
|
129
|
+
pass: r.stderr.includes(text),
|
|
130
|
+
message: () => `expected stderr to contain ${JSON.stringify(text)}\n stderr: ${JSON.stringify(r.stderr)}`
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
toMatchStderr(received, pattern) {
|
|
134
|
+
const r = ensureBatsResult(received);
|
|
135
|
+
const re = pattern instanceof RegExp ? pattern : new RegExp(pattern);
|
|
136
|
+
return {
|
|
137
|
+
pass: re.test(r.stderr),
|
|
138
|
+
message: () => `expected stderr to match ${re}\n stderr: ${JSON.stringify(r.stderr)}`
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
toHaveLine(received, index, text) {
|
|
142
|
+
const r = ensureBatsResult(received);
|
|
143
|
+
return {
|
|
144
|
+
pass: r.lines[index] === text,
|
|
145
|
+
message: () => `expected lines[${index}] to be ${JSON.stringify(text)}, got ${JSON.stringify(r.lines[index])}${ctx(r)}`
|
|
146
|
+
};
|
|
147
|
+
},
|
|
148
|
+
toHaveLineContaining(received, text, index) {
|
|
149
|
+
const r = ensureBatsResult(received);
|
|
150
|
+
return {
|
|
151
|
+
pass: index !== void 0 ? Boolean(r.lines[index]?.includes(text)) : r.lines.some((l) => l.includes(text)),
|
|
152
|
+
message: () => index !== void 0 ? `expected lines[${index}] to contain ${JSON.stringify(text)}, got ${JSON.stringify(r.lines[index])}${ctx(r)}` : `expected some line to contain ${JSON.stringify(text)}\n output: ${JSON.stringify(r.output)}${ctx(r)}`
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
toHaveLineMatching(received, pattern, index) {
|
|
156
|
+
const r = ensureBatsResult(received);
|
|
157
|
+
const re = pattern instanceof RegExp ? pattern : new RegExp(pattern);
|
|
158
|
+
return {
|
|
159
|
+
pass: index !== void 0 ? Boolean(r.lines[index] && re.test(r.lines[index])) : r.lines.some((l) => re.test(l)),
|
|
160
|
+
message: () => index !== void 0 ? `expected lines[${index}] to match ${re}, got ${JSON.stringify(r.lines[index])}${ctx(r)}` : `expected some line to match ${re}\n output: ${JSON.stringify(r.output)}${ctx(r)}`
|
|
161
|
+
};
|
|
162
|
+
},
|
|
163
|
+
toHaveLineCount(received, n) {
|
|
164
|
+
const r = ensureBatsResult(received);
|
|
165
|
+
return {
|
|
166
|
+
pass: r.lines.length === n,
|
|
167
|
+
message: () => `expected ${n} line(s), got ${r.lines.length}`
|
|
168
|
+
};
|
|
169
|
+
},
|
|
170
|
+
toOutputJson(received) {
|
|
171
|
+
const r = ensureBatsResult(received);
|
|
172
|
+
try {
|
|
173
|
+
r.json();
|
|
174
|
+
return {
|
|
175
|
+
pass: true,
|
|
176
|
+
message: () => "expected output not to be valid JSON, but it was"
|
|
177
|
+
};
|
|
178
|
+
} catch (err) {
|
|
179
|
+
return {
|
|
180
|
+
pass: false,
|
|
181
|
+
message: () => `expected output to be valid JSON: ${err.message}\n output: ${JSON.stringify(r.output)}`
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
toEqualJson(received, expected) {
|
|
186
|
+
const r = ensureBatsResult(received);
|
|
187
|
+
let parsed;
|
|
188
|
+
try {
|
|
189
|
+
parsed = r.json();
|
|
190
|
+
} catch (err) {
|
|
191
|
+
return {
|
|
192
|
+
pass: false,
|
|
193
|
+
message: () => `expected output to be valid JSON: ${err.message}`
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
pass: deepEqual(parsed, expected),
|
|
198
|
+
message: () => `expected JSON output to deep-equal ${JSON.stringify(expected)}, got ${JSON.stringify(parsed)}`
|
|
199
|
+
};
|
|
200
|
+
},
|
|
201
|
+
toMatchJson(received, partial) {
|
|
202
|
+
const r = ensureBatsResult(received);
|
|
203
|
+
let parsed;
|
|
204
|
+
try {
|
|
205
|
+
parsed = r.json();
|
|
206
|
+
} catch (err) {
|
|
207
|
+
return {
|
|
208
|
+
pass: false,
|
|
209
|
+
message: () => `expected output to be valid JSON: ${err.message}`
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
pass: partialMatch(parsed, partial),
|
|
214
|
+
message: () => `expected JSON output to match ${JSON.stringify(partial)}, got ${JSON.stringify(parsed)}`
|
|
215
|
+
};
|
|
216
|
+
},
|
|
217
|
+
toHaveJsonValue(received, path, expected) {
|
|
218
|
+
const r = ensureBatsResult(received);
|
|
219
|
+
let parsed;
|
|
220
|
+
try {
|
|
221
|
+
parsed = r.json();
|
|
222
|
+
} catch (err) {
|
|
223
|
+
return {
|
|
224
|
+
pass: false,
|
|
225
|
+
message: () => `expected output to be valid JSON: ${err.message}`
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
const { found, value } = getPath(parsed, path);
|
|
229
|
+
return {
|
|
230
|
+
pass: found && deepEqual(value, expected),
|
|
231
|
+
message: () => !found ? `expected JSON path ${JSON.stringify(path)} to exist` : `expected JSON path ${JSON.stringify(path)} to equal ${JSON.stringify(expected)}, got ${JSON.stringify(value)}`
|
|
232
|
+
};
|
|
233
|
+
},
|
|
234
|
+
toHaveJsonPath(received, path) {
|
|
235
|
+
const r = ensureBatsResult(received);
|
|
236
|
+
let parsed;
|
|
237
|
+
try {
|
|
238
|
+
parsed = r.json();
|
|
239
|
+
} catch (err) {
|
|
240
|
+
return {
|
|
241
|
+
pass: false,
|
|
242
|
+
message: () => `expected output to be valid JSON: ${err.message}`
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
const { found } = getPath(parsed, path);
|
|
246
|
+
return {
|
|
247
|
+
pass: found,
|
|
248
|
+
message: () => `expected JSON path ${JSON.stringify(path)} to exist`
|
|
249
|
+
};
|
|
250
|
+
},
|
|
251
|
+
toMatchSchema(received, schema) {
|
|
252
|
+
const r = ensureBatsResult(received);
|
|
253
|
+
let parsed;
|
|
254
|
+
try {
|
|
255
|
+
parsed = r.json();
|
|
256
|
+
} catch (err) {
|
|
257
|
+
return {
|
|
258
|
+
pass: false,
|
|
259
|
+
message: () => `expected output to be valid JSON: ${err.message}`
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
const v = validate(schema, parsed);
|
|
263
|
+
return {
|
|
264
|
+
pass: v.ok,
|
|
265
|
+
message: () => v.ok ? "expected output not to match schema, but it did" : `expected output to match schema, but validation failed:\n${v.issues.map((i) => ` - ${i}`).join("\n")}`
|
|
266
|
+
};
|
|
267
|
+
},
|
|
268
|
+
toMatchJsonSchema(received, schema) {
|
|
269
|
+
const r = ensureBatsResult(received);
|
|
270
|
+
let parsed;
|
|
271
|
+
try {
|
|
272
|
+
parsed = r.json();
|
|
273
|
+
} catch (err) {
|
|
274
|
+
return {
|
|
275
|
+
pass: false,
|
|
276
|
+
message: () => `expected output to be valid JSON: ${err.message}`
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
const v = validate(schema, parsed);
|
|
280
|
+
return {
|
|
281
|
+
pass: v.ok,
|
|
282
|
+
message: () => v.ok ? "expected output not to match JSON schema, but it did" : `expected output to match JSON schema, but validation failed:\n${v.issues.map((i) => ` - ${i}`).join("\n")}`
|
|
283
|
+
};
|
|
284
|
+
},
|
|
285
|
+
toHaveInvoked(received, cmd, opts) {
|
|
286
|
+
const calls = ensureBatsResult(received).calls[cmd] ?? [];
|
|
287
|
+
const expectedArgs = opts?.args;
|
|
288
|
+
return {
|
|
289
|
+
pass: calls.length > 0 && (expectedArgs === void 0 || calls.some((c) => deepEqual(c.args, expectedArgs))),
|
|
290
|
+
message: () => expectedArgs === void 0 ? `expected ${cmd} to have been invoked at least once. calls: ${JSON.stringify(calls)}` : `expected ${cmd} to have been invoked with args ${JSON.stringify(expectedArgs)}. calls: ${JSON.stringify(calls)}`
|
|
291
|
+
};
|
|
292
|
+
},
|
|
293
|
+
toHaveInvokedTimes(received, cmd, n) {
|
|
294
|
+
const calls = ensureBatsResult(received).calls[cmd] ?? [];
|
|
295
|
+
return {
|
|
296
|
+
pass: calls.length === n,
|
|
297
|
+
message: () => `expected ${cmd} to have been invoked ${n} time(s), got ${calls.length}`
|
|
298
|
+
};
|
|
299
|
+
},
|
|
300
|
+
toHaveInvokedExactly(received, cmd, calls) {
|
|
301
|
+
const actual = ensureBatsResult(received).calls[cmd] ?? [];
|
|
302
|
+
return {
|
|
303
|
+
pass: deepEqual(actual, calls),
|
|
304
|
+
message: () => `expected ${cmd} call history to deep-equal ${JSON.stringify(calls)}, got ${JSON.stringify(actual)}`
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
//#endregion
|
|
310
|
+
export { batsMatchers };
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "vitest-bats",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Test bash scripts with kcov coverage directly in Vitest",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"vitest",
|
|
8
|
+
"bats",
|
|
9
|
+
"bash",
|
|
10
|
+
"shell",
|
|
11
|
+
"testing",
|
|
12
|
+
"kcov",
|
|
13
|
+
"coverage",
|
|
14
|
+
"vitest-plugin"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/spencerbeggs/vitest-bats#readme",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/spencerbeggs/vitest-bats/issues"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/spencerbeggs/vitest-bats.git",
|
|
23
|
+
"directory": "package"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"author": {
|
|
27
|
+
"name": "C. Spencer Beggs",
|
|
28
|
+
"email": "spencer@beggs.codes",
|
|
29
|
+
"url": "https://spencerbeg.gs"
|
|
30
|
+
},
|
|
31
|
+
"type": "module",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"types": "./index.d.ts",
|
|
35
|
+
"import": "./index.js",
|
|
36
|
+
"default": "./index.js"
|
|
37
|
+
},
|
|
38
|
+
"./runtime": {
|
|
39
|
+
"types": "./runtime.d.ts",
|
|
40
|
+
"import": "./runtime.js",
|
|
41
|
+
"default": "./runtime.js"
|
|
42
|
+
},
|
|
43
|
+
"./setup": {
|
|
44
|
+
"types": "./setup.d.ts",
|
|
45
|
+
"import": "./setup.js",
|
|
46
|
+
"default": "./setup.js"
|
|
47
|
+
},
|
|
48
|
+
"./package.json": "./package.json"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@standard-schema/spec": "^1.1.0",
|
|
52
|
+
"ajv": "^8.20.0",
|
|
53
|
+
"ajv-formats": "^3.0.1",
|
|
54
|
+
"fast-xml-parser": "^5.11.1",
|
|
55
|
+
"minimatch": "^10.2.6"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"vitest": "^4.1.0"
|
|
59
|
+
},
|
|
60
|
+
"peerDependenciesMeta": {
|
|
61
|
+
"vitest": {
|
|
62
|
+
"optional": false
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"engines": {
|
|
66
|
+
"node": ">=24.11.0"
|
|
67
|
+
}
|
|
68
|
+
}
|
package/plugin.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { type } from "node:os";
|
|
4
|
+
import { basename, dirname, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
//#region src/plugin.ts
|
|
7
|
+
function getCommandPath(command) {
|
|
8
|
+
try {
|
|
9
|
+
const result = execSync(`command -v ${command}`, {
|
|
10
|
+
encoding: "utf-8",
|
|
11
|
+
stdio: "pipe"
|
|
12
|
+
}).trim();
|
|
13
|
+
return result ? realpathSync(result) : null;
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function detectBatsLibraryPath(libraryName, fileName) {
|
|
19
|
+
const homeDir = process.env.HOME ?? "";
|
|
20
|
+
const possiblePaths = [];
|
|
21
|
+
if (process.env.XDG_CONFIG_HOME) possiblePaths.push(`${process.env.XDG_CONFIG_HOME}/${libraryName}/${fileName}`);
|
|
22
|
+
if (homeDir) possiblePaths.push(`${homeDir}/.config/${libraryName}/${fileName}`);
|
|
23
|
+
if (process.env.XDG_DATA_HOME) possiblePaths.push(`${process.env.XDG_DATA_HOME}/${libraryName}/${fileName}`);
|
|
24
|
+
if (homeDir) possiblePaths.push(`${homeDir}/.local/share/${libraryName}/${fileName}`);
|
|
25
|
+
possiblePaths.push(`/opt/homebrew/lib/${libraryName}/${fileName}`);
|
|
26
|
+
possiblePaths.push(`/usr/local/lib/${libraryName}/${fileName}`);
|
|
27
|
+
possiblePaths.push(`/usr/lib/${libraryName}/${fileName}`);
|
|
28
|
+
for (const path of possiblePaths) if (existsSync(path)) return dirname(realpathSync(path));
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
function getInstallCommand(name, onMacOS) {
|
|
32
|
+
const cmd = {
|
|
33
|
+
bats: {
|
|
34
|
+
mac: "brew install bats-core (requires >= 1.5)",
|
|
35
|
+
linux: "apt-get install bats (requires >= 1.5; some distros ship older — see https://github.com/bats-core/bats-core)"
|
|
36
|
+
},
|
|
37
|
+
"bats-support": {
|
|
38
|
+
mac: "brew install bats-support",
|
|
39
|
+
linux: "apt-get install bats-support"
|
|
40
|
+
},
|
|
41
|
+
kcov: {
|
|
42
|
+
mac: "brew install kcov",
|
|
43
|
+
linux: "apt-get install kcov"
|
|
44
|
+
},
|
|
45
|
+
jq: {
|
|
46
|
+
mac: "brew install jq",
|
|
47
|
+
linux: "apt-get install jq"
|
|
48
|
+
}
|
|
49
|
+
}[name];
|
|
50
|
+
return cmd ? onMacOS ? cmd.mac : cmd.linux : "See documentation";
|
|
51
|
+
}
|
|
52
|
+
function checkDependencies(options, coverageEnabled, onMacOS) {
|
|
53
|
+
const logLevel = options.logLevel ?? "errors-only";
|
|
54
|
+
const deps = [];
|
|
55
|
+
const batsPath = getCommandPath("bats");
|
|
56
|
+
let batsVersionOk = batsPath !== null;
|
|
57
|
+
if (batsPath) try {
|
|
58
|
+
const m = (execSync(`"${batsPath}" --version`, {
|
|
59
|
+
encoding: "utf-8",
|
|
60
|
+
stdio: "pipe"
|
|
61
|
+
}).trim().split(/\s+/).pop() ?? "").match(/^(\d+)\.(\d+)/);
|
|
62
|
+
if (!m) batsVersionOk = false;
|
|
63
|
+
else {
|
|
64
|
+
const major = Number(m[1]);
|
|
65
|
+
const minor = Number(m[2]);
|
|
66
|
+
batsVersionOk = major > 1 || major === 1 && minor >= 5;
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
batsVersionOk = false;
|
|
70
|
+
}
|
|
71
|
+
deps.push({
|
|
72
|
+
name: "bats",
|
|
73
|
+
required: true,
|
|
74
|
+
available: batsVersionOk,
|
|
75
|
+
path: batsPath,
|
|
76
|
+
...batsPath && !batsVersionOk ? { warning: "BATS >= 1.5 required for --separate-stderr" } : {}
|
|
77
|
+
});
|
|
78
|
+
const supportPath = detectBatsLibraryPath("bats-support", "load.bash");
|
|
79
|
+
deps.push({
|
|
80
|
+
name: "bats-support",
|
|
81
|
+
required: true,
|
|
82
|
+
available: supportPath !== null,
|
|
83
|
+
path: supportPath
|
|
84
|
+
});
|
|
85
|
+
const jqPath = getCommandPath("jq");
|
|
86
|
+
deps.push({
|
|
87
|
+
name: "jq",
|
|
88
|
+
required: true,
|
|
89
|
+
available: jqPath !== null,
|
|
90
|
+
path: jqPath
|
|
91
|
+
});
|
|
92
|
+
if (coverageEnabled) {
|
|
93
|
+
const kcovPath = getCommandPath("kcov");
|
|
94
|
+
deps.push({
|
|
95
|
+
name: "kcov",
|
|
96
|
+
required: !onMacOS,
|
|
97
|
+
available: kcovPath !== null,
|
|
98
|
+
path: kcovPath,
|
|
99
|
+
...onMacOS && kcovPath ? { warning: "Partial coverage on macOS due to SIP - use Docker for full coverage" } : {}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
if (batsPath) process.env.BATS_PATH = batsPath;
|
|
103
|
+
if (supportPath) process.env.BATS_SUPPORT_PATH = supportPath;
|
|
104
|
+
if (coverageEnabled) {
|
|
105
|
+
const kcovDep = deps.find((d) => d.name === "kcov");
|
|
106
|
+
if (kcovDep?.path) process.env.KCOV_PATH = kcovDep.path;
|
|
107
|
+
}
|
|
108
|
+
process.env.KCOV_LOG_LEVEL = logLevel;
|
|
109
|
+
if (logLevel === "verbose" || logLevel === "debug") for (const dep of deps) {
|
|
110
|
+
const pathInfo = dep.path ? ` (${dep.path})` : "";
|
|
111
|
+
if (dep.warning) console.warn(` [vitest-bats] ${dep.name}${pathInfo} - ${dep.warning}`);
|
|
112
|
+
else if (dep.available) console.log(` [vitest-bats] ${dep.name}${pathInfo}`);
|
|
113
|
+
else if (dep.required) console.error(` [vitest-bats] ${dep.name} - MISSING (required)`);
|
|
114
|
+
else console.warn(` [vitest-bats] ${dep.name} - not found (optional)`);
|
|
115
|
+
}
|
|
116
|
+
return deps;
|
|
117
|
+
}
|
|
118
|
+
function getMissingDeps(deps) {
|
|
119
|
+
return deps.filter((d) => d.required && !d.available);
|
|
120
|
+
}
|
|
121
|
+
function formatMissingDeps(missing, onMacOS) {
|
|
122
|
+
return `[vitest-bats] Missing required dependencies:\n${missing.map((d) => ` ${d.name}: ${getInstallCommand(d.name, onMacOS)}`).join("\n")}`;
|
|
123
|
+
}
|
|
124
|
+
const VIRTUAL_PREFIX = "\0bats:";
|
|
125
|
+
/**
|
|
126
|
+
* Vitest plugin that wires up `.sh` imports, dependency detection, coverage
|
|
127
|
+
* option handling, matcher setup-file injection, and coverage reporter injection.
|
|
128
|
+
* @public
|
|
129
|
+
*/
|
|
130
|
+
function BatsPlugin(options = {}) {
|
|
131
|
+
const registeredScripts = /* @__PURE__ */ new Set();
|
|
132
|
+
let cacheDir = null;
|
|
133
|
+
function writeManifest() {
|
|
134
|
+
if (!cacheDir) return;
|
|
135
|
+
writeFileSync(resolve(cacheDir, "scripts.json"), JSON.stringify([...registeredScripts], null, " "), "utf-8");
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
name: "vitest-bats",
|
|
139
|
+
enforce: "pre",
|
|
140
|
+
config(config) {
|
|
141
|
+
const setup = (config.test ?? {}).setupFiles;
|
|
142
|
+
return { test: { setupFiles: setup === void 0 ? ["vitest-bats/setup"] : Array.isArray(setup) ? [...setup, "vitest-bats/setup"] : [setup, "vitest-bats/setup"] } };
|
|
143
|
+
},
|
|
144
|
+
resolveId(source, importer) {
|
|
145
|
+
if (source.endsWith(".sh") && importer) {
|
|
146
|
+
const resolved = resolve(dirname(importer), source);
|
|
147
|
+
if (existsSync(resolved)) return VIRTUAL_PREFIX + resolved;
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
},
|
|
151
|
+
load(id) {
|
|
152
|
+
if (!id.startsWith(VIRTUAL_PREFIX)) return null;
|
|
153
|
+
const scriptPath = id.slice(6);
|
|
154
|
+
const scriptName = basename(scriptPath);
|
|
155
|
+
if (!registeredScripts.has(scriptPath)) {
|
|
156
|
+
registeredScripts.add(scriptPath);
|
|
157
|
+
writeManifest();
|
|
158
|
+
}
|
|
159
|
+
return ["import { ScriptBuilder } from \"vitest-bats/runtime\";", `export default new ScriptBuilder(${JSON.stringify(scriptPath)}, ${JSON.stringify(scriptName)});`].join("\n");
|
|
160
|
+
},
|
|
161
|
+
async configureVitest(ctx) {
|
|
162
|
+
const { vitest } = ctx;
|
|
163
|
+
const vitestCacheDir = vitest.config.cacheDir ?? vitest.vite?.config?.cacheDir ?? "node_modules/.vitest";
|
|
164
|
+
cacheDir = resolve(vitestCacheDir, "vitest-bats");
|
|
165
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
166
|
+
const coverageCfg = vitest.config.coverage;
|
|
167
|
+
const coverageEnabled = coverageCfg?.enabled === true;
|
|
168
|
+
const onMacOS = type() === "Darwin";
|
|
169
|
+
const depsMode = options.deps ?? "error";
|
|
170
|
+
const deps = checkDependencies(options, coverageEnabled, onMacOS);
|
|
171
|
+
const missing = getMissingDeps(deps);
|
|
172
|
+
if (missing.length > 0) {
|
|
173
|
+
const msg = formatMissingDeps(missing, onMacOS);
|
|
174
|
+
if (depsMode === "error") throw new Error(msg);
|
|
175
|
+
console.warn(`\n ${msg}`);
|
|
176
|
+
console.warn(" [vitest-bats] Running in degraded mode (deps: 'warn'). BATS integration tests will fail individually.\n");
|
|
177
|
+
}
|
|
178
|
+
const coverageMode = options.coverage ?? "auto";
|
|
179
|
+
const kcovAvailable = deps.some((d) => d.name === "kcov" && d.available);
|
|
180
|
+
const kcovReliable = kcovAvailable && !onMacOS;
|
|
181
|
+
let includeShCoverage = false;
|
|
182
|
+
if (coverageMode === true) {
|
|
183
|
+
if (!kcovAvailable) throw new Error("[vitest-bats] coverage: true requires kcov but it is not available. Install kcov or use coverage: 'auto' to skip shell coverage when unavailable.");
|
|
184
|
+
if (onMacOS) throw new Error("[vitest-bats] coverage: true is not supported on macOS (SIP blocks kcov ptrace). Use Docker for reliable shell coverage or coverage: 'auto'.");
|
|
185
|
+
includeShCoverage = true;
|
|
186
|
+
} else if (coverageMode === "auto") includeShCoverage = kcovReliable;
|
|
187
|
+
process.env.__VITEST_BATS_CACHE_DIR__ = cacheDir;
|
|
188
|
+
if (includeShCoverage) process.env.__VITEST_BATS_KCOV__ = "1";
|
|
189
|
+
if (coverageEnabled) {
|
|
190
|
+
const { BatsCoverageReporter } = await import("./coverage-reporter.js");
|
|
191
|
+
const rawThresholds = coverageCfg?.thresholds;
|
|
192
|
+
const thresholds = rawThresholds ? {
|
|
193
|
+
statements: rawThresholds.statements ?? 0,
|
|
194
|
+
branches: rawThresholds.branches ?? 0,
|
|
195
|
+
functions: rawThresholds.functions ?? 0,
|
|
196
|
+
lines: rawThresholds.lines ?? 0
|
|
197
|
+
} : void 0;
|
|
198
|
+
vitest.config.reporters.unshift(new BatsCoverageReporter(cacheDir, {
|
|
199
|
+
...thresholds ? { thresholds } : {},
|
|
200
|
+
statementPassThrough: !includeShCoverage
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
//#endregion
|
|
208
|
+
export { BatsPlugin };
|
package/runtime.d.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
//#region src/runtime.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* A single recorded invocation of a mocked command, captured back to the test as
|
|
4
|
+
* part of {@link BatsResult.calls}.
|
|
5
|
+
* @public
|
|
6
|
+
*/
|
|
7
|
+
interface MockCall {
|
|
8
|
+
args: string[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The raw fields decoded from a BATS run, wrapped by the {@link BatsResult} class.
|
|
12
|
+
* @public
|
|
13
|
+
*/
|
|
14
|
+
interface BatsResultData {
|
|
15
|
+
status: number;
|
|
16
|
+
output: string;
|
|
17
|
+
stderr: string;
|
|
18
|
+
calls: Record<string, MockCall[]>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Symbol brand identifying a BatsResult instance. Used by matchers to
|
|
22
|
+
* detect BatsResult inputs in a way that survives:
|
|
23
|
+
* - Module-graph duplication (per-entry self-contained bundles produced as
|
|
24
|
+
* the workaround for savvy-web/rslib-builder#158, where each entry has
|
|
25
|
+
* its own copy of the BatsResult class — `instanceof` would fail).
|
|
26
|
+
* - Property name minification (a `constructor.name === "BatsResult"`
|
|
27
|
+
* check would break under any minifier that renames classes).
|
|
28
|
+
*
|
|
29
|
+
* Registered with `Symbol.for(...)` so the symbol is shared across module
|
|
30
|
+
* graphs (the global symbol registry is process-wide).
|
|
31
|
+
* @internal
|
|
32
|
+
*/
|
|
33
|
+
declare const BATS_RESULT_BRAND: unique symbol;
|
|
34
|
+
/**
|
|
35
|
+
* The value returned by `ScriptBuilder.run()`/`.exec()`. Wraps the raw exit status,
|
|
36
|
+
* stdout/stderr (whole and split into lines), and recorded mock calls; assert
|
|
37
|
+
* against it with the `expect.extend` matchers from `vitest-bats/setup`.
|
|
38
|
+
* @public
|
|
39
|
+
*/
|
|
40
|
+
declare class BatsResult {
|
|
41
|
+
/** @internal */
|
|
42
|
+
readonly [BATS_RESULT_BRAND] = true;
|
|
43
|
+
readonly status: number;
|
|
44
|
+
readonly output: string;
|
|
45
|
+
readonly stderr: string;
|
|
46
|
+
readonly lines: string[];
|
|
47
|
+
readonly stderr_lines: string[];
|
|
48
|
+
readonly calls: Record<string, MockCall[]>;
|
|
49
|
+
private _jsonCache;
|
|
50
|
+
constructor(data: BatsResultData);
|
|
51
|
+
json<T = unknown>(): T;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Resets every live `ScriptBuilder`'s `env()`/`flags()`/`mock()` configuration.
|
|
55
|
+
* Called automatically between tests by `vitest-bats/setup`'s `beforeEach` hook.
|
|
56
|
+
* @public
|
|
57
|
+
*/
|
|
58
|
+
declare function resetAllBuilders(): void;
|
|
59
|
+
/**
|
|
60
|
+
* Test-writing API returned by a `.sh` import. Configure with `env()`/`flags()`/
|
|
61
|
+
* `mock()`, then terminate with `run(...args)` or `exec(shellExpr)` to get a
|
|
62
|
+
* {@link BatsResult}.
|
|
63
|
+
* @public
|
|
64
|
+
*/
|
|
65
|
+
declare class ScriptBuilder {
|
|
66
|
+
readonly path: string;
|
|
67
|
+
readonly name: string;
|
|
68
|
+
private _env;
|
|
69
|
+
private _flags;
|
|
70
|
+
private _stubs;
|
|
71
|
+
constructor(path: string, name: string);
|
|
72
|
+
env(vars: Record<string, string>): this;
|
|
73
|
+
flags(value: string): this;
|
|
74
|
+
mock(cmd: string, responses?: Record<string, string>): this;
|
|
75
|
+
run(...args: string[]): BatsResult;
|
|
76
|
+
exec(shellExpr: string): BatsResult;
|
|
77
|
+
reset(): void;
|
|
78
|
+
private execute;
|
|
79
|
+
}
|
|
80
|
+
//#endregion
|
|
81
|
+
export { BATS_RESULT_BRAND, BatsResult, BatsResultData, MockCall, ScriptBuilder, resetAllBuilders };
|
|
82
|
+
//# sourceMappingURL=runtime.d.ts.map
|