Files
Codeman/test/terminal-keycode229-recovery.browser.test.ts
T
Aamer Akhter e8a93ada1f 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.
2026-09-07 19:11:20 -04:00

154 lines
6.5 KiB
TypeScript

/**
* 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([]);
});
});