@osdlc/command-seam 0.0.0-stage → 0.4.0

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +315 -0
  2. package/CONTRIBUTING.md +61 -0
  3. package/LICENSE +21 -0
  4. package/README.md +741 -2
  5. package/SECURITY.md +23 -0
  6. package/async.ts +466 -0
  7. package/defaults.ts +83 -0
  8. package/dist/async.d.ts +146 -0
  9. package/dist/async.d.ts.map +1 -0
  10. package/dist/async.js +368 -0
  11. package/dist/defaults.d.ts +52 -0
  12. package/dist/defaults.d.ts.map +1 -0
  13. package/dist/defaults.js +51 -0
  14. package/dist/groups.d.ts +155 -0
  15. package/dist/groups.d.ts.map +1 -0
  16. package/dist/groups.js +331 -0
  17. package/dist/index.d.ts +30 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +29 -0
  20. package/dist/process.d.ts +146 -0
  21. package/dist/process.d.ts.map +1 -0
  22. package/dist/process.js +175 -0
  23. package/dist/process.test.d.ts +1 -0
  24. package/dist/process.test.d.ts.map +1 -0
  25. package/dist/process.test.js +1 -0
  26. package/dist/quote.d.ts +18 -0
  27. package/dist/quote.d.ts.map +1 -0
  28. package/dist/quote.js +28 -0
  29. package/dist/result.d.ts +211 -0
  30. package/dist/result.d.ts.map +1 -0
  31. package/dist/result.js +219 -0
  32. package/dist/shell.d.ts +84 -0
  33. package/dist/shell.d.ts.map +1 -0
  34. package/dist/shell.js +83 -0
  35. package/dist/stream.d.ts +90 -0
  36. package/dist/stream.d.ts.map +1 -0
  37. package/dist/stream.js +73 -0
  38. package/dist/sync.d.ts +311 -0
  39. package/dist/sync.d.ts.map +1 -0
  40. package/dist/sync.js +406 -0
  41. package/dist/testing.d.ts +158 -0
  42. package/dist/testing.d.ts.map +1 -0
  43. package/dist/testing.js +173 -0
  44. package/groups.ts +360 -0
  45. package/index.ts +98 -0
  46. package/package.json +94 -3
  47. package/process.ts +270 -0
  48. package/quote.ts +31 -0
  49. package/result.ts +403 -0
  50. package/shell.ts +131 -0
  51. package/smoke.mjs +61 -0
  52. package/stream.ts +134 -0
  53. package/sync.ts +604 -0
  54. 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"}