Work around bracketed paste support for node < 20 (#2476)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
parent
75a128e7ee
commit
ab66e3a24e
|
@ -0,0 +1,261 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { renderHook, act } from '@testing-library/react';
|
||||||
|
import { useKeypress, Key } from './useKeypress.js';
|
||||||
|
import { useStdin } from 'ink';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import { PassThrough } from 'stream';
|
||||||
|
|
||||||
|
// Mock the 'ink' module to control stdin
|
||||||
|
vi.mock('ink', async (importOriginal) => {
|
||||||
|
const original = await importOriginal<typeof import('ink')>();
|
||||||
|
return {
|
||||||
|
...original,
|
||||||
|
useStdin: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
class MockStdin extends EventEmitter {
|
||||||
|
isTTY = true;
|
||||||
|
setRawMode = vi.fn();
|
||||||
|
on = this.addListener;
|
||||||
|
removeListener = this.removeListener;
|
||||||
|
write = vi.fn();
|
||||||
|
resume = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useKeypress', () => {
|
||||||
|
let stdin: MockStdin;
|
||||||
|
const mockSetRawMode = vi.fn();
|
||||||
|
const onKeypress = vi.fn();
|
||||||
|
let originalNodeVersion: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
stdin = new MockStdin();
|
||||||
|
(useStdin as vi.Mock).mockReturnValue({
|
||||||
|
stdin,
|
||||||
|
setRawMode: mockSetRawMode,
|
||||||
|
});
|
||||||
|
|
||||||
|
originalNodeVersion = process.versions.node;
|
||||||
|
delete process.env['PASTE_WORKAROUND'];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(process.versions, 'node', {
|
||||||
|
value: originalNodeVersion,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const setNodeVersion = (version: string) => {
|
||||||
|
Object.defineProperty(process.versions, 'node', {
|
||||||
|
value: version,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should not listen if isActive is false', () => {
|
||||||
|
renderHook(() => useKeypress(onKeypress, { isActive: false }));
|
||||||
|
act(() => stdin.pressKey({ name: 'a' }));
|
||||||
|
expect(onKeypress).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should listen for keypress when active', () => {
|
||||||
|
renderHook(() => useKeypress(onKeypress, { isActive: true }));
|
||||||
|
const key = { name: 'a', sequence: 'a' };
|
||||||
|
act(() => stdin.pressKey(key));
|
||||||
|
expect(onKeypress).toHaveBeenCalledWith(expect.objectContaining(key));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set and release raw mode', () => {
|
||||||
|
const { unmount } = renderHook(() =>
|
||||||
|
useKeypress(onKeypress, { isActive: true }),
|
||||||
|
);
|
||||||
|
expect(mockSetRawMode).toHaveBeenCalledWith(true);
|
||||||
|
unmount();
|
||||||
|
expect(mockSetRawMode).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop listening after being unmounted', () => {
|
||||||
|
const { unmount } = renderHook(() =>
|
||||||
|
useKeypress(onKeypress, { isActive: true }),
|
||||||
|
);
|
||||||
|
unmount();
|
||||||
|
act(() => stdin.pressKey({ name: 'a' }));
|
||||||
|
expect(onKeypress).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly identify alt+enter (meta key)', () => {
|
||||||
|
renderHook(() => useKeypress(onKeypress, { isActive: true }));
|
||||||
|
const key = { name: 'return', sequence: '\x1B\r' };
|
||||||
|
act(() => stdin.pressKey(key));
|
||||||
|
expect(onKeypress).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ ...key, meta: true, paste: false }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.each([
|
||||||
|
{
|
||||||
|
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',
|
||||||
|
setup: () => {
|
||||||
|
setNodeVersion('20.0.0');
|
||||||
|
process.env['PASTE_WORKAROUND'] = 'true';
|
||||||
|
},
|
||||||
|
isLegacy: true,
|
||||||
|
},
|
||||||
|
])('Paste Handling in $description', ({ setup, isLegacy }) => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setup();
|
||||||
|
stdin.setLegacy(isLegacy);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should process a paste as a single event', () => {
|
||||||
|
renderHook(() => useKeypress(onKeypress, { isActive: true }));
|
||||||
|
const pasteText = 'hello world';
|
||||||
|
act(() => stdin.paste(pasteText));
|
||||||
|
|
||||||
|
expect(onKeypress).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onKeypress).toHaveBeenCalledWith({
|
||||||
|
name: '',
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
shift: false,
|
||||||
|
paste: true,
|
||||||
|
sequence: pasteText,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle keypress interspersed with pastes', () => {
|
||||||
|
renderHook(() => useKeypress(onKeypress, { isActive: true }));
|
||||||
|
|
||||||
|
const keyA = { name: 'a', sequence: 'a' };
|
||||||
|
act(() => stdin.pressKey(keyA));
|
||||||
|
expect(onKeypress).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ ...keyA, paste: false }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const pasteText = 'pasted';
|
||||||
|
act(() => stdin.paste(pasteText));
|
||||||
|
expect(onKeypress).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ paste: true, sequence: pasteText }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const keyB = { name: 'b', sequence: 'b' };
|
||||||
|
act(() => stdin.pressKey(keyB));
|
||||||
|
expect(onKeypress).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ ...keyB, paste: false }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onKeypress).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit partial paste content if unmounted mid-paste', () => {
|
||||||
|
const { unmount } = renderHook(() =>
|
||||||
|
useKeypress(onKeypress, { isActive: true }),
|
||||||
|
);
|
||||||
|
const pasteText = 'incomplete paste';
|
||||||
|
|
||||||
|
act(() => stdin.startPaste(pasteText));
|
||||||
|
|
||||||
|
// No event should be fired yet.
|
||||||
|
expect(onKeypress).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Unmounting should trigger the flush.
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(onKeypress).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onKeypress).toHaveBeenCalledWith({
|
||||||
|
name: '',
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
shift: false,
|
||||||
|
paste: true,
|
||||||
|
sequence: pasteText,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -7,6 +7,7 @@
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useStdin } from 'ink';
|
import { useStdin } from 'ink';
|
||||||
import readline from 'readline';
|
import readline from 'readline';
|
||||||
|
import { PassThrough } from 'stream';
|
||||||
|
|
||||||
export interface Key {
|
export interface Key {
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -48,7 +49,19 @@ export function useKeypress(
|
||||||
|
|
||||||
setRawMode(true);
|
setRawMode(true);
|
||||||
|
|
||||||
const rl = readline.createInterface({ input: stdin });
|
const keypressStream = new PassThrough();
|
||||||
|
let usePassthrough = false;
|
||||||
|
const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10);
|
||||||
|
if (
|
||||||
|
nodeMajorVersion < 20 ||
|
||||||
|
process.env['PASTE_WORKAROUND'] === '1' ||
|
||||||
|
process.env['PASTE_WORKAROUND'] === 'true'
|
||||||
|
) {
|
||||||
|
// Prior to node 20, node's built-in readline does not support bracketed
|
||||||
|
// paste mode. We hack by detecting it with our own handler.
|
||||||
|
usePassthrough = true;
|
||||||
|
}
|
||||||
|
|
||||||
let isPaste = false;
|
let isPaste = false;
|
||||||
let pasteBuffer = Buffer.alloc(0);
|
let pasteBuffer = Buffer.alloc(0);
|
||||||
|
|
||||||
|
@ -79,11 +92,78 @@ export function useKeypress(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRawKeypress = (data: Buffer) => {
|
||||||
|
const PASTE_MODE_PREFIX = Buffer.from('\x1B[200~');
|
||||||
|
const PASTE_MODE_SUFFIX = Buffer.from('\x1B[201~');
|
||||||
|
|
||||||
|
let pos = 0;
|
||||||
|
while (pos < data.length) {
|
||||||
|
const prefixPos = data.indexOf(PASTE_MODE_PREFIX, pos);
|
||||||
|
const suffixPos = data.indexOf(PASTE_MODE_SUFFIX, pos);
|
||||||
|
|
||||||
|
// Determine which marker comes first, if any.
|
||||||
|
const isPrefixNext =
|
||||||
|
prefixPos !== -1 && (suffixPos === -1 || prefixPos < suffixPos);
|
||||||
|
const isSuffixNext =
|
||||||
|
suffixPos !== -1 && (prefixPos === -1 || suffixPos < prefixPos);
|
||||||
|
|
||||||
|
let nextMarkerPos = -1;
|
||||||
|
let markerLength = 0;
|
||||||
|
|
||||||
|
if (isPrefixNext) {
|
||||||
|
nextMarkerPos = prefixPos;
|
||||||
|
} else if (isSuffixNext) {
|
||||||
|
nextMarkerPos = suffixPos;
|
||||||
|
}
|
||||||
|
markerLength = PASTE_MODE_SUFFIX.length;
|
||||||
|
|
||||||
|
if (nextMarkerPos === -1) {
|
||||||
|
keypressStream.write(data.slice(pos));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextData = data.slice(pos, nextMarkerPos);
|
||||||
|
if (nextData.length > 0) {
|
||||||
|
keypressStream.write(nextData);
|
||||||
|
}
|
||||||
|
const createPasteKeyEvent = (
|
||||||
|
name: 'paste-start' | 'paste-end',
|
||||||
|
): Key => ({
|
||||||
|
name,
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
shift: false,
|
||||||
|
paste: false,
|
||||||
|
sequence: '',
|
||||||
|
});
|
||||||
|
if (isPrefixNext) {
|
||||||
|
handleKeypress(undefined, createPasteKeyEvent('paste-start'));
|
||||||
|
} else if (isSuffixNext) {
|
||||||
|
handleKeypress(undefined, createPasteKeyEvent('paste-end'));
|
||||||
|
}
|
||||||
|
pos = nextMarkerPos + markerLength;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let rl: readline.Interface;
|
||||||
|
if (usePassthrough) {
|
||||||
|
rl = readline.createInterface({ input: keypressStream });
|
||||||
|
readline.emitKeypressEvents(keypressStream, rl);
|
||||||
|
keypressStream.on('keypress', handleKeypress);
|
||||||
|
stdin.on('data', handleRawKeypress);
|
||||||
|
} else {
|
||||||
|
rl = readline.createInterface({ input: stdin });
|
||||||
readline.emitKeypressEvents(stdin, rl);
|
readline.emitKeypressEvents(stdin, rl);
|
||||||
stdin.on('keypress', handleKeypress);
|
stdin.on('keypress', handleKeypress);
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
if (usePassthrough) {
|
||||||
|
keypressStream.removeListener('keypress', handleKeypress);
|
||||||
|
stdin.removeListener('data', handleRawKeypress);
|
||||||
|
} else {
|
||||||
stdin.removeListener('keypress', handleKeypress);
|
stdin.removeListener('keypress', handleKeypress);
|
||||||
|
}
|
||||||
rl.close();
|
rl.close();
|
||||||
setRawMode(false);
|
setRawMode(false);
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue