Skip to content
Merged
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
40 changes: 40 additions & 0 deletions packages/inquirerer/__tests__/inactivity-timeout.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
91 changes: 91 additions & 0 deletions packages/inquirerer/__tests__/keypress-chunks.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
70 changes: 65 additions & 5 deletions packages/inquirerer/src/keypress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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] = [];
Expand Down
11 changes: 10 additions & 1 deletion packages/inquirerer/src/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down
Loading