mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 07:59:42 +02:00
fix(terminal): forward the orphaned input event instead of replaying a guessed key
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.
This commit is contained in:
@@ -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 |
|
| **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 |
|
| **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` | |
|
| **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 |
|
| **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`.
|
★ = 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
|
||||||
|
|
||||||
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 `<html>`. 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`.
|
**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 `<html>`. 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`.
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export const BROWSER_TEST_GLOBS = [
|
|||||||
'test/opencode-resize.test.ts',
|
'test/opencode-resize.test.ts',
|
||||||
'test/webgl-fallback.test.ts',
|
'test/webgl-fallback.test.ts',
|
||||||
'test/terminal-copy-shortcut.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
|
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -3458,7 +3458,7 @@
|
|||||||
<script defer src="notification-manager.js"></script>
|
<script defer src="notification-manager.js"></script>
|
||||||
<script defer src="keyboard-accessory.js"></script>
|
<script defer src="keyboard-accessory.js"></script>
|
||||||
<script defer src="input-cjk.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. -->
|
<!-- Forwards committed input events that xterm drops on Android/GBoard soft keyboards. Must precede terminal-ui.js. -->
|
||||||
<script defer src="terminal-keycode229-recovery.js"></script>
|
<script defer src="terminal-keycode229-recovery.js"></script>
|
||||||
<!-- Hardened markdown HTML sanitizer (wires DOMPurify). Must precede app.js. -->
|
<!-- Hardened markdown HTML sanitizer (wires DOMPurify). Must precede app.js. -->
|
||||||
<script defer src="sanitize-html.js"></script>
|
<script defer src="sanitize-html.js"></script>
|
||||||
|
|||||||
@@ -1,37 +1,44 @@
|
|||||||
/**
|
/**
|
||||||
* Recover explicit keyCode 229 terminal input when a browser reports a key but
|
* @fileoverview Orphaned-input forwarder for xterm's helper textarea.
|
||||||
* never mutates xterm's helper textarea. xterm remains authoritative whenever
|
*
|
||||||
* it emits canonical data or the browser enters a real composition lifecycle.
|
* 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) {
|
(function (global) {
|
||||||
'use strict';
|
'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) {
|
function create(options) {
|
||||||
const textarea = options?.textarea;
|
const textarea = options?.textarea;
|
||||||
const emitRecovered = options?.emitRecovered;
|
const emitRecovered = options?.emitRecovered;
|
||||||
@@ -39,189 +46,142 @@
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const enqueueMicrotask = options.queueMicrotask || global.queueMicrotask.bind(global);
|
const isScreenReaderMode = options.isScreenReaderMode;
|
||||||
const setTimer = options.setTimer || global.setTimeout.bind(global);
|
const setTimer = options.setTimer || global.setTimeout.bind(global);
|
||||||
const clearTimer = options.clearTimer || global.clearTimeout.bind(global);
|
const clearTimer = options.clearTimer || global.clearTimeout.bind(global);
|
||||||
const now = options.now || (() => global.performance?.now?.() ?? Date.now());
|
|
||||||
|
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
let keySequence = 0;
|
// Number of canonical data events xterm has emitted, bumped by the caller's
|
||||||
let activeKey = null;
|
// onData hook. Only its ORDER relative to a keydown matters.
|
||||||
let beforeInputClaim = null;
|
let canonicalCount = 0;
|
||||||
|
let keydownSnapshot = null;
|
||||||
|
let composing = false;
|
||||||
const pending = [];
|
const pending = [];
|
||||||
const recovered = [];
|
|
||||||
|
|
||||||
function removePending(candidate) {
|
function cancelPending() {
|
||||||
const index = pending.indexOf(candidate);
|
for (const candidate of pending.splice(0)) {
|
||||||
if (index !== -1) pending.splice(index, 1);
|
candidate.active = false;
|
||||||
if (candidate.timer !== null) {
|
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 {
|
try {
|
||||||
candidate.timer = setTimer(() => {
|
clearTimer(candidate.timer);
|
||||||
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 {
|
} catch {
|
||||||
removePending(candidate);
|
// A broken timer host must not break input handling.
|
||||||
}
|
}
|
||||||
});
|
candidate.timer = null;
|
||||||
} 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() {
|
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;
|
if (destroyed) return;
|
||||||
keySequence += 1;
|
composing = true;
|
||||||
activeKey = null;
|
|
||||||
beforeInputClaim = null;
|
|
||||||
cancelPending();
|
cancelPending();
|
||||||
recovered.splice(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function consumeTerminalData(data) {
|
function onCompositionEnd() {
|
||||||
if (destroyed) return false;
|
if (destroyed) return;
|
||||||
pruneRecovered();
|
composing = false;
|
||||||
|
|
||||||
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() {
|
function destroy() {
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
destroyed = true;
|
destroyed = true;
|
||||||
activeKey = null;
|
|
||||||
beforeInputClaim = null;
|
|
||||||
cancelPending();
|
cancelPending();
|
||||||
recovered.splice(0);
|
|
||||||
try {
|
try {
|
||||||
textarea.removeEventListener('beforeinput', onCanonicalInput, true);
|
textarea.removeEventListener('input', onInput, true);
|
||||||
textarea.removeEventListener('input', onCanonicalInput, true);
|
textarea.removeEventListener('compositionstart', onCompositionStart, true);
|
||||||
textarea.removeEventListener('compositionstart', resetForCompositionOrFocusLoss, true);
|
textarea.removeEventListener('compositionend', onCompositionEnd, true);
|
||||||
textarea.removeEventListener('blur', resetForCompositionOrFocusLoss, true);
|
} catch {
|
||||||
} 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 {
|
try {
|
||||||
textarea.addEventListener('beforeinput', onCanonicalInput, true);
|
textarea.addEventListener('input', onInput, true);
|
||||||
textarea.addEventListener('input', onCanonicalInput, true);
|
textarea.addEventListener('compositionstart', onCompositionStart, true);
|
||||||
textarea.addEventListener('compositionstart', resetForCompositionOrFocusLoss, true);
|
textarea.addEventListener('compositionend', onCompositionEnd, true);
|
||||||
textarea.addEventListener('blur', resetForCompositionOrFocusLoss, true);
|
|
||||||
} catch {
|
} catch {
|
||||||
destroy();
|
destroy();
|
||||||
return null;
|
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);
|
})(typeof window !== 'undefined' ? window : globalThis);
|
||||||
|
|||||||
@@ -303,6 +303,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// the helper textarea and emit the committed Unicode text.
|
// the helper textarea and emit the committed Unicode text.
|
||||||
this.terminal.attachCustomKeyEventHandler((ev) => {
|
this.terminal.attachCustomKeyEventHandler((ev) => {
|
||||||
try {
|
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);
|
this._keyCode229Recovery?.handleKeyEvent?.(ev);
|
||||||
} catch {
|
} catch {
|
||||||
// The fallback must never interfere with xterm's canonical handler.
|
// 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
|
// mobile connections. The overlay + localStorage persistence ensure input
|
||||||
// survives tab switches and reconnects.
|
// survives tab switches and reconnects.
|
||||||
|
|
||||||
const handleTerminalData = (data, { recovered = false } = {}) => {
|
const handleTerminalData = (data) => {
|
||||||
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
|
// 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
|
// the PTY even while the CJK input field owns focus. Without this exception
|
||||||
// tapping to move the cursor silently does nothing whenever Chinese input
|
// 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
|
// Chrome on Android delivers a `composed: true` input event preceded by a
|
||||||
// mutates xterm's helper textarea, so the character is silently dropped.
|
// keydown, which is exactly the shape xterm's _inputEvent refuses to
|
||||||
// The controller re-emits exactly those keys, and only after xterm has had
|
// forward, so the committed character is silently dropped. The controller
|
||||||
// its own chance to produce the canonical data.
|
// 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 {
|
try {
|
||||||
this._keyCode229Recovery = window.CodemanKeyCode229Recovery?.create?.({
|
this._keyCode229Recovery = window.CodemanKeyCode229Recovery?.create?.({
|
||||||
textarea: this.terminal.textarea,
|
textarea: this.terminal.textarea,
|
||||||
emitRecovered: (data) => handleTerminalData(data, { recovered: true }),
|
emitRecovered: (data) => handleTerminalData(data),
|
||||||
|
isScreenReaderMode: () => this.terminal?.options?.screenReaderMode === true,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
this._keyCode229Recovery = null;
|
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);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -218,90 +218,6 @@ describe('terminal Ctrl+C smart copy', () => {
|
|||||||
expect(res.data.join('')).not.toContain('\x16');
|
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 () => {
|
it('forwards full-width punctuation after a Chinese IME composition', async () => {
|
||||||
await setup('IME-PUNCTUATION', false);
|
await setup('IME-PUNCTUATION', false);
|
||||||
const desktopChunks = await captureImeInput(page);
|
const desktopChunks = await captureImeInput(page);
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { readFileSync } from 'node:fs';
|
||||||
import vm from 'node:vm';
|
import vm from 'node:vm';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
@@ -6,74 +18,66 @@ type Listener = (event: Record<string, unknown>) => void;
|
|||||||
|
|
||||||
function makeTextarea() {
|
function makeTextarea() {
|
||||||
const listeners = new Map<string, Set<Listener>>();
|
const listeners = new Map<string, Set<Listener>>();
|
||||||
|
const registrations: Array<{ type: string; capture: unknown }> = [];
|
||||||
return {
|
return {
|
||||||
addEventListener(type: string, listener: Listener) {
|
addEventListener(type: string, listener: Listener, capture?: unknown) {
|
||||||
const bucket = listeners.get(type) ?? new Set<Listener>();
|
const bucket = listeners.get(type) ?? new Set<Listener>();
|
||||||
bucket.add(listener);
|
bucket.add(listener);
|
||||||
listeners.set(type, bucket);
|
listeners.set(type, bucket);
|
||||||
|
registrations.push({ type, capture });
|
||||||
},
|
},
|
||||||
removeEventListener(type: string, listener: Listener) {
|
removeEventListener(type: string, listener: Listener) {
|
||||||
listeners.get(type)?.delete(listener);
|
listeners.get(type)?.delete(listener);
|
||||||
},
|
},
|
||||||
fire(type: string, event: Record<string, unknown> = {}) {
|
fire(type: string, event: Record<string, unknown> = {}) {
|
||||||
for (const listener of listeners.get(type) ?? []) listener({ type, ...event });
|
for (const listener of [...(listeners.get(type) ?? [])]) listener({ type, ...event });
|
||||||
},
|
},
|
||||||
listenerCount() {
|
listenerCount() {
|
||||||
return [...listeners.values()].reduce((total, bucket) => total + bucket.size, 0);
|
return [...listeners.values()].reduce((total, bucket) => total + bucket.size, 0);
|
||||||
},
|
},
|
||||||
|
registrations() {
|
||||||
|
return [...registrations];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function key(overrides: Record<string, unknown> = {}) {
|
/** A committed-text `input` event of the shape Chrome-on-Android delivers. */
|
||||||
return {
|
function inputEvent(data: string, overrides: Record<string, unknown> = {}) {
|
||||||
type: 'keydown',
|
return { data, inputType: 'insertText', isComposing: false, ...overrides };
|
||||||
key: 'x',
|
|
||||||
keyCode: 229,
|
|
||||||
isComposing: false,
|
|
||||||
ctrlKey: false,
|
|
||||||
altKey: false,
|
|
||||||
metaKey: false,
|
|
||||||
getModifierState: () => 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 source = readFileSync(new URL('../src/web/public/terminal-keycode229-recovery.js', import.meta.url), 'utf8');
|
||||||
const exposed: Record<string, any> = {};
|
const exposed: Record<string, any> = {};
|
||||||
vm.runInNewContext(source, { window: exposed, globalThis: exposed }, { filename: 'terminal-keycode229-recovery.js' });
|
vm.runInNewContext(source, { window: exposed, globalThis: exposed }, { filename: 'terminal-keycode229-recovery.js' });
|
||||||
|
|
||||||
const textarea = makeTextarea();
|
const textarea = makeTextarea();
|
||||||
const emitted: string[] = [];
|
const emitted: string[] = [];
|
||||||
const microtasks: Array<() => void> = [];
|
|
||||||
const timers = new Map<number, () => void>();
|
const timers = new Map<number, () => void>();
|
||||||
let timerId = 0;
|
let timerId = 0;
|
||||||
let now = 1_000;
|
|
||||||
const controller = exposed.CodemanKeyCode229Recovery.create({
|
const controller = exposed.CodemanKeyCode229Recovery.create({
|
||||||
textarea,
|
textarea,
|
||||||
emitRecovered: (data: string) => {
|
emitRecovered: (data: string) => emitted.push(data),
|
||||||
if (emitThrows) throw new Error('recovery callback failed');
|
isScreenReaderMode: () => screenReader,
|
||||||
emitted.push(data);
|
|
||||||
},
|
|
||||||
queueMicrotask: (callback: () => void) => microtasks.push(callback),
|
|
||||||
setTimer: (callback: () => void) => {
|
setTimer: (callback: () => void) => {
|
||||||
const id = ++timerId;
|
const id = ++timerId;
|
||||||
timers.set(id, callback);
|
timers.set(id, callback);
|
||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
clearTimer: (id: number) => timers.delete(id),
|
clearTimer: (id: number) => timers.delete(id),
|
||||||
now: () => now,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
controller,
|
controller,
|
||||||
emitted,
|
emitted,
|
||||||
textarea,
|
textarea,
|
||||||
advance(ms: number) {
|
/** A keydown that carries NO usable key identity, exactly like GBoard's. */
|
||||||
now += ms;
|
keydown(overrides: Record<string, unknown> = {}) {
|
||||||
|
controller.handleKeyEvent({ type: 'keydown', key: 'Unidentified', keyCode: 229, ...overrides });
|
||||||
},
|
},
|
||||||
flushMicrotasks() {
|
input(data: string, overrides: Record<string, unknown> = {}) {
|
||||||
while (microtasks.length) microtasks.shift()!();
|
textarea.fire('input', inputEvent(data, overrides));
|
||||||
},
|
},
|
||||||
flushTimers() {
|
flushTimers() {
|
||||||
for (const [id, callback] of [...timers]) {
|
for (const [id, callback] of [...timers]) {
|
||||||
@@ -85,181 +89,190 @@ function harness({ emitThrows = false } = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('keyCode 229 terminal input recovery', () => {
|
describe('orphaned terminal input recovery', () => {
|
||||||
it('recovers an explicit printable key and Enter after xterm gets the first opportunity', () => {
|
it('forwards the committed text when xterm stayed silent', () => {
|
||||||
const h = harness();
|
const h = harness();
|
||||||
|
h.keydown();
|
||||||
h.controller.handleKeyEvent(key());
|
h.input('x');
|
||||||
expect(h.emitted).toEqual([]);
|
|
||||||
h.flushMicrotasks();
|
|
||||||
expect(h.emitted).toEqual([]);
|
expect(h.emitted).toEqual([]);
|
||||||
h.flushTimers();
|
h.flushTimers();
|
||||||
expect(h.emitted).toEqual(['x']);
|
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();
|
const h = harness();
|
||||||
h.controller.handleKeyEvent(key());
|
h.keydown();
|
||||||
|
h.input('x');
|
||||||
expect(h.controller.consumeTerminalData('x')).toBe(false);
|
h.controller.notifyCanonicalData();
|
||||||
h.flushMicrotasks();
|
|
||||||
h.flushTimers();
|
h.flushTimers();
|
||||||
|
|
||||||
expect(h.emitted).toEqual([]);
|
expect(h.emitted).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cancels fallback when the helper textarea receives browser input or composition', () => {
|
it('registers the input listener in the CAPTURE phase', () => {
|
||||||
const input = harness();
|
// Measured in jsdom and headless chromium: a capture-phase listener on the
|
||||||
input.controller.handleKeyEvent(key());
|
// TARGET calling stopPropagation() (which is what xterm's cancel() does in
|
||||||
input.textarea.fire('input', { data: 'x' });
|
// the branch where it handled the input) stops later BUBBLE listeners on
|
||||||
input.flushMicrotasks();
|
// that same target, because the target is visited twice in the event path.
|
||||||
input.flushTimers();
|
//
|
||||||
expect(input.emitted).toEqual([]);
|
// capture-then-BUBBLE, stopPropagation: ours NEVER fires
|
||||||
|
// capture-then-CAPTURE, stopPropagation: ours still fires
|
||||||
const composition = harness();
|
//
|
||||||
composition.controller.handleKeyEvent(key());
|
// So this must not be "tidied" to bubble: on bubble we would silently stop
|
||||||
composition.textarea.fire('compositionstart');
|
// seeing exactly the events xterm handled, and whether we saw them at all
|
||||||
composition.flushMicrotasks();
|
// would depend on xterm's `options.cancelEvents`, which Codeman never sets.
|
||||||
composition.flushTimers();
|
|
||||||
expect(composition.emitted).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('suppresses one delayed matching canonical value from the recovered key token', () => {
|
|
||||||
const h = harness();
|
const h = harness();
|
||||||
h.controller.handleKeyEvent(key());
|
const input = h.textarea.registrations().filter((entry) => entry.type === 'input');
|
||||||
h.flushMicrotasks();
|
expect(input).toHaveLength(1);
|
||||||
h.flushTimers();
|
expect(input[0].capture).toBe(true);
|
||||||
expect(h.emitted).toEqual(['x']);
|
for (const entry of h.textarea.registrations()) expect(entry.capture).toBe(true);
|
||||||
|
|
||||||
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', () => {
|
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();
|
const h = harness();
|
||||||
h.controller.handleKeyEvent(key());
|
h.keydown();
|
||||||
h.flushMicrotasks();
|
h.controller.notifyCanonicalData();
|
||||||
|
h.input('x');
|
||||||
h.flushTimers();
|
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([]);
|
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();
|
const h = harness();
|
||||||
h.controller.handleKeyEvent(key());
|
|
||||||
h.flushMicrotasks();
|
|
||||||
h.flushTimers();
|
|
||||||
|
|
||||||
h.controller.handleKeyEvent(key());
|
// First keystroke: orphaned, recovered.
|
||||||
h.textarea.fire('input', { data: 'x' });
|
h.keydown();
|
||||||
expect(h.controller.consumeTerminalData('x')).toBe(false);
|
h.input('x');
|
||||||
h.flushMicrotasks();
|
|
||||||
h.flushTimers();
|
h.flushTimers();
|
||||||
expect(h.emitted).toEqual(['x']);
|
expect(h.emitted).toEqual(['x']);
|
||||||
|
|
||||||
h.controller.handleKeyEvent(key({ keyCode: 88 }));
|
// Second identical keystroke, handled by xterm itself.
|
||||||
h.textarea.fire('input', { data: 'x' });
|
h.keydown();
|
||||||
expect(h.controller.consumeTerminalData('x')).toBe(false);
|
h.input('x');
|
||||||
});
|
h.controller.notifyCanonicalData();
|
||||||
|
|
||||||
it('never recovers an ordinary keydown that xterm already handles', () => {
|
|
||||||
const h = harness();
|
|
||||||
h.controller.handleKeyEvent(key({ keyCode: 88 }));
|
|
||||||
h.flushMicrotasks();
|
|
||||||
h.flushTimers();
|
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();
|
const h = harness();
|
||||||
for (const event of [
|
h.keydown({ key: 'Enter' });
|
||||||
key({ isComposing: true }),
|
h.input('a longer commit');
|
||||||
key({ key: 'Process' }),
|
h.flushTimers();
|
||||||
key({ key: 'Unidentified' }),
|
h.flushTimers();
|
||||||
key({ key: 'Dead' }),
|
expect(h.emitted).toEqual(['a longer commit']);
|
||||||
key({ ctrlKey: true }),
|
});
|
||||||
key({ altKey: true }),
|
|
||||||
key({ metaKey: true }),
|
it('recovers a GBoard keydown and never reads key or keyCode (defect 3)', () => {
|
||||||
key({ getModifierState: (name: string) => name === 'AltGraph' }),
|
const h = harness();
|
||||||
key({ type: 'keyup' }),
|
const reads: string[] = [];
|
||||||
key({ key: 'ArrowLeft' }),
|
h.controller.handleKeyEvent({
|
||||||
]) {
|
type: 'keydown',
|
||||||
h.controller.handleKeyEvent(event);
|
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();
|
h.flushTimers();
|
||||||
expect(h.emitted).toEqual([]);
|
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();
|
const h = harness();
|
||||||
expect(h.textarea.listenerCount()).toBeGreaterThan(0);
|
expect(h.textarea.listenerCount()).toBeGreaterThan(0);
|
||||||
h.controller.handleKeyEvent(key());
|
h.keydown();
|
||||||
h.flushMicrotasks();
|
h.input('x');
|
||||||
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);
|
expect(h.pendingTimers()).toBe(1);
|
||||||
|
|
||||||
h.controller.destroy();
|
h.controller.destroy();
|
||||||
expect(h.pendingTimers()).toBe(0);
|
expect(h.pendingTimers()).toBe(0);
|
||||||
expect(h.textarea.listenerCount()).toBe(0);
|
expect(h.textarea.listenerCount()).toBe(0);
|
||||||
|
|
||||||
h.flushTimers();
|
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([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user