Skip to content

Commit d0c93c1

Browse files
committed
test(secrets): make spawn mocks awaitable to match the lib spawn shape
The secrets runners now `await` the lib spawn result (a thenable carrying `.process`), rejecting on non-zero/spawn-error. Update the three makeFakeChild mocks to be `{ process } & Promise<{code,stdout, stderr}>` accordingly: resolve on success, reject (non-numeric code on a spawn error → runner maps to -1) otherwise, and only emit the EventEmitter `error` when a listener exists (the await path doesn't listen; an unhandled `error` event throws).
1 parent 4df6039 commit d0c93c1

3 files changed

Lines changed: 148 additions & 41 deletions

File tree

test/unit/secrets/linux.test.mts

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -83,30 +83,73 @@ function makeWritableStdin(captureInto?: string[]): Writable {
8383
// Works without per-call destructuring. Pass `stdinCapture: []` to swap
8484
// the no-op stdin for a sink that records every chunk written to the
8585
// child.
86+
// The lib's `spawn()` returns `{ process: ChildProcess } & Promise<{ code,
87+
// stdout, stderr }>` — awaitable AND carrying the raw child on `.process`. The
88+
// secrets runners now `await` the spawn result (stdioString → string stdout/
89+
// stderr) and read stdin via `.process.stdin`, so the fake must be a thenable
90+
// too. A non-zero exit code REJECTS (the lib's contract), carrying the same
91+
// `{ code, stdout, stderr }` on the error.
8692
function makeFakeChild(opts: {
8793
stdout?: string | undefined
8894
stderr?: string | undefined
8995
exitCode?: number | null | undefined
9096
emitError?: Error | undefined
9197
stdinCapture?: string[] | undefined
92-
}): { process: FakeChild } {
98+
}): { process: FakeChild } & Promise<{
99+
code: number | null
100+
stdout: string
101+
stderr: string
102+
}> {
93103
const emitter = new EventEmitter() as FakeChild
94104
emitter.stdin = makeWritableStdin(opts.stdinCapture)
95105
emitter.stdout = Readable.from(opts.stdout ? [opts.stdout] : [])
96106
emitter.stderr = Readable.from(opts.stderr ? [opts.stderr] : [])
97-
const stdoutDone = !opts.stdout
98-
? Promise.resolve()
99-
: new Promise<void>(r => emitter.stdout!.on('end', () => r()))
100-
const stderrDone = !opts.stderr
101-
? Promise.resolve()
102-
: new Promise<void>(r => emitter.stderr!.on('end', () => r()))
103-
Promise.all([stdoutDone, stderrDone]).then(() => {
104-
if (opts.emitError) {
105-
emitter.emit('error', opts.emitError)
106-
}
107-
emitter.emit('close', opts.exitCode ?? 0)
107+
const stdout = opts.stdout ?? ''
108+
const stderr = opts.stderr ?? ''
109+
const code = opts.exitCode ?? 0
110+
const settled = new Promise<{
111+
code: number | null
112+
stdout: string
113+
stderr: string
114+
}>((resolve, reject) => {
115+
// Resolve on next tick so callers can attach `.process.stdin` writes first.
116+
// Also emit the raw child's `error`/`close` events: some runners (deleteX)
117+
// still consume the event-style child via `.process.on('close')`, while
118+
// read/write await the Promise — the lib's shape supports both.
119+
queueMicrotask(() => {
120+
if (opts.emitError) {
121+
// Only emit the EventEmitter `error` if something listens — an
122+
// `error` event with no listener throws as an uncaught exception
123+
// (Node semantics). The await-style runners DON'T listen on
124+
// `.process.on('error')`; the event-style ones (deleteX) do.
125+
if (emitter.listenerCount('error') > 0) {
126+
emitter.emit('error', opts.emitError)
127+
}
128+
// A spawn error (e.g. ENOENT) carries a non-numeric `.code` (e.g.
129+
// "ENOENT") on the lib's rejection — the runner's catch maps a
130+
// non-numeric code to status -1.
131+
reject(Object.assign(opts.emitError, { stderr, stdout }))
132+
return
133+
}
134+
emitter.emit('close', code)
135+
// The lib rejects on a non-zero exit, carrying the result on the error.
136+
if (code !== 0) {
137+
reject(
138+
Object.assign(new Error(`exit ${code}`), { code, stderr, stdout }),
139+
)
140+
return
141+
}
142+
resolve({ code, stderr, stdout })
143+
})
108144
})
109-
return { process: emitter }
145+
// A non-zero rejection nobody awaits (the event-style delete path) must not
146+
// surface as an unhandled rejection.
147+
settled.catch(() => {})
148+
// Attach `.process` to the Promise so the awaitable AND `{ process }`
149+
// destructure both work, matching the lib's `{ process } & Promise` shape.
150+
return Object.assign(settled, { process: emitter }) as {
151+
process: FakeChild
152+
} & Promise<{ code: number | null; stdout: string; stderr: string }>
110153
}
111154

112155
async function loadFresh() {

test/unit/secrets/macos.test.mts

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,22 @@ interface FakeChild extends EventEmitter {
6565
// () => makeFakeChild({ ... }))` Just Works without per-call destructuring.
6666
// On a regression where lib-stable's spawn wrapper grows new fields, this
6767
// helper is the single edit point.
68+
// The lib's `spawn()` returns `{ process: ChildProcess } & Promise<{ code,
69+
// stdout, stderr }>` — awaitable AND carrying the raw child on `.process`. The
70+
// runners now `await` the spawn result (stdioString → string stdout/stderr) and
71+
// read stdin via `.process.stdin`; a non-zero exit REJECTS, carrying the result
72+
// on the error. Emit the raw child's events too, for any event-style consumer.
6873
function makeFakeChild(opts: {
6974
stdout?: string | undefined
7075
stderr?: string | undefined
7176
exitCode?: number | null | undefined
7277
emitError?: Error | undefined
7378
noStreams?: boolean | undefined
74-
}): { process: FakeChild } {
79+
}): { process: FakeChild } & Promise<{
80+
code: number | null
81+
stdout: string
82+
stderr: string
83+
}> {
7584
const emitter = new EventEmitter() as FakeChild
7685
if (opts.noStreams) {
7786
emitter.stdout = undefined
@@ -80,21 +89,43 @@ function makeFakeChild(opts: {
8089
emitter.stdout = Readable.from(opts.stdout ? [opts.stdout] : [])
8190
emitter.stderr = Readable.from(opts.stderr ? [opts.stderr] : [])
8291
}
83-
const stdoutDone =
84-
!emitter.stdout || !opts.stdout
85-
? Promise.resolve()
86-
: new Promise<void>(resolve => emitter.stdout!.on('end', () => resolve()))
87-
const stderrDone =
88-
!emitter.stderr || !opts.stderr
89-
? Promise.resolve()
90-
: new Promise<void>(resolve => emitter.stderr!.on('end', () => resolve()))
91-
Promise.all([stdoutDone, stderrDone]).then(() => {
92-
if (opts.emitError) {
93-
emitter.emit('error', opts.emitError)
94-
}
95-
emitter.emit('close', opts.exitCode ?? 0)
92+
const stdout = opts.stdout ?? ''
93+
const stderr = opts.stderr ?? ''
94+
const code = opts.exitCode ?? 0
95+
const settled = new Promise<{
96+
code: number | null
97+
stdout: string
98+
stderr: string
99+
}>((resolve, reject) => {
100+
queueMicrotask(() => {
101+
if (opts.emitError) {
102+
// Only emit the EventEmitter `error` if something listens — an
103+
// `error` event with no listener throws as an uncaught exception
104+
// (Node semantics). The await-style runners DON'T listen on
105+
// `.process.on('error')`; the event-style ones (deleteX) do.
106+
if (emitter.listenerCount('error') > 0) {
107+
emitter.emit('error', opts.emitError)
108+
}
109+
// A spawn error (e.g. ENOENT) carries a non-numeric `.code` (e.g.
110+
// "ENOENT") on the lib's rejection — the runner's catch maps a
111+
// non-numeric code to status -1.
112+
reject(Object.assign(opts.emitError, { stderr, stdout }))
113+
return
114+
}
115+
emitter.emit('close', code)
116+
if (code !== 0) {
117+
reject(
118+
Object.assign(new Error(`exit ${code}`), { code, stderr, stdout }),
119+
)
120+
return
121+
}
122+
resolve({ code, stderr, stdout })
123+
})
96124
})
97-
return { process: emitter }
125+
settled.catch(() => {})
126+
return Object.assign(settled, { process: emitter }) as {
127+
process: FakeChild
128+
} & Promise<{ code: number | null; stdout: string; stderr: string }>
98129
}
99130

100131
async function loadFresh() {

test/unit/secrets/windows-test-harness.mts

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,21 @@ export async function spawnChildMockFactory(
4545
// src/secrets/windows.ts destructures `const { process: cp } = spawn(...)`.
4646
// Return that wrapped shape so `mockSpawn.mockImplementationOnce(() =>
4747
// makeFakeChild({ ... }))` matches the real contract.
48+
// The lib's `spawn()` returns `{ process: ChildProcess } & Promise<{ code,
49+
// stdout, stderr }>` — awaitable AND carrying the raw child on `.process`. The
50+
// runners now `await` the spawn result (stdioString → string stdout/stderr) and
51+
// read stdin via `.process.stdin`; a non-zero exit REJECTS, carrying the result
52+
// on the error. Emit the raw child's events too, for any event-style consumer.
4853
export function makeFakeChild(opts: {
4954
stdout?: string | undefined
5055
stderr?: string | undefined
5156
exitCode?: number | null | undefined
5257
emitError?: Error | undefined
53-
}): { process: FakeChild } {
58+
}): { process: FakeChild } & Promise<{
59+
code: number | null
60+
stdout: string
61+
stderr: string
62+
}> {
5463
const emitter = new EventEmitter() as FakeChild
5564
emitter.stdin = new Writable({
5665
write(_c, _e, cb) {
@@ -59,19 +68,43 @@ export function makeFakeChild(opts: {
5968
})
6069
emitter.stdout = Readable.from(opts.stdout ? [opts.stdout] : [])
6170
emitter.stderr = Readable.from(opts.stderr ? [opts.stderr] : [])
62-
const stdoutDone = !opts.stdout
63-
? Promise.resolve()
64-
: new Promise<void>(r => emitter.stdout.on('end', () => r()))
65-
const stderrDone = !opts.stderr
66-
? Promise.resolve()
67-
: new Promise<void>(r => emitter.stderr.on('end', () => r()))
68-
Promise.all([stdoutDone, stderrDone]).then(() => {
69-
if (opts.emitError) {
70-
emitter.emit('error', opts.emitError)
71-
}
72-
emitter.emit('close', opts.exitCode ?? 0)
71+
const stdout = opts.stdout ?? ''
72+
const stderr = opts.stderr ?? ''
73+
const code = opts.exitCode ?? 0
74+
const settled = new Promise<{
75+
code: number | null
76+
stdout: string
77+
stderr: string
78+
}>((resolve, reject) => {
79+
queueMicrotask(() => {
80+
if (opts.emitError) {
81+
// Only emit the EventEmitter `error` if something listens — an
82+
// `error` event with no listener throws as an uncaught exception
83+
// (Node semantics). The await-style runners DON'T listen on
84+
// `.process.on('error')`; the event-style ones (deleteX) do.
85+
if (emitter.listenerCount('error') > 0) {
86+
emitter.emit('error', opts.emitError)
87+
}
88+
// A spawn error (e.g. ENOENT) carries a non-numeric `.code` (e.g.
89+
// "ENOENT") on the lib's rejection — the runner's catch maps a
90+
// non-numeric code to status -1.
91+
reject(Object.assign(opts.emitError, { stderr, stdout }))
92+
return
93+
}
94+
emitter.emit('close', code)
95+
if (code !== 0) {
96+
reject(
97+
Object.assign(new Error(`exit ${code}`), { code, stderr, stdout }),
98+
)
99+
return
100+
}
101+
resolve({ code, stderr, stdout })
102+
})
73103
})
74-
return { process: emitter }
104+
settled.catch(() => {})
105+
return Object.assign(settled, { process: emitter }) as {
106+
process: FakeChild
107+
} & Promise<{ code: number | null; stdout: string; stderr: string }>
75108
}
76109

77110
export const harness = {

0 commit comments

Comments
 (0)