mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
Merge #541: stop Android autocorrect duplicating the typed line
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -231,4 +231,166 @@ describe('orphaned terminal input recovery wiring', () => {
|
||||
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 ');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -422,3 +422,253 @@ describe('terminal-ui wiring: what counts as "xterm spoke for this keystroke"',
|
||||
expect(typed.emitted, 'xterm really did deliver this one').toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* xterm's `_handleAnyTextareaChanges` diffs the helper textarea with
|
||||
* `newValue.replace(oldValue, '')`, which only works when the keyboard appended.
|
||||
* Autocorrect on space (SwiftKey, Gboard) deletes the word and inserts the corrected
|
||||
* one, and xterm answers by sending the WHOLE value, then the inserted text again:
|
||||
* a real device log produced `testing the peompttesting the prompt rompt `.
|
||||
*/
|
||||
function editSyncHarness() {
|
||||
const source = readFileSync(new URL('../src/web/public/terminal-keycode229-recovery.js', import.meta.url), 'utf8');
|
||||
const exposed: Record<string, any> = {};
|
||||
vm.runInNewContext(source, { window: exposed, globalThis: exposed }, { filename: 'terminal-keycode229-recovery.js' });
|
||||
|
||||
const textarea = Object.assign(makeTextarea(), { value: '' });
|
||||
const sent: string[] = [];
|
||||
const timers = new Map<number, () => void>();
|
||||
let timerId = 0;
|
||||
// xterm's own (flawed) diff, as shipped, so the control case can prove the bug.
|
||||
const xtermOriginal = function (this: any) {
|
||||
const oldValue = textarea.value;
|
||||
exposed.__timers.set(++timerId, () => {
|
||||
const newValue = textarea.value;
|
||||
const diff = newValue.replace(oldValue, '');
|
||||
if (newValue.length > oldValue.length) sent.push(diff);
|
||||
else if (newValue.length < oldValue.length) sent.push('\x7f');
|
||||
else if (newValue !== oldValue) sent.push(newValue);
|
||||
});
|
||||
};
|
||||
exposed.__timers = timers;
|
||||
const helper = {
|
||||
_isComposing: false,
|
||||
_dataAlreadySent: '',
|
||||
_handleAnyTextareaChanges: xtermOriginal,
|
||||
_coreService: { triggerDataEvent: (data: string) => sent.push(data) },
|
||||
};
|
||||
const create = (withSync: boolean) =>
|
||||
exposed.CodemanKeyCode229Recovery.create({
|
||||
textarea,
|
||||
emitRecovered: (data: string) => sent.push(data),
|
||||
getCompositionHelper: withSync ? () => helper : undefined,
|
||||
setTimer: (callback: () => void) => {
|
||||
const id = ++timerId;
|
||||
timers.set(id, callback);
|
||||
return id;
|
||||
},
|
||||
clearTimer: (id: number) => timers.delete(id),
|
||||
});
|
||||
return {
|
||||
exposed,
|
||||
helper,
|
||||
textarea,
|
||||
sent,
|
||||
create,
|
||||
/** One keydown, whose xterm timer is registered at keydown time like the real one. */
|
||||
keydown() {
|
||||
helper._handleAnyTextareaChanges();
|
||||
},
|
||||
edit(value: string) {
|
||||
textarea.value = value;
|
||||
},
|
||||
flush() {
|
||||
for (const [id, callback] of [...timers]) {
|
||||
timers.delete(id);
|
||||
callback();
|
||||
}
|
||||
},
|
||||
/** What the shell line holds once every DEL has been applied. */
|
||||
line() {
|
||||
const out: string[] = [];
|
||||
for (const ch of sent.join('')) {
|
||||
if (ch === '\x7f') out.pop();
|
||||
else out.push(ch);
|
||||
}
|
||||
return out.join('');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('edit-based sync of the helper textarea (autocorrect replacements)', () => {
|
||||
const typeKeys = (h: ReturnType<typeof editSyncHarness>, text: string) => {
|
||||
for (const ch of text) {
|
||||
h.keydown();
|
||||
h.edit(h.textarea.value + ch);
|
||||
h.flush();
|
||||
}
|
||||
};
|
||||
|
||||
// The exact event shape from a device log: per-key appends, then ONE keydown deleting five
|
||||
// characters and a SECOND keydown inserting `rompt `, both landing before any timer runs.
|
||||
const autocorrect = (h: ReturnType<typeof editSyncHarness>) => {
|
||||
typeKeys(h, 'testing the peompt');
|
||||
h.keydown();
|
||||
h.edit('testing the p');
|
||||
h.keydown();
|
||||
h.edit('testing the prompt ');
|
||||
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);
|
||||
autocorrect(h);
|
||||
expect(h.sent.join('')).toBe('testing the peompttesting the prompt rompt ');
|
||||
});
|
||||
|
||||
it('with edit sync the line ends up exactly as the textarea reads', () => {
|
||||
const h = editSyncHarness();
|
||||
h.create(true);
|
||||
autocorrect(h);
|
||||
expect(h.line()).toBe('testing the prompt ');
|
||||
expect(h.sent.filter((s) => s === '\x7f')).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('plain typing is still one chunk per keystroke, and a single delete is one DEL', () => {
|
||||
const h = editSyncHarness();
|
||||
h.create(true);
|
||||
typeKeys(h, 'abc');
|
||||
h.keydown();
|
||||
h.edit('ab');
|
||||
h.flush();
|
||||
expect(h.sent).toEqual(['a', 'b', 'c', '\x7f']);
|
||||
});
|
||||
|
||||
it('a multi-character delete sends one DEL per character, not one DEL in total', () => {
|
||||
const h = editSyncHarness();
|
||||
h.create(true);
|
||||
typeKeys(h, 'hello');
|
||||
h.keydown();
|
||||
h.edit('he');
|
||||
h.flush();
|
||||
expect(h.sent.slice(5)).toEqual(['\x7f', '\x7f', '\x7f']);
|
||||
});
|
||||
|
||||
it('an equal-length rewrite is a delete and a retype, not the whole value again', () => {
|
||||
const h = editSyncHarness();
|
||||
h.create(true);
|
||||
typeKeys(h, 'cat');
|
||||
h.keydown();
|
||||
h.edit('cut');
|
||||
h.flush();
|
||||
expect(h.sent.slice(3)).toEqual(['\x7f', '\x7f', 'ut']);
|
||||
expect(h.line()).toBe('cut');
|
||||
});
|
||||
|
||||
it('does not resend after xterm clears the textarea (Enter), and counts emoji as one character', () => {
|
||||
const h = editSyncHarness();
|
||||
h.create(true);
|
||||
typeKeys(h, 'hi');
|
||||
h.textarea.value = ''; // xterm's own reset after Enter: no input event, no emission
|
||||
typeKeys(h, 'yo');
|
||||
expect(h.sent).toEqual(['h', 'i', 'y', 'o']);
|
||||
|
||||
const e = editSyncHarness();
|
||||
e.create(true);
|
||||
typeKeys(e, 'a😀');
|
||||
e.keydown();
|
||||
e.edit('a');
|
||||
e.flush();
|
||||
expect(e.sent.slice(2)).toEqual(['\x7f']);
|
||||
});
|
||||
|
||||
it('stays out of the way while composing, and leaves xterm alone without its internals', () => {
|
||||
const h = editSyncHarness();
|
||||
h.create(true);
|
||||
h.helper._isComposing = true;
|
||||
h.keydown();
|
||||
h.edit('x');
|
||||
h.flush();
|
||||
expect(h.sent).toEqual([]);
|
||||
|
||||
const bare = editSyncHarness();
|
||||
const original = bare.helper._handleAnyTextareaChanges;
|
||||
bare.create(false);
|
||||
expect(bare.helper._handleAnyTextareaChanges).toBe(original);
|
||||
});
|
||||
|
||||
it('restores xterm’s own handler on destroy', () => {
|
||||
const h = editSyncHarness();
|
||||
const original = h.helper._handleAnyTextareaChanges;
|
||||
const controller = h.create(true);
|
||||
expect(h.helper._handleAnyTextareaChanges).not.toBe(original);
|
||||
controller.destroy();
|
||||
expect(h.helper._handleAnyTextareaChanges).toBe(original);
|
||||
});
|
||||
|
||||
it('terminal-ui.js hands the controller xterm’s composition helper', () => {
|
||||
const terminalSource = readFileSync(new URL('../src/web/public/terminal-ui.js', import.meta.url), 'utf8');
|
||||
expect(terminalSource).toMatch(/getCompositionHelper:\s*\(\)\s*=>\s*this\.terminal\?\._core\?\._compositionHelper/);
|
||||
});
|
||||
|
||||
it('editBetween counts code points and replaces everything after the common prefix', () => {
|
||||
const { editBetween } = editSyncHarness().exposed.CodemanKeyCode229Recovery;
|
||||
expect(editBetween('abc', 'abcd')).toEqual({ deleted: 0, inserted: 'd' });
|
||||
expect(editBetween('abc', 'ab')).toEqual({ deleted: 1, inserted: '' });
|
||||
expect(editBetween('testing the peompt', 'testing the prompt ')).toEqual({ deleted: 5, inserted: 'rompt ' });
|
||||
expect(editBetween('x😀', 'x')).toEqual({ deleted: 1, inserted: '' });
|
||||
expect(editBetween('', '')).toEqual({ deleted: 0, inserted: '' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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