diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index b4f3b17074..719d938c62 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -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) { 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); diff --git a/packages/cli/src/ui/hooks/useKeypress.test.ts b/packages/cli/src/ui/hooks/useKeypress.test.ts index 495946efb5..243152cc42 100644 --- a/packages/cli/src/ui/hooks/useKeypress.test.ts +++ b/packages/cli/src/ui/hooks/useKeypress.test.ts @@ -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) { - 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();