@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/CONTRIBUTING.md +58 -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/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
+ }