@osdlc/command-seam 0.0.0-stage → 0.0.1-stage
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/CHANGELOG.md +328 -0
- package/CONTRIBUTING.md +58 -0
- package/LICENSE +21 -0
- package/README.md +741 -2
- package/SECURITY.md +23 -0
- package/async.ts +466 -0
- package/defaults.ts +83 -0
- package/dist/async.d.ts +146 -0
- package/dist/async.d.ts.map +1 -0
- package/dist/async.js +368 -0
- package/dist/defaults.d.ts +52 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +51 -0
- package/dist/groups.d.ts +155 -0
- package/dist/groups.d.ts.map +1 -0
- package/dist/groups.js +331 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/process.d.ts +146 -0
- package/dist/process.d.ts.map +1 -0
- package/dist/process.js +175 -0
- package/dist/process.test.d.ts +1 -0
- package/dist/process.test.d.ts.map +1 -0
- package/dist/process.test.js +1 -0
- package/dist/quote.d.ts +18 -0
- package/dist/quote.d.ts.map +1 -0
- package/dist/quote.js +28 -0
- package/dist/result.d.ts +211 -0
- package/dist/result.d.ts.map +1 -0
- package/dist/result.js +219 -0
- package/dist/shell.d.ts +84 -0
- package/dist/shell.d.ts.map +1 -0
- package/dist/shell.js +83 -0
- package/dist/stream.d.ts +90 -0
- package/dist/stream.d.ts.map +1 -0
- package/dist/stream.js +73 -0
- package/dist/sync.d.ts +311 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +406 -0
- package/dist/testing.d.ts +158 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +173 -0
- package/groups.ts +360 -0
- package/index.ts +98 -0
- package/package.json +94 -3
- package/process.ts +270 -0
- package/quote.ts +31 -0
- package/result.ts +403 -0
- package/shell.ts +131 -0
- package/smoke.mjs +61 -0
- package/stream.ts +134 -0
- package/sync.ts +604 -0
- package/testing.ts +263 -0
package/dist/sync.js
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The synchronous capture runner: the {@link CommandRunner} interface and its
|
|
3
|
+
* `spawnSync`-backed default, {@link spawnRunner}. Code that runs commands
|
|
4
|
+
* takes a `CommandRunner`; tests pass a fake. Live output and long-lived
|
|
5
|
+
* processes have their own runners (`stream.ts`, `process.ts`).
|
|
6
|
+
*
|
|
7
|
+
* Two ways to call it, like zod's `safeParse` and `parse`:
|
|
8
|
+
* - `runner.run(...)` never throws for a failed command; the caller reads
|
|
9
|
+
* `exitCode`.
|
|
10
|
+
* - `runChecked(runner, ...)` throws {@link CommandFailed} on a non-zero
|
|
11
|
+
* exit, for callers whose only sensible response to failure is to stop.
|
|
12
|
+
*/
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { accessSync, constants, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { delimiter, join, resolve } from 'node:path';
|
|
17
|
+
import { shellJoin } from "./quote.js";
|
|
18
|
+
import { failureText, stderrOrError } from "./result.js";
|
|
19
|
+
/**
|
|
20
|
+
* `{ env }`: `process.env` merged with `opts.env`, minus any key whose
|
|
21
|
+
* override is `undefined`. Always an explicit object built from the current
|
|
22
|
+
* `process.env` at call time — never `undefined`, never an omitted key, and
|
|
23
|
+
* never `process.env` handed through verbatim either.
|
|
24
|
+
*
|
|
25
|
+
* Node's `child_process.spawnSync` inherits live `process.env` whether
|
|
26
|
+
* `env` is omitted, `undefined`, or `process.env` itself. Bun's does not:
|
|
27
|
+
* all three instead take a snapshot — of exactly when is unclear, but
|
|
28
|
+
* empirically it misses anything set on `process.env` after the process
|
|
29
|
+
* started, even as the very first spawn — so a test that points an env var
|
|
30
|
+
* at a fresh temp dir right before spawning would silently see the real,
|
|
31
|
+
* unwritten default instead. Only spreading `process.env`'s current
|
|
32
|
+
* key/values into a brand-new plain object at the call site sidesteps
|
|
33
|
+
* whatever Bun is snapshotting.
|
|
34
|
+
*/
|
|
35
|
+
export function mergedEnv(opts) {
|
|
36
|
+
const env = { ...process.env, ...opts.env };
|
|
37
|
+
for (const [key, value] of Object.entries(env))
|
|
38
|
+
if (value === undefined)
|
|
39
|
+
delete env[key];
|
|
40
|
+
return { env };
|
|
41
|
+
}
|
|
42
|
+
/** The largest delay `setTimeout` honours; a longer one fires at once. */
|
|
43
|
+
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
44
|
+
/**
|
|
45
|
+
* `timeoutMs` as every runner applies it: `undefined` for no cap, else a whole
|
|
46
|
+
* number of milliseconds, at most {@link MAX_TIMER_MS}. See
|
|
47
|
+
* {@link CommandRunOptions.timeoutMs}.
|
|
48
|
+
*
|
|
49
|
+
* @throws `TypeError` for a value that is not a number.
|
|
50
|
+
* @throws `RangeError` for `0`, a negative value or `NaN`.
|
|
51
|
+
*/
|
|
52
|
+
export function normalizeTimeout(timeoutMs) {
|
|
53
|
+
if (timeoutMs === undefined)
|
|
54
|
+
return undefined;
|
|
55
|
+
return timerMs('timeoutMs', timeoutMs, 'positive');
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A delay option as a timer takes it: `undefined` for `Infinity`, else a whole
|
|
59
|
+
* number of milliseconds, at most {@link MAX_TIMER_MS}. `min` says whether `0`
|
|
60
|
+
* is allowed. `name` is the option's name, for the error message.
|
|
61
|
+
*
|
|
62
|
+
* @throws `TypeError` for a value that is not a number.
|
|
63
|
+
* @throws `RangeError` for a value below `min`, or `NaN`.
|
|
64
|
+
*/
|
|
65
|
+
export function timerMs(name, ms, min) {
|
|
66
|
+
if (ms === Infinity)
|
|
67
|
+
return undefined;
|
|
68
|
+
if (typeof ms !== 'number')
|
|
69
|
+
throw new TypeError(`${name} must be a number, got ${typeof ms}`);
|
|
70
|
+
if (min === 'positive' ? !(ms > 0) : !(ms >= 0)) {
|
|
71
|
+
const allowed = min === 'positive' ? 'a positive number' : '0 or more';
|
|
72
|
+
throw new RangeError(`${name} must be ${allowed} or Infinity, got ${ms}`);
|
|
73
|
+
}
|
|
74
|
+
return Math.min(Math.ceil(ms), MAX_TIMER_MS);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* A spawn error message when `cwd` is missing or not a directory, else
|
|
78
|
+
* `undefined`. Checked before spawning: a spawn reports a missing `cwd` as
|
|
79
|
+
* `spawn <binary> ENOENT`, which reads as a missing binary, and Node's async
|
|
80
|
+
* `spawn` throws for a `cwd` that is a file.
|
|
81
|
+
*
|
|
82
|
+
* @param spawnName - The message prefix: `spawn` or `spawnSync`, then the binary.
|
|
83
|
+
* @throws `TypeError` for a NUL byte in `binary`, `argv` or `opts.env`, as the
|
|
84
|
+
* spawn would, so a bad `cwd` does not hide it.
|
|
85
|
+
*/
|
|
86
|
+
export function cwdError(spawnName, binary, argv, opts) {
|
|
87
|
+
let problem;
|
|
88
|
+
try {
|
|
89
|
+
if (statSync(opts.cwd).isDirectory())
|
|
90
|
+
return undefined;
|
|
91
|
+
problem = 'ENOTDIR: cwd %s is not a directory';
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
const code = e.code;
|
|
95
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR')
|
|
96
|
+
return undefined; // the spawn reports it
|
|
97
|
+
problem = 'ENOENT: cwd %s does not exist';
|
|
98
|
+
}
|
|
99
|
+
assertNoNulByte(binary, argv, opts.env);
|
|
100
|
+
return `${spawnName} ${problem.replace('%s', JSON.stringify(opts.cwd))}`;
|
|
101
|
+
}
|
|
102
|
+
/** Throw the `TypeError` a spawn throws for a NUL byte in its arguments. */
|
|
103
|
+
function assertNoNulByte(binary, argv, env) {
|
|
104
|
+
const values = [
|
|
105
|
+
['binary', binary],
|
|
106
|
+
...argv.map((a, i) => [`argv[${i}]`, a]),
|
|
107
|
+
...Object.entries(env ?? {}).flatMap(([k, v]) => [
|
|
108
|
+
[`env key ${JSON.stringify(k)}`, k],
|
|
109
|
+
[`env.${k}`, v ?? ''],
|
|
110
|
+
]),
|
|
111
|
+
];
|
|
112
|
+
for (const [name, value] of values) {
|
|
113
|
+
if (typeof value !== 'string' || !value.includes('\0'))
|
|
114
|
+
continue;
|
|
115
|
+
const e = new TypeError(`${name} must not contain a NUL byte`);
|
|
116
|
+
e.code = 'ERR_INVALID_ARG_VALUE';
|
|
117
|
+
throw e;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** The stand-in result for a child that never started. */
|
|
121
|
+
export function spawnFailure(message) {
|
|
122
|
+
return {
|
|
123
|
+
exitCode: 1,
|
|
124
|
+
neverExited: true,
|
|
125
|
+
reason: 'spawn',
|
|
126
|
+
stdout: '',
|
|
127
|
+
stderr: '',
|
|
128
|
+
error: message,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* How a `spawnSync` that reported no exit status ended. Shared by the sync
|
|
133
|
+
* capture and streaming runners.
|
|
134
|
+
*/
|
|
135
|
+
export function syncStop(proc) {
|
|
136
|
+
const signal = proc.signal ?? undefined;
|
|
137
|
+
const sig = signal === undefined ? {} : { signal };
|
|
138
|
+
const code = proc.error?.code;
|
|
139
|
+
if (code === 'ETIMEDOUT')
|
|
140
|
+
return { reason: 'timeout', ...sig };
|
|
141
|
+
// On the cap, `spawnSync` kills the child with `killSignal`.
|
|
142
|
+
if (code === 'ENOBUFS')
|
|
143
|
+
return { reason: 'output', ...sig };
|
|
144
|
+
if (signal !== undefined)
|
|
145
|
+
return { reason: 'signal', signal };
|
|
146
|
+
return { reason: 'spawn' };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Thrown by {@link runChecked} and `runCheckedAsync` on a non-zero exit.
|
|
150
|
+
* Carries the argv and the full {@link CommandResult}, so a caller can report
|
|
151
|
+
* the output without running the command again.
|
|
152
|
+
*
|
|
153
|
+
* `message` is `command failed (exit N): <argv>`, with argv quoted by
|
|
154
|
+
* `shellJoin`, followed on a new line by the trimmed stderr, or the trimmed
|
|
155
|
+
* `error` when stderr is empty. Nothing follows when both are empty. The argv
|
|
156
|
+
* appears in full, so error logs and reports carry it: pass secrets through
|
|
157
|
+
* `env` or `input`, not argv. Use `detail` to show a person
|
|
158
|
+
* why it failed.
|
|
159
|
+
*/
|
|
160
|
+
export class CommandFailed extends Error {
|
|
161
|
+
/** The arguments the command ran with, without the binary name. */
|
|
162
|
+
argv;
|
|
163
|
+
/** The full result of the failed run. */
|
|
164
|
+
result;
|
|
165
|
+
/**
|
|
166
|
+
* The failure text to show a human: both streams, trimmed, newline-joined,
|
|
167
|
+
* empty ones dropped, with the runner's `error` standing in for an empty
|
|
168
|
+
* stderr. Empty when there is no text at all. The same as
|
|
169
|
+
* `failureText(e.result, { both: true })`.
|
|
170
|
+
*
|
|
171
|
+
* `message` interpolates stderr only, and several commands write the
|
|
172
|
+
* sentence that explains the failure to stdout instead — a rejecting git
|
|
173
|
+
* hook, `git commit` on a clean tree, `git apply --check`. Read this rather
|
|
174
|
+
* than picking a stream at the call site.
|
|
175
|
+
*
|
|
176
|
+
* Both are joined rather than one preferred, because a rejecting hook is
|
|
177
|
+
* the case where both carry a distinct half: git's own "commit failed" on
|
|
178
|
+
* stderr, the hook's reason on stdout. Preferring either drops the half
|
|
179
|
+
* the reader needs.
|
|
180
|
+
*/
|
|
181
|
+
get detail() {
|
|
182
|
+
return failureText(this.result, { both: true });
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* @param argv - The arguments the command ran with.
|
|
186
|
+
* @param result - The result of the failed run.
|
|
187
|
+
*/
|
|
188
|
+
constructor(argv, result) {
|
|
189
|
+
const stderr = stderrOrError(result);
|
|
190
|
+
super(`command failed (exit ${result.exitCode}): ${shellJoin(argv)}` +
|
|
191
|
+
(stderr ? `\n${stderr}` : ''));
|
|
192
|
+
this.name = 'CommandFailed';
|
|
193
|
+
this.argv = argv;
|
|
194
|
+
this.result = result;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
export function runChecked(runner, argv, ...opts) {
|
|
198
|
+
const result = runner.run(argv, ...opts);
|
|
199
|
+
if (result.exitCode !== 0)
|
|
200
|
+
throw new CommandFailed(argv, result);
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Run `fn`; if it throws {@link CommandFailed}, throw `mapper(e)` instead.
|
|
205
|
+
* Any other error propagates untouched, as does `fn`'s return value. Replaces
|
|
206
|
+
* `catch (e) { if (e instanceof CommandFailed) throw new X(...); throw e }`.
|
|
207
|
+
*
|
|
208
|
+
* `mapper` may have side effects (print the output, say) before returning the
|
|
209
|
+
* error to throw. Async version: `mapFailedAsync`.
|
|
210
|
+
*
|
|
211
|
+
* @param fn - The code to run, usually a `runChecked` call.
|
|
212
|
+
* @param mapper - Turns the failure into the error to throw.
|
|
213
|
+
* @returns What `fn` returns.
|
|
214
|
+
* @throws The error `mapper` returns, when `fn` throws `CommandFailed`.
|
|
215
|
+
*/
|
|
216
|
+
export function mapFailed(fn, mapper) {
|
|
217
|
+
try {
|
|
218
|
+
return fn();
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
if (e instanceof CommandFailed)
|
|
222
|
+
throw mapper(e);
|
|
223
|
+
throw e;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Run `fn`; if it throws {@link CommandFailed}, return `onFailed(e)` instead.
|
|
228
|
+
* Any other error propagates untouched. Async version: `catchFailedAsync`.
|
|
229
|
+
*
|
|
230
|
+
* @param fn - The code to run, usually a `runChecked` call.
|
|
231
|
+
* @param onFailed - Turns the failure into a value.
|
|
232
|
+
* @returns What `fn` returns, or what `onFailed` returns.
|
|
233
|
+
*/
|
|
234
|
+
export function catchFailed(fn, onFailed) {
|
|
235
|
+
try {
|
|
236
|
+
return fn();
|
|
237
|
+
}
|
|
238
|
+
catch (e) {
|
|
239
|
+
if (e instanceof CommandFailed)
|
|
240
|
+
return onFailed(e);
|
|
241
|
+
throw e;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// Bun's `bun test` runtime mis-wires child-process stdio once a large module
|
|
245
|
+
// graph is loaded in the test process: the child runs — its own `<in >out`
|
|
246
|
+
// redirects work, filesystem side effects land — but the stdin/stdout/stderr
|
|
247
|
+
// the parent hands it come back empty. Confirmed for sync and async spawns,
|
|
248
|
+
// pipes and parent-opened fds, across every Bun 1.2.x-1.3.x; not reproducible
|
|
249
|
+
// under `bun run`. A suite that shells out after importing a heavy module
|
|
250
|
+
// graph hits it on every run.
|
|
251
|
+
// Only Bun's test runner is affected, so the workaround needs both the Bun
|
|
252
|
+
// runtime and `NODE_ENV=test` (which `bun test` sets). Jest and Vitest also
|
|
253
|
+
// set `NODE_ENV=test`, but they run on Node and keep the pipe path below.
|
|
254
|
+
const CHILD_STDIO_CORRUPTED = process.versions['bun'] !== undefined && process.env['NODE_ENV'] === 'test';
|
|
255
|
+
/**
|
|
256
|
+
* Whether `binary` resolves to an executable file the way a spawn would find
|
|
257
|
+
* it: a name with a `/` is taken relative to `cwd`; a bare name is looked up
|
|
258
|
+
* on the child's `PATH`.
|
|
259
|
+
*/
|
|
260
|
+
function findsExecutable(binary, cwd, path) {
|
|
261
|
+
const isExecutable = (p) => {
|
|
262
|
+
try {
|
|
263
|
+
accessSync(p, constants.X_OK);
|
|
264
|
+
return statSync(p).isFile();
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
if (binary === '')
|
|
271
|
+
return false;
|
|
272
|
+
if (binary.includes('/'))
|
|
273
|
+
return isExecutable(resolve(cwd, binary));
|
|
274
|
+
return (path ?? '/usr/bin:/bin')
|
|
275
|
+
.split(delimiter)
|
|
276
|
+
.some((dir) => isExecutable(resolve(cwd, dir, binary)));
|
|
277
|
+
}
|
|
278
|
+
/** Folds a finished `spawnSync` into a {@link CommandResult}. */
|
|
279
|
+
function fromSpawnSync(proc, stdout, stderr) {
|
|
280
|
+
return {
|
|
281
|
+
exitCode: proc.status ?? 1,
|
|
282
|
+
// Bun reports a failed spawn with `status` undefined, not null.
|
|
283
|
+
...(proc.status == null ? { neverExited: true, ...syncStop(proc) } : {}),
|
|
284
|
+
stdout,
|
|
285
|
+
stderr,
|
|
286
|
+
// `proc.error` is set for a failed spawn, a timeout, the output cap, or a
|
|
287
|
+
// failed stdin write; only the first three leave `status` null (and under
|
|
288
|
+
// Bun the cap can come with a real status too). A stdin write error can
|
|
289
|
+
// come with a child that did exit, so it is kept either way.
|
|
290
|
+
...(proc.error ? { error: proc.error.message } : {}),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Capture output the one way Bun does not corrupt: let the child do its own
|
|
295
|
+
* `<in >out 2>err` redirects to temp files, then read the files back. `binary`
|
|
296
|
+
* and `argv` ride as separate `sh` positional args — never parsed as shell — so
|
|
297
|
+
* arguments containing spaces, metacharacters, or `$()` are inert. `exec`
|
|
298
|
+
* replaces `sh`, so the reported exit status (and the cwd/env/timeout) all
|
|
299
|
+
* belong to the child, matching the pipe path's contract.
|
|
300
|
+
*/
|
|
301
|
+
function runViaChildRedirect(binary, argv, opts, timeoutMs) {
|
|
302
|
+
const { env } = mergedEnv(opts);
|
|
303
|
+
// A binary that cannot be found would make `exec` fail and `sh` exit 127,
|
|
304
|
+
// which reads as the command's own answer. Check first, so the result is
|
|
305
|
+
// the same stand-in the pipe path reports for a failed spawn. This also
|
|
306
|
+
// keeps a binary named like an option (`-a`) from reaching `exec` as one,
|
|
307
|
+
// unless an executable named `-a` is on PATH.
|
|
308
|
+
if (!findsExecutable(binary, opts.cwd, env['PATH'])) {
|
|
309
|
+
return spawnFailure(`spawnSync ${binary} ENOENT`);
|
|
310
|
+
}
|
|
311
|
+
const dir = mkdtempSync(join(tmpdir(), 'command-seam-'));
|
|
312
|
+
try {
|
|
313
|
+
const inFile = join(dir, 'in');
|
|
314
|
+
const outFile = join(dir, 'out');
|
|
315
|
+
const errFile = join(dir, 'err');
|
|
316
|
+
writeFileSync(inFile, opts.input ?? '');
|
|
317
|
+
const proc = spawnSync(
|
|
318
|
+
// An absolute path, so an `env.PATH` override without `/bin` still
|
|
319
|
+
// finds the shell.
|
|
320
|
+
'/bin/sh', [
|
|
321
|
+
'-c',
|
|
322
|
+
'i="$1"; o="$2"; e="$3"; shift 3; exec "$@" <"$i" >"$o" 2>"$e"',
|
|
323
|
+
'sh', // $0
|
|
324
|
+
inFile,
|
|
325
|
+
outFile,
|
|
326
|
+
errFile,
|
|
327
|
+
binary,
|
|
328
|
+
...argv,
|
|
329
|
+
], {
|
|
330
|
+
cwd: opts.cwd,
|
|
331
|
+
timeout: timeoutMs,
|
|
332
|
+
// SIGKILL, not spawnSync's default SIGTERM: the contract is a hard
|
|
333
|
+
// cap, and a child that ignores TERM is left to run to completion —
|
|
334
|
+
// spawnSync signals on expiry and then keeps waiting for an exit.
|
|
335
|
+
killSignal: 'SIGKILL',
|
|
336
|
+
env,
|
|
337
|
+
});
|
|
338
|
+
// The redirect files are created by the child's `>` / `2>`, so neither
|
|
339
|
+
// exists when `sh` itself never ran — a spawn that fails with EAGAIN under
|
|
340
|
+
// a saturated process table is the case that matters, and it must not turn
|
|
341
|
+
// into an ENOENT thrown from the capture layer. Missing reads as empty,
|
|
342
|
+
// matching what the pipe path reports for a child that produced nothing.
|
|
343
|
+
const readOrEmpty = (p) => {
|
|
344
|
+
try {
|
|
345
|
+
return readFileSync(p, 'utf-8');
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
return '';
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
// Output goes to a file, so there is no maxBuffer: it is bounded only by
|
|
352
|
+
// disk.
|
|
353
|
+
return fromSpawnSync(proc, readOrEmpty(outFile), readOrEmpty(errFile));
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
rmSync(dir, { recursive: true, force: true });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* The injected runner when there is one, else `spawnRunner(binary)`.
|
|
361
|
+
*
|
|
362
|
+
* @param binary - The binary for the default runner.
|
|
363
|
+
* @param injected - A runner to use instead, such as a test fake.
|
|
364
|
+
*/
|
|
365
|
+
export function resolveRunner(binary, injected) {
|
|
366
|
+
return injected ?? spawnRunner(binary);
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* The default {@link CommandRunner}: runs `binary` with `spawnSync`, so each
|
|
370
|
+
* call blocks until the child exits. On timeout the child gets `SIGKILL`.
|
|
371
|
+
* Captured output is capped at 256 MB per stream. Under `bun test` (the Bun
|
|
372
|
+
* runtime with `NODE_ENV=test`) it captures through `/bin/sh` and temp files
|
|
373
|
+
* instead of pipes (see `runViaChildRedirect`).
|
|
374
|
+
*
|
|
375
|
+
* @param binary - A name looked up on `PATH`, or a path.
|
|
376
|
+
* @returns A runner for that binary.
|
|
377
|
+
*
|
|
378
|
+
* @example
|
|
379
|
+
* ```ts
|
|
380
|
+
* const git = spawnRunner('git')
|
|
381
|
+
* const r = git.run(['status', '--porcelain'], { cwd: '.', timeoutMs: 10_000 })
|
|
382
|
+
* if (r.exitCode === 0) console.log(r.stdout)
|
|
383
|
+
* ```
|
|
384
|
+
*/
|
|
385
|
+
export function spawnRunner(binary) {
|
|
386
|
+
return {
|
|
387
|
+
run(argv, opts) {
|
|
388
|
+
const timeoutMs = normalizeTimeout(opts.timeoutMs);
|
|
389
|
+
const cwdMessage = cwdError(`spawnSync ${binary}`, binary, argv, opts);
|
|
390
|
+
if (cwdMessage !== undefined)
|
|
391
|
+
return spawnFailure(cwdMessage);
|
|
392
|
+
if (CHILD_STDIO_CORRUPTED)
|
|
393
|
+
return runViaChildRedirect(binary, argv, opts, timeoutMs);
|
|
394
|
+
const proc = spawnSync(binary, argv, {
|
|
395
|
+
cwd: opts.cwd,
|
|
396
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
397
|
+
timeout: timeoutMs,
|
|
398
|
+
// See `runViaChildRedirect`: the default SIGTERM is not a hard cap.
|
|
399
|
+
killSignal: 'SIGKILL',
|
|
400
|
+
input: opts.input,
|
|
401
|
+
...mergedEnv(opts),
|
|
402
|
+
});
|
|
403
|
+
return fromSpawnSync(proc, proc.stdout?.toString() ?? '', proc.stderr?.toString() ?? '');
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@osdlc/command-seam/testing`: fakes for tests, kept out of the main
|
|
3
|
+
* entry so production code never imports them.
|
|
4
|
+
*
|
|
5
|
+
* - `fakeRunner` answers capture-runner calls from a table and records them.
|
|
6
|
+
* - `fakeNeverExited` builds the result of a run that never exited.
|
|
7
|
+
* - `fakeProcess` is a managed-process handle the test ends by hand.
|
|
8
|
+
*/
|
|
9
|
+
import { type ManagedProcessExit, type ManagedProcessHandle, type ManagedProcessRunner, type ManagedProcessRunOptions } from './process.ts';
|
|
10
|
+
import { type AsyncCommandRunner, type AsyncCommandRunOptions } from './async.ts';
|
|
11
|
+
import type { CommandResult, CommandRunner, NeverExitedReason } from './sync.ts';
|
|
12
|
+
/**
|
|
13
|
+
* One call a fake received.
|
|
14
|
+
*
|
|
15
|
+
* @experimental
|
|
16
|
+
*/
|
|
17
|
+
export interface FakeCall<O> {
|
|
18
|
+
argv: string[];
|
|
19
|
+
opts: O;
|
|
20
|
+
}
|
|
21
|
+
/** What a fake answers: fields over `{ exitCode: 0, stdout: '', stderr: '' }`. */
|
|
22
|
+
type FakeAnswer = Partial<CommandResult>;
|
|
23
|
+
/**
|
|
24
|
+
* What `fakeRunner` returns.
|
|
25
|
+
*
|
|
26
|
+
* @experimental
|
|
27
|
+
*/
|
|
28
|
+
export interface FakeRunner {
|
|
29
|
+
/** Pass this to the code under test. */
|
|
30
|
+
runner: CommandRunner;
|
|
31
|
+
/**
|
|
32
|
+
* The same fake as an {@link AsyncCommandRunner}, for async code. It shares
|
|
33
|
+
* the answers and `calls`.
|
|
34
|
+
*/
|
|
35
|
+
asyncRunner: AsyncCommandRunner;
|
|
36
|
+
/**
|
|
37
|
+
* Every call, in order: a copy of its argv and a shallow copy of its
|
|
38
|
+
* options. Typed with the async options, so a call made through
|
|
39
|
+
* `asyncRunner` shows its `signal` or `processGroup`.
|
|
40
|
+
*/
|
|
41
|
+
calls: FakeCall<AsyncCommandRunOptions>[];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A fake {@link CommandRunner} that answers from a table and records every
|
|
45
|
+
* call.
|
|
46
|
+
*
|
|
47
|
+
* `answers` maps an argv to the result fields that differ from a clean
|
|
48
|
+
* empty run: `{ exitCode: 0, stdout: '', stderr: '' }`. The key is each
|
|
49
|
+
* argument quoted with `shellQuote`, joined with single spaces: a plain
|
|
50
|
+
* argument stays as it is, and one with a space or a shell character is
|
|
51
|
+
* single-quoted, so `['-m', 'a b']` is `-m 'a b'` and cannot match
|
|
52
|
+
* `['-m', 'a', 'b']`. It can also be a function of the call; return
|
|
53
|
+
* `undefined` for no answer.
|
|
54
|
+
*
|
|
55
|
+
* A call with no answer reports a failed spawn, as a missing binary would:
|
|
56
|
+
* `exitCode: 1`, `neverExited: true`, `reason: 'spawn'`, and
|
|
57
|
+
* `fakeRunner: no answer for: <key>` in `error`, where `<key>` is the table
|
|
58
|
+
* key that would answer it. So `ran()` is false and `runChecked` throws.
|
|
59
|
+
* Code that reads a failed run as an answer may still go on, so assert on
|
|
60
|
+
* `calls` to be sure every call was expected.
|
|
61
|
+
*
|
|
62
|
+
* Through `asyncRunner`, `signal` and `processGroup` are recorded but do
|
|
63
|
+
* nothing, and `onSpawn` is never called.
|
|
64
|
+
*
|
|
65
|
+
* @param answers - Results by argv, or a function that picks one.
|
|
66
|
+
* @returns The runner, the same fake as an async runner, and the list of
|
|
67
|
+
* calls they received.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```ts
|
|
71
|
+
* const { runner, calls } = fakeRunner({ 'rev-parse --short HEAD': { stdout: 'abc\n' } })
|
|
72
|
+
* runner.run(['rev-parse', '--short', 'HEAD'], { cwd: '/repo' }).stdout // 'abc\n'
|
|
73
|
+
* calls[0]?.opts.cwd // '/repo'
|
|
74
|
+
* ```
|
|
75
|
+
*
|
|
76
|
+
* @experimental
|
|
77
|
+
*/
|
|
78
|
+
export declare function fakeRunner(answers: Readonly<Record<string, FakeAnswer>> | ((argv: string[], opts: AsyncCommandRunOptions) => FakeAnswer | undefined)): FakeRunner;
|
|
79
|
+
/**
|
|
80
|
+
* A fake answer for a run that never exited, with the fields the real
|
|
81
|
+
* runners set together for `reason`: `exitCode: 1`, `neverExited: true`,
|
|
82
|
+
* `reason`, `error: 'fake: <reason>'`, and `signal` where the sync runner
|
|
83
|
+
* sets one (`SIGKILL` for `timeout` and `output`, `SIGTERM` for `signal`).
|
|
84
|
+
* A death by a signal from elsewhere has no `error`, as on the real runners.
|
|
85
|
+
* `fields` override any of these, such as the `signal` or the `error`.
|
|
86
|
+
*
|
|
87
|
+
* @param reason - Why the run never exited.
|
|
88
|
+
* @param fields - Fields to set over the defaults.
|
|
89
|
+
* @returns An answer for `fakeRunner`.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```ts
|
|
93
|
+
* const { runner } = fakeRunner({ 'fetch origin': fakeNeverExited('timeout') })
|
|
94
|
+
* runner.run(['fetch', 'origin'], { cwd: '/repo' }).reason // 'timeout'
|
|
95
|
+
* ```
|
|
96
|
+
*
|
|
97
|
+
* @experimental
|
|
98
|
+
*/
|
|
99
|
+
export declare function fakeNeverExited(reason: NeverExitedReason, fields?: Partial<CommandResult>): Partial<CommandResult>;
|
|
100
|
+
/**
|
|
101
|
+
* Options for {@link fakeProcess}.
|
|
102
|
+
*
|
|
103
|
+
* @experimental
|
|
104
|
+
*/
|
|
105
|
+
export interface FakeProcessOptions {
|
|
106
|
+
/** The handle's pid. Default `4242`. */
|
|
107
|
+
pid?: number | undefined;
|
|
108
|
+
/**
|
|
109
|
+
* Which signals sent through `kill()` end the process, as for a process
|
|
110
|
+
* that does not handle them: `exited` settles with `reason: 'abort'`.
|
|
111
|
+
* `true` (the default) is every signal, `false` none, and a list only
|
|
112
|
+
* those, such as `['SIGKILL']` for a process that survives `SIGTERM`.
|
|
113
|
+
*/
|
|
114
|
+
exitOnKill?: boolean | readonly NodeJS.Signals[] | undefined;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* What `fakeProcess` returns: a handle, plus the controls a test needs.
|
|
118
|
+
*
|
|
119
|
+
* @experimental
|
|
120
|
+
*/
|
|
121
|
+
export interface FakeProcess extends ManagedProcessHandle {
|
|
122
|
+
/**
|
|
123
|
+
* End the process: settle `exited` with these fields over
|
|
124
|
+
* `{ exitCode: 0, signal: null }`. Only the first end counts, as for a
|
|
125
|
+
* real process. To fake a death by a signal from elsewhere, pass
|
|
126
|
+
* `{ exitCode: 1, signal, neverExited: true, reason: 'signal' }`, as
|
|
127
|
+
* `managedProcessRunner` reports it.
|
|
128
|
+
*/
|
|
129
|
+
exit(result?: Partial<ManagedProcessExit>): void;
|
|
130
|
+
/** Every signal passed to `kill()`, in order; `SIGTERM` when none was given. */
|
|
131
|
+
signals: (NodeJS.Signals | number)[];
|
|
132
|
+
/** A runner that returns this handle from every `run`. */
|
|
133
|
+
runner: ManagedProcessRunner;
|
|
134
|
+
/** Every call `runner` received. */
|
|
135
|
+
calls: FakeCall<ManagedProcessRunOptions>[];
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* A fake {@link ManagedProcessHandle}: it starts nothing, and `exited`
|
|
139
|
+
* settles when the test calls `exit()`, or when the code under test calls
|
|
140
|
+
* `kill()` (see `exitOnKill`).
|
|
141
|
+
*
|
|
142
|
+
* @param opts - The pid, and which signals sent through `kill()` end the process.
|
|
143
|
+
* @returns The handle, with `exit()`, the signals sent and a runner that
|
|
144
|
+
* serves it.
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* ```ts
|
|
148
|
+
* const proc = fakeProcess()
|
|
149
|
+
* const server = proc.runner.run(['server.js'], { cwd: '.' })
|
|
150
|
+
* proc.exit({ exitCode: 0 })
|
|
151
|
+
* await server.exited // { exitCode: 0, signal: null }
|
|
152
|
+
* ```
|
|
153
|
+
*
|
|
154
|
+
* @experimental
|
|
155
|
+
*/
|
|
156
|
+
export declare function fakeProcess(opts?: FakeProcessOptions): FakeProcess;
|
|
157
|
+
export {};
|
|
158
|
+
//# sourceMappingURL=testing.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../testing.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAEL,KAAK,kBAAkB,EACvB,KAAK,oBAAoB,EACzB,KAAK,oBAAoB,EACzB,KAAK,wBAAwB,EAC9B,MAAM,cAAc,CAAA;AACrB,OAAO,EAAW,KAAK,kBAAkB,EAAE,KAAK,sBAAsB,EAAE,MAAM,YAAY,CAAA;AAE1F,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAA;AAEhF;;;;GAIG;AACH,MAAM,WAAW,QAAQ,CAAC,CAAC;IACzB,IAAI,EAAE,MAAM,EAAE,CAAA;IACd,IAAI,EAAE,CAAC,CAAA;CACR;AAED,kFAAkF;AAClF,KAAK,UAAU,GAAG,OAAO,CAAC,aAAa,CAAC,CAAA;AAExC;;;;GAIG;AACH,MAAM,WAAW,UAAU;IACzB,wCAAwC;IACxC,MAAM,EAAE,aAAa,CAAA;IACrB;;;OAGG;IACH,WAAW,EAAE,kBAAkB,CAAA;IAC/B;;;;OAIG;IACH,KAAK,EAAE,QAAQ,CAAC,sBAAsB,CAAC,EAAE,CAAA;CAC1C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,wBAAgB,UAAU,CACxB,OAAO,EACH,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC,GACpC,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,sBAAsB,KAAK,UAAU,GAAG,SAAS,CAAC,GAC7E,UAAU,CAmBZ;AAWD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,iBAAiB,EACzB,MAAM,GAAE,OAAO,CAAC,aAAa,CAAM,GAClC,OAAO,CAAC,aAAa,CAAC,CAUxB;AAWD;;;;GAIG;AACH,MAAM,WAAW,kBAAkB;IACjC,wCAAwC;IACxC,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACxB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,OAAO,GAAG,SAAS,MAAM,CAAC,OAAO,EAAE,GAAG,SAAS,CAAA;CAC7D;AAED;;;;GAIG;AACH,MAAM,WAAW,WAAY,SAAQ,oBAAoB;IACvD;;;;;;OAMG;IACH,IAAI,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,kBAAkB,CAAC,GAAG,IAAI,CAAA;IAChD,gFAAgF;IAChF,OAAO,EAAE,CAAC,MAAM,CAAC,OAAO,GAAG,MAAM,CAAC,EAAE,CAAA;IACpC,0DAA0D;IAC1D,MAAM,EAAE,oBAAoB,CAAA;IAC5B,oCAAoC;IACpC,KAAK,EAAE,QAAQ,CAAC,wBAAwB,CAAC,EAAE,CAAA;CAC5C;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,WAAW,CAAC,IAAI,GAAE,kBAAuB,GAAG,WAAW,CAqCtE"}
|