Files
Codeman/test/terminal-keycode229-recovery.browser.test.ts
T
RandalixandClaude Opus 5.5 aa4217b8b1 test: build every in-process WebServer on port 0 (#440)
The files on the guard's legacy list build new WebServer(0, …) and read server.boundPort after start(), as sse-tile-grid-filter does. Files with several servers read each server's own port; quick-start's local helper lost its port parameter so the literal 0 sits where the server is built. Header comments that named a port say "ephemeral". No assertion changed except where it embedded the port.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 16:28:26 +02:00

587 lines
26 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;
* - a character committed in the SAME page task as Enter reaches the send
* path ahead of the `\r`, which is the ordering the zero-delay timer
* alone cannot produce;
* - a TerminalTile (grid tile, split Pane B) wires its own controller, so
* the same shapes come out right there too, addressed to the tile's session.
*
* 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: ephemeral
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
let baseUrl: string;
describe('orphaned terminal input recovery wiring', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
beforeAll(async () => {
server = new WebServer(0, false, true);
await server.start();
baseUrl = `http://localhost:${server.boundPort}`;
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
await page.goto(baseUrl, { 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');
});
/**
* The batched shape an Android soft keyboard actually delivers when the user
* taps the last character and then Enter: the character's keydown, its
* `composed: true` insertText, and Enter's keydown all land in ONE page task,
* before any zero-delay timer can run.
*
* This is the ordering half of the fix, and the half the unit harness cannot
* reach: the unit tests prove WHICH candidate is forwarded, this proves WHEN.
* Resolving the pending candidate only on its 0 ms timer loses the character
* outright here, because by the time that timer runs xterm has already
* emitted the `\r` and bumped the canonical counter past the candidate's
* snapshot, so it stands down. Draining at the next keydown, from xterm's
* custom key handler (which runs before xterm processes that key), puts the
* character on the wire ahead of the `\r`.
*/
async function batchedCommitThenEnter(data: string) {
return page.evaluate(async (text) => {
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[] = [];
try {
app.activeSessionId = 'cod388-browser-batched';
app._localEchoEnabled = false;
app._pendingInput = '';
app._lastKeystrokeTime = 0;
app._sendInputAsync = (_sessionId: string, chunk: string) => sent.push(chunk);
textarea.focus();
// One task, no awaits between the three dispatches.
const charDown = new KeyboardEvent('keydown', {
key: 'Unidentified',
bubbles: true,
cancelable: true,
composed: true,
});
Object.defineProperties(charDown, { keyCode: { value: 65 }, which: { value: 65 } });
textarea.dispatchEvent(charDown);
textarea.value = text;
textarea.dispatchEvent(
new InputEvent('input', { data: text, inputType: 'insertText', bubbles: true, composed: true })
);
const enterDown = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
bubbles: true,
cancelable: true,
composed: true,
});
Object.defineProperties(enterDown, { keyCode: { value: 13 }, which: { value: 13 } });
textarea.dispatchEvent(enterDown);
await new Promise((resolve) => setTimeout(resolve, 80));
return { wire: sent.join('') };
} finally {
app.activeSessionId = originalSessionId;
app._localEchoEnabled = originalLocalEcho;
app._sendInputAsync = originalSendInput;
app._pendingInput = originalPendingInput;
app._lastKeystrokeTime = originalLastKeystrokeTime;
textarea.value = '';
}
}, data);
}
it('delivers a character committed in the same task as Enter BEFORE the carriage return', async () => {
const { wire } = await batchedCommitThenEnter('o');
// Not '\r' (character lost, the defect) and not '\ro' (recovered too late).
expect(wire).toBe('o\r');
});
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([]);
});
/**
* The sequence a real Android device logged (SwiftKey in Edge) when autocorrect fired on
* space: every key is a keyCode-229 keydown plus a plain `insertText`, then ONE keydown that
* deletes five characters and a second that inserts `rompt `. Trusted events through the real
* textarea and the real xterm, so `execCommand` produces the same `beforeinput`/`input`
* pairs the keyboard does. The byte stream is replayed (DEL erases a character) to get the
* line the shell ends up with.
*/
async function autocorrectOnSpace() {
return 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 key229 = () => {
const down = new KeyboardEvent('keydown', { key: 'Unidentified', bubbles: true, cancelable: true });
Object.defineProperties(down, { keyCode: { value: 229 }, which: { value: 229 } });
textarea.dispatchEvent(down);
};
const tick = () => new Promise((resolve) => setTimeout(resolve, 20));
try {
app.activeSessionId = 'cod388-browser-autocorrect';
app._localEchoEnabled = false;
app._pendingInput = '';
app._lastKeystrokeTime = 0;
app._sendInputAsync = (_sessionId: string, chunk: string) => sent.push(chunk);
textarea.value = '';
textarea.focus();
for (const ch of 'testing the peompt') {
key229();
document.execCommand('insertText', false, ch);
await tick();
}
// SwiftKey's autocorrect: both edits in one task, before any timer runs.
key229();
textarea.setSelectionRange(textarea.value.length - 5, textarea.value.length);
document.execCommand('delete');
key229();
document.execCommand('insertText', false, 'rompt ');
await new Promise((resolve) => setTimeout(resolve, 80));
const line: string[] = [];
for (const ch of sent.join('')) {
if (ch === '\x7f') line.pop();
else line.push(ch);
}
return { raw: sent.join(''), line: line.join(''), textarea: textarea.value };
} finally {
app.activeSessionId = originalSessionId;
app._localEchoEnabled = originalLocalEcho;
app._sendInputAsync = originalSendInput;
app._pendingInput = originalPendingInput;
app._lastKeystrokeTime = originalLastKeystrokeTime;
textarea.value = '';
}
});
}
it('an autocorrect that deletes a word and retypes it reaches the shell once, not duplicated', async () => {
const { line, textarea } = await autocorrectOnSpace();
expect(textarea).toBe('testing the prompt ');
expect(line).toBe('testing the prompt ');
});
/**
* The batched Android shape from #441, now with an edit that REWRITES text: the last
* character's 229 keydown and insertText land in the same page task as Enter's keydown.
* xterm clears the textarea for Enter before the edit-sync timer would run, so a timer
* left pending diffed the whole line against '' and sent one DEL per character ahead of
* the submitted line (with local echo on, `hello` + Enter submitted `h`). The edit is
* settled at the next keydown, before xterm sees it. Run with local echo both ways: it
* changes which of the DELs and the `\r` reaches the wire first.
*/
async function lastEditThenEnter(options: { localEcho: boolean; autocorrect: boolean }) {
return page.evaluate(async ({ localEcho, autocorrect }) => {
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 keydown = (init: KeyboardEventInit, keyCode: number) => {
const down = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
Object.defineProperties(down, { keyCode: { value: keyCode }, which: { value: keyCode } });
textarea.dispatchEvent(down);
};
const key229 = () => keydown({ key: 'Unidentified' }, 229);
const tick = () => new Promise((resolve) => setTimeout(resolve, 20));
try {
app.activeSessionId = 'cod388-browser-edit-enter';
app._localEchoEnabled = localEcho;
app._pendingInput = '';
app._lastKeystrokeTime = 0;
app._sendInputAsync = (_sessionId: string, chunk: string) => sent.push(chunk);
textarea.value = '';
textarea.focus();
const typed = autocorrect ? 'testing the peompt' : 'hell';
for (const ch of typed) {
key229();
document.execCommand('insertText', false, ch);
await tick();
}
// ONE task: no awaits between the edit(s) and Enter.
if (autocorrect) {
key229();
textarea.setSelectionRange(textarea.value.length - 5, textarea.value.length);
document.execCommand('delete');
key229();
document.execCommand('insertText', false, 'rompt ');
} else {
key229();
document.execCommand('insertText', false, 'o');
}
keydown({ key: 'Enter', code: 'Enter' }, 13);
await new Promise((resolve) => setTimeout(resolve, 250)); // local echo delays the \r by 80 ms
const line: string[] = [];
for (const ch of sent.join('')) {
if (ch === '\x7f') line.pop();
else line.push(ch);
}
return { raw: sent.join(''), line: line.join('') };
} finally {
app.activeSessionId = originalSessionId;
app._localEchoEnabled = originalLocalEcho;
app._sendInputAsync = originalSendInput;
app._pendingInput = originalPendingInput;
app._lastKeystrokeTime = originalLastKeystrokeTime;
textarea.value = '';
}
}, options);
}
for (const localEcho of [true, false]) {
it(`a 229 last character in the same task as Enter submits the whole line (local echo ${localEcho ? 'on' : 'off'})`, async () => {
const { raw, line } = await lastEditThenEnter({ localEcho, autocorrect: false });
expect(raw).not.toContain('\x7f');
expect(line).toBe('hello\r');
});
it(`an autocorrect plus Enter in one task submits the corrected line (local echo ${localEcho ? 'on' : 'off'})`, async () => {
const { line } = await lastEditThenEnter({ localEcho, autocorrect: true });
expect(line).toBe('testing the prompt \r');
});
}
it('control: without the edit sync, xterm alone reproduces the duplicated line', async () => {
// destroy() puts xterm's own handler back. Keep this LAST: it leaves the controller off.
await page.evaluate(() => (window as any).app._keyCode229Recovery.destroy());
const { line } = await autocorrectOnSpace();
// Byte for byte what the phone sent in the device log.
expect(line).toBe('testing the peompttesting the prompt rompt ');
});
/**
* The same keyboard shapes through a TerminalTile, the pane a grid tile and the split view's
* Pane B are made of. It wires its OWN controller (terminal-tile.js _createKeyCode229Recovery):
* its own xterm, helper textarea and composition helper, sending to its own session through
* `app._sendInputAsync(tileSessionId, …)`, never the active one. Declared after the primary
* pane's control above, which only switched the PRIMARY controller off.
*/
describe("in a TerminalTile (a grid tile, the split view's Pane B)", () => {
const TILE_ID = 'cod541-tile-browser';
beforeAll(async () => {
await page.evaluate(async (id) => {
const w = window as any;
const mount = document.createElement('div');
mount.id = 'tile229Mount';
mount.style.cssText = 'position:fixed;left:0;top:0;width:480px;height:320px;z-index:9999;';
document.body.appendChild(mount);
// A session that does not exist: its socket is refused (4004) and the load finds
// nothing, neither of which the controller needs. Input is stubbed per test below.
const tile = new w.TerminalTile(id, mount, { mode: 'shell' });
w.__tile229 = tile;
await tile.connect();
}, TILE_ID);
await page.waitForFunction(() => (window as any).__tile229?._keyCode229Recovery, null, { timeout: 30000 });
}, 60000);
afterAll(async () => {
await page.evaluate(() => {
const w = window as any;
w.__tile229?.destroy();
delete w.__tile229;
document.getElementById('tile229Mount')?.remove();
});
});
type Scenario = 'autocorrect' | 'lastCharThenEnter' | 'autocorrectThenEnter' | 'orphanThenEnter' | 'selfRescued';
/**
* Runs one keyboard shape against the tile's own textarea (scoped to its mount:
* `document.querySelector` would find the PRIMARY pane's) and reports what reached the send
* path, which session each chunk was addressed to, and how often xterm itself spoke.
*/
async function inTile(scenario: Scenario) {
return page.evaluate(
async ({ scenario, tileId }) => {
const w = window as any;
const app = w.app;
const tile = w.__tile229;
const textarea = document.querySelector('#tile229Mount .xterm-helper-textarea') as HTMLTextAreaElement;
const originalSendInput = app._sendInputAsync;
const originalSessionId = app.activeSessionId;
const rec = tile._keyCode229Recovery;
const sent: Array<[string, string]> = [];
let xtermEmitted = 0;
const keydown = (init: KeyboardEventInit, keyCode: number) => {
const down = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, composed: true, ...init });
Object.defineProperties(down, { keyCode: { value: keyCode }, which: { value: keyCode } });
textarea.dispatchEvent(down);
};
const key229 = () => keydown({ key: 'Unidentified' }, 229);
const enter = () => keydown({ key: 'Enter', code: 'Enter' }, 13);
const tick = () => new Promise((resolve) => setTimeout(resolve, 20));
const typeKeys = async (text: string) => {
for (const ch of text) {
key229();
document.execCommand('insertText', false, ch);
await tick();
}
};
const autocorrectEdit = () => {
key229();
textarea.setSelectionRange(textarea.value.length - 5, textarea.value.length);
document.execCommand('delete');
key229();
document.execCommand('insertText', false, 'rompt ');
};
try {
app.activeSessionId = 'cod541-not-the-tile';
app._sendInputAsync = (sessionId: string, chunk: string) => sent.push([sessionId, chunk]);
if (scenario === 'selfRescued') {
// Counts xterm's own canonical emissions: the tile's onData reads the property at
// call time, and the controller object itself is frozen.
tile._keyCode229Recovery = {
handleKeyEvent: (e: any) => rec.handleKeyEvent(e),
notifyCanonicalData: () => {
xtermEmitted += 1;
return rec.notifyCanonicalData();
},
destroy: () => rec.destroy(),
};
}
textarea.value = '';
textarea.focus();
if (scenario === 'autocorrect') {
await typeKeys('testing the peompt');
autocorrectEdit(); // both edits in one task, before any timer runs
} else if (scenario === 'lastCharThenEnter') {
await typeKeys('hell');
key229();
document.execCommand('insertText', false, 'o');
enter(); // same task
} else if (scenario === 'autocorrectThenEnter') {
await typeKeys('testing the peompt');
autocorrectEdit();
enter(); // same task
} else if (scenario === 'orphanThenEnter') {
// #441's batched shape: a keydown, the composed insertText xterm refuses, Enter.
keydown({ key: 'Unidentified' }, 65);
textarea.value = 'o';
textarea.dispatchEvent(
new InputEvent('input', { data: 'o', inputType: 'insertText', bubbles: true, composed: true })
);
enter();
} else {
key229();
textarea.value = 'y';
textarea.dispatchEvent(
new InputEvent('input', { data: 'y', inputType: 'insertText', bubbles: true, composed: true })
);
}
await new Promise((resolve) => setTimeout(resolve, 120));
const raw = sent.map(([, chunk]) => chunk).join('');
const line: string[] = [];
for (const ch of raw) {
if (ch === '\x7f') line.pop();
else line.push(ch);
}
return {
raw,
line: line.join(''),
textarea: textarea.value,
sessions: [...new Set(sent.map(([sessionId]) => sessionId))],
xtermEmitted,
tileId,
};
} finally {
app._sendInputAsync = originalSendInput;
app.activeSessionId = originalSessionId;
tile._keyCode229Recovery = rec;
textarea.value = '';
}
},
{ scenario, tileId: TILE_ID }
);
}
it('an autocorrect on space reaches the shell once, not duplicated', async () => {
const r = await inTile('autocorrect');
expect(r.textarea).toBe('testing the prompt ');
expect(r.line).toBe('testing the prompt ');
expect(r.sessions).toEqual([TILE_ID]);
});
it('a 229 last character in the same task as Enter submits the whole line', async () => {
const r = await inTile('lastCharThenEnter');
expect(r.raw).not.toContain('\x7f');
expect(r.line).toBe('hello\r');
expect(r.sessions).toEqual([TILE_ID]);
});
it('an autocorrect plus Enter in one task submits the corrected line', async () => {
const r = await inTile('autocorrectThenEnter');
expect(r.line).toBe('testing the prompt \r');
expect(r.sessions).toEqual([TILE_ID]);
});
it('a refused insertText committed in the same task as Enter goes out BEFORE the carriage return', async () => {
const r = await inTile('orphanThenEnter');
expect(r.raw).toBe('o\r');
expect(r.sessions).toEqual([TILE_ID]);
});
it('a 229 keystroke xterm diffed itself is delivered once', async () => {
const r = await inTile('selfRescued');
expect(r.xtermEmitted).toBe(1);
expect(r.raw).toBe('y');
});
it("control: with the tile's controller destroyed, xterm alone duplicates the line", async () => {
// Keep LAST in this block: it leaves the tile's controller off.
await page.evaluate(() => (window as any).__tile229._keyCode229Recovery.destroy());
const r = await inTile('autocorrect');
expect(r.line).toBe('testing the peompttesting the prompt rompt ');
});
});
});