From a58977c65d9c9d8ff6338451fac8553fe367c29f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:48:07 -0700 Subject: [PATCH] test(terminal): pin that Cmd tears down a composition where Ctrl and Shift do not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xterm's composition keydown exempts only keyCode 16/17/18 (Shift/Ctrl/Alt) plus 20/229. macOS Meta — 91/93/224 — is absent, so a Cmd press mid-composition takes _finalizeComposition(false): the overlay goes dark and never recovers, because compositionstart is not re-fired. The user composes the rest of the word blind. Linux and Windows users press Ctrl and are exempt. xterm already has a Meta-aware modifier predicate in wasModifierKeyOnlyEvent, so this is an internal inconsistency rather than a deliberate choice. Owns no reported row and is version-neutral: 5/5 on both 1.4.162 and 1.4.163. The branch is unexercised in all 328 recorded traces, so this is a hazard pin, not a regression guard. Only the teardown is asserted; the likely duplicated commit needs a compositionend the IME kept alive across the Cmd, which no capture contains. Deleting the exemption fails exactly the three paired negatives; adding Meta to it fails exactly the two Cmd arms. --- ...erm-composition-modifier-exemption.test.ts | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 src/renderer/src/components/terminal-pane/terminal-ime-xterm-composition-modifier-exemption.test.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-xterm-composition-modifier-exemption.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-xterm-composition-modifier-exemption.test.ts new file mode 100644 index 00000000000..f8640d5b2fb --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ime-xterm-composition-modifier-exemption.test.ts @@ -0,0 +1,171 @@ +// @vitest-environment happy-dom +// HAZARD PIN — owns no reported row. Read this before treating it as a regression guard. +// +// xterm's CompositionHelper.keydown exempts keyCode 16/17/18 (Shift/Ctrl/Alt) and +// 20/229 from tearing down a live composition. macOS Meta is 91/93/224 and is NOT in +// that set, so a Cmd press mid-composition reaches _finalizeComposition(false), which +// drops the compositionView's `active` class. The overlay never recovers, because the +// IME does not re-fire compositionstart — the rest of the word composes with no visible +// preedit. Linux/Windows users press Ctrl (17) and are exempt; macOS users are not. +// +// Three things a future reader must not misread: +// 1. No reporter has filed this. It matched no open row: every candidate is bound to a +// version window, and this is version-NEUTRAL — defective on both 1.4.162 (pristine +// beta.287) and 1.4.163 (patched), in different shapes. +// 2. The branch is unexercised in all 328 recorded IME traces. Every keydown ever +// captured during composition is either 229 or Shift/16, so the exemption set covers +// the whole corpus and nothing observed reaches the teardown. +// 3. Only the teardown is asserted. A Cmd press also makes the syllable commit twice, +// but that depends on the IME delivering a later compositionend for a composition it +// kept alive across the Cmd — normal IME behavior, yet unverified on hardware, since +// no capture contains this gesture. Deliberately not pinned. +import { Terminal } from '@xterm/xterm' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +function nextEventLoop(): Promise { + return new Promise((resolve) => window.setTimeout(resolve, 0)) +} + +type Harness = { + compositionView: HTMLElement + textarea: HTMLTextAreaElement +} + +function openTerminal(): Harness { + const container = document.createElement('div') + document.body.appendChild(container) + const terminal = new Terminal() + terminal.open(container) + const textarea = terminal.textarea + if (!textarea) { + throw new Error('xterm helper textarea was not created') + } + const compositionView = container.querySelector('.composition-view') + if (!(compositionView instanceof HTMLElement)) { + throw new Error('xterm composition view was not created') + } + return { compositionView, textarea } +} + +function dispatchCompositionEvent( + textarea: HTMLTextAreaElement, + type: 'compositionstart' | 'compositionupdate', + data = '' +): void { + const event = new CompositionEvent(type, { bubbles: true }) + // happy-dom ignores CompositionEventInit.data, but Chromium supplies it. + Object.defineProperty(event, 'data', { value: data }) + textarea.dispatchEvent(event) +} + +function dispatchKey( + textarea: HTMLTextAreaElement, + type: 'keydown' | 'keyup', + keyCode: number, + key: string, + code: string +): void { + const event = new KeyboardEvent(type, { key, code, isComposing: keyCode === 229, bubbles: true }) + Object.defineProperty(event, 'keyCode', { value: keyCode }) + textarea.dispatchEvent(event) +} + +function setValue(textarea: HTMLTextAreaElement, value: string): void { + textarea.value = value + textarea.selectionStart = value.length + textarea.selectionEnd = value.length +} + +/** Advance the preedit by one macOS 2-Set jamo; every such keydown is keyCode 229. */ +async function typeJamo( + { textarea }: Harness, + preedit: string, + code: string, + opening = false +): Promise { + dispatchKey(textarea, 'keydown', 229, 'Process', code) + if (opening) { + dispatchCompositionEvent(textarea, 'compositionstart') + } + setValue(textarea, preedit) + dispatchCompositionEvent(textarea, 'compositionupdate', preedit) + await nextEventLoop() +} + +/** Compose 한, leaving the preedit live and displayed. */ +async function composeHan(harness: Harness): Promise { + await typeJamo(harness, 'ㅎ', 'KeyG', true) + await typeJamo(harness, '하', 'KeyK') + await typeJamo(harness, '한', 'KeyS') +} + +/** Press and release a modifier while the composition is live, then keep composing. */ +async function pressModifierMidComposition( + harness: Harness, + keyCode: number, + key: string, + code: string +): Promise { + dispatchKey(harness.textarea, 'keydown', keyCode, key, code) + await nextEventLoop() + dispatchKey(harness.textarea, 'keyup', keyCode, key, code) + await nextEventLoop() +} + +const EXEMPT_MODIFIERS: [string, number, string, string][] = [ + ['Shift', 16, 'Shift', 'ShiftLeft'], + ['Ctrl', 17, 'Control', 'ControlLeft'], + ['Alt', 18, 'Alt', 'AltLeft'] +] + +describe('xterm composition modifier exemption — macOS Cmd is missing from the safe set', () => { + beforeEach(() => { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + measureText: () => ({ width: 10 }) + } as unknown as CanvasRenderingContext2D) + }) + + afterEach(() => { + vi.restoreAllMocks() + document.body.replaceChildren() + }) + + it.each([ + ['left Cmd', 91, 'Meta', 'MetaLeft'], + ['right Cmd', 93, 'Meta', 'MetaRight'] + ])( + '%s tears the preedit overlay down and it never comes back', + async (_label, keyCode, key, code) => { + const harness = await openTerminal() + await composeHan(harness) + // Precondition: without it, "inactive" below would be vacuous. + expect(harness.compositionView.classList.contains('active')).toBe(true) + + await pressModifierMidComposition(harness, keyCode, key, code) + expect(harness.compositionView.classList.contains('active')).toBe(false) + + // The IME never ended the composition, so the user keeps typing the same word — + // and every further jamo stays invisible, because only compositionstart re-arms + // the overlay and the IME has no reason to send one. + await typeJamo(harness, '한ㄱ', 'KeyR') + expect(harness.compositionView.textContent).toContain('한ㄱ') + expect(harness.compositionView.classList.contains('active')).toBe(false) + } + ) + + it.each(EXEMPT_MODIFIERS)( + 'paired negative: %s is exempt, so the same gesture keeps the preedit displayed', + async (_label, keyCode, key, code) => { + const harness = await openTerminal() + await composeHan(harness) + expect(harness.compositionView.classList.contains('active')).toBe(true) + + await pressModifierMidComposition(harness, keyCode, key, code) + expect(harness.compositionView.classList.contains('active')).toBe(true) + + await typeJamo(harness, '한ㄱ', 'KeyR') + expect(harness.compositionView.textContent).toContain('한ㄱ') + expect(harness.compositionView.classList.contains('active')).toBe(true) + } + ) +})