Use raw writes to stdin in test (#11871)

This commit is contained in:
Tommaso Sciortino
2025-10-23 16:55:30 -07:00
committed by GitHub
parent b16fe7b646
commit 0fe82a2f4e
@@ -33,6 +33,9 @@ vi.mock('ink', async (importOriginal) => {
const PASTE_START = '\x1B[200~'; const PASTE_START = '\x1B[200~';
const PASTE_END = '\x1B[201~'; 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 { class MockStdin extends EventEmitter {
isTTY = true; isTTY = true;
@@ -45,15 +48,6 @@ class MockStdin extends EventEmitter {
write(text: string) { write(text: string) {
this.emit('data', Buffer.from(text)); 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);
}
} }
describe('KeypressContext - Kitty Protocol', () => { describe('KeypressContext - Kitty Protocol', () => {
@@ -171,9 +165,7 @@ describe('KeypressContext - Kitty Protocol', () => {
act(() => result.current.subscribe(keyHandler)); act(() => result.current.subscribe(keyHandler));
// Send kitty protocol sequence for numpad enter with Ctrl (modifier 5): ESC[57414;5u // Send kitty protocol sequence for numpad enter with Ctrl (modifier 5): ESC[57414;5u
act(() => { act(() => stdin.write(`\x1b[57414;5u`));
stdin.write(`\x1b[57414;5u`);
});
expect(keyHandler).toHaveBeenCalledWith( expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -506,15 +498,14 @@ describe('KeypressContext - Kitty Protocol', () => {
act(() => result.current.subscribe(keyHandler)); act(() => result.current.subscribe(keyHandler));
// Send incomplete kitty sequence act(() => stdin.write(INCOMPLETE_KITTY_SEQUENCE));
act(() => stdin.pressKey({ sequence: '\x1b[1' }));
// Send Ctrl+C // Send Ctrl+C
act(() => stdin.write('\x03')); act(() => stdin.write('\x03'));
expect(consoleLogSpy).toHaveBeenCalledWith( expect(consoleLogSpy).toHaveBeenCalledWith(
'[DEBUG] Kitty buffer cleared on Ctrl+C:', '[DEBUG] Kitty buffer cleared on Ctrl+C:',
'\x1b[1', INCOMPLETE_KITTY_SEQUENCE,
); );
// Verify Ctrl+C was handled // Verify Ctrl+C was handled
@@ -543,19 +534,18 @@ describe('KeypressContext - Kitty Protocol', () => {
act(() => result.current.subscribe(keyHandler)); act(() => result.current.subscribe(keyHandler));
// Send incomplete kitty sequence // Send incomplete kitty sequence
const sequence = '\x1b[12'; act(() => stdin.write(INCOMPLETE_KITTY_SEQUENCE));
act(() => stdin.pressKey({ sequence }));
// Verify debug logging for accumulation // Verify debug logging for accumulation
expect(consoleLogSpy).toHaveBeenCalledWith( expect(consoleLogSpy).toHaveBeenCalledWith(
'[DEBUG] Kitty buffer accumulating:', '[DEBUG] Kitty buffer accumulating:',
JSON.stringify(sequence), JSON.stringify(INCOMPLETE_KITTY_SEQUENCE),
); );
// Verify warning for char codes // Verify warning for char codes
expect(consoleWarnSpy).toHaveBeenCalledWith( expect(consoleWarnSpy).toHaveBeenCalledWith(
'Kitty sequence buffer has content:', 'Kitty sequence buffer has content:',
JSON.stringify(sequence), JSON.stringify(INCOMPLETE_KITTY_SEQUENCE),
); );
}); });
}); });
@@ -829,93 +819,75 @@ describe('Kitty Sequence Parsing', () => {
// Terminals to test // Terminals to test
const terminals = ['iTerm2', 'Ghostty', 'MacTerminal', 'VSCodeTerminal']; const terminals = ['iTerm2', 'Ghostty', 'MacTerminal', 'VSCodeTerminal'];
// Key mappings: letter -> [keycode, accented character, shouldHaveMeta] // Key mappings: letter -> [keycode, accented character]
// Note: µ (mu) is sent with meta:false on iTerm2/VSCode const keys: Record<string, [number, string]> = {
const keys: Record<string, [number, string, boolean]> = { a: [97, 'å'],
a: [97, 'å', true], o: [111, 'ø'],
o: [111, 'ø', true], m: [109, 'µ'],
m: [109, 'µ', false],
}; };
it.each( it.each(
terminals.flatMap((terminal) => terminals.flatMap((terminal) =>
Object.entries(keys).map( Object.entries(keys).map(([key, [keycode, accentedChar]]) => {
([key, [keycode, accentedChar, shouldHaveMeta]]) => { if (terminal === 'Ghostty') {
if (terminal === 'Ghostty') { // Ghostty uses kitty protocol sequences
// Ghostty uses kitty protocol sequences return {
return { terminal,
terminal, key,
key, chunk: `\x1b[${keycode};3u`,
kittySequence: `\x1b[${keycode};3u`, expected: {
expected: { name: key,
name: key, ctrl: false,
ctrl: false, meta: true,
meta: true, shift: false,
shift: false, paste: false,
paste: false, kittyProtocol: true,
kittyProtocol: true, },
}, };
}; } else if (terminal === 'MacTerminal') {
} else if (terminal === 'MacTerminal') { // Mac Terminal sends ESC + letter
// Mac Terminal sends ESC + letter return {
return { terminal,
terminal, key,
key, kitty: false,
kitty: false, chunk: `\x1b${key}`,
input: { expected: {
sequence: `\x1b${key}`, sequence: `\x1b${key}`,
name: key, name: key,
ctrl: false, ctrl: false,
meta: true, meta: true,
shift: false, shift: false,
paste: false, paste: false,
}, },
expected: { };
sequence: `\x1b${key}`, } else {
name: key, // iTerm2 and VSCode send accented characters (å, ø, µ)
ctrl: false, // Note: µ (mu) is sent with meta:false on iTerm2/VSCode but
meta: true, // gets converted to m with meta:true
shift: false, return {
paste: false, terminal,
}, key,
}; chunk: accentedChar,
} else { expected: {
// iTerm2 and VSCode send accented characters (å, ø, µ) name: key,
// Note: µ comes with meta:false but gets converted to m with meta:true ctrl: false,
return { meta: true, // Always expect meta:true after conversion
terminal, shift: false,
key, paste: false,
input: { sequence: accentedChar,
name: key, },
ctrl: false, };
meta: shouldHaveMeta, }
shift: false, }),
paste: false,
sequence: accentedChar,
},
expected: {
name: key,
ctrl: false,
meta: true, // Always expect meta:true after conversion
shift: false,
paste: false,
sequence: accentedChar,
},
};
}
},
),
), ),
)( )(
'should handle Alt+$key in $terminal', 'should handle Alt+$key in $terminal',
({ ({
kittySequence, chunk,
input,
expected, expected,
kitty = true, kitty = true,
}: { }: {
kittySequence?: string; chunk: string;
input?: Partial<Key>;
expected: Partial<Key>; expected: Partial<Key>;
kitty?: boolean; kitty?: boolean;
}) => { }) => {
@@ -930,11 +902,7 @@ describe('Kitty Sequence Parsing', () => {
}); });
act(() => result.current.subscribe(keyHandler)); act(() => result.current.subscribe(keyHandler));
if (kittySequence) { act(() => stdin.write(chunk));
act(() => stdin.write(kittySequence));
} else if (input) {
act(() => stdin.pressKey(input));
}
expect(keyHandler).toHaveBeenCalledWith( expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining(expected), expect.objectContaining(expected),
@@ -978,8 +946,7 @@ describe('Kitty Sequence Parsing', () => {
act(() => result.current.subscribe(keyHandler)); act(() => result.current.subscribe(keyHandler));
// Send incomplete kitty sequence act(() => stdin.write(INCOMPLETE_KITTY_SEQUENCE));
act(() => stdin.pressKey({ sequence: '\x1b[1;' }));
// Should not broadcast immediately // Should not broadcast immediately
expect(keyHandler).not.toHaveBeenCalled(); expect(keyHandler).not.toHaveBeenCalled();
@@ -991,15 +958,13 @@ describe('Kitty Sequence Parsing', () => {
expect(keyHandler).not.toHaveBeenCalled(); expect(keyHandler).not.toHaveBeenCalled();
// Advance past timeout // Advance past timeout
act(() => { act(() => vi.advanceTimersByTime(10));
vi.advanceTimersByTime(10);
});
// Should now broadcast the incomplete sequence as regular input // Should now broadcast the incomplete sequence as regular input
expect(keyHandler).toHaveBeenCalledWith( expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
name: '', name: '',
sequence: '\x1b[1;', sequence: INCOMPLETE_KITTY_SEQUENCE,
paste: false, paste: false,
}), }),
); );
@@ -1079,8 +1044,7 @@ describe('Kitty Sequence Parsing', () => {
act(() => result.current.subscribe(keyHandler)); act(() => result.current.subscribe(keyHandler));
// Send incomplete kitty sequence act(() => stdin.write(INCOMPLETE_KITTY_SEQUENCE));
act(() => stdin.pressKey({ sequence: '\x1b[1;' }));
// Press Ctrl+C // Press Ctrl+C
act(() => stdin.write('\x03')); act(() => stdin.write('\x03'));
@@ -1189,13 +1153,13 @@ describe('Kitty Sequence Parsing', () => {
act(() => result.current.subscribe(keyHandler)); act(() => result.current.subscribe(keyHandler));
// Start incomplete sequence // Start incomplete sequence
act(() => stdin.pressKey({ sequence: '\x1b[1' })); act(() => stdin.write('\x1b[97;13'));
// Advance time partway // Advance time partway
act(() => vi.advanceTimersByTime(30)); act(() => vi.advanceTimersByTime(30));
// Add more to sequence // Add more to sequence
act(() => stdin.write('3')); act(() => stdin.write('5'));
// Advance time from the first timeout point // Advance time from the first timeout point
act(() => vi.advanceTimersByTime(25)); act(() => vi.advanceTimersByTime(25));
@@ -1209,7 +1173,7 @@ describe('Kitty Sequence Parsing', () => {
// Should now parse as complete enter key // Should now parse as complete enter key
expect(keyHandler).toHaveBeenCalledWith( expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
name: 'return', name: 'a',
kittyProtocol: true, kittyProtocol: true,
}), }),
); );
@@ -1221,8 +1185,7 @@ describe('Kitty Sequence Parsing', () => {
act(() => result.current.subscribe(keyHandler)); act(() => result.current.subscribe(keyHandler));
// Send incomplete kitty sequence act(() => stdin.write(INCOMPLETE_KITTY_SEQUENCE));
act(() => stdin.pressKey({ sequence: '\x1b[1;' }));
// Incomplete sequence should be buffered, not broadcast // Incomplete sequence should be buffered, not broadcast
expect(keyHandler).not.toHaveBeenCalled(); expect(keyHandler).not.toHaveBeenCalled();
@@ -1235,7 +1198,7 @@ describe('Kitty Sequence Parsing', () => {
expect(keyHandler).toHaveBeenCalledWith( expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
name: '', name: '',
sequence: '\x1b[1;', sequence: INCOMPLETE_KITTY_SEQUENCE,
paste: false, paste: false,
}), }),
); );
@@ -1247,8 +1210,7 @@ describe('Kitty Sequence Parsing', () => {
act(() => result.current.subscribe(keyHandler)); act(() => result.current.subscribe(keyHandler));
// Send incomplete kitty sequence act(() => stdin.write(INCOMPLETE_KITTY_SEQUENCE));
act(() => stdin.pressKey({ sequence: '\x1b[1;' }));
// Incomplete sequence should be buffered, not broadcast // Incomplete sequence should be buffered, not broadcast
expect(keyHandler).not.toHaveBeenCalled(); expect(keyHandler).not.toHaveBeenCalled();
@@ -1261,7 +1223,7 @@ describe('Kitty Sequence Parsing', () => {
expect(keyHandler).toHaveBeenCalledWith( expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
name: '', name: '',
sequence: '\x1b[1;', sequence: INCOMPLETE_KITTY_SEQUENCE,
paste: false, paste: false,
}), }),
); );
@@ -1274,8 +1236,7 @@ describe('Kitty Sequence Parsing', () => {
act(() => result.current.subscribe(keyHandler)); act(() => result.current.subscribe(keyHandler));
// Send incomplete kitty sequence act(() => stdin.write(INCOMPLETE_KITTY_SEQUENCE));
act(() => stdin.pressKey({ sequence: '\x1b[1;' }));
// Incomplete sequence should be buffered, not broadcast // Incomplete sequence should be buffered, not broadcast
expect(keyHandler).not.toHaveBeenCalled(); expect(keyHandler).not.toHaveBeenCalled();
@@ -1288,7 +1249,7 @@ describe('Kitty Sequence Parsing', () => {
expect(keyHandler).toHaveBeenCalledWith( expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
name: '', name: '',
sequence: '\x1b[1;', sequence: INCOMPLETE_KITTY_SEQUENCE,
paste: false, paste: false,
}), }),
); );