* fix(terminal): show a preedit the IME resumes without a compositionstart Typing 2-Set Korean shows committed syllables but not the in-progress jamo, so the user composes each syllable blind. Long-standing hole in the vendored terminal library, not a regression: the same test fails identically against the bundle this branch starts from. The `.active` class that CSS keys `display: block` off is added only in `compositionstart` and dropped in `_finalizeComposition`. Some IMEs (observed on Windows/WSL Korean) resume a composition with a bare `compositionupdate` and no second `compositionstart`, by which point `compositionend` has already hidden the overlay, so the resumed preedit is written into a hidden element and never positioned. `updateCompositionElements` also early-returned on `!_isComposing`, so it would not lay the overlay out either. Re-show the overlay on an update that carries data, and key the layout guard on the shown overlay instead. `_isComposing` is deliberately left alone, so no commit bookkeeping changes and `onData` stays byte-identical. The two guards are equivalent on every pre-existing path: `compositionstart` sets both, `_finalizeComposition` clears both. The bundle hunks are the same two edits applied to the shipped minified output; the sourcemaps are carried through unchanged. * test(terminal): prove the resumed-preedit fix against a recorded Windows capture The synthetic test pins the shape; this replays events a real Microsoft Korean IME emitted on Windows/WSL. The capture holds three compositionupdates that resume a composition with no second compositionstart — the exact ordering that wrote the preedit into a hidden overlay. Without the fix all three report shown:false; with it all three are visible. Fixture derived from the sealed 11919-windows-wsl-current capture, which is read-only and unmodified. Co-authored-by: Orca <help@stably.ai> * test(terminal): stop the recorded Hangul fixture pinning a derivation artifact The capture logs each event twice — a dispatch record and a batched next-frame re-log. Deriving from both replayed every event twice, which made three compositionupdates appear to land after a session had ended. Filtered to dispatch records the capture holds zero resumes and 11 balanced sessions, so the previous toHaveLength(3) was pinning an artifact of the derivation. Re-scoped to what the capture does prove: the preedit stays visible across all 37 real updates. Verified by reverting the patch that this passes either way, so it is coverage and the synthetic test remains the discriminator. Both facts are now stated in the file. Co-authored-by: Orca <help@stably.ai> * fix(terminal): restore the preedit visibility patch onto its own branch The previous commit accidentally reverted it: checking main's patch and lockfile into the worktree to test whether a test discriminates also stages them, so the commit that followed swept them up. Co-authored-by: Orca <help@stably.ai> * fix(terminal): claim printable keydowns structurally so committed text survives Co-authored-by: Orca <help@stably.ai> * chore(reliability-gates): retarget the IME forwarding gate after the allowlist removal The gate listed terminal-ime-input-source.test.ts, which went with the input-source allowlist. Points at the substituted-text commit test instead, which covers what the gate is actually protecting: text committed outside a composition session reaching the pty exactly once. Co-authored-by: Orca <help@stably.ai> * docs(terminal): record why withholding a claimed keydown needs no timer The predicate withholds a keydown's byte until the commit arrives, so a key the IME eats without committing would be dropped. Measured across the recorded corpus that case does not occur, and the browser marks IME-owned presses on the keydown itself. Both facts belong next to the predicate rather than only in a handoff note, since the obvious fix for the imagined gap is a timer, and a timer here once wrote a newline the user never typed. Co-authored-by: Orca <help@stably.ai> * test(terminal): pin the kitty all-keys-as-escape-codes hole explicitly Flag 8 asks for every printable key as an escape code; this path sends the committed text raw instead. That is a deliberate trade, not an oversight, but it was untested — the suite only covered the disambiguate flag. Pinning it makes the choice visible and records the gate to use if it ever needs closing. Co-authored-by: Orca <help@stably.ai> * fix(terminal): keep the kitty key-release report for presses that reached the pty Claiming the keyup unconditionally suppressed xterm's release report. That was sized for the old design, which claimed only a short punctuation list; the structural claim takes every printable keydown, so on macOS an app that negotiated kitty report_event_types stopped seeing releases for ordinary typing and would treat every printable key as held down. Suppress the release only when the press put nothing on the wire — swallowed by the input source, or owned by a composition transaction. xterm emits nothing from keyup unless kitty report_event_types (or win32 input mode) is on, so letting it through is inert everywhere else. Co-authored-by: Orca <help@stably.ai> * test(terminal): pin the macOS key-binding substitution against #11170 An OS key-binding remap of the character a Korean layout puts on Backquote is honoured everywhere on macOS except the terminal, which sent the raw layout character to the PTY. The substitution is applied inside the system text input path, so it exists only on keypress.charCode and the input event's data; the keydown still carries the layout character. Nothing needs to parse the binding file - Chromium has already applied it by the time `input` fires. The reported build sent the raw character. A later punctuation table happened to list that one character, which closed the issue by enumeration rather than by design, and the structural claim removes the table entirely. Without a test the fix could regress silently on a change that never mentions the issue. Replays the reporter's captured event shape and pairs it with the same physical key carrying no substitution, so a fix that rewrote the Backquote position unconditionally would fail. Discrimination checked by mutation: suppressing the structural claim, and separately removing the single table character on a pre-rewrite tree, each make the replay send the raw layout character while both negatives stay green. Co-authored-by: Orca <help@stably.ai> * test(terminal): cover the other Korean layout on the remapped key Korean layouts disagree about what the backquote position produces: two of them give the currency sign the issue reports, one gives an asterisk. One key-binding entry has to survive either, but honouring the substitution by listing characters covers only the ones someone remembered to list - which is why the reported character worked and this one did not. This arm discriminates without a mutation: it fails on the pre-rewrite tree and passes on the structural claim. The harness supplies no input-source classification, modelling a source the older design did not recognise, including the window before its async probe resolves. With the source recognised the older design claimed all ASCII punctuation and covered this too, so the gap was real but conditional; the header says so rather than letting the failure read as unconditional. Co-authored-by: Orca <help@stably.ai> * test(terminal): drop the Won-setting arm from the keybinding-dict replay The Won-to-backquote feature was reverted, so the module this replay imported no longer exists. The #11170 coverage is unaffected: the remaining arms pin the substitution itself, which never depended on that setting. Co-authored-by: Orca <help@stably.ai> * test(terminal): correct the fixture's provenance count The header said two derived cases when there are four, and counted the second layout arm as a negative when it is a positive. Each case already carries its own recorded flag and note; this stops the summary contradicting them, which matters in the one field whose whole job is provenance. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
bf406720a0
commit
cb9aa12fff
|
|
@ -6658,13 +6658,14 @@
|
||||||
"invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY.",
|
"invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY.",
|
||||||
"oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary and long-held letter-to-digit typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit selectors submit only the committed Chinese text, while the legacy orphaned-letter-keyup sequence sends no selector byte to the PTY. Real legacy IME commit preservation and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.",
|
"oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary and long-held letter-to-digit typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit selectors submit only the committed Chinese text, while the legacy orphaned-letter-keyup sequence sends no selector byte to the PTY. Real legacy IME commit preservation and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.",
|
||||||
"commands": [
|
"commands": [
|
||||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
|
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
|
||||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts",
|
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts",
|
||||||
"pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts"
|
"pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts"
|
||||||
],
|
],
|
||||||
"testFiles": [
|
"testFiles": [
|
||||||
"src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts",
|
"src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts",
|
||||||
"src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts",
|
"src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts",
|
||||||
|
"src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts",
|
||||||
"src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts",
|
"src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts",
|
||||||
"src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts",
|
"src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts",
|
||||||
"src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts",
|
"src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts",
|
||||||
|
|
@ -6687,6 +6688,13 @@
|
||||||
"synthetic and native input-source paths are classified for covered fixtures"
|
"synthetic and native input-source paths are classified for covered fixtures"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"file": "src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts",
|
||||||
|
"assertions": [
|
||||||
|
"an OS key-binding substitution reaches the PTY instead of the raw layout character",
|
||||||
|
"the same key with no substitution in play still sends the layout character"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"file": "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts",
|
"file": "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts",
|
||||||
"assertions": ["paste/runtime forwarding avoids duplicate terminal payloads"]
|
"assertions": ["paste/runtime forwarding avoids duplicate terminal payloads"]
|
||||||
|
|
@ -6739,6 +6747,15 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"evidenceRuns": [
|
"evidenceRuns": [
|
||||||
|
{
|
||||||
|
"date": "2026-08-09",
|
||||||
|
"runner": "local",
|
||||||
|
"platform": "macos",
|
||||||
|
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
|
||||||
|
"result": "passed",
|
||||||
|
"durationSeconds": 0.7,
|
||||||
|
"summary": "8 test file(s) passed, 143 tests, after adding the OS key-binding substitution replay. Discrimination checked by mutation rather than assumed: suppressing the structural claim, and separately removing the single character the older punctuation table happened to list, each make the replay send the raw layout character while both paired negatives stay green."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"date": "2026-07-11",
|
"date": "2026-07-11",
|
||||||
"runner": "local",
|
"runner": "local",
|
||||||
|
|
@ -6770,10 +6787,10 @@
|
||||||
"date": "2026-07-07",
|
"date": "2026-07-07",
|
||||||
"runner": "local",
|
"runner": "local",
|
||||||
"platform": "macos",
|
"platform": "macos",
|
||||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
|
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
|
||||||
"result": "passed",
|
"result": "passed",
|
||||||
"durationSeconds": 0.7,
|
"durationSeconds": 0.7,
|
||||||
"summary": "7 test file(s) passed, 144 tests passed on the Linux/Sogou candidate-key branch (includes held-key repeat guard coverage)."
|
"summary": "7 test file(s) passed, 144 tests passed on the Linux/Sogou candidate-key branch (includes held-key repeat guard coverage). The command shown is the gate's current one, which the manifest check requires; the run itself predates two of the files it now lists. The 2026-08-09 entry is the one that covers the current slice."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "2026-07-07",
|
"date": "2026-07-07",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
{
|
||||||
|
"recordedFrom": "issue #11170, probe page in Chromium on macOS (reporter's hardware, Korean input source active)",
|
||||||
|
"platform": "macos",
|
||||||
|
"note": "Exactly one case is recorded: `remapped`, the reporter's verbatim capture, where keydown and keypress both still carry the layout character and only charCode and the input event's data carry the substituted one. Every other case is DERIVED from it by changing one thing - the layout character, or the substitution's presence - and is marked `recorded: false` with its own note saying what was changed and why. Do not read the four derived cases as independent captures. No beforeinput record exists in the capture; none is invented here.",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "remapped",
|
||||||
|
"note": "Verbatim from the issue. ~/Library/KeyBindings/DefaultKeyBinding.dict maps the character the Korean layout puts on Backquote to a backtick. keydown.key is the raw layout character; charCode 96 and input.data carry the OS substitution.",
|
||||||
|
"recorded": true,
|
||||||
|
"expectedPty": "`",
|
||||||
|
"dom": [
|
||||||
|
{
|
||||||
|
"type": "keydown",
|
||||||
|
"key": "₩",
|
||||||
|
"code": "Backquote",
|
||||||
|
"keyCode": 192,
|
||||||
|
"isComposing": false,
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "keypress",
|
||||||
|
"key": "₩",
|
||||||
|
"code": "Backquote",
|
||||||
|
"keyCode": 96,
|
||||||
|
"charCode": 96,
|
||||||
|
"isComposing": false,
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "input",
|
||||||
|
"data": "`",
|
||||||
|
"inputType": "insertText",
|
||||||
|
"isComposing": false,
|
||||||
|
"value": "`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "keyup",
|
||||||
|
"key": "₩",
|
||||||
|
"code": "Backquote",
|
||||||
|
"keyCode": 192,
|
||||||
|
"isComposing": false,
|
||||||
|
"value": "`"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "remapped-other-korean-layout",
|
||||||
|
"note": "DERIVED, not separately recorded: the `remapped` capture with the layout character changed to the one 세벌식 최종 puts on the same physical key. Korean layouts disagree about that position - 두벌식 and 세벌식 390 give U+20A9, 세벌식 최종 gives an asterisk - so one dict entry has to survive either. Honouring the substitution by listing characters can only cover the ones someone remembered to list.",
|
||||||
|
"recorded": false,
|
||||||
|
"expectedPty": "`",
|
||||||
|
"dom": [
|
||||||
|
{
|
||||||
|
"type": "keydown",
|
||||||
|
"key": "*",
|
||||||
|
"code": "Backquote",
|
||||||
|
"keyCode": 192,
|
||||||
|
"isComposing": false,
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "keypress",
|
||||||
|
"key": "*",
|
||||||
|
"code": "Backquote",
|
||||||
|
"keyCode": 96,
|
||||||
|
"charCode": 96,
|
||||||
|
"isComposing": false,
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "input",
|
||||||
|
"data": "`",
|
||||||
|
"inputType": "insertText",
|
||||||
|
"isComposing": false,
|
||||||
|
"value": "`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "keyup",
|
||||||
|
"key": "*",
|
||||||
|
"code": "Backquote",
|
||||||
|
"keyCode": 192,
|
||||||
|
"isComposing": false,
|
||||||
|
"value": "`"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "no-remap-korean",
|
||||||
|
"note": "DERIVED, not separately recorded: the `remapped` capture with the dict substitution removed, so the layout's own character is what the text system commits. charCode and input.data become U+20A9 to match keydown.key. This is the paired negative - the same physical key with no remap in play must still deliver the layout character.",
|
||||||
|
"recorded": false,
|
||||||
|
"expectedPty": "₩",
|
||||||
|
"dom": [
|
||||||
|
{
|
||||||
|
"type": "keydown",
|
||||||
|
"key": "₩",
|
||||||
|
"code": "Backquote",
|
||||||
|
"keyCode": 192,
|
||||||
|
"isComposing": false,
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "keypress",
|
||||||
|
"key": "₩",
|
||||||
|
"code": "Backquote",
|
||||||
|
"keyCode": 8361,
|
||||||
|
"charCode": 8361,
|
||||||
|
"isComposing": false,
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "input",
|
||||||
|
"data": "₩",
|
||||||
|
"inputType": "insertText",
|
||||||
|
"isComposing": false,
|
||||||
|
"value": "₩"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "keyup",
|
||||||
|
"key": "₩",
|
||||||
|
"code": "Backquote",
|
||||||
|
"keyCode": 192,
|
||||||
|
"isComposing": false,
|
||||||
|
"value": "₩"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "no-remap-ascii",
|
||||||
|
"note": "DERIVED, not separately recorded. The issue states that with ABC selected keydown.key is already the backtick and nothing is wrong; this is that sequence. Guards the case where a fix rewrites the Backquote position unconditionally.",
|
||||||
|
"recorded": false,
|
||||||
|
"expectedPty": "`",
|
||||||
|
"dom": [
|
||||||
|
{
|
||||||
|
"type": "keydown",
|
||||||
|
"key": "`",
|
||||||
|
"code": "Backquote",
|
||||||
|
"keyCode": 192,
|
||||||
|
"isComposing": false,
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "keypress",
|
||||||
|
"key": "`",
|
||||||
|
"code": "Backquote",
|
||||||
|
"keyCode": 96,
|
||||||
|
"charCode": 96,
|
||||||
|
"isComposing": false,
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "input",
|
||||||
|
"data": "`",
|
||||||
|
"inputType": "insertText",
|
||||||
|
"isComposing": false,
|
||||||
|
"value": "`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "keyup",
|
||||||
|
"key": "`",
|
||||||
|
"code": "Backquote",
|
||||||
|
"keyCode": 192,
|
||||||
|
"isComposing": false,
|
||||||
|
"value": "`"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "remapped-neighbour-key",
|
||||||
|
"note": "DERIVED, not separately recorded. An ordinary letter pressed straight after the remapped key, to catch a fix that leaves the claim armed and swallows or duplicates the next keystroke.",
|
||||||
|
"recorded": false,
|
||||||
|
"expectedPty": "a",
|
||||||
|
"dom": [
|
||||||
|
{
|
||||||
|
"type": "keydown",
|
||||||
|
"key": "a",
|
||||||
|
"code": "KeyA",
|
||||||
|
"keyCode": 65,
|
||||||
|
"isComposing": false,
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "keypress",
|
||||||
|
"key": "a",
|
||||||
|
"code": "KeyA",
|
||||||
|
"keyCode": 97,
|
||||||
|
"charCode": 97,
|
||||||
|
"isComposing": false,
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "input",
|
||||||
|
"data": "a",
|
||||||
|
"inputType": "insertText",
|
||||||
|
"isComposing": false,
|
||||||
|
"value": "a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "keyup",
|
||||||
|
"key": "a",
|
||||||
|
"code": "KeyA",
|
||||||
|
"keyCode": 65,
|
||||||
|
"isComposing": false,
|
||||||
|
"value": "a"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
// @vitest-environment happy-dom
|
||||||
|
// #11170: a ~/Library/KeyBindings/DefaultKeyBinding.dict remap is honoured everywhere on macOS
|
||||||
|
// except the terminal, which sends the raw layout character to the PTY.
|
||||||
|
//
|
||||||
|
// The OS applies the substitution inside the AppKit text input path, so it exists only on
|
||||||
|
// keypress.charCode and on the input event's `data` — keydown.key still carries the layout
|
||||||
|
// character. Nothing here needs to parse the dict: Chromium's renderer is already the text
|
||||||
|
// system's client and has applied it by the time `input` fires. The bug was that the keydown
|
||||||
|
// manufactured a byte and preventDefault'd, tearing the text pipeline down before the
|
||||||
|
// substitution could arrive.
|
||||||
|
//
|
||||||
|
// This pins the behaviour across a design change rather than introducing it. The reported build
|
||||||
|
// sent the raw character; a later punctuation table happened to list that one character, which
|
||||||
|
// closed the issue by enumeration. Replacing the table with a structural claim removes the entry,
|
||||||
|
// so without a test the fix could regress silently on a change that never mentions #11170.
|
||||||
|
// Both designs fail this file when their respective claim is removed.
|
||||||
|
//
|
||||||
|
// The harness supplies no input-source classification, which models a source the older design did
|
||||||
|
// not recognise — including the window before its async probe resolves. That is the condition
|
||||||
|
// under which the second layout arm separates the two designs: with the source recognised the
|
||||||
|
// older one claimed all ASCII punctuation and covered it too, so the gap was real but conditional.
|
||||||
|
// The structural claim has no such condition, which is the point.
|
||||||
|
//
|
||||||
|
// Replays the recorded event shape from the issue rather than an authored one, and pairs it with
|
||||||
|
// the same physical key carrying no substitution — a fix that rewrote the Backquote position
|
||||||
|
// unconditionally would pass the positive case and be badly wrong.
|
||||||
|
import { Terminal } from '@xterm/xterm'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import trace from './__fixtures__/macos-keybinding-dict-backquote-trace.json'
|
||||||
|
import { installTerminalImeNativeTextForwarder } from './terminal-ime-native-text-forwarder'
|
||||||
|
import { shouldBypassXtermKeyboardEvent } from './xterm-bypass-policy'
|
||||||
|
|
||||||
|
type RecordedEvent = {
|
||||||
|
type: string
|
||||||
|
key?: string
|
||||||
|
code?: string
|
||||||
|
keyCode?: number
|
||||||
|
charCode?: number
|
||||||
|
data?: string
|
||||||
|
inputType?: string
|
||||||
|
isComposing?: boolean
|
||||||
|
value?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecordedCase = {
|
||||||
|
name: string
|
||||||
|
expectedPty: string
|
||||||
|
dom: RecordedEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const CASES = trace.cases as RecordedCase[]
|
||||||
|
|
||||||
|
function caseNamed(name: string): RecordedCase {
|
||||||
|
const found = CASES.find((entry) => entry.name === name)
|
||||||
|
if (!found) {
|
||||||
|
throw new Error(`fixture case '${name}' missing`)
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEvent(recorded: RecordedEvent): Event {
|
||||||
|
if (recorded.type === 'input' || recorded.type === 'beforeinput') {
|
||||||
|
const input = new InputEvent(recorded.type, {
|
||||||
|
isComposing: recorded.isComposing,
|
||||||
|
bubbles: true
|
||||||
|
})
|
||||||
|
// happy-dom drops these from InputEventInit; Chromium supplies them.
|
||||||
|
Object.defineProperty(input, 'inputType', { value: recorded.inputType ?? '' })
|
||||||
|
Object.defineProperty(input, 'data', { value: recorded.data ?? null })
|
||||||
|
Object.defineProperty(input, 'composed', { value: true })
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
const keyboard = new KeyboardEvent(recorded.type, {
|
||||||
|
key: recorded.key,
|
||||||
|
code: recorded.code,
|
||||||
|
isComposing: recorded.isComposing,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true
|
||||||
|
})
|
||||||
|
Object.defineProperty(keyboard, 'keyCode', { value: recorded.keyCode ?? 0 })
|
||||||
|
Object.defineProperty(keyboard, 'charCode', { value: recorded.charCode ?? 0 })
|
||||||
|
return keyboard
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors the handler order in use-terminal-pane-lifecycle.ts: the native-text claim,
|
||||||
|
* then the bypass policy. */
|
||||||
|
function open() {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
const terminal = new Terminal()
|
||||||
|
terminal.open(container)
|
||||||
|
const forwarder = installTerminalImeNativeTextForwarder({
|
||||||
|
terminalElement: terminal.element,
|
||||||
|
isComposing: () => false,
|
||||||
|
sendInput: (data) => terminal.input(data)
|
||||||
|
})
|
||||||
|
terminal.attachCustomKeyEventHandler((event) => {
|
||||||
|
if (forwarder.claimKeyEvent(event)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !shouldBypassXtermKeyboardEvent(event, {
|
||||||
|
isMac: true,
|
||||||
|
hasSelection: false,
|
||||||
|
kittyKeyboardFlags: 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const emitted: string[] = []
|
||||||
|
terminal.onData((data) => emitted.push(data))
|
||||||
|
return { emitted, terminal, forwarder }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replays recorded cases in order and returns the bytes that reached the PTY. */
|
||||||
|
function replay(names: string[]): string {
|
||||||
|
const { emitted, terminal, forwarder } = open()
|
||||||
|
const textarea = terminal.textarea!
|
||||||
|
for (const name of names) {
|
||||||
|
for (const recorded of caseNamed(name).dom) {
|
||||||
|
const event = buildEvent(recorded)
|
||||||
|
// Why: the recorded keydown is what a real browser only emits the rest of the sequence
|
||||||
|
// after, so a claim that preventDefaults must truncate the replay exactly as Chromium would.
|
||||||
|
if (recorded.type !== 'keydown' && recorded.type !== 'keyup') {
|
||||||
|
textarea.value = recorded.value ?? ''
|
||||||
|
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
|
||||||
|
}
|
||||||
|
textarea.dispatchEvent(event)
|
||||||
|
if (recorded.type === 'keydown' && event.defaultPrevented) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
forwarder.dispose()
|
||||||
|
terminal.dispose()
|
||||||
|
return emitted.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('#11170 — a DefaultKeyBinding.dict remap reaches the PTY', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||||
|
measureText: () => ({ width: 10 })
|
||||||
|
} as unknown as CanvasRenderingContext2D)
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
document.body.replaceChildren()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends the remapped character, not the raw layout character', () => {
|
||||||
|
expect(replay(['remapped'])).toBe(caseNamed('remapped').expectedPty)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Same physical key, same dict entry, on the Korean layout that puts an asterisk there instead.
|
||||||
|
// This is the arm that discriminates without a mutation: the older design honoured the
|
||||||
|
// substitution by listing characters, and listed the reported one but not this one.
|
||||||
|
it('sends the remapped character on the other Korean layout too', () => {
|
||||||
|
const remapped = caseNamed('remapped-other-korean-layout')
|
||||||
|
expect(replay([remapped.name])).toBe(remapped.expectedPty)
|
||||||
|
})
|
||||||
|
|
||||||
|
// The paired negative. Same physical key, same keydown.key, no substitution in play.
|
||||||
|
it('still sends the layout character when nothing remaps the key', () => {
|
||||||
|
expect(replay(['no-remap-korean'])).toBe(caseNamed('no-remap-korean').expectedPty)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still sends the plain backquote under an ASCII layout', () => {
|
||||||
|
expect(replay(['no-remap-ascii'])).toBe(caseNamed('no-remap-ascii').expectedPty)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves the next keystroke untouched', () => {
|
||||||
|
expect(replay(['remapped', 'remapped-neighbour-key'])).toBe('`a')
|
||||||
|
})
|
||||||
|
|
||||||
|
// The dict's "~₩" rule (Option+the key -> the layout character) is out of scope: Option chords
|
||||||
|
// are deliberately excluded from the claim, and the option-as-alt setting consumes Option first.
|
||||||
|
it('does not claim the Option chord the dict also binds', () => {
|
||||||
|
const { emitted, terminal, forwarder } = open()
|
||||||
|
const textarea = terminal.textarea!
|
||||||
|
const chord = new KeyboardEvent('keydown', {
|
||||||
|
key: '₩',
|
||||||
|
code: 'Backquote',
|
||||||
|
altKey: true,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true
|
||||||
|
})
|
||||||
|
Object.defineProperty(chord, 'keyCode', { value: 192 })
|
||||||
|
textarea.dispatchEvent(chord)
|
||||||
|
forwarder.dispose()
|
||||||
|
terminal.dispose()
|
||||||
|
expect(emitted.join('')).not.toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue