@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/testing.ts
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
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
|
+
|
|
10
|
+
import {
|
|
11
|
+
signalName,
|
|
12
|
+
type ManagedProcessExit,
|
|
13
|
+
type ManagedProcessHandle,
|
|
14
|
+
type ManagedProcessRunner,
|
|
15
|
+
type ManagedProcessRunOptions,
|
|
16
|
+
} from './process.ts'
|
|
17
|
+
import { asAsync, type AsyncCommandRunner, type AsyncCommandRunOptions } from './async.ts'
|
|
18
|
+
import { shellQuote } from './quote.ts'
|
|
19
|
+
import type { CommandResult, CommandRunner, NeverExitedReason } from './sync.ts'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One call a fake received.
|
|
23
|
+
*
|
|
24
|
+
* @experimental
|
|
25
|
+
*/
|
|
26
|
+
export interface FakeCall<O> {
|
|
27
|
+
argv: string[]
|
|
28
|
+
opts: O
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What a fake answers: fields over `{ exitCode: 0, stdout: '', stderr: '' }`. */
|
|
32
|
+
type FakeAnswer = Partial<CommandResult>
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* What `fakeRunner` returns.
|
|
36
|
+
*
|
|
37
|
+
* @experimental
|
|
38
|
+
*/
|
|
39
|
+
export interface FakeRunner {
|
|
40
|
+
/** Pass this to the code under test. */
|
|
41
|
+
runner: CommandRunner
|
|
42
|
+
/**
|
|
43
|
+
* The same fake as an {@link AsyncCommandRunner}, for async code. It shares
|
|
44
|
+
* the answers and `calls`.
|
|
45
|
+
*/
|
|
46
|
+
asyncRunner: AsyncCommandRunner
|
|
47
|
+
/**
|
|
48
|
+
* Every call, in order: a copy of its argv and a shallow copy of its
|
|
49
|
+
* options. Typed with the async options, so a call made through
|
|
50
|
+
* `asyncRunner` shows its `signal` or `processGroup`.
|
|
51
|
+
*/
|
|
52
|
+
calls: FakeCall<AsyncCommandRunOptions>[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A fake {@link CommandRunner} that answers from a table and records every
|
|
57
|
+
* call.
|
|
58
|
+
*
|
|
59
|
+
* `answers` maps an argv to the result fields that differ from a clean
|
|
60
|
+
* empty run: `{ exitCode: 0, stdout: '', stderr: '' }`. The key is each
|
|
61
|
+
* argument quoted with `shellQuote`, joined with single spaces: a plain
|
|
62
|
+
* argument stays as it is, and one with a space or a shell character is
|
|
63
|
+
* single-quoted, so `['-m', 'a b']` is `-m 'a b'` and cannot match
|
|
64
|
+
* `['-m', 'a', 'b']`. It can also be a function of the call; return
|
|
65
|
+
* `undefined` for no answer.
|
|
66
|
+
*
|
|
67
|
+
* A call with no answer reports a failed spawn, as a missing binary would:
|
|
68
|
+
* `exitCode: 1`, `neverExited: true`, `reason: 'spawn'`, and
|
|
69
|
+
* `fakeRunner: no answer for: <key>` in `error`, where `<key>` is the table
|
|
70
|
+
* key that would answer it. So `ran()` is false and `runChecked` throws.
|
|
71
|
+
* Code that reads a failed run as an answer may still go on, so assert on
|
|
72
|
+
* `calls` to be sure every call was expected.
|
|
73
|
+
*
|
|
74
|
+
* Through `asyncRunner`, `signal` and `processGroup` are recorded but do
|
|
75
|
+
* nothing, and `onSpawn` is never called.
|
|
76
|
+
*
|
|
77
|
+
* @param answers - Results by argv, or a function that picks one.
|
|
78
|
+
* @returns The runner, the same fake as an async runner, and the list of
|
|
79
|
+
* calls they received.
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* ```ts
|
|
83
|
+
* const { runner, calls } = fakeRunner({ 'rev-parse --short HEAD': { stdout: 'abc\n' } })
|
|
84
|
+
* runner.run(['rev-parse', '--short', 'HEAD'], { cwd: '/repo' }).stdout // 'abc\n'
|
|
85
|
+
* calls[0]?.opts.cwd // '/repo'
|
|
86
|
+
* ```
|
|
87
|
+
*
|
|
88
|
+
* @experimental
|
|
89
|
+
*/
|
|
90
|
+
export function fakeRunner(
|
|
91
|
+
answers:
|
|
92
|
+
| Readonly<Record<string, FakeAnswer>>
|
|
93
|
+
| ((argv: string[], opts: AsyncCommandRunOptions) => FakeAnswer | undefined),
|
|
94
|
+
): FakeRunner {
|
|
95
|
+
const calls: FakeCall<AsyncCommandRunOptions>[] = []
|
|
96
|
+
const lookup =
|
|
97
|
+
typeof answers === 'function'
|
|
98
|
+
? answers
|
|
99
|
+
: (argv: string[]) => {
|
|
100
|
+
const key = keyOf(argv)
|
|
101
|
+
return Object.hasOwn(answers, key) ? answers[key] : undefined
|
|
102
|
+
}
|
|
103
|
+
const runner: CommandRunner = {
|
|
104
|
+
run(argv, opts: AsyncCommandRunOptions) {
|
|
105
|
+
calls.push({ argv: [...argv], opts: { ...opts } })
|
|
106
|
+
const answer = lookup(argv, opts)
|
|
107
|
+
if (answer !== undefined) return { exitCode: 0, stdout: '', stderr: '', ...answer }
|
|
108
|
+
const error = `fakeRunner: no answer for: ${keyOf(argv)}`
|
|
109
|
+
return { exitCode: 1, stdout: '', stderr: '', neverExited: true, reason: 'spawn', error }
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
return { runner, asyncRunner: asAsync(runner), calls }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A `fakeRunner` table key. Not `shellJoin`: argv here has no binary name, so
|
|
117
|
+
* a first argument with `=` (such as `--git-dir=/r`) is not a command name
|
|
118
|
+
* and needs no quotes.
|
|
119
|
+
*/
|
|
120
|
+
function keyOf(argv: readonly string[]): string {
|
|
121
|
+
return argv.map(shellQuote).join(' ')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A fake answer for a run that never exited, with the fields the real
|
|
126
|
+
* runners set together for `reason`: `exitCode: 1`, `neverExited: true`,
|
|
127
|
+
* `reason`, `error: 'fake: <reason>'`, and `signal` where the sync runner
|
|
128
|
+
* sets one (`SIGKILL` for `timeout` and `output`, `SIGTERM` for `signal`).
|
|
129
|
+
* A death by a signal from elsewhere has no `error`, as on the real runners.
|
|
130
|
+
* `fields` override any of these, such as the `signal` or the `error`.
|
|
131
|
+
*
|
|
132
|
+
* @param reason - Why the run never exited.
|
|
133
|
+
* @param fields - Fields to set over the defaults.
|
|
134
|
+
* @returns An answer for `fakeRunner`.
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* ```ts
|
|
138
|
+
* const { runner } = fakeRunner({ 'fetch origin': fakeNeverExited('timeout') })
|
|
139
|
+
* runner.run(['fetch', 'origin'], { cwd: '/repo' }).reason // 'timeout'
|
|
140
|
+
* ```
|
|
141
|
+
*
|
|
142
|
+
* @experimental
|
|
143
|
+
*/
|
|
144
|
+
export function fakeNeverExited(
|
|
145
|
+
reason: NeverExitedReason,
|
|
146
|
+
fields: Partial<CommandResult> = {},
|
|
147
|
+
): Partial<CommandResult> {
|
|
148
|
+
const signal = NEVER_EXITED_SIGNAL[reason]
|
|
149
|
+
return {
|
|
150
|
+
exitCode: 1,
|
|
151
|
+
neverExited: true,
|
|
152
|
+
reason,
|
|
153
|
+
...(signal === undefined ? {} : { signal }),
|
|
154
|
+
...(reason === 'signal' ? {} : { error: `fake: ${reason}` }),
|
|
155
|
+
...fields,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The signal the sync runner reports for each reason, where it reports one. */
|
|
160
|
+
const NEVER_EXITED_SIGNAL: Record<NeverExitedReason, NodeJS.Signals | undefined> = {
|
|
161
|
+
spawn: undefined,
|
|
162
|
+
timeout: 'SIGKILL',
|
|
163
|
+
abort: undefined,
|
|
164
|
+
signal: 'SIGTERM',
|
|
165
|
+
output: 'SIGKILL',
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Options for {@link fakeProcess}.
|
|
170
|
+
*
|
|
171
|
+
* @experimental
|
|
172
|
+
*/
|
|
173
|
+
export interface FakeProcessOptions {
|
|
174
|
+
/** The handle's pid. Default `4242`. */
|
|
175
|
+
pid?: number | undefined
|
|
176
|
+
/**
|
|
177
|
+
* Which signals sent through `kill()` end the process, as for a process
|
|
178
|
+
* that does not handle them: `exited` settles with `reason: 'abort'`.
|
|
179
|
+
* `true` (the default) is every signal, `false` none, and a list only
|
|
180
|
+
* those, such as `['SIGKILL']` for a process that survives `SIGTERM`.
|
|
181
|
+
*/
|
|
182
|
+
exitOnKill?: boolean | readonly NodeJS.Signals[] | undefined
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* What `fakeProcess` returns: a handle, plus the controls a test needs.
|
|
187
|
+
*
|
|
188
|
+
* @experimental
|
|
189
|
+
*/
|
|
190
|
+
export interface FakeProcess extends ManagedProcessHandle {
|
|
191
|
+
/**
|
|
192
|
+
* End the process: settle `exited` with these fields over
|
|
193
|
+
* `{ exitCode: 0, signal: null }`. Only the first end counts, as for a
|
|
194
|
+
* real process. To fake a death by a signal from elsewhere, pass
|
|
195
|
+
* `{ exitCode: 1, signal, neverExited: true, reason: 'signal' }`, as
|
|
196
|
+
* `managedProcessRunner` reports it.
|
|
197
|
+
*/
|
|
198
|
+
exit(result?: Partial<ManagedProcessExit>): void
|
|
199
|
+
/** Every signal passed to `kill()`, in order; `SIGTERM` when none was given. */
|
|
200
|
+
signals: (NodeJS.Signals | number)[]
|
|
201
|
+
/** A runner that returns this handle from every `run`. */
|
|
202
|
+
runner: ManagedProcessRunner
|
|
203
|
+
/** Every call `runner` received. */
|
|
204
|
+
calls: FakeCall<ManagedProcessRunOptions>[]
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A fake {@link ManagedProcessHandle}: it starts nothing, and `exited`
|
|
209
|
+
* settles when the test calls `exit()`, or when the code under test calls
|
|
210
|
+
* `kill()` (see `exitOnKill`).
|
|
211
|
+
*
|
|
212
|
+
* @param opts - The pid, and which signals sent through `kill()` end the process.
|
|
213
|
+
* @returns The handle, with `exit()`, the signals sent and a runner that
|
|
214
|
+
* serves it.
|
|
215
|
+
*
|
|
216
|
+
* @example
|
|
217
|
+
* ```ts
|
|
218
|
+
* const proc = fakeProcess()
|
|
219
|
+
* const server = proc.runner.run(['server.js'], { cwd: '.' })
|
|
220
|
+
* proc.exit({ exitCode: 0 })
|
|
221
|
+
* await server.exited // { exitCode: 0, signal: null }
|
|
222
|
+
* ```
|
|
223
|
+
*
|
|
224
|
+
* @experimental
|
|
225
|
+
*/
|
|
226
|
+
export function fakeProcess(opts: FakeProcessOptions = {}): FakeProcess {
|
|
227
|
+
const pid = opts.pid ?? 4242
|
|
228
|
+
const exitOnKill = opts.exitOnKill ?? true
|
|
229
|
+
let settle: (exit: ManagedProcessExit) => void = () => {}
|
|
230
|
+
let settled = false
|
|
231
|
+
const exited = new Promise<ManagedProcessExit>((resolve) => (settle = resolve))
|
|
232
|
+
const exit = (result: Partial<ManagedProcessExit> = {}): void => {
|
|
233
|
+
if (settled) return
|
|
234
|
+
settled = true
|
|
235
|
+
settle({ exitCode: 0, signal: null, ...result })
|
|
236
|
+
}
|
|
237
|
+
const signals: (NodeJS.Signals | number)[] = []
|
|
238
|
+
const calls: FakeCall<ManagedProcessRunOptions>[] = []
|
|
239
|
+
|
|
240
|
+
const proc: FakeProcess = {
|
|
241
|
+
pid,
|
|
242
|
+
exited,
|
|
243
|
+
kill(signal: NodeJS.Signals | number = 'SIGTERM'): void {
|
|
244
|
+
signals.push(signal)
|
|
245
|
+
const name = typeof signal === 'number' ? signalName(signal) : signal
|
|
246
|
+
// Signal 0 (a probe) and unknown numbers end nothing.
|
|
247
|
+
if (name === undefined) return
|
|
248
|
+
if (exitOnKill === false) return
|
|
249
|
+
if (exitOnKill !== true && !exitOnKill.includes(name)) return
|
|
250
|
+
exit({ exitCode: 1, signal: name, neverExited: true, reason: 'abort' })
|
|
251
|
+
},
|
|
252
|
+
exit,
|
|
253
|
+
signals,
|
|
254
|
+
calls,
|
|
255
|
+
runner: {
|
|
256
|
+
run(argv, runOpts) {
|
|
257
|
+
calls.push({ argv: [...argv], opts: { ...runOpts } })
|
|
258
|
+
return proc
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
}
|
|
262
|
+
return proc
|
|
263
|
+
}
|