diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index d189ad1ba..78c600951 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -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.", "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": [ - "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 run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts" ], "testFiles": [ "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", @@ -6687,6 +6688,13 @@ "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", "assertions": ["paste/runtime forwarding avoids duplicate terminal payloads"] @@ -6739,6 +6747,15 @@ } ], "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", "runner": "local", @@ -6770,10 +6787,10 @@ "date": "2026-07-07", "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-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", "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", diff --git a/src/renderer/src/components/terminal-pane/__fixtures__/macos-keybinding-dict-backquote-trace.json b/src/renderer/src/components/terminal-pane/__fixtures__/macos-keybinding-dict-backquote-trace.json new file mode 100644 index 000000000..2f4e30983 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/__fixtures__/macos-keybinding-dict-backquote-trace.json @@ -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" + } + ] + } + ] +} diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts new file mode 100644 index 000000000..7ff1c7ca7 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts @@ -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('') + }) +})