From e8a93ada1f36d200cb70faf384ed3efa5bc3ac0f Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Mon, 7 Sep 2026 19:11:20 -0400 Subject: [PATCH] fix(terminal): forward the orphaned input event instead of replaying a guessed key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous shape guessed the character from `event.key` on keydown, re-emitted it, and then tried to suppress a late canonical copy with a 250 ms character-keyed dedupe. Review found three defects in that, all reproducible: the dedupe matched on the character alone with nothing scoping a candidate to the keydown that created it, so the same character typed twice inside the window had its second, real byte swallowed; anything whose committed text differed from `event.key` (Enter, IME punctuation) was delivered twice, because the dedupe could never match it; and the trigger ignored `key === 'Unidentified'`, which is what a soft keyboard reports, so it may never have fired where it was needed. The input event already carries the committed text in `ev.data` — exactly what xterm itself would have forwarded — so nothing has to be guessed. The controller now only decides WHETHER to forward, by asking whether xterm produced canonical data since the keydown that began the keystroke. No character-keyed matching survives, so the first two defects are structurally impossible rather than defended against, and nothing reads `key`/`keyCode`, so the third cannot recur. Three details are load-bearing and each has a test that fails without it: - The "did xterm speak?" snapshot is taken at KEYDOWN, not at the input event. `_keyPress` emits and sets `_keyPressHandled` before `input` fires, so a snapshot read at input time already contains that emission, reads it as silence, and delivers the character twice. - Our `input` listener is registered with `capture: true`. The target is visited twice in the event path, so a capture listener calling `stopPropagation()` stops later BUBBLE listeners on that same target; xterm's `cancel()` runs exactly in the branch where it handled the input, so on bubble we would never observe handled events, and whether we observed them at all would hang off `options.cancelEvents`. Measured in jsdom and headless chromium; the table is in the module header. - Enter is deliberately no longer special-cased. That mapping is what made the committed text differ from the re-emitted value in the first place. The scope is also narrower than the old name suggests, and the browser test now proves it rather than assuming it. For a keydown that reports keyCode 229 xterm ALREADY self-rescues, via `CompositionHelper._handleAnyTextareaChanges()` diffing the helper textarea on a 0 ms timer. A test asserting "we recovered it" there passes while xterm does all the work, so the browser tests assert WHO delivered the byte: zero canonical emissions for the genuinely orphaned case, exactly one delivery for the case xterm rescues itself. Also addresses review notes: the module gains an `@fileoverview` with `@dependency`/`@loadorder` and an entry in the load-order list and module inventory, and the wiring test moves out of the Ctrl+C smart-copy file into its own. The keydown hook deliberately still runs for every key event rather than moving behind the 229 gate: gating it would reinstate exactly the blindness described above, and it is now a single counter assignment. --- CLAUDE.md | 4 +- config/test-suites.ts | 1 + src/web/public/index.html | 2 +- .../public/terminal-keycode229-recovery.js | 320 ++++++++--------- src/web/public/terminal-ui.js | 42 ++- test/terminal-copy-shortcut.test.ts | 84 ----- ...rminal-keycode229-recovery.browser.test.ts | 153 ++++++++ test/terminal-keycode229-recovery.test.ts | 337 +++++++++--------- 8 files changed, 500 insertions(+), 443 deletions(-) create mode 100644 test/terminal-keycode229-recovery.browser.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 6ba242f7..4f9e8362 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,7 +169,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | **Attachments** | `src/attachment-registry.ts`, `attachment-magic`, `generated-artifact-attachments`, `session-attachment-history`, `document-preview-cache`, `document-thumbnailer`, `document-conversion-limiter`, `config/attachment-guard` | See Key Patterns | | **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`) | `templates/` holds the CLAUDE.md scaffold generated into new cases | | **Web** | `src/web/server.ts` ★, `sse-events.ts`, `routes/*.ts` (25 modules + barrel; `session-routes.ts` ★), `route-helpers.ts`, `ports/*.ts`, `middleware/auth.ts`, `schemas.ts`, `self-update.ts`, `plan-usage-latest.ts`, `ws-connection-registry.ts`, `heic-jpeg-converter.ts` + `heic-jpeg-worker.ts` | | -| **Frontend** | `src/web/public/app.js` (~6.7K lines, core) + 31 modules + `sw.js` | See Frontend section for the load order, which is authoritative | +| **Frontend** | `src/web/public/app.js` (~6.7K lines, core) + 32 modules + `sw.js` | See Frontend section for the load order, which is authoritative | | **Types** | `src/types/index.ts` (barrel) → 22 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts | ★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`. @@ -286,7 +286,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ### Frontend -Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). +Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. **Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on ``. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. ⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart. ⚠️ The terminal-pane styles may animate **transform / opacity / clip-path only**, xterm's FitAddon derives rows+cols from `getComputedStyle(parent).width/height`, so animating width/height/padding there would resize the PTY. ⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings → Appearance, full per-surface lab at `?animlab=1`. diff --git a/config/test-suites.ts b/config/test-suites.ts index cef400cc..02cc154b 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -26,6 +26,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/opencode-resize.test.ts', 'test/webgl-fallback.test.ts', 'test/terminal-copy-shortcut.test.ts', + 'test/terminal-keycode229-recovery.browser.test.ts', 'test/codex-predictive-echo.test.ts', // also needs a real codex binary ]; diff --git a/src/web/public/index.html b/src/web/public/index.html index c7a48ebb..0ed86223 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3458,7 +3458,7 @@ - + diff --git a/src/web/public/terminal-keycode229-recovery.js b/src/web/public/terminal-keycode229-recovery.js index 491b5ec0..4acf32a4 100644 --- a/src/web/public/terminal-keycode229-recovery.js +++ b/src/web/public/terminal-keycode229-recovery.js @@ -1,37 +1,44 @@ /** - * 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. + * @fileoverview Orphaned-input forwarder for xterm's helper textarea. + * + * xterm's `CoreBrowserTerminal._inputEvent` only forwards an `insertText` + * input event while `(!ev.composed || !this._keyDownSeen)` holds. A soft + * keyboard that delivers a `composed: true` input event after a keydown fails + * that guard, so xterm returns without emitting and the committed character is + * silently dropped. + * + * ⚠ The gap is NARROWER than "keyCode 229", and assuming otherwise produces a + * controller that looks useful while doing nothing. For a keydown that really + * does report `keyCode: 229`, xterm ALREADY self-rescues: `CompositionHelper + * .keydown()` calls `_handleAnyTextareaChanges()`, which snapshots + * `textarea.value` and diffs it on a 0 ms timer, emitting the difference + * itself. Measured in headless chromium against a real terminal: for a 229 + * keydown xterm emits and this controller correctly stands down. What is left + * unrescued is a refused `insertText` where NO 229 diff was scheduled — that is + * the case this module exists for, and the case its browser test asserts by + * checking WHO delivered the byte rather than merely that one arrived. + * + * The recovery never guesses the character: the `input` event already carries + * the real committed text in `ev.data`, which is exactly what xterm itself + * would have forwarded. We only decide WHETHER to forward it, by asking + * whether xterm produced any canonical data since the keydown that started the + * keystroke. That snapshot must be taken at KEYDOWN, not at the input event: + * xterm's `_keyPress` emits and sets `_keyPressHandled` before `input` fires, + * so a snapshot read at input time would already contain that emission and the + * character would be delivered twice. + * + * Listener registration is load-bearing, in BOTH phase and order. xterm + * registers its own `input` listener in `terminal.open()` with `capture: + * true`, and ours is added afterwards, so at-target it runs second. It must + * also be a CAPTURE listener; see the measured table at the addEventListener + * call below. + * + * @dependency none (standalone IIFE; consumed by terminal-ui.js) + * @loadorder 5.55 (before app.js/terminal-ui.js, which create the controller) */ (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; @@ -39,189 +46,142 @@ return null; } - const enqueueMicrotask = options.queueMicrotask || global.queueMicrotask.bind(global); + const isScreenReaderMode = options.isScreenReaderMode; 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; + // Number of canonical data events xterm has emitted, bumped by the caller's + // onData hook. Only its ORDER relative to a keydown matters. + let canonicalCount = 0; + let keydownSnapshot = null; + let composing = false; 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; + function cancelPending() { + for (const candidate of pending.splice(0)) { + candidate.active = false; + if (candidate.timer !== null) { 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); + clearTimer(candidate.timer); } catch { - removePending(candidate); + // A broken timer host must not break input handling. } - }); - } 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; + candidate.timer = null; } } - claimCanonicalInput(inputData); } - function resetForCompositionOrFocusLoss() { + function resolveCandidate(candidate) { + const index = pending.indexOf(candidate); + if (index !== -1) pending.splice(index, 1); + candidate.timer = null; + if (!candidate.active || destroyed) return; + candidate.active = false; + // xterm (or its keypress path) spoke for this keystroke — it is already + // on its way to the PTY, so there is nothing to recover. + if (canonicalCount > candidate.snapshot) return; + try { + emitRecovered(candidate.data); + } catch { + // Recovery is best effort; a failed delivery must never throw into the + // browser's input handling. + } + } + + /** Called from xterm's onData hook: xterm produced canonical data. */ + function notifyCanonicalData() { + canonicalCount += 1; + } + + /** + * Snapshot the canonical counter at every keydown. This deliberately reads + * NOTHING else off the event — not `key`, not `keyCode`. Gating it on + * keyCode 229 would make the recovery inert on exactly the devices it + * exists for, whose keydowns report `key: 'Unidentified'`. It is a single + * assignment, so running it for every keydown costs nothing. + */ + function handleKeyEvent(event) { + if (destroyed || event?.type !== 'keydown') return; + keydownSnapshot = canonicalCount; + } + + function onInput(event) { + if (destroyed || composing || event?.isComposing) return; + if (event.inputType !== 'insertText') return; + const data = event.data; + if (typeof data !== 'string' || data === '') return; + try { + if (isScreenReaderMode?.()) return; + } catch { + return; + } + + const candidate = { + data, + snapshot: keydownSnapshot ?? canonicalCount, + active: true, + timer: null, + }; + pending.push(candidate); + try { + candidate.timer = setTimer(() => resolveCandidate(candidate), 0); + } catch { + cancelPending(); + } + } + + function onCompositionStart() { if (destroyed) return; - keySequence += 1; - activeKey = null; - beforeInputClaim = null; + composing = true; 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 onCompositionEnd() { + if (destroyed) return; + composing = false; } 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 {} + textarea.removeEventListener('input', onInput, true); + textarea.removeEventListener('compositionstart', onCompositionStart, true); + textarea.removeEventListener('compositionend', onCompositionEnd, true); + } catch { + // Teardown is best effort; the terminal is being replaced anyway. + } } + // capture: true, not bubble. The target (the textarea) is visited TWICE in + // the event path, so a capture-phase listener on it calling + // stopPropagation() still stops later BUBBLE-phase listeners on that same + // target. xterm's `_inputEvent` calls `this.cancel(ev)` (preventDefault + + // stopPropagation) exactly in the branch where it HANDLED the input, so on + // bubble we would never see handled events — and whether we saw them at + // all would hang off xterm's `options.cancelEvents`, which Codeman does not + // set. Measured (jsdom and headless chromium agree): + // + // capture-then-BUBBLE, no stop: xterm -> ours + // capture-then-BUBBLE, stopPropagation: xterm (ours never fires) + // capture-then-CAPTURE, no stop: xterm -> ours + // capture-then-CAPTURE, stopPropagation: xterm -> ours (still fires) + // + // On capture we therefore observe EVERY input event uniformly, and the + // canonicalCount snapshot alone decides whether to forward. try { - textarea.addEventListener('beforeinput', onCanonicalInput, true); - textarea.addEventListener('input', onCanonicalInput, true); - textarea.addEventListener('compositionstart', resetForCompositionOrFocusLoss, true); - textarea.addEventListener('blur', resetForCompositionOrFocusLoss, true); + textarea.addEventListener('input', onInput, true); + textarea.addEventListener('compositionstart', onCompositionStart, true); + textarea.addEventListener('compositionend', onCompositionEnd, true); } catch { destroy(); return null; } - return Object.freeze({ handleKeyEvent, consumeTerminalData, destroy }); + return Object.freeze({ handleKeyEvent, notifyCanonicalData, destroy }); } - global.CodemanKeyCode229Recovery = Object.freeze({ create, terminalDataForEvent }); + global.CodemanKeyCode229Recovery = Object.freeze({ create }); })(typeof window !== 'undefined' ? window : globalThis); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 4555eb86..27ebcf80 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -303,6 +303,11 @@ Object.assign(CodemanApp.prototype, { // the helper textarea and emit the committed Unicode text. this.terminal.attachCustomKeyEventHandler((ev) => { try { + // Deliberately runs for EVERY keydown, not just keyCode 229: the + // controller snapshots a counter and reads nothing off the event, and + // the devices this exists for report `key: 'Unidentified'` with no + // reliable identity to gate on. Gating it would make recovery inert + // exactly where it is needed. Cost is one assignment. this._keyCode229Recovery?.handleKeyEvent?.(ev); } catch { // The fallback must never interfere with xterm's canonical handler. @@ -1041,14 +1046,7 @@ Object.assign(CodemanApp.prototype, { // mobile connections. The overlay + localStorage persistence ensure input // survives tab switches and reconnects. - 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. - } - } + const handleTerminalData = (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 @@ -1372,19 +1370,35 @@ 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. + // Chrome on Android delivers a `composed: true` input event preceded by a + // keydown, which is exactly the shape xterm's _inputEvent refuses to + // forward, so the committed character is silently dropped. The controller + // forwards the input event's own `data` when xterm produced nothing for + // that keystroke. Created AFTER terminal.open() on purpose: for an event + // targeting the textarea, at-target listeners run in registration order, + // so xterm's listener (added in open()) still runs first. The controller + // registers its own listener with `capture: true`; on bubble xterm's + // `cancel()` (stopPropagation) would swallow exactly the handled events — + // see the measured table in terminal-keycode229-recovery.js. try { this._keyCode229Recovery = window.CodemanKeyCode229Recovery?.create?.({ textarea: this.terminal.textarea, - emitRecovered: (data) => handleTerminalData(data, { recovered: true }), + emitRecovered: (data) => handleTerminalData(data), + isScreenReaderMode: () => this.terminal?.options?.screenReaderMode === true, }); } catch { this._keyCode229Recovery = null; } - this.terminal.onData((data) => handleTerminalData(data)); + this.terminal.onData((data) => { + // Canonical xterm data. Telling the controller is what lets it know a + // keystroke was already delivered and needs no recovery. + try { + this._keyCode229Recovery?.notifyCanonicalData?.(); + } catch { + // Bookkeeping must never block real input. + } + handleTerminalData(data); + }); }, /** diff --git a/test/terminal-copy-shortcut.test.ts b/test/terminal-copy-shortcut.test.ts index 018ebff9..f719ed3c 100644 --- a/test/terminal-copy-shortcut.test.ts +++ b/test/terminal-copy-shortcut.test.ts @@ -218,90 +218,6 @@ 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); diff --git a/test/terminal-keycode229-recovery.browser.test.ts b/test/terminal-keycode229-recovery.browser.test.ts new file mode 100644 index 00000000..8f980fc0 --- /dev/null +++ b/test/terminal-keycode229-recovery.browser.test.ts @@ -0,0 +1,153 @@ +/** + * Wiring for the orphaned-input recovery controller, in a real browser. + * + * The controller's decision logic is unit-tested in + * test/terminal-keycode229-recovery.test.ts. What can only be proven with a + * real xterm instance is the wiring: + * + * - our `input` listener is registered AFTER xterm's, so xterm's `cancel()` + * (stopPropagation, not stopImmediatePropagation) does not silence it; + * - a `composed: true` insertText preceded by a keydown — the shape Chrome on + * Android delivers — is dropped by xterm and recovered by us, exactly once; + * - a keystroke xterm DOES handle is delivered exactly once, not twice. + * + * Browser-driven, so it is excluded from `npm run test:ci` like the other + * Playwright suites. Run locally: + * npm run test:browser -- test/terminal-keycode229-recovery.browser.test.ts + * + * Port: 3186 (per CLAUDE.md, ports 3150+ for tests) + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { chromium, type Browser, type Page } from 'playwright'; +import { WebServer } from '../src/web/server.js'; + +const PORT = 3186; +const BASE_URL = `http://localhost:${PORT}`; + +describe('orphaned terminal input recovery wiring', () => { + let server: WebServer; + let browser: Browser; + let page: Page; + + beforeAll(async () => { + server = new WebServer(PORT, false, true); + await server.start(); + browser = await chromium.launch({ headless: true }); + page = await browser.newPage(); + await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 }); + await page.waitForFunction(() => (window as any).app?._keyCode229Recovery, null, { timeout: 30000 }); + }, 90000); + + afterAll(async () => { + if (browser) await browser.close(); + if (server) await server.stop(); + }, 60000); + + /** + * Drive one keystroke through the real textarea and report what reached the + * PTY send path. `dispatchInput` mirrors GBoard: a keydown with no usable key + * identity, then a `composed: true` insertText that xterm refuses to forward. + */ + async function keystroke(options: { data: string; dispatchInput: boolean; keyCode: number }) { + return page.evaluate(async ({ data, dispatchInput, keyCode }) => { + 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[] = []; + let xtermEmitted = 0; + const rec = app._keyCode229Recovery; + + try { + app.activeSessionId = 'cod388-browser-regression'; + app._localEchoEnabled = false; + app._pendingInput = ''; + app._lastKeystrokeTime = 0; + app._sendInputAsync = (_sessionId: string, chunk: string) => sent.push(chunk); + // The controller object is Object.freeze()d, so count xterm's own + // canonical emissions by swapping the (writable) property on app. + app._keyCode229Recovery = { + handleKeyEvent: (e: any) => rec.handleKeyEvent(e), + notifyCanonicalData: () => { + xtermEmitted += 1; + return rec.notifyCanonicalData(); + }, + destroy: () => rec.destroy(), + }; + textarea.focus(); + + const down = new KeyboardEvent('keydown', { + key: 'Unidentified', + bubbles: true, + cancelable: true, + composed: true, + }); + Object.defineProperties(down, { keyCode: { value: keyCode }, which: { value: keyCode } }); + textarea.dispatchEvent(down); + + if (dispatchInput) { + textarea.value = data; + textarea.dispatchEvent( + new InputEvent('input', { data, inputType: 'insertText', bubbles: true, composed: true }) + ); + } + + await new Promise((resolve) => setTimeout(resolve, 60)); + return { sent, xtermEmitted }; + } finally { + app.activeSessionId = originalSessionId; + app._localEchoEnabled = originalLocalEcho; + app._sendInputAsync = originalSendInput; + app._pendingInput = originalPendingInput; + app._lastKeystrokeTime = originalLastKeystrokeTime; + app._keyCode229Recovery = rec; + textarea.value = ''; + } + }, options); + } + + /** + * ⚠ The gap this controller actually fills is NARROWER than "keyCode 229", + * and that matters for what these tests can prove. + * + * xterm already self-recovers keyCode 229: `CompositionHelper.keydown()` + * calls `_handleAnyTextareaChanges()`, which snapshots `textarea.value` and + * diffs it on a 0 ms timer, emitting the difference itself. So for a 229 + * keydown there is nothing orphaned to recover, and a test asserting "we + * recovered it" would pass while xterm did all the work — measured: xterm + * emits, our controller correctly stands down. + * + * The real gap is an `insertText` input event that xterm's `_inputEvent` + * refuses (`composed: true` with a keydown seen) where NO 229 diff was + * scheduled to rescue it. These tests therefore assert WHO delivered the + * byte, via `xtermEmitted`, not merely that a byte arrived. + */ + it('recovers a composed insertText that xterm dropped and did not self-rescue', async () => { + const { sent, xtermEmitted } = await keystroke({ data: 'x', dispatchInput: true, keyCode: 65 }); + expect(xtermEmitted).toBe(0); // xterm delivered nothing: genuinely orphaned + expect(sent.join('')).toBe('x'); // ...so this byte is ours + }); + + it('does not duplicate a keystroke xterm self-rescued via its own 0 ms diff', async () => { + const { sent, xtermEmitted } = await keystroke({ data: 'y', dispatchInput: true, keyCode: 229 }); + expect(xtermEmitted).toBe(1); // xterm's 229 textarea diff spoke + expect(sent.join('')).toBe('y'); // exactly once — we must not add a second copy + }); + + it('recovers the same character twice when both keystrokes are orphaned', async () => { + const first = await keystroke({ data: 'z', dispatchInput: true, keyCode: 65 }); + const second = await keystroke({ data: 'z', dispatchInput: true, keyCode: 65 }); + expect(first.sent.join('')).toBe('z'); + expect(second.sent.join('')).toBe('z'); + }); + + it('sends nothing for a keydown that produces no input event', async () => { + const { sent } = await keystroke({ data: 'q', dispatchInput: false, keyCode: 65 }); + expect(sent).toEqual([]); + }); +}); diff --git a/test/terminal-keycode229-recovery.test.ts b/test/terminal-keycode229-recovery.test.ts index 247d1653..25cf87dd 100644 --- a/test/terminal-keycode229-recovery.test.ts +++ b/test/terminal-keycode229-recovery.test.ts @@ -1,3 +1,15 @@ +/** + * Orphaned-input recovery for xterm's helper textarea (PR #388 / COD-27). + * + * xterm's CoreBrowserTerminal._inputEvent only forwards an `insertText` input + * event when `(!ev.composed || !this._keyDownSeen)`. Chrome-on-Android's soft + * keyboard produces `composed: true` input events preceded by a keydown, so + * that guard is false and the committed character is silently dropped. + * + * The controller under test forwards the event's own `data` when — and only + * when — xterm produced no canonical data for that keystroke. These tests + * drive it with synthetic events and an injected timer; no browser is needed. + */ import { readFileSync } from 'node:fs'; import vm from 'node:vm'; import { describe, expect, it } from 'vitest'; @@ -6,74 +18,66 @@ type Listener = (event: Record) => void; function makeTextarea() { const listeners = new Map>(); + const registrations: Array<{ type: string; capture: unknown }> = []; return { - addEventListener(type: string, listener: Listener) { + addEventListener(type: string, listener: Listener, capture?: unknown) { const bucket = listeners.get(type) ?? new Set(); bucket.add(listener); listeners.set(type, bucket); + registrations.push({ type, capture }); }, removeEventListener(type: string, listener: Listener) { listeners.get(type)?.delete(listener); }, fire(type: string, event: Record = {}) { - for (const listener of listeners.get(type) ?? []) listener({ type, ...event }); + for (const listener of [...(listeners.get(type) ?? [])]) listener({ type, ...event }); }, listenerCount() { return [...listeners.values()].reduce((total, bucket) => total + bucket.size, 0); }, + registrations() { + return [...registrations]; + }, }; } -function key(overrides: Record = {}) { - return { - type: 'keydown', - key: 'x', - keyCode: 229, - isComposing: false, - ctrlKey: false, - altKey: false, - metaKey: false, - getModifierState: () => false, - ...overrides, - }; +/** A committed-text `input` event of the shape Chrome-on-Android delivers. */ +function inputEvent(data: string, overrides: Record = {}) { + return { data, inputType: 'insertText', isComposing: false, ...overrides }; } -function harness({ emitThrows = false } = {}) { +function harness({ screenReader = false } = {}) { const source = readFileSync(new URL('../src/web/public/terminal-keycode229-recovery.js', import.meta.url), 'utf8'); const exposed: Record = {}; 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 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), + emitRecovered: (data: string) => emitted.push(data), + isScreenReaderMode: () => screenReader, 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; + /** A keydown that carries NO usable key identity, exactly like GBoard's. */ + keydown(overrides: Record = {}) { + controller.handleKeyEvent({ type: 'keydown', key: 'Unidentified', keyCode: 229, ...overrides }); }, - flushMicrotasks() { - while (microtasks.length) microtasks.shift()!(); + input(data: string, overrides: Record = {}) { + textarea.fire('input', inputEvent(data, overrides)); }, flushTimers() { for (const [id, callback] of [...timers]) { @@ -85,181 +89,190 @@ function harness({ emitThrows = false } = {}) { }; } -describe('keyCode 229 terminal input recovery', () => { - it('recovers an explicit printable key and Enter after xterm gets the first opportunity', () => { +describe('orphaned terminal input recovery', () => { + it('forwards the committed text when xterm stayed silent', () => { const h = harness(); - - h.controller.handleKeyEvent(key()); - expect(h.emitted).toEqual([]); - h.flushMicrotasks(); + h.keydown(); + h.input('x'); 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', () => { + it('forwards nothing when xterm emitted canonical data after the keydown', () => { + // The "xterm handled it" case is decided by the COUNTER, never by assuming + // the input event does not reach us. On capture it always does (xterm's + // cancel() only stops later BUBBLE listeners), so this test dispatches the + // real input event AND has xterm emit canonical data for that keystroke. const h = harness(); - h.controller.handleKeyEvent(key()); - - expect(h.controller.consumeTerminalData('x')).toBe(false); - h.flushMicrotasks(); + h.keydown(); + h.input('x'); + h.controller.notifyCanonicalData(); 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', () => { + it('registers the input listener in the CAPTURE phase', () => { + // Measured in jsdom and headless chromium: a capture-phase listener on the + // TARGET calling stopPropagation() (which is what xterm's cancel() does in + // the branch where it handled the input) stops later BUBBLE listeners on + // that same target, because the target is visited twice in the event path. + // + // capture-then-BUBBLE, stopPropagation: ours NEVER fires + // capture-then-CAPTURE, stopPropagation: ours still fires + // + // So this must not be "tidied" to bubble: on bubble we would silently stop + // seeing exactly the events xterm handled, and whether we saw them at all + // would depend on xterm's `options.cancelEvents`, which Codeman never sets. 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); + const input = h.textarea.registrations().filter((entry) => entry.type === 'input'); + expect(input).toHaveLength(1); + expect(input[0].capture).toBe(true); + for (const entry of h.textarea.registrations()) expect(entry.capture).toBe(true); }); - it('does not suppress an unattributed same byte after recovery', () => { + it('forwards nothing on the keypress path, where canonical data precedes the input event', () => { + // xterm's _keyPress calls triggerDataEvent() and sets _keyPressHandled + // BEFORE the input event fires. The "did xterm speak?" snapshot therefore + // has to be taken at keydown; taken at input time it would already include + // this emission and the character would be delivered twice. const h = harness(); - h.controller.handleKeyEvent(key()); - h.flushMicrotasks(); + h.keydown(); + h.controller.notifyCanonicalData(); + h.input('x'); 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', () => { + it('does not let a stale candidate swallow a later identical keystroke (defect 1)', () => { 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(); + // First keystroke: orphaned, recovered. + h.keydown(); + h.input('x'); 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(); + // Second identical keystroke, handled by xterm itself. + h.keydown(); + h.input('x'); + h.controller.notifyCanonicalData(); h.flushTimers(); - expect(h.emitted).toEqual([]); + // Exactly one recovery total, and the second keystroke's canonical byte was + // never claimed or suppressed by the first one. + expect(h.emitted).toEqual(['x']); }); - it('does not recover real composition, unidentified keys, modifiers, or keyup', () => { + it('forwards committed text that no keydown key could describe, exactly once (defect 2)', () => { 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.keydown({ key: 'Enter' }); + h.input('a longer commit'); + h.flushTimers(); + h.flushTimers(); + expect(h.emitted).toEqual(['a longer commit']); + }); + + it('recovers a GBoard keydown and never reads key or keyCode (defect 3)', () => { + const h = harness(); + const reads: string[] = []; + h.controller.handleKeyEvent({ + type: 'keydown', + get key() { + reads.push('key'); + return 'Unidentified'; + }, + get keyCode() { + reads.push('keyCode'); + return 229; + }, + get which() { + reads.push('which'); + return 229; + }, + }); + h.input('x'); + h.flushTimers(); + expect(h.emitted).toEqual(['x']); + expect(reads).toEqual([]); + }); + + it('ignores input events that are not committed text', () => { + const h = harness(); + for (const inputType of ['insertCompositionText', 'deleteContentBackward', 'insertLineBreak', 'insertFromPaste']) { + h.keydown(); + h.input('x', { inputType }); } - h.flushMicrotasks(); + h.keydown(); + h.input(''); + h.keydown(); + h.textarea.fire('input', { data: null, inputType: 'insertText' }); h.flushTimers(); expect(h.emitted).toEqual([]); }); - it('expires deduplication and destroys listeners and scheduled work', () => { + it('ignores composition and cancels pending candidates on compositionstart', () => { + const composing = harness(); + composing.keydown(); + composing.input('x', { isComposing: true }); + composing.flushTimers(); + expect(composing.emitted).toEqual([]); + + const lifecycle = harness(); + lifecycle.textarea.fire('compositionstart'); + lifecycle.keydown(); + lifecycle.input('中'); + lifecycle.flushTimers(); + expect(lifecycle.emitted).toEqual([]); + + // compositionstart arriving after a candidate is queued must cancel it. + const cancelled = harness(); + cancelled.keydown(); + cancelled.input('x'); + cancelled.textarea.fire('compositionstart'); + cancelled.flushTimers(); + expect(cancelled.emitted).toEqual([]); + + // compositionend releases the gate again. + cancelled.textarea.fire('compositionend'); + cancelled.keydown(); + cancelled.input('y'); + cancelled.flushTimers(); + expect(cancelled.emitted).toEqual(['y']); + }); + + it('stays out of the way in screen reader mode', () => { + const h = harness({ screenReader: true }); + h.keydown(); + h.input('x'); + h.flushTimers(); + expect(h.emitted).toEqual([]); + }); + + it('recovers an input event that arrives with no preceding keydown', () => { + const h = harness(); + h.input('x'); + h.flushTimers(); + expect(h.emitted).toEqual(['x']); + }); + + it('clears timers and listeners on destroy', () => { 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(); + h.keydown(); + h.input('x'); 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']); + expect(h.emitted).toEqual([]); + + // Nothing fires after destroy, even if a stray event is delivered. + h.textarea.fire('input', inputEvent('y')); + h.flushTimers(); + expect(h.emitted).toEqual([]); }); });