From 41ba29d79aa70e58b67035233d80fbaac89dbd9f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:47:03 -0700 Subject: [PATCH] test(e2e): headless preedit-geometry coverage for Korean and CJK terminal input (#14500) * test(e2e): headless preedit-geometry coverage for Korean and CJK terminal input Both IME defects that shipped and were reverted passed a suite of ~3000 IME assertions, because every one of them checked bytes reaching the pty and a preedit rendered into a hidden overlay satisfies all of them while the user composes blind. The one arm that asserted real geometry was headful-gated and macOS-only, so it never ran in CI. Drives composition through CDP Input.imeSetComposition rather than a native input source, which removes the accessibility grant, the system input source and the visible window that forced that gate, so this runs in the ordinary electron-headless project. The load-bearing assertion is the composition overlay's real bounding rect. Verified to have teeth: with max-width 0 and overflow hidden injected, the active class, the textContent, display block and checkVisibility all still pass, and only the rect assertion fails. * test(e2e): restore the CDP composition drivers the preedit specs need The trimmed copy on main kept only the key-dispatch helpers, so the composition drivers the geometry specs import were missing. Adds them back: setImeComposition, commitImeText, dispatchImeProcessKey, composeHangulSyllable and dispatchResumedCompositionUpdate. The shared helpers are unchanged. Co-authored-by: Orca --------- Co-authored-by: Orca --- .../windows-wsl-2set-hangul-dom-trace.json | 2279 +++++++++++++++++ .../terminal-cjk-ime-committed-text.spec.ts | 312 +++ tests/e2e/terminal-ime-cdp-composition.ts | 88 +- .../e2e/terminal-ime-preedit-overlay-probe.ts | 102 + .../terminal-ime-recorded-dom-trace-replay.ts | 198 ++ ...terminal-korean-preedit-visibility.spec.ts | 207 ++ 6 files changed, 3185 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/fixtures/windows-wsl-2set-hangul-dom-trace.json create mode 100644 tests/e2e/terminal-cjk-ime-committed-text.spec.ts create mode 100644 tests/e2e/terminal-ime-preedit-overlay-probe.ts create mode 100644 tests/e2e/terminal-ime-recorded-dom-trace-replay.ts create mode 100644 tests/e2e/terminal-korean-preedit-visibility.spec.ts diff --git a/tests/e2e/fixtures/windows-wsl-2set-hangul-dom-trace.json b/tests/e2e/fixtures/windows-wsl-2set-hangul-dom-trace.json new file mode 100644 index 00000000000..9ac792c2fba --- /dev/null +++ b/tests/e2e/fixtures/windows-wsl-2set-hangul-dom-trace.json @@ -0,0 +1,2279 @@ +{ + "recordedFrom": "11919-windows-wsl-current/11919-current-owner.json", + "inputFramework": "windows-ime", + "engine": "microsoft-korean", + "note": "Windows/WSL 2-Set Korean, 11 committed syllables. Only the capture's immediate-phase records are kept: the source recorder logged every event twice, once on dispatch and once as a batched next-frame snapshot, so keeping both would replay each event a second time and manufacture orderings the IME never emitted.", + "onData": [ + { + "data": "문", + "hex": "ebacb8", + "now": 2465839, + "wallMs": 1785868361391 + }, + { + "data": "제", + "hex": "eca09c", + "now": 2466055.2000000477, + "wallMs": 1785868361607 + }, + { + "data": "\r", + "hex": "0d", + "now": 2466056.2999999523, + "wallMs": 1785868361608 + }, + { + "data": "모", + "hex": "ebaaa8", + "now": 2466991, + "wallMs": 1785868362543 + }, + { + "data": "르", + "hex": "eba5b4", + "now": 2467240.4000000954, + "wallMs": 1785868362792 + }, + { + "data": "겠", + "hex": "eab2a0", + "now": 2467522.5999999046, + "wallMs": 1785868363074 + }, + { + "data": "네", + "hex": "eb84a4", + "now": 2467761.5999999046, + "wallMs": 1785868363313 + }, + { + "data": "\r", + "hex": "0d", + "now": 2467761.7999999523, + "wallMs": 1785868363314 + }, + { + "data": "안", + "hex": "ec9588", + "now": 2468805.4000000954, + "wallMs": 1785868364357 + }, + { + "data": "녕", + "hex": "eb8595", + "now": 2469178, + "wallMs": 1785868364730 + }, + { + "data": "하", + "hex": "ed9598", + "now": 2469555.0999999046, + "wallMs": 1785868365107 + }, + { + "data": "세", + "hex": "ec84b8", + "now": 2469801.7000000477, + "wallMs": 1785868365353 + }, + { + "data": "요", + "hex": "ec9a94", + "now": 2469916.4000000954, + "wallMs": 1785868365468 + }, + { + "data": "\r", + "hex": "0d", + "now": 2469916.7000000477, + "wallMs": 1785868365468 + }, + { + "data": "h", + "hex": "68", + "now": 2471057, + "wallMs": 1785868366609 + }, + { + "data": "e", + "hex": "65", + "now": 2471177, + "wallMs": 1785868366729 + }, + { + "data": "l", + "hex": "6c", + "now": 2471302.2999999523, + "wallMs": 1785868366854 + }, + { + "data": "l", + "hex": "6c", + "now": 2471427.7000000477, + "wallMs": 1785868366979 + }, + { + "data": "o", + "hex": "6f", + "now": 2471553.2999999523, + "wallMs": 1785868367105 + }, + { + "data": "\r", + "hex": "0d", + "now": 2471678.7000000477, + "wallMs": 1785868367230 + } + ], + "dom": [ + { + "type": "keyup", + "key": "HangulMode", + "code": "", + "keyCode": 21, + "isComposing": false, + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": false, + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "compositionstart", + "data": "", + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "compositionupdate", + "data": "ㅁ", + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "beforeinput", + "data": "ㅁ", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "input", + "data": "ㅁ", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "ㅁ", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "ㅁ", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keyup", + "key": "a", + "code": "", + "keyCode": 65, + "isComposing": true, + "value": "ㅁ", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "ㅁ", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "compositionupdate", + "data": "무", + "value": "ㅁ", + "selectionStart": 0, + "selectionEnd": 1 + }, + { + "type": "beforeinput", + "data": "무", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "ㅁ", + "selectionStart": 0, + "selectionEnd": 1 + }, + { + "type": "input", + "data": "무", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "무", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "무", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keyup", + "key": "n", + "code": "", + "keyCode": 78, + "isComposing": true, + "value": "무", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "무", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "compositionupdate", + "data": "문", + "value": "무", + "selectionStart": 0, + "selectionEnd": 1 + }, + { + "type": "beforeinput", + "data": "문", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "무", + "selectionStart": 0, + "selectionEnd": 1 + }, + { + "type": "input", + "data": "문", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "문", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "문", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keyup", + "key": "s", + "code": "", + "keyCode": 83, + "isComposing": true, + "value": "문", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "문", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "compositionupdate", + "data": "묹", + "value": "문", + "selectionStart": 0, + "selectionEnd": 1 + }, + { + "type": "beforeinput", + "data": "묹", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "문", + "selectionStart": 0, + "selectionEnd": 1 + }, + { + "type": "input", + "data": "묹", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "묹", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "묹", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keyup", + "key": "w", + "code": "", + "keyCode": 87, + "isComposing": true, + "value": "묹", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "묹", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "compositionupdate", + "data": "문", + "value": "묹", + "selectionStart": 0, + "selectionEnd": 1 + }, + { + "type": "beforeinput", + "data": "문", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "묹", + "selectionStart": 0, + "selectionEnd": 1 + }, + { + "type": "input", + "data": "문", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "문", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "compositionend", + "data": "문", + "value": "문", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "compositionstart", + "data": "", + "value": "문", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "compositionupdate", + "data": "제", + "value": "문", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "beforeinput", + "data": "제", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "문", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "input", + "data": "제", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "문제", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "문제", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "keyup", + "key": "p", + "code": "", + "keyCode": 80, + "isComposing": true, + "value": "문제", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "문제", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "compositionupdate", + "data": "제", + "value": "문제", + "selectionStart": 1, + "selectionEnd": 2 + }, + { + "type": "beforeinput", + "data": "제", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "문제", + "selectionStart": 1, + "selectionEnd": 2 + }, + { + "type": "input", + "data": "제", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "문제", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "compositionend", + "data": "제", + "value": "문제", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "keydown", + "key": "Enter", + "code": "", + "keyCode": 13, + "isComposing": false, + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": false, + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "keyup", + "key": "Enter", + "code": "", + "keyCode": 13, + "isComposing": false, + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": false, + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "compositionstart", + "data": "", + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "compositionupdate", + "data": "ㅁ", + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "beforeinput", + "data": "ㅁ", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "input", + "data": "ㅁ", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "ㅁ", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "ㅁ", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keyup", + "key": "a", + "code": "", + "keyCode": 65, + "isComposing": true, + "value": "ㅁ", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "ㅁ", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "compositionupdate", + "data": "모", + "value": "ㅁ", + "selectionStart": 0, + "selectionEnd": 1 + }, + { + "type": "beforeinput", + "data": "모", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "ㅁ", + "selectionStart": 0, + "selectionEnd": 1 + }, + { + "type": "input", + "data": "모", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "모", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "모", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keyup", + "key": "h", + "code": "", + "keyCode": 72, + "isComposing": true, + "value": "모", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "모", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "compositionupdate", + "data": "몰", + "value": "모", + "selectionStart": 0, + "selectionEnd": 1 + }, + { + "type": "beforeinput", + "data": "몰", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "모", + "selectionStart": 0, + "selectionEnd": 1 + }, + { + "type": "input", + "data": "몰", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "몰", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "몰", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keyup", + "key": "f", + "code": "", + "keyCode": 70, + "isComposing": true, + "value": "몰", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "몰", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "compositionupdate", + "data": "모", + "value": "몰", + "selectionStart": 0, + "selectionEnd": 1 + }, + { + "type": "beforeinput", + "data": "모", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "몰", + "selectionStart": 0, + "selectionEnd": 1 + }, + { + "type": "input", + "data": "모", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "모", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "compositionend", + "data": "모", + "value": "모", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "compositionstart", + "data": "", + "value": "모", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "compositionupdate", + "data": "르", + "value": "모", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "beforeinput", + "data": "르", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "모", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "input", + "data": "르", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "모르", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "모르", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "keyup", + "key": "m", + "code": "", + "keyCode": 77, + "isComposing": true, + "value": "모르", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "모르", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "compositionupdate", + "data": "륵", + "value": "모르", + "selectionStart": 1, + "selectionEnd": 2 + }, + { + "type": "beforeinput", + "data": "륵", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "모르", + "selectionStart": 1, + "selectionEnd": 2 + }, + { + "type": "input", + "data": "륵", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "모륵", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "모륵", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "keyup", + "key": "r", + "code": "", + "keyCode": 82, + "isComposing": true, + "value": "모륵", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "모륵", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "compositionupdate", + "data": "르", + "value": "모륵", + "selectionStart": 1, + "selectionEnd": 2 + }, + { + "type": "beforeinput", + "data": "르", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "모륵", + "selectionStart": 1, + "selectionEnd": 2 + }, + { + "type": "input", + "data": "르", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "모르", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "compositionend", + "data": "르", + "value": "모르", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "compositionstart", + "data": "", + "value": "모르", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "compositionupdate", + "data": "게", + "value": "모르", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "beforeinput", + "data": "게", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "모르", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "input", + "data": "게", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "모르게", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "모르게", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "keyup", + "key": "p", + "code": "", + "keyCode": 80, + "isComposing": true, + "value": "모르게", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "모르게", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "keydown", + "key": "Shift", + "code": "", + "keyCode": 16, + "isComposing": true, + "value": "모르게", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "모르게", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "compositionupdate", + "data": "겠", + "value": "모르게", + "selectionStart": 2, + "selectionEnd": 3 + }, + { + "type": "beforeinput", + "data": "겠", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "모르게", + "selectionStart": 2, + "selectionEnd": 3 + }, + { + "type": "input", + "data": "겠", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "모르겠", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "모르겠", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "keyup", + "key": "T", + "code": "", + "keyCode": 84, + "isComposing": true, + "value": "모르겠", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "모르겠", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "keyup", + "key": "Shift", + "code": "", + "keyCode": 16, + "isComposing": true, + "value": "모르겠", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "모르겠", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "compositionupdate", + "data": "겠", + "value": "모르겠", + "selectionStart": 2, + "selectionEnd": 3 + }, + { + "type": "beforeinput", + "data": "겠", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "모르겠", + "selectionStart": 2, + "selectionEnd": 3 + }, + { + "type": "input", + "data": "겠", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "모르겠", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "compositionend", + "data": "겠", + "value": "모르겠", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "compositionstart", + "data": "", + "value": "모르겠", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "compositionupdate", + "data": "ㄴ", + "value": "모르겠", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "beforeinput", + "data": "ㄴ", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "모르겠", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "input", + "data": "ㄴ", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "모르겠ㄴ", + "selectionStart": 4, + "selectionEnd": 4 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "모르겠ㄴ", + "selectionStart": 4, + "selectionEnd": 4 + }, + { + "type": "keyup", + "key": "s", + "code": "", + "keyCode": 83, + "isComposing": true, + "value": "모르겠ㄴ", + "selectionStart": 4, + "selectionEnd": 4 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "모르겠ㄴ", + "selectionStart": 4, + "selectionEnd": 4 + }, + { + "type": "compositionupdate", + "data": "네", + "value": "모르겠ㄴ", + "selectionStart": 3, + "selectionEnd": 4 + }, + { + "type": "beforeinput", + "data": "네", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "모르겠ㄴ", + "selectionStart": 3, + "selectionEnd": 4 + }, + { + "type": "input", + "data": "네", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "모르겠네", + "selectionStart": 4, + "selectionEnd": 4 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "모르겠네", + "selectionStart": 4, + "selectionEnd": 4 + }, + { + "type": "keyup", + "key": "p", + "code": "", + "keyCode": 80, + "isComposing": true, + "value": "모르겠네", + "selectionStart": 4, + "selectionEnd": 4 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "모르겠네", + "selectionStart": 4, + "selectionEnd": 4 + }, + { + "type": "compositionupdate", + "data": "네", + "value": "모르겠네", + "selectionStart": 3, + "selectionEnd": 4 + }, + { + "type": "beforeinput", + "data": "네", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "모르겠네", + "selectionStart": 3, + "selectionEnd": 4 + }, + { + "type": "input", + "data": "네", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "모르겠네", + "selectionStart": 4, + "selectionEnd": 4 + }, + { + "type": "compositionend", + "data": "네", + "value": "모르겠네", + "selectionStart": 4, + "selectionEnd": 4 + }, + { + "type": "keydown", + "key": "Enter", + "code": "", + "keyCode": 13, + "isComposing": false, + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": false, + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "keyup", + "key": "Enter", + "code": "", + "keyCode": 13, + "isComposing": false, + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": false, + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "compositionstart", + "data": "", + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "compositionupdate", + "data": "ㅇ", + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "beforeinput", + "data": "ㅇ", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "input", + "data": "ㅇ", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "ㅇ", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "ㅇ", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keyup", + "key": "d", + "code": "", + "keyCode": 68, + "isComposing": true, + "value": "ㅇ", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "ㅇ", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "compositionupdate", + "data": "아", + "value": "ㅇ", + "selectionStart": 0, + "selectionEnd": 1 + }, + { + "type": "beforeinput", + "data": "아", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "ㅇ", + "selectionStart": 0, + "selectionEnd": 1 + }, + { + "type": "input", + "data": "아", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "아", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "아", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keyup", + "key": "k", + "code": "", + "keyCode": 75, + "isComposing": true, + "value": "아", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "아", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "compositionupdate", + "data": "안", + "value": "아", + "selectionStart": 0, + "selectionEnd": 1 + }, + { + "type": "beforeinput", + "data": "안", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "아", + "selectionStart": 0, + "selectionEnd": 1 + }, + { + "type": "input", + "data": "안", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "안", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keyup", + "key": "s", + "code": "", + "keyCode": 83, + "isComposing": true, + "value": "안", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "안", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "compositionupdate", + "data": "안", + "value": "안", + "selectionStart": 0, + "selectionEnd": 1 + }, + { + "type": "beforeinput", + "data": "안", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안", + "selectionStart": 0, + "selectionEnd": 1 + }, + { + "type": "input", + "data": "안", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "compositionend", + "data": "안", + "value": "안", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "compositionstart", + "data": "", + "value": "안", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "compositionupdate", + "data": "ㄴ", + "value": "안", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "beforeinput", + "data": "ㄴ", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안", + "selectionStart": 1, + "selectionEnd": 1 + }, + { + "type": "input", + "data": "ㄴ", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안ㄴ", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "안ㄴ", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "keyup", + "key": "s", + "code": "", + "keyCode": 83, + "isComposing": true, + "value": "안ㄴ", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "안ㄴ", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "compositionupdate", + "data": "녀", + "value": "안ㄴ", + "selectionStart": 1, + "selectionEnd": 2 + }, + { + "type": "beforeinput", + "data": "녀", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안ㄴ", + "selectionStart": 1, + "selectionEnd": 2 + }, + { + "type": "input", + "data": "녀", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안녀", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "안녀", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "keyup", + "key": "u", + "code": "", + "keyCode": 85, + "isComposing": true, + "value": "안녀", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "안녀", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "compositionupdate", + "data": "녕", + "value": "안녀", + "selectionStart": 1, + "selectionEnd": 2 + }, + { + "type": "beforeinput", + "data": "녕", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안녀", + "selectionStart": 1, + "selectionEnd": 2 + }, + { + "type": "input", + "data": "녕", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안녕", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "안녕", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "keyup", + "key": "d", + "code": "", + "keyCode": 68, + "isComposing": true, + "value": "안녕", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "안녕", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "compositionupdate", + "data": "녕", + "value": "안녕", + "selectionStart": 1, + "selectionEnd": 2 + }, + { + "type": "beforeinput", + "data": "녕", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안녕", + "selectionStart": 1, + "selectionEnd": 2 + }, + { + "type": "input", + "data": "녕", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안녕", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "compositionend", + "data": "녕", + "value": "안녕", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "compositionstart", + "data": "", + "value": "안녕", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "compositionupdate", + "data": "ㅎ", + "value": "안녕", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "beforeinput", + "data": "ㅎ", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안녕", + "selectionStart": 2, + "selectionEnd": 2 + }, + { + "type": "input", + "data": "ㅎ", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안녕ㅎ", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "안녕ㅎ", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "keyup", + "key": "g", + "code": "", + "keyCode": 71, + "isComposing": true, + "value": "안녕ㅎ", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "안녕ㅎ", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "compositionupdate", + "data": "하", + "value": "안녕ㅎ", + "selectionStart": 2, + "selectionEnd": 3 + }, + { + "type": "beforeinput", + "data": "하", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안녕ㅎ", + "selectionStart": 2, + "selectionEnd": 3 + }, + { + "type": "input", + "data": "하", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안녕하", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "안녕하", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "keyup", + "key": "k", + "code": "", + "keyCode": 75, + "isComposing": true, + "value": "안녕하", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "안녕하", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "compositionupdate", + "data": "핫", + "value": "안녕하", + "selectionStart": 2, + "selectionEnd": 3 + }, + { + "type": "beforeinput", + "data": "핫", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안녕하", + "selectionStart": 2, + "selectionEnd": 3 + }, + { + "type": "input", + "data": "핫", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안녕핫", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "안녕핫", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "keyup", + "key": "t", + "code": "", + "keyCode": 84, + "isComposing": true, + "value": "안녕핫", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "안녕핫", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "compositionupdate", + "data": "하", + "value": "안녕핫", + "selectionStart": 2, + "selectionEnd": 3 + }, + { + "type": "beforeinput", + "data": "하", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안녕핫", + "selectionStart": 2, + "selectionEnd": 3 + }, + { + "type": "input", + "data": "하", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안녕하", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "compositionend", + "data": "하", + "value": "안녕하", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "compositionstart", + "data": "", + "value": "안녕하", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "compositionupdate", + "data": "세", + "value": "안녕하", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "beforeinput", + "data": "세", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안녕하", + "selectionStart": 3, + "selectionEnd": 3 + }, + { + "type": "input", + "data": "세", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안녕하세", + "selectionStart": 4, + "selectionEnd": 4 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "안녕하세", + "selectionStart": 4, + "selectionEnd": 4 + }, + { + "type": "keyup", + "key": "p", + "code": "", + "keyCode": 80, + "isComposing": true, + "value": "안녕하세", + "selectionStart": 4, + "selectionEnd": 4 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "안녕하세", + "selectionStart": 4, + "selectionEnd": 4 + }, + { + "type": "compositionupdate", + "data": "셍", + "value": "안녕하세", + "selectionStart": 3, + "selectionEnd": 4 + }, + { + "type": "beforeinput", + "data": "셍", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안녕하세", + "selectionStart": 3, + "selectionEnd": 4 + }, + { + "type": "input", + "data": "셍", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안녕하셍", + "selectionStart": 4, + "selectionEnd": 4 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "안녕하셍", + "selectionStart": 4, + "selectionEnd": 4 + }, + { + "type": "keyup", + "key": "d", + "code": "", + "keyCode": 68, + "isComposing": true, + "value": "안녕하셍", + "selectionStart": 4, + "selectionEnd": 4 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "안녕하셍", + "selectionStart": 4, + "selectionEnd": 4 + }, + { + "type": "compositionupdate", + "data": "세", + "value": "안녕하셍", + "selectionStart": 3, + "selectionEnd": 4 + }, + { + "type": "beforeinput", + "data": "세", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안녕하셍", + "selectionStart": 3, + "selectionEnd": 4 + }, + { + "type": "input", + "data": "세", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안녕하세", + "selectionStart": 4, + "selectionEnd": 4 + }, + { + "type": "compositionend", + "data": "세", + "value": "안녕하세", + "selectionStart": 4, + "selectionEnd": 4 + }, + { + "type": "compositionstart", + "data": "", + "value": "안녕하세", + "selectionStart": 4, + "selectionEnd": 4 + }, + { + "type": "compositionupdate", + "data": "요", + "value": "안녕하세", + "selectionStart": 4, + "selectionEnd": 4 + }, + { + "type": "beforeinput", + "data": "요", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안녕하세", + "selectionStart": 4, + "selectionEnd": 4 + }, + { + "type": "input", + "data": "요", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안녕하세요", + "selectionStart": 5, + "selectionEnd": 5 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "안녕하세요", + "selectionStart": 5, + "selectionEnd": 5 + }, + { + "type": "keyup", + "key": "y", + "code": "", + "keyCode": 89, + "isComposing": true, + "value": "안녕하세요", + "selectionStart": 5, + "selectionEnd": 5 + }, + { + "type": "keydown", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": true, + "value": "안녕하세요", + "selectionStart": 5, + "selectionEnd": 5 + }, + { + "type": "compositionupdate", + "data": "요", + "value": "안녕하세요", + "selectionStart": 4, + "selectionEnd": 5 + }, + { + "type": "beforeinput", + "data": "요", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안녕하세요", + "selectionStart": 4, + "selectionEnd": 5 + }, + { + "type": "input", + "data": "요", + "inputType": "insertCompositionText", + "isComposing": true, + "value": "안녕하세요", + "selectionStart": 5, + "selectionEnd": 5 + }, + { + "type": "compositionend", + "data": "요", + "value": "안녕하세요", + "selectionStart": 5, + "selectionEnd": 5 + }, + { + "type": "keydown", + "key": "Enter", + "code": "", + "keyCode": 13, + "isComposing": false, + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "keyup", + "key": "Process", + "code": "", + "keyCode": 229, + "isComposing": false, + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "keyup", + "key": "Enter", + "code": "", + "keyCode": 13, + "isComposing": false, + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "keydown", + "key": "h", + "code": "", + "keyCode": 72, + "isComposing": false, + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "keyup", + "key": "h", + "code": "", + "keyCode": 72, + "isComposing": false, + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "keydown", + "key": "e", + "code": "", + "keyCode": 69, + "isComposing": false, + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "keyup", + "key": "e", + "code": "", + "keyCode": 69, + "isComposing": false, + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "keydown", + "key": "l", + "code": "", + "keyCode": 76, + "isComposing": false, + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "keyup", + "key": "l", + "code": "", + "keyCode": 76, + "isComposing": false, + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "keydown", + "key": "l", + "code": "", + "keyCode": 76, + "isComposing": false, + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "keyup", + "key": "l", + "code": "", + "keyCode": 76, + "isComposing": false, + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "keydown", + "key": "o", + "code": "", + "keyCode": 79, + "isComposing": false, + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "keyup", + "key": "o", + "code": "", + "keyCode": 79, + "isComposing": false, + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "keydown", + "key": "Enter", + "code": "", + "keyCode": 13, + "isComposing": false, + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + }, + { + "type": "keyup", + "key": "Enter", + "code": "", + "keyCode": 13, + "isComposing": false, + "value": "", + "selectionStart": 0, + "selectionEnd": 0 + } + ] +} diff --git a/tests/e2e/terminal-cjk-ime-committed-text.spec.ts b/tests/e2e/terminal-cjk-ime-committed-text.spec.ts new file mode 100644 index 00000000000..fb98b2d1975 --- /dev/null +++ b/tests/e2e/terminal-cjk-ime-committed-text.spec.ts @@ -0,0 +1,312 @@ +/** + * Headless end-to-end coverage for Japanese and Chinese terminal input. + * + * Three shapes are covered, and the second is the one the whole suite used to be blind to: + * + * 1. Phrase-level composition — a Japanese preedit that grows to several characters and converts + * to kanji, and a pinyin preedit that spends most of its life as multi-letter romanisation. + * Both are asserted on the overlay's real geometry rather than on the bytes they later emit. + * 2. Committed text that arrives with **no composition session at all**. Full-width punctuation + * (`,` `。` `、`) and full-width digits are typed as a single keystroke that the input source + * rewrites; there is no compositionstart/update/end around them. Every IME test in the repo + * was composition-session-shaped, so this shape was invisible to the suite by construction — + * which is how `,` reaching the shell as an ASCII `,` shipped to users. This is the macOS + * shape, and the tests for it pin the macOS ownership policy. + * 3. The same full-width punctuation arriving **inside** a composition session, which is how the + * Windows and Linux frameworks deliver it. Same user-facing guarantee, different ordering. + * + * The assertion for shapes 2 and 3 is deliberately "the ASCII byte never appears" rather than "the + * substitution was applied": the correct design never manufactures the ASCII byte in the first + * place, so pinning its absence stays true under a forwarder rewrite as well as under a + * structural one. + */ +import type { CDPSession } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { closeTerminalImePaneArena, openTerminalImePaneArena } from './terminal-ime-pane-arena' +import { readTerminalImeBoundaryTrace } from './terminal-ime-boundary-probe' +import { + commitImeText, + dispatchImeProcessKey, + dispatchImeRewrittenPrintableKey, + dispatchImeSubstitutedTextKey, + dispatchPlainEnter, + setImeComposition, + type ImeKeyIdentity +} from './terminal-ime-cdp-composition' +import { + createTerminalImeByteReader, + removeTerminalImeByteReader, + startTerminalImeByteReader, + waitForTerminalImeBytes +} from './terminal-ime-byte-reader' +import { expectPreeditHidden, expectPreeditRendered } from './terminal-ime-preedit-overlay-probe' +import { applyImePlatformPolicy, type ImePlatformPolicy } from './terminal-ime-platform-policy' + +/** Frames a Japanese IME shows while typing にほんご and converting it to 日本語. */ +const JAPANESE_FRAMES = ['に', 'にほ', 'にほん', 'にほんご', '日本語'] as const + +/** + * Substitutions an East Asian input source commits from one keystroke, with no composition session + * around them. `ascii` is the character the physical key carries in the Latin layout — the byte the + * user must never see — and `glyph` is what the input source actually committed. + */ +type SubstitutedKeystroke = ImeKeyIdentity & { glyph: string; ascii: string } + +const FULL_WIDTH_PUNCTUATION: readonly SubstitutedKeystroke[] = [ + { key: ',', code: 'Comma', keyCode: 188, glyph: ',', ascii: ',' }, + { key: '.', code: 'Period', keyCode: 190, glyph: '。', ascii: '.' }, + { key: ',', code: 'Comma', keyCode: 188, glyph: '、', ascii: ',' } +] + +const FULL_WIDTH_DIGITS: readonly SubstitutedKeystroke[] = [ + { key: '1', code: 'Digit1', keyCode: 49, glyph: '1', ascii: '1' }, + { key: '2', code: 'Digit2', keyCode: 50, glyph: '2', ascii: '2' }, + { key: '3', code: 'Digit3', keyCode: 51, glyph: '3', ascii: '3' } +] + +const SUBSTITUTION_GROUPS = [ + { label: 'punctuation', keystrokes: FULL_WIDTH_PUNCTUATION }, + { label: 'digits', keystrokes: FULL_WIDTH_DIGITS } +] as const + +/** + * The two ways a substituted keystroke can reach the renderer. Both are real; only the second one + * regressed, and only the second one can regress, which is why running both is the point. + */ +const SUBSTITUTION_SHAPES: readonly { + name: string + slug: string + dispatch: (session: CDPSession, keystroke: SubstitutedKeystroke) => Promise +}[] = [ + { + // Green, and honest about its limits: xterm's own key handler emits `event.key`, so this shape + // survives with or without a forwarder and would have passed throughout the regression. It + // guards the frameworks that do rewrite `key`; it is not the regression guard. + name: 'the keydown already carries the substituted glyph', + slug: 'rewritten-keydown', + dispatch: (session, keystroke) => + dispatchImeRewrittenPrintableKey(session, { + key: keystroke.glyph, + code: keystroke.code, + keyCode: keystroke.keyCode + }) + }, + { + // The regression guard. Red on `main`, green from the structural-forwarder layer down. + // + // The keydown carries the plain Latin `,` and the `,` exists only in the following `input` + // event, so anything that produces bytes from the keydown emits the ASCII form and destroys + // the real one. Today the bypass that would prevent that is gated on the OS reporting an + // input source whose id matches a 23-term allowlist. That read returns null on every + // non-macOS host and `com.apple.keylayout.*` on a macOS runner with no CJK source selected, + // and the preload API surface is frozen so no spec can stub it — meaning correctness here is + // not expressible as a test until the gate goes away. + // + // Closed by the structural rule one layer below: bytes for printable characters come only from + // the `input` event, decided on the event's own shape with no input-source read. Verified on + // real macOS hardware — with an Apple pinyin source selected, the pty receives ef bc 8c e3 80 82 + // (,。) where main sends ASCII. Note main can pass this by luck: an input source whose id + // happens to contain an allowlist term, such as Sogou's, satisfies the old gate. + name: 'the keydown still carries the ASCII layout key', + slug: 'substituted-insert-text', + dispatch: (session, keystroke) => + dispatchImeSubstitutedTextKey(session, keystroke, keystroke.glyph) + } +] + +/** + * The two substitution shapes above are macOS-only, and deliberately so: the keydown bypass that + * owns single-keystroke IME commits installs only when the renderer reports macOS, because only + * the macOS text system delivers a substituted glyph with the plain layout key still on the + * keydown. Windows and Linux frameworks claim the same keystroke as VK_PROCESSKEY and route the + * glyph through a composition session instead, which the separate composition-session test below + * covers. Running the macOS shapes under a Linux policy would assert a sequence no Linux input + * framework produces. + */ +const FULL_WIDTH_SESSION_PUNCTUATION = [ + { key: ',', code: 'Comma', keyCode: 188, glyph: ',', ascii: ',' }, + { key: '.', code: 'Period', keyCode: 190, glyph: '。', ascii: '.' } +] as const + +test.describe('Terminal CJK IME committed text', () => { + test('shows a growing Japanese phrase preedit and commits the converted kanji', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + const arena = await openTerminalImePaneArena(orcaPage) + const reader = createTerminalImeByteReader(testRepoPath, 1) + let completed = false + try { + await startTerminalImeByteReader(orcaPage, arena.ptyId, reader) + await expectPreeditHidden(orcaPage, 'before composing') + await dispatchImeProcessKey(arena.session, { key: 'Process', code: 'KeyN' }) + + const widthByFrame = new Map() + for (const frame of JAPANESE_FRAMES) { + await setImeComposition(arena.session, frame) + const sample = await expectPreeditRendered(orcaPage, frame, `composing ${frame}`) + widthByFrame.set(frame, sample.rect.width) + } + // A phrase-level preedit must widen as it grows. An overlay pinned to one cell renders only + // the first character, which is a shape every non-geometric assertion reports as correct. + expect(widthByFrame.get('にほんご')!).toBeGreaterThan(widthByFrame.get('に')!) + expect(widthByFrame.get('日本語')!).toBeGreaterThan(widthByFrame.get('に')!) + + await commitImeText(arena.session, '日本語') + await expectPreeditHidden(orcaPage, 'after committing 日本語') + await dispatchPlainEnter(arena.session) + + const received = await waitForTerminalImeBytes(orcaPage, reader) + expect(received).toEqual([Buffer.from('日本語\n').toString('hex')]) + completed = true + } finally { + await closeTerminalImePaneArena(arena, testInfo, 'japanese-phrase-preedit', !completed) + removeTerminalImeByteReader(reader) + } + }) + + for (const shape of SUBSTITUTION_SHAPES) { + for (const group of SUBSTITUTION_GROUPS) { + test(`sends full-width ${group.label} and never their ASCII form when ${shape.name}`, async ({ + orcaPage, + testRepoPath + }, testInfo) => { + await applyImePlatformPolicy(orcaPage, 'mac') + const arena = await openTerminalImePaneArena(orcaPage) + const reader = createTerminalImeByteReader(testRepoPath, 1) + const expected = group.keystrokes.map((keystroke) => keystroke.glyph).join('') + let completed = false + try { + await startTerminalImeByteReader(orcaPage, arena.ptyId, reader) + for (const keystroke of group.keystrokes) { + await shape.dispatch(arena.session, keystroke) + await orcaPage.waitForTimeout(60) + } + await dispatchPlainEnter(arena.session) + + const sent = (await readTerminalImeBoundaryTrace(orcaPage)).onData.join('') + for (const keystroke of group.keystrokes) { + expect( + sent, + `${keystroke.glyph} reached the PTY as ASCII ${keystroke.ascii}` + ).not.toContain(keystroke.ascii) + } + expect(sent).toBe(`${expected}\r`) + + const received = await waitForTerminalImeBytes(orcaPage, reader) + expect(received).toEqual([Buffer.from(`${expected}\n`).toString('hex')]) + completed = true + } finally { + await closeTerminalImePaneArena( + arena, + testInfo, + `full-width-${group.label}-${shape.slug}`, + !completed + ) + removeTerminalImeByteReader(reader) + } + }) + } + } + + test('forwards Chinese pinyin conversions and their trailing full-width stop together', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + await applyImePlatformPolicy(orcaPage, 'mac') + const arena = await openTerminalImePaneArena(orcaPage) + const reader = createTerminalImeByteReader(testRepoPath, 1) + let completed = false + try { + await startTerminalImeByteReader(orcaPage, arena.ptyId, reader) + await dispatchImeProcessKey(arena.session, { key: 'Process', code: 'KeyN' }) + const widthByFrame = new Map() + for (const frame of ['n', 'ni', 'niha', 'nihao', '你好']) { + await setImeComposition(arena.session, frame) + const sample = await expectPreeditRendered(orcaPage, frame, `composing ${frame}`) + widthByFrame.set(frame, sample.rect.width) + } + // Pinyin spends most of its life as a multi-letter romanisation before any Chinese appears, + // so an overlay pinned to a single cell shows the user only the first letter of what they + // typed. Width is the only property that catches that; text content looks correct. + expect(widthByFrame.get('nihao')!).toBeGreaterThan(widthByFrame.get('n')!) + expect(widthByFrame.get('你好')!).toBeGreaterThan(widthByFrame.get('n')!) + + await commitImeText(arena.session, '你好') + await expectPreeditHidden(orcaPage, 'after committing 你好') + + // The distinct risk here is the adjacency, not the substitution: the stop arrives with no + // composition session immediately after one closed, so a tracker that still believes a + // composition is open swallows it. Dispatched in the glyph-carrying shape so this stays a + // test of the transition rather than a second copy of the known-broken case above. + await dispatchImeRewrittenPrintableKey(arena.session, { + key: '。', + code: 'Period', + keyCode: 190 + }) + await orcaPage.waitForTimeout(60) + await dispatchPlainEnter(arena.session) + + const trace = await readTerminalImeBoundaryTrace(orcaPage) + expect(trace.onData.join('')).toBe('你好。\r') + + const received = await waitForTerminalImeBytes(orcaPage, reader) + expect(received).toEqual([Buffer.from('你好。\n').toString('hex')]) + completed = true + } finally { + await closeTerminalImePaneArena(arena, testInfo, 'pinyin-with-full-width-stop', !completed) + removeTerminalImeByteReader(reader) + } + }) + + for (const policy of ['windows', 'linux'] as const satisfies readonly ImePlatformPolicy[]) { + test(`sends full-width punctuation committed through a composition session on ${policy}`, async ({ + orcaPage, + testRepoPath + }, testInfo) => { + // SYNTHESISED, and the reason is worth stating: the recorded corpus contains no Windows or + // Linux capture of full-width punctuation, only of Hangul and pinyin. What is not synthesised + // is the shape — on these platforms the framework claims the punctuation key as + // VK_PROCESSKEY and routes the substituted glyph through a real composition arena.session, which is + // what `Input.imeSetComposition` opens, rather than through the macOS insertText path the + // tests above cover. The ASCII form is asserted absent rather than the substitution asserted + // present, so this stays true of any design that never manufactures the ASCII byte. + await applyImePlatformPolicy(orcaPage, policy) + const arena = await openTerminalImePaneArena(orcaPage) + const reader = createTerminalImeByteReader(testRepoPath, 1) + const expected = FULL_WIDTH_SESSION_PUNCTUATION.map((entry) => entry.glyph).join('') + let completed = false + try { + await startTerminalImeByteReader(orcaPage, arena.ptyId, reader) + for (const entry of FULL_WIDTH_SESSION_PUNCTUATION) { + await dispatchImeProcessKey(arena.session, { key: 'Process', code: entry.code }) + await setImeComposition(arena.session, entry.glyph) + await expectPreeditRendered(orcaPage, entry.glyph, `composing ${entry.glyph}`) + await commitImeText(arena.session, entry.glyph) + await expectPreeditHidden(orcaPage, `after committing ${entry.glyph}`) + } + await dispatchPlainEnter(arena.session) + + const sent = (await readTerminalImeBoundaryTrace(orcaPage)).onData.join('') + for (const entry of FULL_WIDTH_SESSION_PUNCTUATION) { + expect(sent, `${entry.glyph} reached the PTY as ASCII ${entry.ascii}`).not.toContain( + entry.ascii + ) + } + expect(sent).toBe(`${expected}\r`) + + const received = await waitForTerminalImeBytes(orcaPage, reader) + expect(received).toEqual([Buffer.from(`${expected}\n`).toString('hex')]) + completed = true + } finally { + await closeTerminalImePaneArena( + arena, + testInfo, + `full-width-punctuation-session-${policy}`, + !completed + ) + removeTerminalImeByteReader(reader) + } + }) + } +}) diff --git a/tests/e2e/terminal-ime-cdp-composition.ts b/tests/e2e/terminal-ime-cdp-composition.ts index e035db8bef0..a151b23aed5 100644 --- a/tests/e2e/terminal-ime-cdp-composition.ts +++ b/tests/e2e/terminal-ime-cdp-composition.ts @@ -1,4 +1,4 @@ -import type { CDPSession } from '@stablyai/playwright-test' +import type { CDPSession, Page } from '@stablyai/playwright-test' /** * Dispatches the key shapes an input source or a system text substitution produces, through CDP. @@ -87,3 +87,89 @@ export async function dispatchPlainEnter(session: CDPSession): Promise { }) } } + +/** + * `Input.imeSetComposition` opens a genuine Chromium composition session — the same one a native + * input source would — so preedit geometry can be asserted with no system input source at all. + */ +export async function setImeComposition(session: CDPSession, text: string): Promise { + const length = Array.from(text).length + await session.send('Input.imeSetComposition', { + text, + selectionStart: length, + selectionEnd: length + }) +} + +export async function commitImeText(session: CDPSession, text: string): Promise { + await session.send('Input.insertText', { text }) +} + +/** IME preedit keystrokes reach the renderer as VK_PROCESSKEY (229) with no text payload. */ +export async function dispatchImeProcessKey( + session: CDPSession, + identity: Pick +): Promise { + for (const type of ['rawKeyDown', 'keyUp'] as const) { + await session.send('Input.dispatchKeyEvent', { + type, + key: identity.key, + code: identity.code, + windowsVirtualKeyCode: 229, + nativeVirtualKeyCode: 229, + text: '', + unmodifiedText: '' + }) + } +} + +/** Drives one syllable's jamo frames; `pauseMs` 0 models key repeat reaching the renderer. */ +export async function composeHangulSyllable( + session: CDPSession, + page: Page, + frames: readonly { jamoKey: ImeKeyIdentity; preedit: string }[], + pauseMs = 60 +): Promise { + for (const frame of frames) { + await dispatchImeProcessKey(session, frame.jamoKey) + await setImeComposition(session, frame.preedit) + if (pauseMs > 0) { + await page.waitForTimeout(pauseMs) + } + } +} + +/** + * Emits a bare `compositionupdate` with no `compositionstart` ahead of it. + * + * SYNTHESISED, not replayed, and the reason is worth stating: the recorded Windows/WSL Hangul + * capture in `fixtures/windows-wsl-2set-hangul-dom-trace.json` does **not** contain this ordering + * — every one of its 37 composition updates sits inside an open start/end pair. The shape is + * nevertheless reachable by construction, because xterm adds `.active` to the overlay only in its + * `compositionstart` handler and its `compositionupdate` handler writes `textContent` without + * ever re-adding it. CDP cannot produce the ordering either: `Input.imeSetComposition` always + * opens a session first. So this is dispatched directly. + */ +export async function dispatchResumedCompositionUpdate(page: Page, data: string): Promise { + await page.evaluate((preedit: string) => { + const textarea = document.querySelector('.xterm-helper-textarea:focus') + if (!textarea) { + throw new Error('xterm helper textarea is not focused') + } + textarea.dispatchEvent( + new CompositionEvent('compositionupdate', { + bubbles: true, + data: preedit + }) + ) + textarea.dispatchEvent( + new InputEvent('input', { + bubbles: true, + composed: true, + data: preedit, + inputType: 'insertCompositionText', + isComposing: true + }) + ) + }, data) +} diff --git a/tests/e2e/terminal-ime-preedit-overlay-probe.ts b/tests/e2e/terminal-ime-preedit-overlay-probe.ts new file mode 100644 index 00000000000..d60b62b1680 --- /dev/null +++ b/tests/e2e/terminal-ime-preedit-overlay-probe.ts @@ -0,0 +1,102 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect } from '@stablyai/playwright-test' + +/** + * Samples the xterm preedit overlay's real geometry. + * + * Why geometry and not the `active` class: an overlay forced to + * `max-width: 0; overflow: hidden` is invisible on screen, yet keeps its class, its + * `textContent`, `display: block` and `checkVisibility() === true`. The bounding rect is the + * only property that discriminates, and it is also the only one a DOM emulator cannot produce — + * happy-dom reports all-zero rects in every state, so a unit-level arm passes over an overlay + * that never renders. + */ +export type PreeditOverlaySample = { + found: boolean + active: boolean + text: string + rect: { width: number; height: number } + checkVisibility: boolean | null + display: string + visibility: string + opacity: string + maxWidth: string + overflow: string +} + +export function readPreeditOverlay(): PreeditOverlaySample { + const textarea = + document.querySelector('.xterm-helper-textarea:focus') ?? + document.querySelector('.xterm-helper-textarea') + const view = textarea?.parentElement?.querySelector('.composition-view') ?? null + if (!view) { + return { + found: false, + active: false, + text: '', + rect: { width: 0, height: 0 }, + checkVisibility: null, + display: '', + visibility: '', + opacity: '', + maxWidth: '', + overflow: '' + } + } + const style = getComputedStyle(view) + const rect = view.getBoundingClientRect() + return { + found: true, + active: view.classList.contains('active'), + // The overlay wraps its text in LRM marks; strip them so assertions read as the user sees it. + text: (view.textContent ?? '').replaceAll('‎', ''), + rect: { width: rect.width, height: rect.height }, + checkVisibility: typeof view.checkVisibility === 'function' ? view.checkVisibility() : null, + display: style.display, + visibility: style.visibility, + opacity: style.opacity, + maxWidth: style.maxWidth, + overflow: style.overflow + } +} + +export async function samplePreeditOverlay(page: Page): Promise { + return page.evaluate(readPreeditOverlay) +} + +export async function expectPreeditRendered( + page: Page, + expectedText: string, + message: string +): Promise { + await expect + .poll(async () => (await samplePreeditOverlay(page)).text, { message }) + .toBe(expectedText) + const sample = await samplePreeditOverlay(page) + assertPreeditRendered(sample, expectedText, message) + return sample +} + +export function assertPreeditRendered( + sample: PreeditOverlaySample, + expectedText: string, + message: string +): void { + expect(sample.found, `${message}: no composition overlay exists`).toBe(true) + expect(sample.text, `${message}: overlay text`).toBe(expectedText) + expect(sample.active, `${message}: overlay is not marked active`).toBe(true) + expect(sample.display, `${message}: overlay is display:none`).not.toBe('none') + expect(sample.visibility, `${message}: overlay is not visible`).toBe('visible') + expect(sample.checkVisibility, `${message}: overlay fails checkVisibility`).not.toBe(false) + // The load-bearing pair. Do not weaken these to a class or visibility check. + expect(sample.rect.width, `${message}: overlay has zero width`).toBeGreaterThan(0) + expect(sample.rect.height, `${message}: overlay has zero height`).toBeGreaterThan(0) + expect(sample.maxWidth, `${message}: overlay is clipped to zero width`).not.toBe('0px') +} + +export async function expectPreeditHidden(page: Page, message: string): Promise { + await expect.poll(async () => (await samplePreeditOverlay(page)).active, { message }).toBe(false) + const sample = await samplePreeditOverlay(page) + expect(sample.rect.width, `${message}: overlay still occupies width`).toBe(0) + expect(sample.rect.height, `${message}: overlay still occupies height`).toBe(0) +} diff --git a/tests/e2e/terminal-ime-recorded-dom-trace-replay.ts b/tests/e2e/terminal-ime-recorded-dom-trace-replay.ts new file mode 100644 index 00000000000..9b73a5b4008 --- /dev/null +++ b/tests/e2e/terminal-ime-recorded-dom-trace-replay.ts @@ -0,0 +1,198 @@ +import type { Page } from '@stablyai/playwright-test' +import { + samplePreeditOverlay, + type PreeditOverlaySample +} from './terminal-ime-preedit-overlay-probe' + +/** + * Replays a recorded IME DOM trace against the live terminal one event at a time, sampling the + * preedit overlay after every composition event. + * + * Each event costs a CDP round-trip. That is deliberate: it lets xterm's deferred composition + * timers and the renderer's layout run between events the way they do under a real IME, so a + * per-event geometry sample measures an overlay that has actually been positioned. + */ +export type RecordedImeDomEvent = { + type: string + data?: string + inputType?: string + key?: string + code?: string + keyCode?: number + isComposing?: boolean + value?: string + selectionStart?: number + selectionEnd?: number +} + +export type RecordedImeDomTrace = { + recordedFrom: string + inputFramework: string + engine: string + /** Present on the Linux captures, where X11 and Wayland emit different orderings. */ + displayServer?: string + note: string + onData?: { data: string }[] + dom: RecordedImeDomEvent[] +} + +export type ReplayedCompositionSample = { + index: number + type: string + data: string + compositionOpen: boolean + overlay: PreeditOverlaySample +} + +export type RecordedTraceReplay = { + samples: ReplayedCompositionSample[] + onData: string +} + +const COMPOSITION_EVENT_TYPES = new Set(['compositionstart', 'compositionupdate', 'compositionend']) + +async function startRecordedTraceOnDataCapture(page: Page): Promise { + await page.evaluate(() => { + const target = window as unknown as { __recordedTraceOnData: string[] } + target.__recordedTraceOnData = [] + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + throw new Error('No active terminal pane for the recorded trace replay') + } + pane.terminal.onData((data) => target.__recordedTraceOnData.push(data)) + }) +} + +async function dispatchRecordedEvents( + page: Page, + recorded: readonly RecordedImeDomEvent[] +): Promise { + await page.evaluate((events: RecordedImeDomEvent[]) => { + const textarea = document.querySelector('.xterm-helper-textarea:focus') + if (!textarea) { + throw new Error('xterm helper textarea is not focused') + } + for (const event of events) { + // The recorded value/selection is what the recorder observed *during* this event, so it has + // to be in place before dispatch. xterm reads `textarea.value` inside its own handlers rather + // than off the event, so applying it afterwards hands every handler the previous event's + // state. + if (event.value !== undefined) { + textarea.value = event.value + } + if (event.selectionStart !== undefined && event.selectionEnd !== undefined) { + textarea.setSelectionRange(event.selectionStart, event.selectionEnd) + } + // keypress is recorded on Windows, where the IME lets Enter through to the textarea's own + // default action; replaying it as a CompositionEvent would invent an event no IME ever sent. + if (event.type === 'keydown' || event.type === 'keyup' || event.type === 'keypress') { + const keyboard = new KeyboardEvent(event.type, { + key: event.key, + code: event.code, + isComposing: event.isComposing, + bubbles: true, + cancelable: true + }) + Object.defineProperty(keyboard, 'keyCode', { value: event.keyCode }) + textarea.dispatchEvent(keyboard) + } else if (event.type === 'input' || event.type === 'beforeinput') { + textarea.dispatchEvent( + new InputEvent(event.type, { + bubbles: true, + cancelable: event.type === 'beforeinput', + composed: true, + data: event.data ?? null, + inputType: event.inputType ?? '', + isComposing: event.isComposing + }) + ) + } else { + textarea.dispatchEvent( + new CompositionEvent(event.type, { + bubbles: true, + data: event.data ?? '' + }) + ) + } + } + }, recorded as RecordedImeDomEvent[]) +} + +/** + * Groups a `compositionend` with the `beforeinput`/`input` events that immediately follow it. + * + * Chromium dispatches a commit's `compositionend` and the `input` carrying the committed text + * inside one task. The replay's per-event round-trip inserts a task boundary the IME never + * produced, and xterm arms a deferred finalizer on `compositionend` that reads the textarea when it + * runs — so a boundary there lets the finalizer settle the commit against a textarea the committed + * text has not reached yet. On IBus, whose `compositionend` is empty and whose text arrives only in + * the following `insertText`, that swallowed every syllable and made a working build look broken. + * + * Only the commit tail is fused. Composition updates keep their own round-trip, which is what lets + * xterm's deferred overlay positioning run before each geometry sample. + */ +function nextRecordedEventGroup( + dom: readonly RecordedImeDomEvent[], + start: number +): RecordedImeDomEvent[] { + const group = [dom[start]] + if (dom[start].type !== 'compositionend') { + return group + } + for (let index = start + 1; index < dom.length; index += 1) { + if (dom[index].type !== 'input' && dom[index].type !== 'beforeinput') { + break + } + group.push(dom[index]) + } + return group +} + +export async function replayRecordedImeDomTrace( + page: Page, + trace: RecordedImeDomTrace +): Promise { + await startRecordedTraceOnDataCapture(page) + + const samples: ReplayedCompositionSample[] = [] + let compositionOpen = false + + for (let index = 0; index < trace.dom.length; ) { + const group = nextRecordedEventGroup(trace.dom, index) + const recorded = group[0] + await dispatchRecordedEvents(page, group) + index += group.length + if (!COMPOSITION_EVENT_TYPES.has(recorded.type)) { + continue + } + if (recorded.type === 'compositionstart') { + compositionOpen = true + } + samples.push({ + index: index - group.length, + type: recorded.type, + data: recorded.data ?? '', + compositionOpen, + overlay: await samplePreeditOverlay(page) + }) + if (recorded.type === 'compositionend') { + compositionOpen = false + } + } + + const onData = await page.evaluate(() => + ((window as unknown as { __recordedTraceOnData?: string[] }).__recordedTraceOnData ?? []).join( + '' + ) + ) + return { samples, onData } +} diff --git a/tests/e2e/terminal-korean-preedit-visibility.spec.ts b/tests/e2e/terminal-korean-preedit-visibility.spec.ts new file mode 100644 index 00000000000..cf1f9e46d36 --- /dev/null +++ b/tests/e2e/terminal-korean-preedit-visibility.spec.ts @@ -0,0 +1,207 @@ +/** + * Headless end-to-end coverage for 2-Set Korean terminal input, asserting what the user sees. + * + * Two IME defects shipped past a suite of ~3000 passing IME assertions. Both were invisible to it + * for the same reason: every assertion was about bytes reaching the PTY, and a preedit rendered + * into a hidden overlay satisfies all of them while the user composes blind. The real-geometry + * coverage that would have caught it existed, but was `@headful`, env-gated and macOS-only, so it + * never ran in CI. + * + * This file closes that gap. Composition is driven through CDP `Input.imeSetComposition`, which + * opens a genuine Chromium composition session with no native IME, no accessibility grant and no + * system input source — so the geometry assertions run in the normal headless CI project. + */ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { expect, test } from './helpers/orca-app' +import { closeTerminalImePaneArena, openTerminalImePaneArena } from './terminal-ime-pane-arena' +import { + commitImeText, + composeHangulSyllable, + dispatchPlainEnter, + dispatchResumedCompositionUpdate, + type ImeKeyIdentity +} from './terminal-ime-cdp-composition' +import { readTerminalImeBoundaryTrace } from './terminal-ime-boundary-probe' +import { + createTerminalImeByteReader, + removeTerminalImeByteReader, + startTerminalImeByteReader, + waitForTerminalImeBytes +} from './terminal-ime-byte-reader' +import { + expectPreeditHidden, + expectPreeditRendered, + samplePreeditOverlay +} from './terminal-ime-preedit-overlay-probe' +import { applyImePlatformPolicy } from './terminal-ime-platform-policy' +import { + replayRecordedImeDomTrace, + type RecordedImeDomTrace +} from './terminal-ime-recorded-dom-trace-replay' + +/** 2-Set Korean maps each jamo to a QWERTY position; the IME rewrites `key` to the jamo itself. */ +const JAMO: Record = { + ㅎ: { key: 'ㅎ', code: 'KeyG', keyCode: 71 }, + ㅏ: { key: 'ㅏ', code: 'KeyK', keyCode: 75 }, + ㄴ: { key: 'ㄴ', code: 'KeyS', keyCode: 83 }, + ㄱ: { key: 'ㄱ', code: 'KeyR', keyCode: 82 }, + ㅡ: { key: 'ㅡ', code: 'KeyM', keyCode: 77 }, + ㄹ: { key: 'ㄹ', code: 'KeyF', keyCode: 70 } +} + +/** ㅎ → ㅏ → ㄴ assembles 한; the preedit shows the partially assembled syllable at each step. */ +const HAN_FRAMES = [ + { jamoKey: JAMO['ㅎ'], preedit: 'ㅎ' }, + { jamoKey: JAMO['ㅏ'], preedit: '하' }, + { jamoKey: JAMO['ㄴ'], preedit: '한' } +] as const + +/** ㄱ → ㅡ → ㄹ assembles 글. */ +const GEUL_FRAMES = [ + { jamoKey: JAMO['ㄱ'], preedit: 'ㄱ' }, + { jamoKey: JAMO['ㅡ'], preedit: '그' }, + { jamoKey: JAMO['ㄹ'], preedit: '글' } +] as const + +const RECORDED_TRACE = JSON.parse( + readFileSync(path.join(__dirname, 'fixtures', 'windows-wsl-2set-hangul-dom-trace.json'), 'utf8') +) as RecordedImeDomTrace + +test.describe('Terminal 2-Set Korean preedit visibility', () => { + test('shows every assembling jamo at non-zero size and commits the syllable ahead of the newline', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + const arena = await openTerminalImePaneArena(orcaPage) + const reader = createTerminalImeByteReader(testRepoPath, 1) + let completed = false + try { + await startTerminalImeByteReader(orcaPage, arena.ptyId, reader) + await expectPreeditHidden(orcaPage, 'before composing') + + for (const frame of HAN_FRAMES) { + await composeHangulSyllable(arena.session, orcaPage, [frame]) + await expectPreeditRendered(orcaPage, frame.preedit, `composing ${frame.preedit}`) + } + + await commitImeText(arena.session, '한') + await expectPreeditHidden(orcaPage, 'after committing 한') + + await dispatchPlainEnter(arena.session) + + const received = await waitForTerminalImeBytes(orcaPage, reader) + expect(received).toEqual([Buffer.from('한\n').toString('hex')]) + + const trace = await readTerminalImeBoundaryTrace(orcaPage) + // The ordering the user reported as broken: the syllable must precede the newline. + expect(trace.onData.join('')).toBe('한\r') + completed = true + } finally { + await closeTerminalImePaneArena(arena, testInfo, 'korean-preedit-visibility', !completed) + removeTerminalImeByteReader(reader) + } + }) + + test('keeps a preedit the IME resumes without a compositionstart visible', async ({ + orcaPage + }, testInfo) => { + // Red on `main`, by design. xterm adds `.active` to the overlay only in its `compositionstart` + // handler, so a preedit resumed by a bare `compositionupdate` is written into a hidden element + // and the user composes blind while the committed bytes still land correctly — which is why no + // byte-level assertion ever saw it. Pre-existing and broken in every shipped build; closed by + // the visibility fix in xterm's own composition helper one layer below this one. + const arena = await openTerminalImePaneArena(orcaPage) + let completed = false + try { + // Synthesised, not replayed — see dispatchResumedCompositionUpdate for why the recorded + // corpus cannot supply this ordering and why it is still reachable in production. + await dispatchResumedCompositionUpdate(orcaPage, '한') + + const sample = await samplePreeditOverlay(orcaPage) + expect(sample.found, 'no composition overlay exists').toBe(true) + expect(sample.text, 'the resumed preedit text never reached the overlay').toBe('한') + expect( + sample.rect.width, + 'the resumed preedit was written into an overlay with zero width — the user composes blind' + ).toBeGreaterThan(0) + expect(sample.rect.height, 'the resumed preedit overlay has zero height').toBeGreaterThan(0) + completed = true + } finally { + await closeTerminalImePaneArena(arena, testInfo, 'korean-resumed-preedit', !completed) + } + }) + + test('renders the preedit at every update of a recorded Windows/WSL Hangul session', async ({ + orcaPage + }, testInfo) => { + // Pinned to the Windows policy because the trace is a Windows recording. Without the pin it + // ran under whatever the runner reported — macOS locally, Linux on the CI shards — so the one + // platform it was named for was the one platform it never exercised. + await applyImePlatformPolicy(orcaPage, 'windows') + const arena = await openTerminalImePaneArena(orcaPage) + let completed = false + try { + const replay = await replayRecordedImeDomTrace(orcaPage, RECORDED_TRACE) + const updates = replay.samples.filter( + (sample) => sample.type === 'compositionupdate' && sample.data.length > 0 + ) + // If the fixture is ever replaced with one that carries no updates this assertion keeps the + // rest of the test from passing vacuously. + expect(updates.length, 'the recorded trace carries no composition updates').toBe(37) + + const invisible = updates.filter( + (sample) => sample.overlay.rect.width === 0 || sample.overlay.rect.height === 0 + ) + expect( + invisible.map((sample) => ({ index: sample.index, data: sample.data })), + 'these recorded preedit frames were written into an overlay with no size' + ).toEqual([]) + + const committed = replay.samples + .filter((sample) => sample.type === 'compositionend' && sample.data.length > 0) + .map((sample) => sample.data) + expect(committed.join('')).toBe('문제모르겠네안녕하세요') + + // The capture's own byte stream, asserted rather than carried unused: it is the only thing + // in this test that would notice a syllable being dropped between the overlay and the PTY, + // and the trailing ASCII `hello` pins that a plain word after a Korean session is unharmed. + expect(replay.onData).toBe((RECORDED_TRACE.onData ?? []).map((entry) => entry.data).join('')) + expect(replay.onData).toBe('문제\r모르겠네\r안녕하세요\rhello\r') + completed = true + } finally { + await closeTerminalImePaneArena(arena, testInfo, 'korean-recorded-trace-preedit', !completed) + } + }) + + test('loses and duplicates nothing when back-to-back syllables commit at full speed', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + const arena = await openTerminalImePaneArena(orcaPage) + const reader = createTerminalImeByteReader(testRepoPath, 1) + let completed = false + try { + await startTerminalImeByteReader(orcaPage, arena.ptyId, reader) + // No settle time between frames or between syllables: the cadence a fast typist produces, + // and the one that used to drop or double a syllable at the boundary. + for (let repetition = 0; repetition < 4; repetition += 1) { + await composeHangulSyllable(arena.session, orcaPage, HAN_FRAMES, 0) + await commitImeText(arena.session, '한') + await composeHangulSyllable(arena.session, orcaPage, GEUL_FRAMES, 0) + await commitImeText(arena.session, '글') + } + await dispatchPlainEnter(arena.session) + + const received = await waitForTerminalImeBytes(orcaPage, reader) + expect(received).toEqual([Buffer.from(`${'한글'.repeat(4)}\n`).toString('hex')]) + + const trace = await readTerminalImeBoundaryTrace(orcaPage) + expect(trace.onData.join('')).toBe(`${'한글'.repeat(4)}\r`) + completed = true + } finally { + await closeTerminalImePaneArena(arena, testInfo, 'korean-fast-cadence', !completed) + removeTerminalImeByteReader(reader) + } + }) +})