mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
fix(terminal): settle the edit-sync diff at the next keydown so Enter cannot erase the line (#541 review)
A pending 229 edit plus Enter in one page task: xterm clears the textarea for CR before the edit timer runs, so the timer diffed the whole line against '' and sent one DEL per character ahead of the submitted line. The pending diff is now applied synchronously from handleKeyEvent, before flushPending() (which keeps the orphan candidate from sending the character twice). Tests: unit (3) and browser (4, local echo on and off, plain last character and autocorrect), each verified to fail without the settle call. xterm-private-api guard now names the CompositionHelper fields this depends on and checks the shipped bundle; CLAUDE.md notes the edit-based 229 diff. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
0c71b753ef
commit
f21ab39a89
@@ -301,6 +301,91 @@ describe('orphaned terminal input recovery wiring', () => {
|
||||
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());
|
||||
|
||||
@@ -520,6 +520,57 @@ describe('edit-based sync of the helper textarea (autocorrect replacements)', ()
|
||||
h.flush();
|
||||
};
|
||||
|
||||
// The batched Android shape (#441): the last character's keydown and `insertText` arrive in the
|
||||
// SAME page task as Enter's keydown. xterm's own Enter handling clears the textarea before the
|
||||
// edit's timer runs, so a timer left pending would diff the whole line against '' and send one
|
||||
// DEL per character AHEAD of the submitted line. The edit is therefore settled at the next
|
||||
// keydown, before xterm sees that key.
|
||||
it('settles a pending edit at the next keydown, so Enter in the same task cannot erase the line', () => {
|
||||
const h = editSyncHarness();
|
||||
const controller = h.create(true);
|
||||
typeKeys(h, 'hell');
|
||||
h.keydown();
|
||||
h.edit('hello');
|
||||
controller.handleKeyEvent({ type: 'keydown', key: 'Enter', keyCode: 13 });
|
||||
h.textarea.value = ''; // xterm's CR handling, which runs after the custom key handler
|
||||
h.flush();
|
||||
expect(h.sent.join('')).toBe('hello');
|
||||
expect(h.sent).not.toContain('\x7f');
|
||||
});
|
||||
|
||||
it('autocorrect and Enter in one task submits the corrected line, not a run of DELs', () => {
|
||||
const h = editSyncHarness();
|
||||
const controller = h.create(true);
|
||||
typeKeys(h, 'testing the peompt');
|
||||
h.keydown();
|
||||
h.edit('testing the p');
|
||||
h.keydown();
|
||||
h.edit('testing the prompt ');
|
||||
controller.handleKeyEvent({ type: 'keydown', key: 'Enter', keyCode: 13 });
|
||||
h.textarea.value = '';
|
||||
h.flush();
|
||||
expect(h.line()).toBe('testing the prompt ');
|
||||
expect(h.sent.filter((c) => c === '\x7f')).toHaveLength(5); // the five deleted characters, nothing more
|
||||
});
|
||||
|
||||
it('settling first stands the same keystroke’s orphan candidate down (no double send)', () => {
|
||||
const h = editSyncHarness();
|
||||
const controller = h.create(true);
|
||||
// xterm's canonical-data hook, as terminal-ui.js wires it.
|
||||
const origTrigger = h.helper._coreService.triggerDataEvent;
|
||||
h.helper._coreService.triggerDataEvent = (data: string) => {
|
||||
origTrigger(data);
|
||||
controller.notifyCanonicalData();
|
||||
};
|
||||
controller.handleKeyEvent({ type: 'keydown', key: 'Unidentified', keyCode: 229 });
|
||||
h.keydown();
|
||||
h.edit('o');
|
||||
h.textarea.fire('input', inputEvent('o'));
|
||||
controller.handleKeyEvent({ type: 'keydown', key: 'Enter', keyCode: 13 });
|
||||
h.flush();
|
||||
expect(h.sent.join('')).toBe('o');
|
||||
});
|
||||
|
||||
it('control: xterm alone duplicates the line when the keyboard autocorrects', () => {
|
||||
const h = editSyncHarness();
|
||||
h.create(false);
|
||||
|
||||
@@ -47,12 +47,37 @@ describe('xterm private-API dependency guard', () => {
|
||||
expect(
|
||||
lock.packages['node_modules/@xterm/xterm']?.version,
|
||||
'xterm moved off the verified version — re-verify _kickRenderer in a real browser ' +
|
||||
'(terminal-ui.js: _core._renderService._renderDebouncer._animationFrame), then update ' +
|
||||
'(terminal-ui.js: _core._renderService._renderDebouncer._animationFrame) AND the ' +
|
||||
'CompositionHelper fields installEditSync() uses (terminal-keycode229-recovery.js: ' +
|
||||
'_handleAnyTextareaChanges, _coreService, _isComposing, _dataAlreadySent), then update ' +
|
||||
'VERIFIED_XTERM_VERSION here. The accessor is optional-chained, so a renamed field ' +
|
||||
'degrades to a silent no-op and the freeze it heals comes back unnoticed.'
|
||||
).toBe(VERIFIED_XTERM_VERSION);
|
||||
});
|
||||
|
||||
// terminal-keycode229-recovery.js swaps in an edit-based replacement for xterm's
|
||||
// CompositionHelper._handleAnyTextareaChanges (Android autocorrect = delete + insert, which xterm's
|
||||
// append-only diff duplicates). It reaches `_compositionHelper`, `_coreService`, `_isComposing`
|
||||
// and `_dataAlreadySent`; if xterm renames any of them the install quietly falls back to xterm's own
|
||||
// handler and the duplication returns. Property names survive minification, so a string check on
|
||||
// the shipped bundle catches a rename on upgrade.
|
||||
it('still ships the composition-helper fields the edit-based 229 sync depends on', () => {
|
||||
const bundle = readFileSync(resolve(root, 'node_modules/@xterm/xterm/lib/xterm.js'), 'utf8');
|
||||
for (const name of [
|
||||
'_handleAnyTextareaChanges',
|
||||
'_compositionHelper',
|
||||
'_coreService',
|
||||
'_isComposing',
|
||||
'_dataAlreadySent',
|
||||
]) {
|
||||
expect(
|
||||
bundle,
|
||||
`xterm no longer mentions ${name}: re-verify terminal-keycode229-recovery.js installEditSync() ` +
|
||||
'(src/web/public) against the new CompositionHelper before bumping VERIFIED_XTERM_VERSION'
|
||||
).toContain(name);
|
||||
}
|
||||
});
|
||||
|
||||
// If someone deletes the watchdog, this guard is pointless noise — keep the
|
||||
// two tied together so the range check cannot outlive what it protects.
|
||||
it('is guarding a watchdog that still exists', () => {
|
||||
|
||||
Reference in New Issue
Block a user