Use raw writes to stdin where possible in tests (#11837)

This commit is contained in:
Tommaso Sciortino
2025-10-23 14:41:21 -07:00
committed by GitHub
parent 5e70a7dd46
commit aa6ae954ef
2 changed files with 122 additions and 523 deletions

View File

@@ -31,33 +31,29 @@ vi.mock('ink', async (importOriginal) => {
};
});
const PASTE_START = '\x1B[200~';
const PASTE_END = '\x1B[201~';
class MockStdin extends EventEmitter {
isTTY = true;
setRawMode = vi.fn();
override on = this.addListener;
override removeListener = super.removeListener;
write = vi.fn();
resume = vi.fn();
pause = vi.fn();
// Helper to simulate a keypress event
write(text: string) {
this.emit('data', Buffer.from(text));
}
/**
* Used to directly simulate keyPress events. Certain keypress events might
* be impossible to fire in certain versions of node. This allows us to
* sidestep readline entirely and just emit the keypress we want.
*/
pressKey(key: Partial<Key>) {
this.emit('keypress', null, key);
}
// Helper to simulate a kitty protocol sequence
sendKittySequence(sequence: string) {
this.emit('data', Buffer.from(sequence));
}
// Helper to simulate a paste event
sendPaste(text: string) {
const PASTE_MODE_PREFIX = `\x1b[200~`;
const PASTE_MODE_SUFFIX = `\x1b[201~`;
this.emit('data', Buffer.from(PASTE_MODE_PREFIX));
this.emit('data', Buffer.from(text));
this.emit('data', Buffer.from(PASTE_MODE_SUFFIX));
}
}
describe('KeypressContext - Kitty Protocol', () => {
@@ -94,13 +90,11 @@ describe('KeypressContext - Kitty Protocol', () => {
wrapper({ children, kittyProtocolEnabled: true }),
});
act(() => {
result.current.subscribe(keyHandler);
});
act(() => result.current.subscribe(keyHandler));
// Send kitty protocol sequence for regular enter: ESC[13u
act(() => {
stdin.sendKittySequence(`\x1b[13u`);
stdin.write(`\x1b[13u`);
});
expect(keyHandler).toHaveBeenCalledWith(
@@ -122,13 +116,11 @@ describe('KeypressContext - Kitty Protocol', () => {
wrapper({ children, kittyProtocolEnabled: true }),
});
act(() => {
result.current.subscribe(keyHandler);
});
act(() => result.current.subscribe(keyHandler));
// Send kitty protocol sequence for numpad enter: ESC[57414u
act(() => {
stdin.sendKittySequence(`\x1b[57414u`);
stdin.write(`\x1b[57414u`);
});
expect(keyHandler).toHaveBeenCalledWith(
@@ -150,13 +142,11 @@ describe('KeypressContext - Kitty Protocol', () => {
wrapper({ children, kittyProtocolEnabled: true }),
});
act(() => {
result.current.subscribe(keyHandler);
});
act(() => result.current.subscribe(keyHandler));
// Send kitty protocol sequence for numpad enter with Shift (modifier 2): ESC[57414;2u
act(() => {
stdin.sendKittySequence(`\x1b[57414;2u`);
stdin.write(`\x1b[57414;2u`);
});
expect(keyHandler).toHaveBeenCalledWith(
@@ -178,13 +168,11 @@ describe('KeypressContext - Kitty Protocol', () => {
wrapper({ children, kittyProtocolEnabled: true }),
});
act(() => {
result.current.subscribe(keyHandler);
});
act(() => result.current.subscribe(keyHandler));
// Send kitty protocol sequence for numpad enter with Ctrl (modifier 5): ESC[57414;5u
act(() => {
stdin.sendKittySequence(`\x1b[57414;5u`);
stdin.write(`\x1b[57414;5u`);
});
expect(keyHandler).toHaveBeenCalledWith(
@@ -206,13 +194,11 @@ describe('KeypressContext - Kitty Protocol', () => {
wrapper({ children, kittyProtocolEnabled: true }),
});
act(() => {
result.current.subscribe(keyHandler);
});
act(() => result.current.subscribe(keyHandler));
// Send kitty protocol sequence for numpad enter with Alt (modifier 3): ESC[57414;3u
act(() => {
stdin.sendKittySequence(`\x1b[57414;3u`);
stdin.write(`\x1b[57414;3u`);
});
expect(keyHandler).toHaveBeenCalledWith(
@@ -234,13 +220,11 @@ describe('KeypressContext - Kitty Protocol', () => {
wrapper({ children, kittyProtocolEnabled: false }),
});
act(() => {
result.current.subscribe(keyHandler);
});
act(() => result.current.subscribe(keyHandler));
// Send kitty protocol sequence for numpad enter
act(() => {
stdin.sendKittySequence(`\x1b[57414u`);
stdin.write(`\x1b[57414u`);
});
// When kitty protocol is disabled, the sequence should be passed through
@@ -263,13 +247,11 @@ describe('KeypressContext - Kitty Protocol', () => {
wrapper({ children, kittyProtocolEnabled: true }),
});
act(() => {
result.current.subscribe(keyHandler);
});
act(() => result.current.subscribe(keyHandler));
// Send kitty protocol sequence for escape: ESC[27u
act(() => {
stdin.sendKittySequence('\x1b[27u');
stdin.write('\x1b[27u');
});
expect(keyHandler).toHaveBeenCalledWith(
@@ -288,7 +270,7 @@ describe('KeypressContext - Kitty Protocol', () => {
act(() => result.current.subscribe(keyHandler));
act(() => {
stdin.sendKittySequence(`\x1b[9u`);
stdin.write(`\x1b[9u`);
});
expect(keyHandler).toHaveBeenCalledWith(
@@ -307,7 +289,7 @@ describe('KeypressContext - Kitty Protocol', () => {
// Modifier 2 is Shift
act(() => {
stdin.sendKittySequence(`\x1b[9;2u`);
stdin.write(`\x1b[9;2u`);
});
expect(keyHandler).toHaveBeenCalledWith(
@@ -325,7 +307,7 @@ describe('KeypressContext - Kitty Protocol', () => {
act(() => result.current.subscribe(keyHandler));
act(() => {
stdin.sendKittySequence(`\x1b[127u`);
stdin.write(`\x1b[127u`);
});
expect(keyHandler).toHaveBeenCalledWith(
@@ -344,7 +326,7 @@ describe('KeypressContext - Kitty Protocol', () => {
// Modifier 3 is Alt/Option
act(() => {
stdin.sendKittySequence(`\x1b[127;3u`);
stdin.write(`\x1b[127;3u`);
});
expect(keyHandler).toHaveBeenCalledWith(
@@ -363,7 +345,7 @@ describe('KeypressContext - Kitty Protocol', () => {
// Modifier 5 is Ctrl
act(() => {
stdin.sendKittySequence(`\x1b[127;5u`);
stdin.write(`\x1b[127;5u`);
});
expect(keyHandler).toHaveBeenCalledWith(
@@ -385,13 +367,13 @@ describe('KeypressContext - Kitty Protocol', () => {
wrapper,
});
act(() => {
result.current.subscribe(keyHandler);
});
act(() => result.current.subscribe(keyHandler));
// Simulate a bracketed paste event
act(() => {
stdin.sendPaste(pastedText);
stdin.write(PASTE_START);
stdin.write(pastedText);
stdin.write(PASTE_END);
});
await waitFor(() => {
@@ -437,13 +419,11 @@ describe('KeypressContext - Kitty Protocol', () => {
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => {
result.current.subscribe(keyHandler);
});
act(() => result.current.subscribe(keyHandler));
// Send a kitty sequence
act(() => {
stdin.sendKittySequence('\x1b[27u');
stdin.write('\x1b[27u');
});
expect(keyHandler).toHaveBeenCalled();
@@ -466,14 +446,10 @@ describe('KeypressContext - Kitty Protocol', () => {
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => {
result.current.subscribe(keyHandler);
});
act(() => result.current.subscribe(keyHandler));
// Send a complete kitty sequence for escape
act(() => {
stdin.sendKittySequence('\x1b[27u');
});
act(() => stdin.write('\x1b[27u'));
expect(consoleLogSpy).toHaveBeenCalledWith(
'[DEBUG] Kitty buffer accumulating:',
@@ -502,15 +478,11 @@ describe('KeypressContext - Kitty Protocol', () => {
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => {
result.current.subscribe(keyHandler);
});
act(() => result.current.subscribe(keyHandler));
// Send a long sequence starting with a valid kitty prefix to trigger overflow
const longSequence = '\x1b[1;' + '1'.repeat(100);
act(() => {
stdin.sendKittySequence(longSequence);
});
act(() => stdin.write(longSequence));
expect(consoleLogSpy).toHaveBeenCalledWith(
'[DEBUG] Kitty buffer overflow, clearing:',
@@ -532,31 +504,13 @@ describe('KeypressContext - Kitty Protocol', () => {
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => {
result.current.subscribe(keyHandler);
});
act(() => result.current.subscribe(keyHandler));
// Send incomplete kitty sequence
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
sequence: '\x1b[1',
});
});
act(() => stdin.pressKey({ sequence: '\x1b[1' }));
// Send Ctrl+C
act(() => {
stdin.pressKey({
name: 'c',
ctrl: true,
meta: false,
shift: false,
sequence: '\x03',
});
});
act(() => stdin.write('\x03'));
expect(consoleLogSpy).toHaveBeenCalledWith(
'[DEBUG] Kitty buffer cleared on Ctrl+C:',
@@ -586,21 +540,11 @@ describe('KeypressContext - Kitty Protocol', () => {
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => {
result.current.subscribe(keyHandler);
});
act(() => result.current.subscribe(keyHandler));
// Send incomplete kitty sequence
const sequence = '\x1b[12';
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
sequence,
});
});
act(() => stdin.pressKey({ sequence }));
// Verify debug logging for accumulation
expect(consoleLogSpy).toHaveBeenCalledWith(
@@ -629,6 +573,9 @@ describe('KeypressContext - Kitty Protocol', () => {
{ sequence: `\x1b[1~`, expected: { name: 'home' } },
{ sequence: `\x1b[4~`, expected: { name: 'end' } },
{ sequence: `\x1b[2~`, expected: { name: 'insert' } },
// Reverse tabs
{ sequence: `\x1b[Z`, expected: { name: 'tab', shift: true } },
{ sequence: `\x1b[1;2Z`, expected: { name: 'tab', shift: true } },
// Legacy Arrows
{
sequence: `\x1b[A`,
@@ -662,7 +609,7 @@ describe('KeypressContext - Kitty Protocol', () => {
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() => stdin.sendKittySequence(sequence));
act(() => stdin.write(sequence));
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining(expected),
@@ -671,33 +618,14 @@ describe('KeypressContext - Kitty Protocol', () => {
);
});
describe('Shift+Tab forms', () => {
it.each([
{ sequence: `\x1b[Z`, description: 'legacy reverse Tab' },
{ sequence: `\x1b[1;2Z`, description: 'parameterized reverse Tab' },
])(
'should recognize $description "$sequence" as Shift+Tab',
({ sequence }) => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() => stdin.sendKittySequence(sequence));
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({ name: 'tab', shift: true }),
);
},
);
});
describe('Double-tap and batching', () => {
it('should emit two delete events for double-tap CSI[3~', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() => stdin.sendKittySequence(`\x1b[3~`));
act(() => stdin.sendKittySequence(`\x1b[3~`));
act(() => stdin.write(`\x1b[3~`));
act(() => stdin.write(`\x1b[3~`));
expect(keyHandler).toHaveBeenNthCalledWith(
1,
@@ -714,7 +642,7 @@ describe('KeypressContext - Kitty Protocol', () => {
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() => stdin.sendKittySequence(`\x1b[3~\x1b[5~`));
act(() => stdin.write(`\x1b[3~\x1b[5~`));
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({ name: 'delete' }),
@@ -732,15 +660,9 @@ describe('KeypressContext - Kitty Protocol', () => {
// Incomplete ESC sequence then a complete Delete
act(() => {
// Provide an incomplete ESC sequence chunk with a real ESC character
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
sequence: '\x1b[1;',
});
stdin.write('\x1b[1;');
});
act(() => stdin.sendKittySequence(`\x1b[3~`));
act(() => stdin.write(`\x1b[3~`));
expect(keyHandler).toHaveBeenCalledTimes(1);
expect(keyHandler).toHaveBeenCalledWith(
@@ -786,20 +708,9 @@ describe('Drag and Drop Handling', () => {
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => {
result.current.subscribe(keyHandler);
});
act(() => result.current.subscribe(keyHandler));
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: SINGLE_QUOTE,
});
});
act(() => stdin.write(SINGLE_QUOTE));
expect(keyHandler).not.toHaveBeenCalled();
});
@@ -809,20 +720,9 @@ describe('Drag and Drop Handling', () => {
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => {
result.current.subscribe(keyHandler);
});
act(() => result.current.subscribe(keyHandler));
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: DOUBLE_QUOTE,
});
});
act(() => stdin.write(DOUBLE_QUOTE));
expect(keyHandler).not.toHaveBeenCalled();
});
@@ -834,33 +734,13 @@ describe('Drag and Drop Handling', () => {
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => {
result.current.subscribe(keyHandler);
});
act(() => result.current.subscribe(keyHandler));
// Start by single quote
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: SINGLE_QUOTE,
});
});
act(() => stdin.write(SINGLE_QUOTE));
// Send single character
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: 'a',
});
});
act(() => stdin.write('a'));
// Character should not be immediately broadcast
expect(keyHandler).not.toHaveBeenCalled();
@@ -885,66 +765,16 @@ describe('Drag and Drop Handling', () => {
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => {
result.current.subscribe(keyHandler);
});
act(() => result.current.subscribe(keyHandler));
// Start by single quote
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: SINGLE_QUOTE,
});
});
act(() => stdin.write(SINGLE_QUOTE));
// Send multiple characters
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: 'p',
});
});
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: 'a',
});
});
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: 't',
});
});
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: 'h',
});
});
act(() => stdin.write('p'));
act(() => stdin.write('a'));
act(() => stdin.write('t'));
act(() => stdin.write('h'));
// Characters should not be immediately broadcast
expect(keyHandler).not.toHaveBeenCalled();
@@ -1101,7 +931,7 @@ describe('Kitty Sequence Parsing', () => {
act(() => result.current.subscribe(keyHandler));
if (kittySequence) {
act(() => stdin.sendKittySequence(kittySequence));
act(() => stdin.write(kittySequence));
} else if (input) {
act(() => stdin.pressKey(input));
}
@@ -1126,16 +956,7 @@ describe('Kitty Sequence Parsing', () => {
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() =>
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: '\\',
}),
);
act(() => stdin.write('\\'));
// Advance timers to trigger the backslash timeout
act(() => {
@@ -1155,29 +976,16 @@ describe('Kitty Sequence Parsing', () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => {
result.current.subscribe(keyHandler);
});
act(() => result.current.subscribe(keyHandler));
// Send incomplete kitty sequence
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: '\x1b[1;',
});
});
act(() => stdin.pressKey({ sequence: '\x1b[1;' }));
// Should not broadcast immediately
expect(keyHandler).not.toHaveBeenCalled();
// Advance time just before timeout
act(() => {
vi.advanceTimersByTime(KITTY_SEQUENCE_TIMEOUT_MS - 5);
});
act(() => vi.advanceTimersByTime(KITTY_SEQUENCE_TIMEOUT_MS - 5));
// Still shouldn't broadcast
expect(keyHandler).not.toHaveBeenCalled();
@@ -1201,22 +1009,11 @@ describe('Kitty Sequence Parsing', () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => {
result.current.subscribe(keyHandler);
});
act(() => result.current.subscribe(keyHandler));
// Send a CSI sequence that doesn't match kitty patterns
// ESC[m is SGR reset, not a kitty sequence
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: '\x1b[m',
});
});
act(() => stdin.write('\x1b[m'));
// Should broadcast immediately as it's not a valid kitty pattern
expect(keyHandler).toHaveBeenCalledWith(
@@ -1232,21 +1029,10 @@ describe('Kitty Sequence Parsing', () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => {
result.current.subscribe(keyHandler);
});
act(() => result.current.subscribe(keyHandler));
// Send complete kitty sequence for Ctrl+A
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: '\x1b[97;5u',
});
});
act(() => stdin.write('\x1b[97;5u'));
// Should parse and broadcast immediately
expect(keyHandler).toHaveBeenCalledWith(
@@ -1262,21 +1048,10 @@ describe('Kitty Sequence Parsing', () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => {
result.current.subscribe(keyHandler);
});
act(() => result.current.subscribe(keyHandler));
// Send multiple kitty sequences at once
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: '\x1b[97;5u\x1b[98;5u', // Ctrl+a followed by Ctrl+b
});
});
// Send Ctrl+a followed by Ctrl+b
act(() => stdin.write('\x1b[97;5u\x1b[98;5u'));
// Should parse both sequences
expect(keyHandler).toHaveBeenCalledTimes(2);
@@ -1302,38 +1077,16 @@ describe('Kitty Sequence Parsing', () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => {
result.current.subscribe(keyHandler);
});
act(() => result.current.subscribe(keyHandler));
// Send incomplete kitty sequence
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: '\x1b[1;',
});
});
act(() => stdin.pressKey({ sequence: '\x1b[1;' }));
// Press Ctrl+C
act(() => {
stdin.pressKey({
name: 'c',
ctrl: true,
meta: false,
shift: false,
paste: false,
sequence: '\x03',
});
});
act(() => stdin.write('\x03'));
// Advance past timeout
act(() => {
vi.advanceTimersByTime(KITTY_SEQUENCE_TIMEOUT_MS + 10);
});
act(() => vi.advanceTimersByTime(KITTY_SEQUENCE_TIMEOUT_MS + 10));
// Should only have received Ctrl+C, not the incomplete sequence
expect(keyHandler).toHaveBeenCalledTimes(1);
@@ -1349,21 +1102,11 @@ describe('Kitty Sequence Parsing', () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => {
result.current.subscribe(keyHandler);
});
act(() => result.current.subscribe(keyHandler));
// Send valid kitty sequence followed by invalid CSI
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: '\x1b[13u\x1b[!', // Valid enter, then invalid sequence
});
});
// Valid enter, then invalid sequence
act(() => stdin.write('\x1b[13u\x1b[!'));
// Should parse valid sequence and flush invalid immediately
expect(keyHandler).toHaveBeenCalledTimes(2);
@@ -1390,21 +1133,10 @@ describe('Kitty Sequence Parsing', () => {
wrapper({ children, kittyProtocolEnabled: false }),
});
act(() => {
result.current.subscribe(keyHandler);
});
act(() => result.current.subscribe(keyHandler));
// Send what would be a kitty sequence
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: '\x1b[13u',
});
});
act(() => stdin.write('\x1b[13u'));
// Should pass through without parsing
expect(keyHandler).toHaveBeenCalledWith(
@@ -1454,58 +1186,25 @@ describe('Kitty Sequence Parsing', () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => {
result.current.subscribe(keyHandler);
});
act(() => result.current.subscribe(keyHandler));
// Start incomplete sequence
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: '\x1b[1',
});
});
act(() => stdin.pressKey({ sequence: '\x1b[1' }));
// Advance time partway
act(() => {
vi.advanceTimersByTime(30);
});
act(() => vi.advanceTimersByTime(30));
// Add more to sequence
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: '3',
});
});
act(() => stdin.write('3'));
// Advance time from the first timeout point
act(() => {
vi.advanceTimersByTime(25);
});
act(() => vi.advanceTimersByTime(25));
// Should not have timed out yet (timeout restarted)
expect(keyHandler).not.toHaveBeenCalled();
// Complete the sequence
act(() => {
stdin.pressKey({
name: undefined,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: 'u',
});
});
act(() => stdin.write('u'));
// Should now parse as complete enter key
expect(keyHandler).toHaveBeenCalledWith(
@@ -1520,27 +1219,16 @@ describe('Kitty Sequence Parsing', () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => {
result.current.subscribe(keyHandler);
});
act(() => result.current.subscribe(keyHandler));
// Send incomplete kitty sequence
act(() => {
stdin.pressKey({
sequence: '\x1b[1;',
});
});
act(() => stdin.pressKey({ sequence: '\x1b[1;' }));
// Incomplete sequence should be buffered, not broadcast
expect(keyHandler).not.toHaveBeenCalled();
// Send FOCUS_IN event
const FOCUS_IN = '\x1b[I';
act(() => {
stdin.pressKey({
sequence: FOCUS_IN,
});
});
act(() => stdin.write('\x1b[I'));
// The buffered sequence should be flushed
expect(keyHandler).toHaveBeenCalledTimes(1);
@@ -1557,27 +1245,16 @@ describe('Kitty Sequence Parsing', () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => {
result.current.subscribe(keyHandler);
});
act(() => result.current.subscribe(keyHandler));
// Send incomplete kitty sequence
act(() => {
stdin.pressKey({
sequence: '\x1b[1;',
});
});
act(() => stdin.pressKey({ sequence: '\x1b[1;' }));
// Incomplete sequence should be buffered, not broadcast
expect(keyHandler).not.toHaveBeenCalled();
// Send FOCUS_OUT event
const FOCUS_OUT = '\x1b[O';
act(() => {
stdin.pressKey({
sequence: FOCUS_OUT,
});
});
act(() => stdin.write('\x1b[O'));
// The buffered sequence should be flushed
expect(keyHandler).toHaveBeenCalledTimes(1);
@@ -1595,25 +1272,16 @@ describe('Kitty Sequence Parsing', () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => {
result.current.subscribe(keyHandler);
});
act(() => result.current.subscribe(keyHandler));
// Send incomplete kitty sequence
act(() => {
stdin.pressKey({
sequence: '\x1b[1;',
});
});
act(() => stdin.pressKey({ sequence: '\x1b[1;' }));
// Incomplete sequence should be buffered, not broadcast
expect(keyHandler).not.toHaveBeenCalled();
// Send paste start sequence
const PASTE_MODE_PREFIX = `\x1b[200~`;
act(() => {
stdin.emit('data', Buffer.from(PASTE_MODE_PREFIX));
});
act(() => stdin.write(`\x1b[200~`));
// The buffered sequence should be flushed
expect(keyHandler).toHaveBeenCalledTimes(1);
@@ -1629,13 +1297,11 @@ describe('Kitty Sequence Parsing', () => {
const pastedText = 'hello';
const PASTE_MODE_SUFFIX = `\x1b[201~`;
act(() => {
stdin.emit('data', Buffer.from(pastedText));
stdin.emit('data', Buffer.from(PASTE_MODE_SUFFIX));
stdin.write(pastedText);
stdin.write(PASTE_MODE_SUFFIX);
});
act(() => {
vi.runAllTimers();
});
act(() => vi.runAllTimers());
// The paste event should be broadcast
expect(keyHandler).toHaveBeenCalledTimes(2);

View File

@@ -6,12 +6,10 @@
import React from 'react';
import { renderHook, act } from '@testing-library/react';
import type { Key } from './useKeypress.js';
import { useKeypress } from './useKeypress.js';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { useStdin } from 'ink';
import { EventEmitter } from 'node:events';
import { PassThrough } from 'node:stream';
import type { Mock } from 'vitest';
// Mock the 'ink' module to control stdin
@@ -23,35 +21,8 @@ vi.mock('ink', async (importOriginal) => {
};
});
// Mock the 'readline' module
vi.mock('readline', () => {
const mockedReadline = {
createInterface: vi.fn().mockReturnValue({ close: vi.fn() }),
// The paste workaround involves replacing stdin with a PassThrough stream.
// This mock ensures that when emitKeypressEvents is called on that
// stream, we simulate the 'keypress' events that the hook expects.
emitKeypressEvents: vi.fn((stream: EventEmitter) => {
if (stream instanceof PassThrough) {
stream.on('data', (data) => {
const str = data.toString();
for (const char of str) {
stream.emit('keypress', null, {
name: char,
sequence: char,
ctrl: false,
meta: false,
shift: false,
});
}
});
}
}),
};
return {
...mockedReadline,
default: mockedReadline,
};
});
const PASTE_START = '\x1B[200~';
const PASTE_END = '\x1B[201~';
class MockStdin extends EventEmitter {
isTTY = true;
@@ -59,45 +30,11 @@ class MockStdin extends EventEmitter {
setRawMode = vi.fn();
override on = this.addListener;
override removeListener = super.removeListener;
write = vi.fn();
resume = vi.fn();
pause = vi.fn();
private isLegacy = false;
setLegacy(isLegacy: boolean) {
this.isLegacy = isLegacy;
}
// Helper to simulate a full paste event.
paste(text: string) {
if (this.isLegacy) {
const PASTE_START = '\x1B[200~';
const PASTE_END = '\x1B[201~';
this.emit('data', Buffer.from(`${PASTE_START}${text}${PASTE_END}`));
} else {
this.emit('keypress', null, { name: 'paste-start' });
this.emit('keypress', null, { sequence: text });
this.emit('keypress', null, { name: 'paste-end' });
}
}
// Helper to simulate the start of a paste, without the end.
startPaste(text: string) {
if (this.isLegacy) {
this.emit('data', Buffer.from('\x1B[200~' + text));
} else {
this.emit('keypress', null, { name: 'paste-start' });
this.emit('keypress', null, { sequence: text });
}
}
// Helper to simulate a single keypress event.
pressKey(key: Partial<Key>) {
if (this.isLegacy) {
this.emit('data', Buffer.from(key.sequence ?? ''));
} else {
this.emit('keypress', null, key);
}
write(text: string) {
this.emit('data', Buffer.from(text));
}
}
@@ -140,7 +77,7 @@ describe('useKeypress', () => {
renderHook(() => useKeypress(onKeypress, { isActive: false }), {
wrapper,
});
act(() => stdin.pressKey({ name: 'a' }));
act(() => stdin.write('a'));
expect(onKeypress).not.toHaveBeenCalled();
});
@@ -152,7 +89,7 @@ describe('useKeypress', () => {
{ key: { name: 'down', sequence: '\x1b[B' } },
])('should listen for keypress when active for key $key.name', ({ key }) => {
renderHook(() => useKeypress(onKeypress, { isActive: true }), { wrapper });
act(() => stdin.pressKey(key));
act(() => stdin.write(key.sequence));
expect(onKeypress).toHaveBeenCalledWith(expect.objectContaining(key));
});
@@ -172,14 +109,14 @@ describe('useKeypress', () => {
{ wrapper },
);
unmount();
act(() => stdin.pressKey({ name: 'a' }));
act(() => stdin.write('a'));
expect(onKeypress).not.toHaveBeenCalled();
});
it('should correctly identify alt+enter (meta key)', () => {
renderHook(() => useKeypress(onKeypress, { isActive: true }), { wrapper });
const key = { name: 'return', sequence: '\x1B\r' };
act(() => stdin.pressKey(key));
act(() => stdin.write(key.sequence));
expect(onKeypress).toHaveBeenCalledWith(
expect.objectContaining({ ...key, meta: true, paste: false }),
);
@@ -189,12 +126,10 @@ describe('useKeypress', () => {
{
description: 'Modern Node (>= v20)',
setup: () => setNodeVersion('20.0.0'),
isLegacy: false,
},
{
description: 'Legacy Node (< v20)',
setup: () => setNodeVersion('18.0.0'),
isLegacy: true,
},
{
description: 'Workaround Env Var',
@@ -202,12 +137,10 @@ describe('useKeypress', () => {
setNodeVersion('20.0.0');
vi.stubEnv('PASTE_WORKAROUND', 'true');
},
isLegacy: true,
},
])('in $description', ({ setup, isLegacy }) => {
])('in $description', ({ setup }) => {
beforeEach(() => {
setup();
stdin.setLegacy(isLegacy);
});
it('should process a paste as a single event', () => {
@@ -215,7 +148,7 @@ describe('useKeypress', () => {
wrapper,
});
const pasteText = 'hello world';
act(() => stdin.paste(pasteText));
act(() => stdin.write(PASTE_START + pasteText + PASTE_END));
expect(onKeypress).toHaveBeenCalledTimes(1);
expect(onKeypress).toHaveBeenCalledWith({
@@ -234,19 +167,19 @@ describe('useKeypress', () => {
});
const keyA = { name: 'a', sequence: 'a' };
act(() => stdin.pressKey(keyA));
act(() => stdin.write('a'));
expect(onKeypress).toHaveBeenCalledWith(
expect.objectContaining({ ...keyA, paste: false }),
);
const pasteText = 'pasted';
act(() => stdin.paste(pasteText));
act(() => stdin.write(PASTE_START + pasteText + PASTE_END));
expect(onKeypress).toHaveBeenCalledWith(
expect.objectContaining({ paste: true, sequence: pasteText }),
);
const keyB = { name: 'b', sequence: 'b' };
act(() => stdin.pressKey(keyB));
act(() => stdin.write('b'));
expect(onKeypress).toHaveBeenCalledWith(
expect.objectContaining({ ...keyB, paste: false }),
);
@@ -261,7 +194,7 @@ describe('useKeypress', () => {
);
const pasteText = 'incomplete paste';
act(() => stdin.startPaste(pasteText));
act(() => stdin.write(PASTE_START + pasteText));
// No event should be fired yet.
expect(onKeypress).not.toHaveBeenCalled();