diff --git a/packages/inquirerer/__tests__/inactivity-timeout.test.ts b/packages/inquirerer/__tests__/inactivity-timeout.test.ts new file mode 100644 index 0000000..5bb0c81 --- /dev/null +++ b/packages/inquirerer/__tests__/inactivity-timeout.test.ts @@ -0,0 +1,40 @@ +import readline from 'readline'; +import { Readable, Writable } from 'stream'; + +import { Inquirerer } from '../src'; + +jest.mock('readline'); + +const timeoutOf = (prompter: Inquirerer): number | undefined => + (prompter as unknown as { timeout?: number }).timeout; + +describe('default inactivity timeout', () => { + const output = new Writable({ write(_chunk, _enc, cb) { cb(); } }); + + beforeEach(() => { + readline.createInterface = jest.fn().mockReturnValue({ question: jest.fn(), close: jest.fn() }); + }); + + const inputStream = (isTTY: boolean): Readable => + Object.assign(new Readable({ read() {} }), { isTTY }); + + it('is not armed for a person at a terminal', () => { + const prompter = new Inquirerer({ input: inputStream(true), output }); + expect(timeoutOf(prompter)).toBeUndefined(); + }); + + it('is armed when the prompter expects input from a non-terminal stream', () => { + const prompter = new Inquirerer({ input: inputStream(false), output }); + expect(timeoutOf(prompter)).toBe(15_000); + }); + + it('honors an explicit timeout at a terminal', () => { + const prompter = new Inquirerer({ input: inputStream(true), output, timeout: 500 }); + expect(timeoutOf(prompter)).toBe(500); + }); + + it('stays unarmed in noTty mode', () => { + const prompter = new Inquirerer({ noTty: true, input: inputStream(false), output }); + expect(timeoutOf(prompter)).toBeUndefined(); + }); +}); diff --git a/packages/inquirerer/__tests__/keypress-chunks.test.ts b/packages/inquirerer/__tests__/keypress-chunks.test.ts new file mode 100644 index 0000000..5302abe --- /dev/null +++ b/packages/inquirerer/__tests__/keypress-chunks.test.ts @@ -0,0 +1,91 @@ +import { Readable } from 'stream'; + +import { KEY_CODES, segmentKeys, TerminalKeypress } from '../src/keypress'; + +describe('segmentKeys', () => { + it('returns single keys untouched', () => { + expect(segmentKeys('a')).toEqual(['a']); + expect(segmentKeys('')).toEqual([]); + }); + + it('splits a coalesced chunk of printable characters', () => { + expect(segmentKeys('hunter2')).toEqual(['h', 'u', 'n', 't', 'e', 'r', '2']); + }); + + it('keeps CSI sequences whole', () => { + expect(segmentKeys(`a${KEY_CODES.UP_ARROW}b`)).toEqual(['a', KEY_CODES.UP_ARROW, 'b']); + expect(segmentKeys(`${KEY_CODES.CTRL_LEFT}${KEY_CODES.DELETE}`)).toEqual([ + KEY_CODES.CTRL_LEFT, + KEY_CODES.DELETE + ]); + expect(segmentKeys(KEY_CODES.SHIFT_ENTER)).toEqual([KEY_CODES.SHIFT_ENTER]); + }); + + it('keeps SS3 and meta sequences whole', () => { + expect(segmentKeys(`${KEY_CODES.HOME_ALT}x`)).toEqual([KEY_CODES.HOME_ALT, 'x']); + expect(segmentKeys(`${KEY_CODES.ALT_B}${KEY_CODES.ALT_BACKSPACE}`)).toEqual([ + KEY_CODES.ALT_B, + KEY_CODES.ALT_BACKSPACE + ]); + }); + + it('splits by code point so astral characters survive', () => { + expect(segmentKeys('a🎉b')).toEqual(['a', '🎉', 'b']); + }); +}); + +describe('TerminalKeypress chunk dispatch', () => { + const setup = () => { + const input = new Readable({ read() {} }); + const keypress = new TerminalKeypress(true, input, { exit: jest.fn() as never }); + return { input, keypress }; + }; + + it('delivers every key in a coalesced chunk', () => { + const { input, keypress } = setup(); + const seen: string[] = []; + for (const char of ['h', 'u', 'n', 't']) keypress.on(char, () => seen.push(char)); + + input.emit('data', 'hunt'); + + expect(seen).toEqual(['h', 'u', 'n', 't']); + keypress.destroy(); + }); + + it('dispatches a registered multi-character sequence exactly once', () => { + const { input, keypress } = setup(); + const up = jest.fn(); + keypress.on(KEY_CODES.UP_ARROW, up); + + input.emit('data', KEY_CODES.UP_ARROW); + + expect(up).toHaveBeenCalledTimes(1); + keypress.destroy(); + }); + + it('stops mid-chunk once a handler pauses the keypress', () => { + const { input, keypress } = setup(); + const seen: string[] = []; + keypress.on('a', () => { + seen.push('a'); + keypress.pause(); + }); + keypress.on('b', () => seen.push('b')); + + input.emit('data', 'ab'); + + expect(seen).toEqual(['a']); + keypress.destroy(); + }); + + it('exits on ctrl-c arriving inside a larger chunk', () => { + const input = new Readable({ read() {} }); + const exit = jest.fn(); + const keypress = new TerminalKeypress(true, input, { exit: exit as never }); + + input.emit('data', `a${KEY_CODES.CTRL_C}`); + + expect(exit).toHaveBeenCalledWith(0); + keypress.destroy(); + }); +}); diff --git a/packages/inquirerer/src/keypress.ts b/packages/inquirerer/src/keypress.ts index b715db1..3475263 100644 --- a/packages/inquirerer/src/keypress.ts +++ b/packages/inquirerer/src/keypress.ts @@ -76,6 +76,52 @@ export const KEY_CODES = { ALT_DOWN: '\u001b[1;3B', }; +const ESC = '\u001b'; +const CSI_FINAL_MIN = 0x40; +const CSI_FINAL_MAX = 0x7e; + +const isCsiFinal = (char: string): boolean => { + const code = char.codePointAt(0)!; + return code >= CSI_FINAL_MIN && code <= CSI_FINAL_MAX; +}; + +/** + * Split one stdin data chunk into individual keys. + * + * A chunk is not one key: pasting, fast typing, or a terminal batching its + * output all deliver several keys at once. Escape sequences are kept whole — + * CSI (`ESC [ … final`), SS3 (`ESC O x`), and meta (`ESC x`) — and everything + * else is split per code point so astral characters survive. + */ +export function segmentKeys(chunk: string): string[] { + const chars = Array.from(chunk); + if (chars.length <= 1) return chars.length === 1 ? [chunk] : []; + + const keys: string[] = []; + let i = 0; + while (i < chars.length) { + if (chars[i] !== ESC || i + 1 >= chars.length) { + keys.push(chars[i]); + i++; + continue; + } + + let end: number; + if (chars[i + 1] === '[') { + end = i + 2; + while (end < chars.length && !isCsiFinal(chars[end])) end++; + end++; + } else { + // SS3 (ESC O x) and meta (ESC x) are both ESC plus one character. + end = i + 3 <= chars.length && chars[i + 1] === 'O' ? i + 3 : i + 2; + } + end = Math.min(end, chars.length); + keys.push(chars.slice(i, end).join('')); + i = end; + } + return keys; +} + /** * Handles keyboard input for interactive prompts. * @@ -116,17 +162,31 @@ export class TerminalKeypress { } private setupListeners(): void { - this.dataHandler = (key: string) => { + this.dataHandler = (chunk: string) => { if (!this.active) return; - const handlers = this.listeners[key]; - handlers?.forEach(handler => handler()); - if (key === KEY_CODES.CTRL_C) { - this.proc.exit(0); + // A chunk carrying several keypresses (paste, fast typing) matches no + // single registered key, so dispatch it key by key. An exact match wins + // first: consumers may register sequences the segmenter does not know. + const keys = this.listeners[chunk] ? [chunk] : segmentKeys(chunk); + for (const key of keys) { + if (!this.active) return; + this.dispatch(key); } }; this.input.on('data', this.dataHandler); } + private dispatch(key: string): void { + const handlers = this.listeners[key]; + if (handlers) { + // Copy: a handler may register or remove handlers for the same key. + for (const handler of [...handlers]) handler(); + } + if (key === KEY_CODES.CTRL_C) { + this.proc.exit(0); + } + } + on(key: string, callback: KeyHandler): void { if (!this.listeners[key]) { this.listeners[key] = []; diff --git a/packages/inquirerer/src/prompt.ts b/packages/inquirerer/src/prompt.ts index 472e04c..8a2d37b 100644 --- a/packages/inquirerer/src/prompt.ts +++ b/packages/inquirerer/src/prompt.ts @@ -198,6 +198,11 @@ export class PromptTimeoutError extends Error { const DEFAULT_NON_TTY_TIMEOUT = 15_000; +/** True when the stream is a terminal a person can type into. */ +function isInteractiveInput(input: Readable): boolean { + return (input as Readable & { isTTY?: boolean }).isTTY === true; +} + export interface InquirererOptions { noTty?: boolean; input?: Readable; @@ -250,7 +255,11 @@ export class Inquirerer { if (timeout !== undefined) { this.timeout = timeout; - } else if (!noTty) { + } else if (!noTty && !isInteractiveInput(input)) { + // The default timeout exists to fail a CI run that prompts with nothing + // to answer it, and its error text says so. A person at a real terminal + // is allowed to think for as long as they like, so only arm it when the + // input stream is not a TTY while the prompter still expects input. this.timeout = DEFAULT_NON_TTY_TIMEOUT; }