mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
fix(terminal): recover dropped keyCode 229 input
Android/GBoard-style keyboards fire keydown with keyCode 229 and, on some paths, never mutate xterm's helper textarea. xterm has nothing to diff, so it emits no data and the typed character is silently dropped: it never reaches the PTY and never appears on screen. terminal-keycode229-recovery.js is a standalone controller that re-emits exactly those keys, and only once. xterm stays authoritative throughout: - Only an explicit keyCode 229 keydown carrying a single printable key (or Enter) is eligible; Process/Unidentified/Dead, modifiers, AltGraph and a live composition are all left alone. - The re-emit is scheduled from a microtask and then a zero-delay timer, so xterm's own textarea diff always gets the first opportunity; canonical data for the same key cancels the pending fallback. - compositionstart and blur drop every pending candidate, so a real IME composition lifecycle is never second-guessed. - After a recovery, one late canonical value attributed to that key token (via beforeinput/input on the helper textarea) is suppressed so the character cannot be delivered twice; the record expires after 250ms and an unattributed byte is never suppressed. terminal-ui.js wires it at the two existing choke points — the custom key handler and the onData registration, the latter now a named handler so the recovery path can re-enter it — with both hooks wrapped so a failure in the fallback can never break canonical input. Unit coverage drives the module directly in a vm; the wiring itself is covered end-to-end in the (browser-only) terminal-copy-shortcut suite.
This commit is contained in:
@@ -83,6 +83,7 @@ appendFileSync(
|
||||
|
||||
// 4. Minify frontend assets
|
||||
run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite');
|
||||
run('minify terminal-keycode229-recovery.js', 'npx esbuild dist/web/public/terminal-keycode229-recovery.js --minify --outfile=dist/web/public/terminal-keycode229-recovery.js --allow-overwrite');
|
||||
run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite');
|
||||
run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite');
|
||||
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
|
||||
@@ -110,6 +111,7 @@ console.log('\n[build] content-hash cache busting');
|
||||
'notification-manager.js',
|
||||
'keyboard-accessory.js',
|
||||
'input-cjk.js',
|
||||
'terminal-keycode229-recovery.js',
|
||||
'sanitize-html.js',
|
||||
'app.js',
|
||||
'tab-rail-resize.js',
|
||||
|
||||
@@ -3458,6 +3458,8 @@
|
||||
<script defer src="notification-manager.js"></script>
|
||||
<script defer src="keyboard-accessory.js"></script>
|
||||
<script defer src="input-cjk.js"></script>
|
||||
<!-- Recovers Android/GBoard keydowns that report keyCode 229 without touching xterm's helper textarea. Must precede terminal-ui.js. -->
|
||||
<script defer src="terminal-keycode229-recovery.js"></script>
|
||||
<!-- Hardened markdown HTML sanitizer (wires DOMPurify). Must precede app.js. -->
|
||||
<script defer src="sanitize-html.js"></script>
|
||||
<script defer src="app.js"></script>
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Recover explicit keyCode 229 terminal input when a browser reports a key but
|
||||
* never mutates xterm's helper textarea. xterm remains authoritative whenever
|
||||
* it emits canonical data or the browser enters a real composition lifecycle.
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
const LATE_INPUT_WINDOW_MS = 250;
|
||||
const MAX_RECOVERED_RECORDS = 32;
|
||||
|
||||
function explicitTerminalDataForEvent(event) {
|
||||
if (!event || event.type !== 'keydown' || event.isComposing) return null;
|
||||
if (event.ctrlKey || event.altKey || event.metaKey) return null;
|
||||
try {
|
||||
if (event.getModifierState?.('AltGraph')) return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = event.key;
|
||||
if (key === 'Enter') return '\r';
|
||||
if (key === 'Process' || key === 'Unidentified' || key === 'Dead') return null;
|
||||
if (typeof key !== 'string' || Array.from(key).length !== 1) return null;
|
||||
const codePoint = key.codePointAt(0);
|
||||
if (codePoint === undefined || codePoint < 32 || codePoint === 127) return null;
|
||||
return key;
|
||||
}
|
||||
|
||||
function terminalDataForEvent(event) {
|
||||
if (event?.keyCode !== 229) return null;
|
||||
return explicitTerminalDataForEvent(event);
|
||||
}
|
||||
|
||||
function create(options) {
|
||||
const textarea = options?.textarea;
|
||||
const emitRecovered = options?.emitRecovered;
|
||||
if (!textarea?.addEventListener || !textarea?.removeEventListener || typeof emitRecovered !== 'function') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const enqueueMicrotask = options.queueMicrotask || global.queueMicrotask.bind(global);
|
||||
const setTimer = options.setTimer || global.setTimeout.bind(global);
|
||||
const clearTimer = options.clearTimer || global.clearTimeout.bind(global);
|
||||
const now = options.now || (() => global.performance?.now?.() ?? Date.now());
|
||||
|
||||
let destroyed = false;
|
||||
let keySequence = 0;
|
||||
let activeKey = null;
|
||||
let beforeInputClaim = null;
|
||||
const pending = [];
|
||||
const recovered = [];
|
||||
|
||||
function removePending(candidate) {
|
||||
const index = pending.indexOf(candidate);
|
||||
if (index !== -1) pending.splice(index, 1);
|
||||
if (candidate.timer !== null) {
|
||||
try {
|
||||
clearTimer(candidate.timer);
|
||||
} catch {}
|
||||
candidate.timer = null;
|
||||
}
|
||||
candidate.active = false;
|
||||
}
|
||||
|
||||
function cancelPending(predicate = () => true) {
|
||||
for (const candidate of [...pending]) {
|
||||
if (predicate(candidate)) removePending(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
function pruneRecovered() {
|
||||
const current = now();
|
||||
for (let index = recovered.length - 1; index >= 0; index -= 1) {
|
||||
if (recovered[index].expiresAt < current) recovered.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyEvent(event) {
|
||||
if (destroyed || event?.type !== 'keydown') return;
|
||||
const record = {
|
||||
sequence: ++keySequence,
|
||||
data: explicitTerminalDataForEvent(event),
|
||||
candidate: null,
|
||||
};
|
||||
activeKey = record;
|
||||
const data = terminalDataForEvent(event);
|
||||
const candidate = data === null ? null : { sequence: record.sequence, data, active: true, timer: null };
|
||||
if (candidate) {
|
||||
record.candidate = candidate;
|
||||
pending.push(candidate);
|
||||
}
|
||||
try {
|
||||
// The custom key handler runs before xterm's CompositionHelper. Queueing
|
||||
// our timer from a microtask places it after xterm's own zero-delay
|
||||
// textarea diff, while keeping the recovery delay to one browser task.
|
||||
enqueueMicrotask(() => {
|
||||
if (activeKey === record) activeKey = null;
|
||||
if (destroyed || !candidate?.active) return;
|
||||
try {
|
||||
candidate.timer = setTimer(() => {
|
||||
if (destroyed || !candidate.active) return;
|
||||
removePending(candidate);
|
||||
try {
|
||||
emitRecovered(candidate.data);
|
||||
} catch {
|
||||
// No dedupe record is retained when delivery fails. A later
|
||||
// canonical xterm value must remain free to pass through.
|
||||
return;
|
||||
}
|
||||
pruneRecovered();
|
||||
recovered.push({
|
||||
sequence: candidate.sequence,
|
||||
data: candidate.data,
|
||||
expiresAt: now() + LATE_INPUT_WINDOW_MS,
|
||||
claimedByInput: false,
|
||||
});
|
||||
if (recovered.length > MAX_RECOVERED_RECORDS) {
|
||||
recovered.splice(0, recovered.length - MAX_RECOVERED_RECORDS);
|
||||
}
|
||||
}, 0);
|
||||
} catch {
|
||||
removePending(candidate);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
if (activeKey === record) activeKey = null;
|
||||
if (candidate) removePending(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
function claimCanonicalInput(data) {
|
||||
if (activeKey?.data === data) {
|
||||
if (activeKey.candidate?.active) removePending(activeKey.candidate);
|
||||
return;
|
||||
}
|
||||
const matchingPending = pending.find((candidate) => candidate.active && candidate.data === data);
|
||||
if (matchingPending) {
|
||||
removePending(matchingPending);
|
||||
return;
|
||||
}
|
||||
const matchingRecovery = recovered.find((record) => !record.claimedByInput && record.data === data);
|
||||
if (matchingRecovery) matchingRecovery.claimedByInput = true;
|
||||
}
|
||||
|
||||
function onCanonicalInput(event) {
|
||||
if (destroyed) return;
|
||||
const inputData = typeof event?.data === 'string' ? event.data : null;
|
||||
if (inputData === null) return;
|
||||
if (event.type === 'input' && beforeInputClaim?.data === inputData) {
|
||||
beforeInputClaim = null;
|
||||
return;
|
||||
}
|
||||
if (event.type === 'beforeinput') {
|
||||
const claim = { data: inputData };
|
||||
beforeInputClaim = claim;
|
||||
try {
|
||||
enqueueMicrotask(() => {
|
||||
if (beforeInputClaim === claim) beforeInputClaim = null;
|
||||
});
|
||||
} catch {
|
||||
beforeInputClaim = null;
|
||||
}
|
||||
}
|
||||
claimCanonicalInput(inputData);
|
||||
}
|
||||
|
||||
function resetForCompositionOrFocusLoss() {
|
||||
if (destroyed) return;
|
||||
keySequence += 1;
|
||||
activeKey = null;
|
||||
beforeInputClaim = null;
|
||||
cancelPending();
|
||||
recovered.splice(0);
|
||||
}
|
||||
|
||||
function consumeTerminalData(data) {
|
||||
if (destroyed) return false;
|
||||
pruneRecovered();
|
||||
|
||||
if (activeKey?.data === data) {
|
||||
if (activeKey.candidate?.active) removePending(activeKey.candidate);
|
||||
return false;
|
||||
}
|
||||
|
||||
const canonical = pending.find((candidate) => candidate.active && candidate.data === data);
|
||||
if (canonical) {
|
||||
removePending(canonical);
|
||||
return false;
|
||||
}
|
||||
|
||||
const duplicateIndex = recovered.findIndex((record) => record.data === data && record.claimedByInput);
|
||||
if (duplicateIndex === -1) return false;
|
||||
recovered.splice(duplicateIndex, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
activeKey = null;
|
||||
beforeInputClaim = null;
|
||||
cancelPending();
|
||||
recovered.splice(0);
|
||||
try {
|
||||
textarea.removeEventListener('beforeinput', onCanonicalInput, true);
|
||||
textarea.removeEventListener('input', onCanonicalInput, true);
|
||||
textarea.removeEventListener('compositionstart', resetForCompositionOrFocusLoss, true);
|
||||
textarea.removeEventListener('blur', resetForCompositionOrFocusLoss, true);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
textarea.addEventListener('beforeinput', onCanonicalInput, true);
|
||||
textarea.addEventListener('input', onCanonicalInput, true);
|
||||
textarea.addEventListener('compositionstart', resetForCompositionOrFocusLoss, true);
|
||||
textarea.addEventListener('blur', resetForCompositionOrFocusLoss, true);
|
||||
} catch {
|
||||
destroy();
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.freeze({ handleKeyEvent, consumeTerminalData, destroy });
|
||||
}
|
||||
|
||||
global.CodemanKeyCode229Recovery = Object.freeze({ create, terminalDataForEvent });
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -232,12 +232,22 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Terminal Setup — xterm.js config and input handling
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
_destroyKeyCode229Recovery() {
|
||||
try {
|
||||
this._keyCode229Recovery?.destroy?.();
|
||||
} catch {
|
||||
// Recovery is optional; terminal replacement must continue.
|
||||
}
|
||||
this._keyCode229Recovery = null;
|
||||
},
|
||||
|
||||
initTerminal() {
|
||||
// Load scrollback setting from localStorage, treating DEFAULT_SCROLLBACK as a floor
|
||||
// so users who picked up the previous (smaller) default get the new minimum on upgrade.
|
||||
const stored = parseInt(localStorage.getItem('codeman-scrollback'));
|
||||
const scrollback = Number.isFinite(stored) && stored > 0 ? Math.max(stored, DEFAULT_SCROLLBACK) : DEFAULT_SCROLLBACK;
|
||||
|
||||
this._destroyKeyCode229Recovery();
|
||||
this.terminal = new Terminal({
|
||||
theme: { ...window.codemanCurrentXtermTheme() },
|
||||
fontFamily: window.CodemanTerminalFont.resolve(this.loadAppSettingsFromStorage?.().terminalFontFamily),
|
||||
@@ -292,6 +302,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
// punctuation; returning false here would stop xterm before it can diff
|
||||
// the helper textarea and emit the committed Unicode text.
|
||||
this.terminal.attachCustomKeyEventHandler((ev) => {
|
||||
try {
|
||||
this._keyCode229Recovery?.handleKeyEvent?.(ev);
|
||||
} catch {
|
||||
// The fallback must never interfere with xterm's canonical handler.
|
||||
}
|
||||
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
|
||||
|
||||
// Let the app's Alt/Option session-nav and Command Palette shortcuts reach the document keydown handler
|
||||
@@ -1026,7 +1041,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
// mobile connections. The overlay + localStorage persistence ensure input
|
||||
// survives tab switches and reconnects.
|
||||
|
||||
this.terminal.onData((data) => {
|
||||
const handleTerminalData = (data, { recovered = false } = {}) => {
|
||||
if (!recovered) {
|
||||
try {
|
||||
if (this._keyCode229Recovery?.consumeTerminalData?.(data)) return;
|
||||
} catch {
|
||||
// A broken dedupe guard must fail open to canonical xterm data.
|
||||
}
|
||||
}
|
||||
// Mouse SGR reports (tap-to-position) are NOT IME input — they must reach
|
||||
// the PTY even while the CJK input field owns focus. Without this exception
|
||||
// tapping to move the cursor silently does nothing whenever Chinese input
|
||||
@@ -1348,7 +1370,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Android/GBoard fires keydown with keyCode 229 and, on some paths, never
|
||||
// mutates xterm's helper textarea, so the character is silently dropped.
|
||||
// The controller re-emits exactly those keys, and only after xterm has had
|
||||
// its own chance to produce the canonical data.
|
||||
try {
|
||||
this._keyCode229Recovery = window.CodemanKeyCode229Recovery?.create?.({
|
||||
textarea: this.terminal.textarea,
|
||||
emitRecovered: (data) => handleTerminalData(data, { recovered: true }),
|
||||
});
|
||||
} catch {
|
||||
this._keyCode229Recovery = null;
|
||||
}
|
||||
this.terminal.onData((data) => handleTerminalData(data));
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -218,6 +218,90 @@ describe('terminal Ctrl+C smart copy', () => {
|
||||
expect(res.data.join('')).not.toContain('\x16');
|
||||
});
|
||||
|
||||
it('recovers explicit keyCode 229 input once when the helper textarea never mutates', async () => {
|
||||
await setup('KEYCODE-229-RECOVERY', false);
|
||||
const result = await page.evaluate(async () => {
|
||||
const app = (window as any).app;
|
||||
const textarea = document.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement;
|
||||
const originalSessionId = app.activeSessionId;
|
||||
const originalLocalEcho = app._localEchoEnabled;
|
||||
const originalSendInput = app._sendInputAsync;
|
||||
const originalPendingInput = app._pendingInput;
|
||||
const originalLastKeystrokeTime = app._lastKeystrokeTime;
|
||||
const sent: string[] = [];
|
||||
const dispatch229 = (key: string) => {
|
||||
for (const type of ['keydown', 'keyup']) {
|
||||
const event = new KeyboardEvent(type, {
|
||||
key,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
});
|
||||
Object.defineProperties(event, { keyCode: { value: 229 }, which: { value: 229 } });
|
||||
textarea.dispatchEvent(event);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
app.activeSessionId = 'cod388-browser-regression';
|
||||
app._localEchoEnabled = false;
|
||||
app._pendingInput = '';
|
||||
app._lastKeystrokeTime = 0;
|
||||
app._sendInputAsync = (_sessionId: string, data: string) => sent.push(data);
|
||||
textarea.focus();
|
||||
|
||||
dispatch229('x');
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 30));
|
||||
const afterRecovery = [...sent];
|
||||
|
||||
// A browser that supplies its canonical input late must not duplicate
|
||||
// the character already recovered for this key token.
|
||||
textarea.dispatchEvent(
|
||||
new InputEvent('beforeinput', { data: 'x', inputType: 'insertText', bubbles: true, composed: true })
|
||||
);
|
||||
textarea.value = 'x';
|
||||
textarea.dispatchEvent(
|
||||
new InputEvent('input', { data: 'x', inputType: 'insertText', bubbles: true, composed: true })
|
||||
);
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 0));
|
||||
const afterLateInput = [...sent];
|
||||
|
||||
dispatch229('Enter');
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 30));
|
||||
const final = [...sent];
|
||||
|
||||
// Two 229 candidates can overlap while the main thread is busy. A
|
||||
// canonical value for the first must resolve that candidate without
|
||||
// cancelling the second candidate's fallback.
|
||||
const overlapStart = sent.length;
|
||||
dispatch229('a');
|
||||
dispatch229('b');
|
||||
const busyUntil = performance.now() + 25;
|
||||
while (performance.now() < busyUntil) {
|
||||
// Deliberately hold the browser task so both xterm/fallback timers
|
||||
// remain queued while canonical input for `a` is prepared.
|
||||
}
|
||||
app.terminal._core.coreService.triggerDataEvent('a', true);
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 30));
|
||||
return { afterRecovery, afterLateInput, final, overlap: sent.slice(overlapStart) };
|
||||
} finally {
|
||||
app.activeSessionId = originalSessionId;
|
||||
app._localEchoEnabled = originalLocalEcho;
|
||||
app._sendInputAsync = originalSendInput;
|
||||
app._pendingInput = originalPendingInput;
|
||||
app._lastKeystrokeTime = originalLastKeystrokeTime;
|
||||
textarea.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
afterRecovery: ['x'],
|
||||
afterLateInput: ['x'],
|
||||
final: ['x', '\r'],
|
||||
overlap: ['a', 'b'],
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards full-width punctuation after a Chinese IME composition', async () => {
|
||||
await setup('IME-PUNCTUATION', false);
|
||||
const desktopChunks = await captureImeInput(page);
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
type Listener = (event: Record<string, unknown>) => void;
|
||||
|
||||
function makeTextarea() {
|
||||
const listeners = new Map<string, Set<Listener>>();
|
||||
return {
|
||||
addEventListener(type: string, listener: Listener) {
|
||||
const bucket = listeners.get(type) ?? new Set<Listener>();
|
||||
bucket.add(listener);
|
||||
listeners.set(type, bucket);
|
||||
},
|
||||
removeEventListener(type: string, listener: Listener) {
|
||||
listeners.get(type)?.delete(listener);
|
||||
},
|
||||
fire(type: string, event: Record<string, unknown> = {}) {
|
||||
for (const listener of listeners.get(type) ?? []) listener({ type, ...event });
|
||||
},
|
||||
listenerCount() {
|
||||
return [...listeners.values()].reduce((total, bucket) => total + bucket.size, 0);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function key(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
type: 'keydown',
|
||||
key: 'x',
|
||||
keyCode: 229,
|
||||
isComposing: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
getModifierState: () => false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function harness({ emitThrows = false } = {}) {
|
||||
const source = readFileSync(new URL('../src/web/public/terminal-keycode229-recovery.js', import.meta.url), 'utf8');
|
||||
const exposed: Record<string, any> = {};
|
||||
vm.runInNewContext(source, { window: exposed, globalThis: exposed }, { filename: 'terminal-keycode229-recovery.js' });
|
||||
|
||||
const textarea = makeTextarea();
|
||||
const emitted: string[] = [];
|
||||
const microtasks: Array<() => void> = [];
|
||||
const timers = new Map<number, () => void>();
|
||||
let timerId = 0;
|
||||
let now = 1_000;
|
||||
const controller = exposed.CodemanKeyCode229Recovery.create({
|
||||
textarea,
|
||||
emitRecovered: (data: string) => {
|
||||
if (emitThrows) throw new Error('recovery callback failed');
|
||||
emitted.push(data);
|
||||
},
|
||||
queueMicrotask: (callback: () => void) => microtasks.push(callback),
|
||||
setTimer: (callback: () => void) => {
|
||||
const id = ++timerId;
|
||||
timers.set(id, callback);
|
||||
return id;
|
||||
},
|
||||
clearTimer: (id: number) => timers.delete(id),
|
||||
now: () => now,
|
||||
});
|
||||
|
||||
return {
|
||||
controller,
|
||||
emitted,
|
||||
textarea,
|
||||
advance(ms: number) {
|
||||
now += ms;
|
||||
},
|
||||
flushMicrotasks() {
|
||||
while (microtasks.length) microtasks.shift()!();
|
||||
},
|
||||
flushTimers() {
|
||||
for (const [id, callback] of [...timers]) {
|
||||
timers.delete(id);
|
||||
callback();
|
||||
}
|
||||
},
|
||||
pendingTimers: () => timers.size,
|
||||
};
|
||||
}
|
||||
|
||||
describe('keyCode 229 terminal input recovery', () => {
|
||||
it('recovers an explicit printable key and Enter after xterm gets the first opportunity', () => {
|
||||
const h = harness();
|
||||
|
||||
h.controller.handleKeyEvent(key());
|
||||
expect(h.emitted).toEqual([]);
|
||||
h.flushMicrotasks();
|
||||
expect(h.emitted).toEqual([]);
|
||||
h.flushTimers();
|
||||
expect(h.emitted).toEqual(['x']);
|
||||
|
||||
h.controller.handleKeyEvent(key({ key: 'Enter' }));
|
||||
h.flushMicrotasks();
|
||||
h.flushTimers();
|
||||
expect(h.emitted).toEqual(['x', '\r']);
|
||||
});
|
||||
|
||||
it('lets matching canonical terminal data win before fallback', () => {
|
||||
const h = harness();
|
||||
h.controller.handleKeyEvent(key());
|
||||
|
||||
expect(h.controller.consumeTerminalData('x')).toBe(false);
|
||||
h.flushMicrotasks();
|
||||
h.flushTimers();
|
||||
|
||||
expect(h.emitted).toEqual([]);
|
||||
});
|
||||
|
||||
it('cancels fallback when the helper textarea receives browser input or composition', () => {
|
||||
const input = harness();
|
||||
input.controller.handleKeyEvent(key());
|
||||
input.textarea.fire('input', { data: 'x' });
|
||||
input.flushMicrotasks();
|
||||
input.flushTimers();
|
||||
expect(input.emitted).toEqual([]);
|
||||
|
||||
const composition = harness();
|
||||
composition.controller.handleKeyEvent(key());
|
||||
composition.textarea.fire('compositionstart');
|
||||
composition.flushMicrotasks();
|
||||
composition.flushTimers();
|
||||
expect(composition.emitted).toEqual([]);
|
||||
});
|
||||
|
||||
it('suppresses one delayed matching canonical value from the recovered key token', () => {
|
||||
const h = harness();
|
||||
h.controller.handleKeyEvent(key());
|
||||
h.flushMicrotasks();
|
||||
h.flushTimers();
|
||||
expect(h.emitted).toEqual(['x']);
|
||||
|
||||
h.textarea.fire('beforeinput', { data: 'x' });
|
||||
h.flushMicrotasks();
|
||||
expect(h.controller.consumeTerminalData('x')).toBe(true);
|
||||
expect(h.controller.consumeTerminalData('x')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not suppress an unattributed same byte after recovery', () => {
|
||||
const h = harness();
|
||||
h.controller.handleKeyEvent(key());
|
||||
h.flushMicrotasks();
|
||||
h.flushTimers();
|
||||
|
||||
expect(h.controller.consumeTerminalData('x')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not let an immediate ordinary same-character key cancel pending recovery', () => {
|
||||
const h = harness();
|
||||
h.controller.handleKeyEvent(key());
|
||||
h.controller.handleKeyEvent(key({ keyCode: 88 }));
|
||||
|
||||
expect(h.controller.consumeTerminalData('x')).toBe(false);
|
||||
h.flushMicrotasks();
|
||||
h.flushTimers();
|
||||
expect(h.emitted).toEqual(['x']);
|
||||
});
|
||||
|
||||
it('resolves overlapping eligible candidates independently of the latest keydown', () => {
|
||||
const h = harness();
|
||||
h.controller.handleKeyEvent(key({ key: 'x' }));
|
||||
h.controller.handleKeyEvent(key({ key: 'y' }));
|
||||
|
||||
expect(h.controller.consumeTerminalData('x')).toBe(false);
|
||||
h.flushMicrotasks();
|
||||
h.flushTimers();
|
||||
expect(h.emitted).toEqual(['y']);
|
||||
});
|
||||
|
||||
it('suppresses a claimed recovery even after a newer ordinary keydown', () => {
|
||||
const h = harness();
|
||||
h.controller.handleKeyEvent(key({ key: 'x' }));
|
||||
h.flushMicrotasks();
|
||||
h.flushTimers();
|
||||
h.textarea.fire('beforeinput', { data: 'x' });
|
||||
|
||||
h.controller.handleKeyEvent(key({ key: 'y', keyCode: 89 }));
|
||||
expect(h.controller.consumeTerminalData('x')).toBe(true);
|
||||
});
|
||||
|
||||
it('fails open when the recovery callback throws', () => {
|
||||
const h = harness({ emitThrows: true });
|
||||
h.controller.handleKeyEvent(key());
|
||||
h.flushMicrotasks();
|
||||
h.flushTimers();
|
||||
h.textarea.fire('beforeinput', { data: 'x' });
|
||||
|
||||
expect(h.controller.consumeTerminalData('x')).toBe(false);
|
||||
expect(h.emitted).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps rapid repeated 229 keys and an ordinary same-character key distinct', () => {
|
||||
const h = harness();
|
||||
h.controller.handleKeyEvent(key());
|
||||
h.flushMicrotasks();
|
||||
h.flushTimers();
|
||||
|
||||
h.controller.handleKeyEvent(key());
|
||||
h.textarea.fire('input', { data: 'x' });
|
||||
expect(h.controller.consumeTerminalData('x')).toBe(false);
|
||||
h.flushMicrotasks();
|
||||
h.flushTimers();
|
||||
expect(h.emitted).toEqual(['x']);
|
||||
|
||||
h.controller.handleKeyEvent(key({ keyCode: 88 }));
|
||||
h.textarea.fire('input', { data: 'x' });
|
||||
expect(h.controller.consumeTerminalData('x')).toBe(false);
|
||||
});
|
||||
|
||||
it('never recovers an ordinary keydown that xterm already handles', () => {
|
||||
const h = harness();
|
||||
h.controller.handleKeyEvent(key({ keyCode: 88 }));
|
||||
h.flushMicrotasks();
|
||||
h.flushTimers();
|
||||
|
||||
expect(h.emitted).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not recover real composition, unidentified keys, modifiers, or keyup', () => {
|
||||
const h = harness();
|
||||
for (const event of [
|
||||
key({ isComposing: true }),
|
||||
key({ key: 'Process' }),
|
||||
key({ key: 'Unidentified' }),
|
||||
key({ key: 'Dead' }),
|
||||
key({ ctrlKey: true }),
|
||||
key({ altKey: true }),
|
||||
key({ metaKey: true }),
|
||||
key({ getModifierState: (name: string) => name === 'AltGraph' }),
|
||||
key({ type: 'keyup' }),
|
||||
key({ key: 'ArrowLeft' }),
|
||||
]) {
|
||||
h.controller.handleKeyEvent(event);
|
||||
}
|
||||
h.flushMicrotasks();
|
||||
h.flushTimers();
|
||||
expect(h.emitted).toEqual([]);
|
||||
});
|
||||
|
||||
it('expires deduplication and destroys listeners and scheduled work', () => {
|
||||
const h = harness();
|
||||
expect(h.textarea.listenerCount()).toBeGreaterThan(0);
|
||||
h.controller.handleKeyEvent(key());
|
||||
h.flushMicrotasks();
|
||||
h.flushTimers();
|
||||
h.advance(500);
|
||||
h.textarea.fire('input', { data: 'x' });
|
||||
expect(h.controller.consumeTerminalData('x')).toBe(false);
|
||||
|
||||
h.controller.handleKeyEvent(key({ key: 'y' }));
|
||||
h.flushMicrotasks();
|
||||
expect(h.pendingTimers()).toBe(1);
|
||||
h.controller.destroy();
|
||||
expect(h.pendingTimers()).toBe(0);
|
||||
expect(h.textarea.listenerCount()).toBe(0);
|
||||
h.flushTimers();
|
||||
expect(h.emitted).toEqual(['x']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user