Download packages/cli/src/ui/contexts/KeypressContext.test.tsx from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 43.8 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/cli/src/ui/contexts/KeypressContext.test.tsx
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/cli/src/ui/contexts/KeypressContext.test.tsx
-
curl -L -o KeypressContext.test.tsx https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/cli/src/ui/contexts/KeypressContext.test.tsx
43.8 kB
| /** | |
| * @license | |
| * Copyright 2025 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import { debugLogger } from '@google/gemini-cli-core'; | |
| import { act } from 'react'; | |
| import { renderHookWithProviders } from '../../test-utils/render.js'; | |
| import { createMockSettings } from '../../test-utils/settings.js'; | |
| import { waitFor } from '../../test-utils/async.js'; | |
| import { vi, afterAll, beforeAll, type Mock } from 'vitest'; | |
| import { | |
| useKeypressContext, | |
| ESC_TIMEOUT, | |
| FAST_RETURN_TIMEOUT, | |
| KeypressPriority, | |
| type Key, | |
| } from './KeypressContext.js'; | |
| import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js'; | |
| import { useStdin } from 'ink'; | |
| import { EventEmitter } from 'node:events'; | |
| // Mock the 'ink' module to control stdin | |
| vi.mock('ink', async (importOriginal) => { | |
| const original = await importOriginal<typeof import('ink')>(); | |
| return { | |
| ...original, | |
| useStdin: vi.fn(), | |
| }; | |
| }); | |
| const PASTE_START = '\x1B[200~'; | |
| const PASTE_END = '\x1B[201~'; | |
| // readline will not emit most incomplete kitty sequences but it will give | |
| // up on sequences like this where the modifier (135) has more than two digits. | |
| const INCOMPLETE_KITTY_SEQUENCE = '\x1b[97;135'; | |
| class MockStdin extends EventEmitter { | |
| isTTY = true; | |
| setRawMode = vi.fn(); | |
| override on = this.addListener; | |
| override removeListener = super.removeListener; | |
| resume = vi.fn(); | |
| pause = vi.fn(); | |
| write(text: string) { | |
| this.emit('data', text); | |
| } | |
| } | |
| // Helper function to setup keypress test with standard configuration | |
| const setupKeypressTest = async () => { | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders(() => useKeypressContext()); | |
| act(() => result.current.subscribe(keyHandler)); | |
| return { result, keyHandler }; | |
| }; | |
| describe('KeypressContext', () => { | |
| let stdin: MockStdin; | |
| const mockSetRawMode = vi.fn(); | |
| beforeAll(() => vi.useFakeTimers()); | |
| afterAll(() => vi.useRealTimers()); | |
| beforeEach(() => { | |
| vi.clearAllMocks(); | |
| stdin = new MockStdin(); | |
| (useStdin as Mock).mockReturnValue({ | |
| stdin, | |
| setRawMode: mockSetRawMode, | |
| }); | |
| }); | |
| describe('Enter key handling', () => { | |
| it.each([ | |
| { | |
| name: 'regular enter key (keycode 13)', | |
| sequence: '\x1b[13u', | |
| }, | |
| { | |
| name: 'numpad enter key (keycode 57414)', | |
| sequence: '\x1b[57414u', | |
| }, | |
| ])('should recognize $name in kitty protocol', async ({ sequence }) => { | |
| const { keyHandler } = await setupKeypressTest(); | |
| act(() => stdin.write(sequence)); | |
| expect(keyHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| name: 'enter', | |
| shift: false, | |
| ctrl: false, | |
| cmd: false, | |
| }), | |
| ); | |
| }); | |
| it('should handle backslash return', async () => { | |
| const { keyHandler } = await setupKeypressTest(); | |
| act(() => stdin.write('\\\r')); | |
| expect(keyHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| name: 'enter', | |
| shift: true, | |
| ctrl: false, | |
| cmd: false, | |
| }), | |
| ); | |
| }); | |
| it.each([ | |
| { | |
| modifier: 'Shift', | |
| sequence: '\x1b[57414;2u', | |
| expected: { shift: true, ctrl: false, cmd: false }, | |
| }, | |
| { | |
| modifier: 'Ctrl', | |
| sequence: '\x1b[57414;5u', | |
| expected: { shift: false, ctrl: true, cmd: false }, | |
| }, | |
| { | |
| modifier: 'Alt', | |
| sequence: '\x1b[57414;3u', | |
| expected: { shift: false, alt: true, ctrl: false, cmd: false }, | |
| }, | |
| ])( | |
| 'should handle numpad enter with $modifier modifier', | |
| async ({ sequence, expected }) => { | |
| const { keyHandler } = await setupKeypressTest(); | |
| act(() => stdin.write(sequence)); | |
| expect(keyHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| name: 'enter', | |
| ...expected, | |
| }), | |
| ); | |
| }, | |
| ); | |
| it('should recognize \n (LF) as ctrl+j', async () => { | |
| const { keyHandler } = await setupKeypressTest(); | |
| act(() => stdin.write('\n')); | |
| expect(keyHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| name: 'j', | |
| shift: false, | |
| ctrl: true, | |
| cmd: false, | |
| }), | |
| ); | |
| }); | |
| it('should recognize \\x1b\\n as Alt+Enter (return with meta)', async () => { | |
| const { keyHandler } = await setupKeypressTest(); | |
| act(() => stdin.write('\x1b\n')); | |
| expect(keyHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| name: 'enter', | |
| shift: false, | |
| alt: true, | |
| ctrl: false, | |
| cmd: false, | |
| }), | |
| ); | |
| }); | |
| }); | |
| describe('Fast return buffering', () => { | |
| let kittySpy: ReturnType<typeof vi.spyOn>; | |
| beforeEach(() => { | |
| kittySpy = vi | |
| .spyOn(terminalCapabilityManager, 'isKittyProtocolEnabled') | |
| .mockReturnValue(false); | |
| }); | |
| afterEach(() => kittySpy.mockRestore()); | |
| it('should buffer return key pressed quickly after another key', async () => { | |
| const { keyHandler } = await setupKeypressTest(); | |
| act(() => stdin.write('a')); | |
| expect(keyHandler).toHaveBeenLastCalledWith( | |
| expect.objectContaining({ | |
| name: 'a', | |
| shift: false, | |
| alt: false, | |
| ctrl: false, | |
| cmd: false, | |
| }), | |
| ); | |
| act(() => stdin.write('\r')); | |
| expect(keyHandler).toHaveBeenLastCalledWith( | |
| expect.objectContaining({ | |
| name: 'enter', | |
| sequence: '\r', | |
| insertable: true, | |
| shift: true, | |
| alt: false, | |
| ctrl: false, | |
| cmd: false, | |
| }), | |
| ); | |
| }); | |
| it('should NOT buffer return key if delay is long enough', async () => { | |
| const { keyHandler } = await setupKeypressTest(); | |
| act(() => stdin.write('a')); | |
| vi.advanceTimersByTime(FAST_RETURN_TIMEOUT + 1); | |
| act(() => stdin.write('\r')); | |
| expect(keyHandler).toHaveBeenLastCalledWith( | |
| expect.objectContaining({ | |
| name: 'enter', | |
| shift: false, | |
| alt: false, | |
| ctrl: false, | |
| cmd: false, | |
| }), | |
| ); | |
| }); | |
| }); | |
| describe('Escape key handling', () => { | |
| it('should recognize escape key (keycode 27) in kitty protocol', async () => { | |
| const { keyHandler } = await setupKeypressTest(); | |
| // Send kitty protocol sequence for escape: ESC[27u | |
| act(() => { | |
| stdin.write('\x1b[27u'); | |
| }); | |
| expect(keyHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| name: 'escape', | |
| shift: false, | |
| alt: false, | |
| ctrl: false, | |
| cmd: false, | |
| }), | |
| ); | |
| }); | |
| it('should stop propagation when a higher priority handler returns true', async () => { | |
| const higherPriorityHandler = vi.fn(() => true); | |
| const lowerPriorityHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders(() => | |
| useKeypressContext(), | |
| ); | |
| act(() => { | |
| result.current.subscribe(higherPriorityHandler, KeypressPriority.High); | |
| result.current.subscribe(lowerPriorityHandler, KeypressPriority.Normal); | |
| }); | |
| act(() => stdin.write('\x1b[27u')); | |
| expect(higherPriorityHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ name: 'escape' }), | |
| ); | |
| expect(lowerPriorityHandler).not.toHaveBeenCalled(); | |
| }); | |
| it('should continue propagation when a higher priority handler does not consume the event', async () => { | |
| const higherPriorityHandler = vi.fn(() => false); | |
| const lowerPriorityHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders(() => | |
| useKeypressContext(), | |
| ); | |
| act(() => { | |
| result.current.subscribe(higherPriorityHandler, KeypressPriority.High); | |
| result.current.subscribe(lowerPriorityHandler, KeypressPriority.Normal); | |
| }); | |
| act(() => stdin.write('\x1b[27u')); | |
| expect(higherPriorityHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ name: 'escape' }), | |
| ); | |
| expect(lowerPriorityHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ name: 'escape' }), | |
| ); | |
| }); | |
| it('should handle double Escape', async () => { | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders(() => | |
| useKeypressContext(), | |
| ); | |
| act(() => result.current.subscribe(keyHandler)); | |
| act(() => { | |
| stdin.write('\x1b'); | |
| vi.advanceTimersByTime(10); | |
| stdin.write('\x1b'); | |
| expect(keyHandler).not.toHaveBeenCalled(); | |
| vi.advanceTimersByTime(ESC_TIMEOUT); | |
| expect(keyHandler).toHaveBeenNthCalledWith( | |
| 1, | |
| expect.objectContaining({ | |
| name: 'escape', | |
| shift: false, | |
| alt: false, | |
| cmd: false, | |
| }), | |
| ); | |
| expect(keyHandler).toHaveBeenNthCalledWith( | |
| 2, | |
| expect.objectContaining({ | |
| name: 'escape', | |
| shift: false, | |
| alt: false, | |
| cmd: false, | |
| }), | |
| ); | |
| }); | |
| }); | |
| it('should handle lone Escape key (keycode 27) with timeout when kitty protocol is enabled', async () => { | |
| // Use real timers for this test to avoid issues with stream/buffer timing | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders(() => | |
| useKeypressContext(), | |
| ); | |
| act(() => result.current.subscribe(keyHandler)); | |
| // Send just ESC | |
| act(() => { | |
| stdin.write('\x1b'); | |
| // Should be buffered initially | |
| expect(keyHandler).not.toHaveBeenCalled(); | |
| vi.advanceTimersByTime(ESC_TIMEOUT + 10); | |
| expect(keyHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| name: 'escape', | |
| shift: false, | |
| alt: false, | |
| cmd: false, | |
| }), | |
| ); | |
| }); | |
| }); | |
| }); | |
| describe('Tab, Backspace, and Space handling', () => { | |
| it.each([ | |
| { | |
| name: 'Tab key', | |
| inputSequence: '\x1b[9u', | |
| expected: { name: 'tab', shift: false }, | |
| }, | |
| { | |
| name: 'Shift+Tab', | |
| inputSequence: '\x1b[9;2u', | |
| expected: { name: 'tab', shift: true }, | |
| }, | |
| { | |
| name: 'Backspace', | |
| inputSequence: '\x1b[127u', | |
| expected: { name: 'backspace', alt: false, cmd: false }, | |
| }, | |
| { | |
| name: 'Alt+Backspace', | |
| inputSequence: '\x1b[127;3u', | |
| expected: { name: 'backspace', alt: true, cmd: false }, | |
| }, | |
| { | |
| name: 'Ctrl+Backspace', | |
| inputSequence: '\x1b[127;5u', | |
| expected: { name: 'backspace', alt: false, ctrl: true, cmd: false }, | |
| }, | |
| { | |
| name: 'Shift+Space', | |
| inputSequence: '\x1b[32;2u', | |
| expected: { | |
| name: 'space', | |
| shift: true, | |
| insertable: true, | |
| sequence: ' ', | |
| }, | |
| }, | |
| { | |
| name: 'Ctrl+Space', | |
| inputSequence: '\x1b[32;5u', | |
| expected: { | |
| name: 'space', | |
| ctrl: true, | |
| insertable: false, | |
| sequence: '\x1b[32;5u', | |
| }, | |
| }, | |
| ])( | |
| 'should recognize $name in kitty protocol', | |
| async ({ inputSequence, expected }) => { | |
| const { keyHandler } = await setupKeypressTest(); | |
| act(() => { | |
| stdin.write(inputSequence); | |
| }); | |
| expect(keyHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| ...expected, | |
| }), | |
| ); | |
| }, | |
| ); | |
| }); | |
| describe('paste mode', () => { | |
| it.each([ | |
| { | |
| name: 'handle multiline paste as a single event', | |
| pastedText: 'This \n is \n a \n multiline \n paste.', | |
| writeSequence: (text: string) => { | |
| stdin.write(PASTE_START); | |
| stdin.write(text); | |
| stdin.write(PASTE_END); | |
| }, | |
| }, | |
| { | |
| name: 'handle paste start code split over multiple writes', | |
| pastedText: 'pasted content', | |
| writeSequence: (text: string) => { | |
| stdin.write(PASTE_START.slice(0, 3)); | |
| stdin.write(PASTE_START.slice(3)); | |
| stdin.write(text); | |
| stdin.write(PASTE_END); | |
| }, | |
| }, | |
| { | |
| name: 'handle paste end code split over multiple writes', | |
| pastedText: 'pasted content', | |
| writeSequence: (text: string) => { | |
| stdin.write(PASTE_START); | |
| stdin.write(text); | |
| stdin.write(PASTE_END.slice(0, 3)); | |
| stdin.write(PASTE_END.slice(3)); | |
| }, | |
| }, | |
| ])('should $name', async ({ pastedText, writeSequence }) => { | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders(() => | |
| useKeypressContext(), | |
| ); | |
| act(() => result.current.subscribe(keyHandler)); | |
| act(() => writeSequence(pastedText)); | |
| await waitFor(() => { | |
| expect(keyHandler).toHaveBeenCalledTimes(1); | |
| }); | |
| expect(keyHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| name: 'paste', | |
| sequence: pastedText, | |
| }), | |
| ); | |
| }); | |
| it('should parse valid OSC 52 response', async () => { | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders(() => | |
| useKeypressContext(), | |
| ); | |
| act(() => result.current.subscribe(keyHandler)); | |
| const base64Data = Buffer.from('Hello OSC 52').toString('base64'); | |
| const sequence = `\x1b]52;c;${base64Data}\x07`; | |
| act(() => stdin.write(sequence)); | |
| await waitFor(() => { | |
| expect(keyHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| name: 'paste', | |
| sequence: 'Hello OSC 52', | |
| }), | |
| ); | |
| }); | |
| }); | |
| it('should handle split OSC 52 response', async () => { | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders(() => | |
| useKeypressContext(), | |
| ); | |
| act(() => result.current.subscribe(keyHandler)); | |
| const base64Data = Buffer.from('Split Paste').toString('base64'); | |
| const sequence = `\x1b]52;c;${base64Data}\x07`; | |
| // Split the sequence | |
| const part1 = sequence.slice(0, 5); | |
| const part2 = sequence.slice(5); | |
| act(() => stdin.write(part1)); | |
| act(() => stdin.write(part2)); | |
| await waitFor(() => { | |
| expect(keyHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| name: 'paste', | |
| sequence: 'Split Paste', | |
| }), | |
| ); | |
| }); | |
| }); | |
| it('should handle OSC 52 response terminated by ESC \\', async () => { | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders(() => | |
| useKeypressContext(), | |
| ); | |
| act(() => result.current.subscribe(keyHandler)); | |
| const base64Data = Buffer.from('Terminated by ST').toString('base64'); | |
| const sequence = `\x1b]52;c;${base64Data}\x1b\\`; | |
| act(() => stdin.write(sequence)); | |
| await waitFor(() => { | |
| expect(keyHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| name: 'paste', | |
| sequence: 'Terminated by ST', | |
| }), | |
| ); | |
| }); | |
| }); | |
| it('should ignore unknown OSC sequences', async () => { | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders(() => | |
| useKeypressContext(), | |
| ); | |
| act(() => result.current.subscribe(keyHandler)); | |
| const sequence = `\x1b]1337;File=name=Zm9vCg==\x07`; | |
| act(() => stdin.write(sequence)); | |
| await act(async () => { | |
| vi.advanceTimersByTime(0); | |
| }); | |
| expect(keyHandler).not.toHaveBeenCalled(); | |
| }); | |
| it('should ignore invalid OSC 52 format', async () => { | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders(() => | |
| useKeypressContext(), | |
| ); | |
| act(() => result.current.subscribe(keyHandler)); | |
| const sequence = `\x1b]52;x;notbase64\x07`; | |
| act(() => stdin.write(sequence)); | |
| await act(async () => { | |
| vi.advanceTimersByTime(0); | |
| }); | |
| expect(keyHandler).not.toHaveBeenCalled(); | |
| }); | |
| }); | |
| describe('debug keystroke logging', () => { | |
| let debugLoggerSpy: ReturnType<typeof vi.spyOn>; | |
| beforeEach(() => { | |
| debugLoggerSpy = vi | |
| .spyOn(debugLogger, 'log') | |
| .mockImplementation(() => {}); | |
| }); | |
| afterEach(() => { | |
| debugLoggerSpy.mockRestore(); | |
| }); | |
| it('should not log keystrokes when debugKeystrokeLogging is false', async () => { | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders( | |
| () => useKeypressContext(), | |
| { | |
| settings: createMockSettings({ | |
| general: { debugKeystrokeLogging: false }, | |
| }), | |
| }, | |
| ); | |
| act(() => result.current.subscribe(keyHandler)); | |
| // Send a kitty sequence | |
| act(() => { | |
| stdin.write('\x1b[27u'); | |
| }); | |
| expect(keyHandler).toHaveBeenCalled(); | |
| expect(debugLoggerSpy).not.toHaveBeenCalledWith( | |
| expect.stringContaining('[DEBUG] Kitty'), | |
| ); | |
| }); | |
| it('should log kitty buffer accumulation when debugKeystrokeLogging is true', async () => { | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders( | |
| () => useKeypressContext(), | |
| { | |
| settings: createMockSettings({ | |
| general: { debugKeystrokeLogging: true }, | |
| }), | |
| }, | |
| ); | |
| act(() => result.current.subscribe(keyHandler)); | |
| // Send a complete kitty sequence for escape | |
| act(() => stdin.write('\x1b[27u')); | |
| expect(debugLoggerSpy).toHaveBeenCalledWith( | |
| `[DEBUG] Raw StdIn: ${JSON.stringify('\x1b[27u')}`, | |
| ); | |
| }); | |
| it('should show char codes when debugKeystrokeLogging is true even without debug mode', async () => { | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders( | |
| () => useKeypressContext(), | |
| { | |
| settings: createMockSettings({ | |
| general: { debugKeystrokeLogging: true }, | |
| }), | |
| }, | |
| ); | |
| act(() => result.current.subscribe(keyHandler)); | |
| // Send incomplete kitty sequence | |
| act(() => stdin.write(INCOMPLETE_KITTY_SEQUENCE)); | |
| // Verify debug logging for accumulation | |
| expect(debugLoggerSpy).toHaveBeenCalledWith( | |
| `[DEBUG] Raw StdIn: ${JSON.stringify(INCOMPLETE_KITTY_SEQUENCE)}`, | |
| ); | |
| }); | |
| }); | |
| describe('Parameterized functional keys', () => { | |
| it.each([ | |
| // CSI-u numeric keys | |
| { sequence: `\x1b[53;5u`, expected: { name: '5', ctrl: true } }, | |
| { sequence: `\x1b[51;2u`, expected: { name: '3', shift: true } }, | |
| // ModifyOtherKeys | |
| { sequence: `\x1b[27;2;13~`, expected: { name: 'enter', shift: true } }, | |
| { sequence: `\x1b[27;5;13~`, expected: { name: 'enter', ctrl: true } }, | |
| { sequence: `\x1b[27;5;9~`, expected: { name: 'tab', ctrl: true } }, | |
| { | |
| sequence: `\x1b[27;6;9~`, | |
| expected: { name: 'tab', shift: true, ctrl: true }, | |
| }, | |
| // Unicode CJK (Kitty/modifyOtherKeys scalar values) | |
| { | |
| sequence: '\x1b[44032u', | |
| expected: { name: '가', sequence: '가', insertable: true }, | |
| }, | |
| { | |
| sequence: '\x1b[27;1;44032~', | |
| expected: { name: '가', sequence: '가', insertable: true }, | |
| }, | |
| // XTerm Function Key | |
| { sequence: `\x1b[1;129A`, expected: { name: 'up' } }, | |
| { sequence: `\x1b[1;2H`, expected: { name: 'home', shift: true } }, | |
| { sequence: `\x1b[1;5F`, expected: { name: 'end', ctrl: true } }, | |
| { sequence: `\x1b[1;1P`, expected: { name: 'f1' } }, | |
| { | |
| sequence: `\x1b[1;3Q`, | |
| expected: { name: 'f2', alt: true, cmd: false }, | |
| }, | |
| // Tilde Function Keys | |
| { sequence: `\x1b[3~`, expected: { name: 'delete' } }, | |
| { sequence: `\x1b[5~`, expected: { name: 'pageup' } }, | |
| { sequence: `\x1b[6~`, expected: { name: 'pagedown' } }, | |
| { sequence: `\x1b[1~`, expected: { name: 'home' } }, | |
| { sequence: `\x1b[4~`, expected: { name: 'end' } }, | |
| { sequence: `\x1b[2~`, expected: { name: 'insert' } }, | |
| { sequence: `\x1b[11~`, expected: { name: 'f1' } }, | |
| { sequence: `\x1b[17~`, expected: { name: 'f6' } }, | |
| { sequence: `\x1b[23~`, expected: { name: 'f11' } }, | |
| { sequence: `\x1b[24~`, expected: { name: 'f12' } }, | |
| { sequence: `\x1b[25~`, expected: { name: 'f13' } }, | |
| { sequence: `\x1b[34~`, expected: { name: 'f20' } }, | |
| // Kitty Extended Function Keys (F13-F35) | |
| { sequence: `\x1b[302u`, expected: { name: 'f13' } }, | |
| { sequence: `\x1b[324u`, expected: { name: 'f35' } }, | |
| // Modifier / Special Keys (Kitty Protocol) | |
| { sequence: `\x1b[57358u`, expected: { name: 'capslock' } }, | |
| { sequence: `\x1b[57362u`, expected: { name: 'pausebreak' } }, | |
| // Reverse tabs | |
| { sequence: `\x1b[Z`, expected: { name: 'tab', shift: true } }, | |
| { sequence: `\x1b[1;2Z`, expected: { name: 'tab', shift: true } }, | |
| { sequence: `\x1bOZ`, expected: { name: 'tab', shift: true } }, | |
| // Legacy Arrows | |
| { | |
| sequence: `\x1b[A`, | |
| expected: { | |
| name: 'up', | |
| shift: false, | |
| alt: false, | |
| ctrl: false, | |
| cmd: false, | |
| }, | |
| }, | |
| { | |
| sequence: `\x1b[B`, | |
| expected: { | |
| name: 'down', | |
| shift: false, | |
| alt: false, | |
| ctrl: false, | |
| cmd: false, | |
| }, | |
| }, | |
| { | |
| sequence: `\x1b[C`, | |
| expected: { | |
| name: 'right', | |
| shift: false, | |
| alt: false, | |
| ctrl: false, | |
| cmd: false, | |
| }, | |
| }, | |
| { | |
| sequence: `\x1b[D`, | |
| expected: { | |
| name: 'left', | |
| shift: false, | |
| alt: false, | |
| ctrl: false, | |
| cmd: false, | |
| }, | |
| }, | |
| // Legacy Home/End | |
| { | |
| sequence: `\x1b[H`, | |
| expected: { | |
| name: 'home', | |
| shift: false, | |
| alt: false, | |
| ctrl: false, | |
| cmd: false, | |
| }, | |
| }, | |
| { | |
| sequence: `\x1b[F`, | |
| expected: { | |
| name: 'end', | |
| shift: false, | |
| alt: false, | |
| ctrl: false, | |
| cmd: false, | |
| }, | |
| }, | |
| { | |
| sequence: `\x1b[5H`, | |
| expected: { | |
| name: 'home', | |
| shift: false, | |
| alt: false, | |
| ctrl: true, | |
| cmd: false, | |
| }, | |
| }, | |
| ])( | |
| 'should recognize sequence "$sequence" as $expected.name', | |
| async ({ sequence, expected }) => { | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders(() => | |
| useKeypressContext(), | |
| ); | |
| act(() => result.current.subscribe(keyHandler)); | |
| act(() => stdin.write(sequence)); | |
| expect(keyHandler).toHaveBeenCalledWith( | |
| expect.objectContaining(expected), | |
| ); | |
| }, | |
| ); | |
| }); | |
| describe('Numpad support', () => { | |
| it.each([ | |
| { | |
| sequence: '\x1bOj', | |
| expected: { name: '*', sequence: '*', insertable: true }, | |
| }, | |
| { | |
| sequence: '\x1bOk', | |
| expected: { name: '+', sequence: '+', insertable: true }, | |
| }, | |
| { | |
| sequence: '\x1bOm', | |
| expected: { name: '-', sequence: '-', insertable: true }, | |
| }, | |
| { | |
| sequence: '\x1bOo', | |
| expected: { name: '/', sequence: '/', insertable: true }, | |
| }, | |
| { | |
| sequence: '\x1bOp', | |
| expected: { name: '0', sequence: '0', insertable: true }, | |
| }, | |
| { | |
| sequence: '\x1bOq', | |
| expected: { name: '1', sequence: '1', insertable: true }, | |
| }, | |
| { | |
| sequence: '\x1bOr', | |
| expected: { name: '2', sequence: '2', insertable: true }, | |
| }, | |
| { | |
| sequence: '\x1bOs', | |
| expected: { name: '3', sequence: '3', insertable: true }, | |
| }, | |
| { | |
| sequence: '\x1bOt', | |
| expected: { name: '4', sequence: '4', insertable: true }, | |
| }, | |
| { | |
| sequence: '\x1bOu', | |
| expected: { name: '5', sequence: '5', insertable: true }, | |
| }, | |
| { | |
| sequence: '\x1bOv', | |
| expected: { name: '6', sequence: '6', insertable: true }, | |
| }, | |
| { | |
| sequence: '\x1bOw', | |
| expected: { name: '7', sequence: '7', insertable: true }, | |
| }, | |
| { | |
| sequence: '\x1bOx', | |
| expected: { name: '8', sequence: '8', insertable: true }, | |
| }, | |
| { | |
| sequence: '\x1bOy', | |
| expected: { name: '9', sequence: '9', insertable: true }, | |
| }, | |
| { | |
| sequence: '\x1bOn', | |
| expected: { name: '.', sequence: '.', insertable: true }, | |
| }, | |
| // Kitty Numpad Support (CSI-u) | |
| { | |
| sequence: '\x1b[57404u', | |
| expected: { name: 'numpad5', sequence: '5', insertable: true }, | |
| }, | |
| { | |
| modifier: 'Ctrl', | |
| sequence: '\x1b[57404;5u', | |
| expected: { name: 'numpad5', ctrl: true, insertable: false }, | |
| }, | |
| { | |
| sequence: '\x1b[57411u', | |
| expected: { name: 'numpad_multiply', sequence: '*', insertable: true }, | |
| }, | |
| ])( | |
| 'should recognize numpad sequence "$sequence" as $expected.name', | |
| async ({ sequence, expected }) => { | |
| const { keyHandler } = await setupKeypressTest(); | |
| act(() => stdin.write(sequence)); | |
| expect(keyHandler).toHaveBeenCalledWith( | |
| expect.objectContaining(expected), | |
| ); | |
| }, | |
| ); | |
| }); | |
| describe('Double-tap and batching', () => { | |
| it('should emit two delete events for double-tap CSI[3~', async () => { | |
| const { keyHandler } = await setupKeypressTest(); | |
| act(() => stdin.write(`\x1b[3~`)); | |
| act(() => stdin.write(`\x1b[3~`)); | |
| expect(keyHandler).toHaveBeenNthCalledWith( | |
| 1, | |
| expect.objectContaining({ | |
| name: 'delete', | |
| shift: false, | |
| alt: false, | |
| ctrl: false, | |
| cmd: false, | |
| }), | |
| ); | |
| expect(keyHandler).toHaveBeenNthCalledWith( | |
| 2, | |
| expect.objectContaining({ | |
| name: 'delete', | |
| shift: false, | |
| alt: false, | |
| ctrl: false, | |
| cmd: false, | |
| }), | |
| ); | |
| }); | |
| it('should parse two concatenated tilde-coded sequences in one chunk', async () => { | |
| const { keyHandler } = await setupKeypressTest(); | |
| act(() => stdin.write(`\x1b[3~\x1b[5~`)); | |
| expect(keyHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ name: 'delete' }), | |
| ); | |
| expect(keyHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ name: 'pageup' }), | |
| ); | |
| }); | |
| }); | |
| describe('Cross-terminal Alt key handling (simulating macOS)', () => { | |
| let originalPlatform: NodeJS.Platform; | |
| beforeEach(() => { | |
| originalPlatform = process.platform; | |
| Object.defineProperty(process, 'platform', { | |
| value: 'darwin', | |
| configurable: true, | |
| }); | |
| }); | |
| afterEach(() => { | |
| Object.defineProperty(process, 'platform', { | |
| value: originalPlatform, | |
| configurable: true, | |
| }); | |
| }); | |
| // Terminals to test | |
| const terminals = ['iTerm2', 'Ghostty', 'MacTerminal', 'VSCodeTerminal']; | |
| // Key mappings: letter -> [keycode, accented character, shift] | |
| const keys: Record<string, [number, string, boolean]> = { | |
| b: [98, '\u222B', false], | |
| f: [102, '\u0192', false], | |
| m: [109, '\u00B5', false], | |
| z: [122, '\u03A9', false], | |
| Z: [122, '\u00B8', true], | |
| }; | |
| it.each( | |
| terminals.flatMap((terminal) => | |
| Object.entries(keys).map( | |
| ([key, [keycode, accentedChar, shiftValue]]) => { | |
| if (terminal === 'Ghostty') { | |
| // Ghostty uses kitty protocol sequences | |
| // Modifier 3 is Alt, 4 is Shift+Alt | |
| const modifier = shiftValue ? 4 : 3; | |
| return { | |
| terminal, | |
| key, | |
| chunk: `\x1b[${keycode};${modifier}u`, | |
| expected: { | |
| name: key.toLowerCase(), | |
| shift: shiftValue, | |
| alt: true, | |
| ctrl: false, | |
| cmd: false, | |
| }, | |
| }; | |
| } else if (terminal === 'MacTerminal') { | |
| // Mac Terminal sends ESC + letter | |
| const chunk = shiftValue | |
| ? `\x1b${key.toUpperCase()}` | |
| : `\x1b${key.toLowerCase()}`; | |
| return { | |
| terminal, | |
| key, | |
| kitty: false, | |
| chunk, | |
| expected: { | |
| sequence: chunk, | |
| name: key.toLowerCase(), | |
| shift: shiftValue, | |
| alt: true, | |
| ctrl: false, | |
| cmd: false, | |
| }, | |
| }; | |
| } else { | |
| // iTerm2 and VSCode send accented characters (å, ø, µ, Ω, ¸) | |
| return { | |
| terminal, | |
| key, | |
| chunk: accentedChar, | |
| expected: { | |
| name: key.toLowerCase(), | |
| shift: shiftValue, | |
| alt: true, // Always expect alt:true after conversion | |
| ctrl: false, | |
| cmd: false, | |
| sequence: accentedChar, | |
| }, | |
| }; | |
| } | |
| }, | |
| ), | |
| ), | |
| )( | |
| 'should handle Alt+$key in $terminal', | |
| async ({ | |
| chunk, | |
| expected, | |
| }: { | |
| chunk: string; | |
| expected: Partial<Key>; | |
| }) => { | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders(() => | |
| useKeypressContext(), | |
| ); | |
| act(() => result.current.subscribe(keyHandler)); | |
| act(() => stdin.write(chunk)); | |
| expect(keyHandler).toHaveBeenCalledWith( | |
| expect.objectContaining(expected), | |
| ); | |
| }, | |
| ); | |
| }); | |
| describe('Backslash key handling', () => { | |
| it('should treat backslash as a regular keystroke', async () => { | |
| const { keyHandler } = await setupKeypressTest(); | |
| act(() => stdin.write('\\')); | |
| // Advance timers to trigger the backslash timeout | |
| act(() => { | |
| vi.runAllTimers(); | |
| }); | |
| expect(keyHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| sequence: '\\', | |
| shift: false, | |
| alt: false, | |
| ctrl: false, | |
| cmd: false, | |
| }), | |
| ); | |
| }); | |
| }); | |
| it('should timeout and flush incomplete kitty sequences after 50ms', async () => { | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders(() => | |
| useKeypressContext(), | |
| ); | |
| act(() => result.current.subscribe(keyHandler)); | |
| act(() => stdin.write(INCOMPLETE_KITTY_SEQUENCE)); | |
| // Should not broadcast immediately | |
| expect(keyHandler).not.toHaveBeenCalled(); | |
| // Advance time just before timeout | |
| // eslint-disable-next-line @typescript-eslint/no-floating-promises | |
| act(() => vi.advanceTimersByTime(ESC_TIMEOUT - 5)); | |
| // Still shouldn't broadcast | |
| expect(keyHandler).not.toHaveBeenCalled(); | |
| // Advance past timeout | |
| // eslint-disable-next-line @typescript-eslint/no-floating-promises | |
| act(() => vi.advanceTimersByTime(10)); | |
| // Should now broadcast the incomplete sequence as regular input | |
| expect(keyHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| name: 'undefined', | |
| sequence: INCOMPLETE_KITTY_SEQUENCE, | |
| shift: false, | |
| alt: false, | |
| ctrl: false, | |
| cmd: false, | |
| }), | |
| ); | |
| }); | |
| it('should immediately flush non-kitty CSI sequences', async () => { | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders(() => | |
| useKeypressContext(), | |
| ); | |
| 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.write('\x1b[m')); | |
| // Should broadcast immediately as it's not a valid kitty pattern | |
| expect(keyHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| sequence: '\x1b[m', | |
| shift: false, | |
| alt: false, | |
| ctrl: false, | |
| cmd: false, | |
| }), | |
| ); | |
| }); | |
| it('should parse valid kitty sequences immediately when complete', async () => { | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders(() => | |
| useKeypressContext(), | |
| ); | |
| act(() => result.current.subscribe(keyHandler)); | |
| // Send complete kitty sequence for Ctrl+A | |
| act(() => stdin.write('\x1b[97;5u')); | |
| // Should parse and broadcast immediately | |
| expect(keyHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| name: 'a', | |
| ctrl: true, | |
| }), | |
| ); | |
| }); | |
| it('should handle batched kitty sequences correctly', async () => { | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders(() => | |
| useKeypressContext(), | |
| ); | |
| act(() => result.current.subscribe(keyHandler)); | |
| // Send Ctrl+a followed by Ctrl+b | |
| act(() => stdin.write('\x1b[97;5u\x1b[98;5u')); | |
| // Should parse both sequences | |
| expect(keyHandler).toHaveBeenCalledTimes(2); | |
| expect(keyHandler).toHaveBeenNthCalledWith( | |
| 1, | |
| expect.objectContaining({ | |
| name: 'a', | |
| ctrl: true, | |
| }), | |
| ); | |
| expect(keyHandler).toHaveBeenNthCalledWith( | |
| 2, | |
| expect.objectContaining({ | |
| name: 'b', | |
| ctrl: true, | |
| }), | |
| ); | |
| }); | |
| it('should handle mixed valid and invalid sequences', async () => { | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders(() => | |
| useKeypressContext(), | |
| ); | |
| act(() => result.current.subscribe(keyHandler)); | |
| // Send valid kitty sequence followed by invalid CSI | |
| // Valid enter, then invalid sequence | |
| act(() => stdin.write('\x1b[13u\x1b[!')); | |
| // Should parse valid sequence and flush invalid immediately | |
| expect(keyHandler).toHaveBeenCalledTimes(2); | |
| expect(keyHandler).toHaveBeenNthCalledWith( | |
| 1, | |
| expect.objectContaining({ | |
| name: 'enter', | |
| }), | |
| ); | |
| expect(keyHandler).toHaveBeenNthCalledWith( | |
| 2, | |
| expect.objectContaining({ | |
| sequence: '\x1b[!', | |
| }), | |
| ); | |
| }); | |
| it.each([1, ESC_TIMEOUT - 1])( | |
| 'should handle sequences arriving character by character with %s ms delay', | |
| async (delay) => { | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders(() => | |
| useKeypressContext(), | |
| ); | |
| act(() => result.current.subscribe(keyHandler)); | |
| // Send kitty sequence character by character | |
| for (const char of '\x1b[27u') { | |
| act(() => stdin.write(char)); | |
| // Advance time but not enough to timeout | |
| vi.advanceTimersByTime(delay); | |
| } | |
| // Should parse once complete | |
| await waitFor(() => { | |
| expect(keyHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| name: 'escape', | |
| }), | |
| ); | |
| }); | |
| }, | |
| ); | |
| it('should reset timeout when new input arrives', async () => { | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders(() => | |
| useKeypressContext(), | |
| ); | |
| act(() => result.current.subscribe(keyHandler)); | |
| // Start incomplete sequence | |
| act(() => stdin.write('\x1b[97;13')); | |
| // Advance time partway | |
| // eslint-disable-next-line @typescript-eslint/no-floating-promises | |
| act(() => vi.advanceTimersByTime(30)); | |
| // Add more to sequence | |
| act(() => stdin.write('5')); | |
| // Advance time from the first timeout point | |
| // eslint-disable-next-line @typescript-eslint/no-floating-promises | |
| act(() => vi.advanceTimersByTime(25)); | |
| // Should not have timed out yet (timeout restarted) | |
| expect(keyHandler).not.toHaveBeenCalled(); | |
| // Complete the sequence | |
| act(() => stdin.write('u')); | |
| // Should now parse as complete enter key | |
| expect(keyHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| name: 'a', | |
| }), | |
| ); | |
| }); | |
| describe('SGR Mouse Handling', () => { | |
| it('should ignore SGR mouse sequences', async () => { | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders(() => | |
| useKeypressContext(), | |
| ); | |
| act(() => result.current.subscribe(keyHandler)); | |
| // Send various SGR mouse sequences | |
| act(() => { | |
| stdin.write('\x1b[<0;10;20M'); // Mouse press | |
| stdin.write('\x1b[<0;10;20m'); // Mouse release | |
| stdin.write('\x1b[<32;30;40M'); // Mouse drag | |
| stdin.write('\x1b[<64;5;5M'); // Scroll up | |
| }); | |
| // Should not broadcast any of these as keystrokes | |
| expect(keyHandler).not.toHaveBeenCalled(); | |
| }); | |
| it('should handle mixed SGR mouse and key sequences', async () => { | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders(() => | |
| useKeypressContext(), | |
| ); | |
| act(() => result.current.subscribe(keyHandler)); | |
| // Send mouse event then a key press | |
| act(() => { | |
| stdin.write('\x1b[<0;10;20M'); | |
| stdin.write('a'); | |
| }); | |
| // Should only broadcast 'a' | |
| expect(keyHandler).toHaveBeenCalledTimes(1); | |
| expect(keyHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| name: 'a', | |
| sequence: 'a', | |
| shift: false, | |
| alt: false, | |
| ctrl: false, | |
| cmd: false, | |
| }), | |
| ); | |
| }); | |
| it('should ignore X11 mouse sequences', async () => { | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders(() => | |
| useKeypressContext(), | |
| ); | |
| act(() => result.current.subscribe(keyHandler)); | |
| // Send X11 mouse sequence: ESC [ M followed by 3 bytes | |
| // Space is 32. 32+0=32 (button 0), 32+33=65 ('A', col 33), 32+34=66 ('B', row 34) | |
| const x11Seq = '\x1b[M AB'; | |
| act(() => stdin.write(x11Seq)); | |
| // Should not broadcast as keystrokes | |
| expect(keyHandler).not.toHaveBeenCalled(); | |
| }); | |
| it('should not flush slow SGR mouse sequences as garbage', async () => { | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders(() => | |
| useKeypressContext(), | |
| ); | |
| act(() => result.current.subscribe(keyHandler)); | |
| // Send start of SGR sequence | |
| act(() => stdin.write('\x1b[<')); | |
| // Advance time past the normal kitty timeout (50ms) | |
| // eslint-disable-next-line @typescript-eslint/no-floating-promises | |
| act(() => vi.advanceTimersByTime(ESC_TIMEOUT + 10)); | |
| // Send the rest | |
| act(() => stdin.write('0;37;25M')); | |
| // Should NOT have flushed the prefix as garbage, and should have consumed the whole thing | |
| expect(keyHandler).not.toHaveBeenCalled(); | |
| }); | |
| it('should ignore specific SGR mouse sequence sandwiched between keystrokes', async () => { | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders(() => | |
| useKeypressContext(), | |
| ); | |
| act(() => result.current.subscribe(keyHandler)); | |
| act(() => { | |
| stdin.write('H'); | |
| stdin.write('\x1b[<64;96;8M'); | |
| stdin.write('I'); | |
| }); | |
| expect(keyHandler).toHaveBeenCalledTimes(2); | |
| expect(keyHandler).toHaveBeenNthCalledWith( | |
| 1, | |
| expect.objectContaining({ name: 'h', sequence: 'H', shift: true }), | |
| ); | |
| expect(keyHandler).toHaveBeenNthCalledWith( | |
| 2, | |
| expect.objectContaining({ name: 'i', sequence: 'I', shift: true }), | |
| ); | |
| }); | |
| }); | |
| describe('Ignored Sequences', () => { | |
| it.each([ | |
| { name: 'Focus In', sequence: '\x1b[I' }, | |
| { name: 'Focus Out', sequence: '\x1b[O' }, | |
| { name: 'SGR Mouse Release', sequence: '\u001b[<0;44;18m' }, | |
| { name: 'something mouse', sequence: '\u001b[<0;53;19M' }, | |
| { name: 'another mouse', sequence: '\u001b[<0;29;19m' }, | |
| ])('should ignore $name sequence', async ({ sequence }) => { | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders(() => | |
| useKeypressContext(), | |
| ); | |
| act(() => result.current.subscribe(keyHandler)); | |
| for (const char of sequence) { | |
| act(() => stdin.write(char)); | |
| // eslint-disable-next-line @typescript-eslint/no-floating-promises | |
| act(() => vi.advanceTimersByTime(0)); | |
| } | |
| act(() => stdin.write('HI')); | |
| expect(keyHandler).toHaveBeenCalledTimes(2); | |
| expect(keyHandler).toHaveBeenNthCalledWith( | |
| 1, | |
| expect.objectContaining({ name: 'h', sequence: 'H', shift: true }), | |
| ); | |
| expect(keyHandler).toHaveBeenNthCalledWith( | |
| 2, | |
| expect.objectContaining({ name: 'i', sequence: 'I', shift: true }), | |
| ); | |
| }); | |
| it('should handle F12', async () => { | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders(() => | |
| useKeypressContext(), | |
| ); | |
| act(() => result.current.subscribe(keyHandler)); | |
| act(() => { | |
| stdin.write('\u001b[24~'); | |
| }); | |
| expect(keyHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| name: 'f12', | |
| sequence: '\u001b[24~', | |
| shift: false, | |
| alt: false, | |
| ctrl: false, | |
| cmd: false, | |
| }), | |
| ); | |
| }); | |
| }); | |
| describe('Individual Character Input', () => { | |
| it.each([ | |
| 'abc', // ASCII character | |
| '你好', // Chinese characters | |
| 'こんにちは', // Japanese characters | |
| '안녕하세요', // Korean characters | |
| 'A你B好C', // Mixed characters | |
| ])('should correctly handle string "%s"', async (inputString) => { | |
| const keyHandler = vi.fn(); | |
| const { result } = await renderHookWithProviders(() => | |
| useKeypressContext(), | |
| ); | |
| act(() => result.current.subscribe(keyHandler)); | |
| act(() => stdin.write(inputString)); | |
| expect(keyHandler).toHaveBeenCalledTimes(inputString.length); | |
| for (const char of inputString) { | |
| expect(keyHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ sequence: char, name: char.toLowerCase() }), | |
| ); | |
| } | |
| }); | |
| }); | |
| describe('Greek support', () => { | |
| afterEach(() => { | |
| vi.unstubAllEnvs(); | |
| }); | |
| it.each([ | |
| { | |
| lang: 'en_US.UTF-8', | |
| expected: { name: 'z', alt: true, insertable: false }, | |
| desc: 'non-Greek locale (Option+z)', | |
| }, | |
| { | |
| lang: 'el_GR.UTF-8', | |
| expected: { name: '', insertable: true }, | |
| desc: 'Greek LANG', | |
| }, | |
| { | |
| lcAll: 'el_GR.UTF-8', | |
| expected: { name: '', insertable: true }, | |
| desc: 'Greek LC_ALL', | |
| }, | |
| { | |
| lang: 'en_US.UTF-8', | |
| lcAll: 'el_GR.UTF-8', | |
| expected: { name: '', insertable: true }, | |
| desc: 'LC_ALL overriding non-Greek LANG', | |
| }, | |
| { | |
| lang: 'el_GR.UTF-8', | |
| char: '\u00B8', | |
| expected: { name: 'z', alt: true, shift: true }, | |
| desc: 'Cedilla (\u00B8) in Greek locale (should be Option+Shift+z)', | |
| }, | |
| ])( | |
| 'should handle $char correctly in $desc', | |
| async ({ lang, lcAll, char = '\u03A9', expected }) => { | |
| if (lang) vi.stubEnv('LANG', lang); | |
| if (lcAll) vi.stubEnv('LC_ALL', lcAll); | |
| const { keyHandler } = await setupKeypressTest(); | |
| act(() => stdin.write(char)); | |
| expect(keyHandler).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| ...expected, | |
| sequence: char, | |
| }), | |
| ); | |
| }, | |
| ); | |
| }); | |
| }); | |