Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions src/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ export async function read<T extends string | number = string> ({
}
/* c8 ignore stop */

let finished = false

const done = () => {
finished = true
rl.close()
clearTimeout(timer)
m.mute()
Expand Down Expand Up @@ -121,12 +124,24 @@ export async function read<T extends string | number = string> ({
/* c8 ignore stop */
})

// TODO: add tests for sigint
/* c8 ignore start */
// readline emits 'close' without ever emitting 'line' when the input stream
// ends first, which is what happens any time stdin is not something a user
// can type into: /dev/null, an already closed pipe, a child process spawned
// without stdin. Settle the promise here or it never settles at all, and the
// caller waits on it until the event loop empties and the process exits with
// no error to report. Treated as a cancel, same as SIGINT, since both mean no
// answer is coming.
rl.on('close', () => {
if (!finished) {
onError(new Error('canceled'))
}
})

// onError() calls done(), which closes the interface. An explicit close
// here would re enter through the 'close' listener above and run the
// cleanup twice.
rl.on('SIGINT', () => {
rl.close()
onError(new Error('canceled'))
})
/* c8 ignore stop */
})
}
37 changes: 37 additions & 0 deletions test/basic.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { test } from 'node:test'
import { strict as assert } from 'node:assert'
import { PassThrough } from 'node:stream'
import { read } from '../src/read.ts'
import spawnRead from './fixtures/setup.ts'

Expand Down Expand Up @@ -73,6 +74,42 @@ const main = () => {
// @ts-expect-error
await assert.rejects(() => read({ default: {} }))
})

test('input stream ends without a line', async () => {
const input = new PassThrough()
const output = new PassThrough()
input.end()

await assert.rejects(
() => read({ prompt: 'Username:', input, output }),
/canceled/
)
})

test('input stream ends after a line still resolves', async () => {
const input = new PassThrough()
const output = new PassThrough()
input.end('a user\n')

assert.equal(await read({ prompt: 'Username:', input, output }), 'a user')
})

test('SIGINT rejects with canceled and leaves read usable', async () => {
const input = new PassThrough()
const output = new PassThrough()

const p = read({ prompt: 'Username:', input, output, terminal: true })
// ctrl+c keypress; readline turns it into a 'SIGINT' event in terminal mode
input.write('\x03')
await assert.rejects(() => p, /canceled/)
input.end()

// a fresh read after the cancel still works
const input2 = new PassThrough()
const output2 = new PassThrough()
input2.end('after\n')
assert.equal(await read({ prompt: 'Username:', input: input2, output: output2 }), 'after')
})
}

if (process.argv[2] === 'child') {
Expand Down