mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
#541 fixed Android autocorrect duplicating the typed line in the primary pane: xterm's keyCode-229 textarea diff is append-only, so an autocorrect on space (delete a word, insert the corrected one) sent the whole line again. The fix, an edit-based diff that sends one DEL per deleted code point and then the inserted text, lives in terminal-keycode229-recovery.js together with #441's next-keydown drain (a character committed in the same task as Enter goes out ahead of the \r) and the original orphaned-insertText recovery. Only the primary pane created that controller, so a grid tile or the split's Pane B still ran xterm's stock behaviour. Both are gated on width alone (1180 CSS px), which a wide Android tablet clears. TerminalTile now creates its own controller in connect(), after the xterm opens and before the first await, handed this tile's textarea, this tile's CompositionHelper and _onTerminalData as the send path, so recovered bytes go to the tile's own session through the exactly-once queue. As in the primary pane, handleKeyEvent runs first in the custom key handler, above the keyCode-229 early return, and notifyCanonicalData sits in the onData lambda, gated on the same two CodemanTerminalInput predicates, never in _onTerminalData, which the recovered bytes also take. destroy() tears the controller down before disposing the xterm, which restores xterm's own diff and removes the capture listeners. No mode or device gate, matching the primary. The module itself is unchanged apart from its header; terminal-ui.js gains only a comment naming the twin. Tests: test/terminal-tile-input.test.ts now loads the real module into its vm harness (with window timers, without which create() would silently throw and every test would run against no controller) and drives a fake CompositionHelper carrying xterm's own append-only diff. It covers install and restore on the tile's own helper and textarea, autocorrect sent as an edit (with a control reproducing the device-log duplicate), the last character and an autocorrect each followed by Enter in one task, a self-rescued 229 key delivered once, the onData gate ignoring query replies and focus reports, two refused inserts after one keydown both recovered, robustness when the controller throws, per-tile controllers, and a source pin keeping the call above the early return. Removing the create, the handleKeyEvent call, the notify, its gate, or the destroy each turns at least one of them red, as does moving the notify into _onTerminalData. The browser suite gains a TerminalTile block in test/terminal-keycode229-recovery.browser.test.ts (real xterm, trusted execCommand input, chunks asserted to address the tile's session, with a destroyed-controller control). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
587 lines
26 KiB
TypeScript
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: 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');
|
|
});
|
|
|
|
/**
|
|
* 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 ');
|
|
});
|
|
});
|
|
});
|