From 7da66f6de833d93e2dc6ca45068d2c6e429fb5f2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 6 Aug 2026 07:05:19 -0700 Subject: [PATCH] test(terminal): pin the duplicated commit when Meta interrupts a composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _finalizeComposition(false) sends textarea.value.substring(start, end) but cannot clear the IME-owned textarea, so a later compositionend re-sends the same range. Meta reaches that path because CompositionHelper exempts only Shift/Ctrl/Alt; xterm's own wasModifierKeyOnlyEvent covers Meta four ways, so the omission is an internal inconsistency rather than a choice. Companion to the modifier-exemption guard, which deliberately pins only the overlay teardown. This pins the data consequence. HAZARD PIN: owns no reported row. The trigger is unverified on hardware — no capture in the corpus contains a Meta-during-composition gesture, and whether macOS keeps the composition alive across it is unmeasured. The duplication follows from the code given that sequence; whether users reach the sequence is the open half. An earlier premise that Space (keyCode 32) reaches this path was refuted by a corpus scan: 0 of 731 evidence files carry a keyCode-32 Space while composing, against 171 at 229, and 229 returns early. --- ...e-xterm-composition-commit-overlap.test.ts | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 src/renderer/src/components/terminal-pane/terminal-ime-xterm-composition-commit-overlap.test.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-xterm-composition-commit-overlap.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-xterm-composition-commit-overlap.test.ts new file mode 100644 index 00000000000..664acd2bf70 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ime-xterm-composition-commit-overlap.test.ts @@ -0,0 +1,239 @@ +// @vitest-environment happy-dom +// HAZARD PIN — owns no reported row. Read this before treating it as a regression guard. +// +// Two ways xterm's composition commit path corrupts the bytes the PTY child reads. +// Both are asserted at onData, not in the DOM: the defect is in the data, not the paint. +// +// 1. A non-exempt keydown during a live composition reaches _finalizeComposition(false) +// — the IMMEDIATE branch, which computes its range from a live selectionEnd and does +// not consult _compositionSuffix at all. It commits that range at once, and nothing +// records what it consumed, so the IME's own compositionend commits an overlapping +// range and the syllable reaches onData TWICE. macOS Meta (91/93/224) is the +// production instance: CompositionHelper.keydown exempts only 16/17/18 and 20/229, so +// Cmd takes this path where Ctrl does not. This is the data consequence of the +// teardown pinned by terminal-ime-xterm-composition-modifier-exemption.test.ts, which +// deliberately asserted only the overlay and left the duplicated commit unpinned. +// Cmd was checked against the swallow in (2) and does NOT reach it: Cmd duplicates, +// it does not drop. The two hazards below share no trigger. +// +// 2. An uncomposed insertText landing in the window after the commit timer has already +// sent is swallowed whole. _isSendingComposition stays true for one macrotask after +// the timer cleared _pendingCompositionStart; handleCompositionInput passes the first +// check, then reads the cleared sentinel and substitutes '' for the data. +// +// Four things a future reader must not misread: +// 1. No reporter has filed either of these. #12164 was considered and rejected: its +// comment 1 is untyped agent OUTPUT doubling on the wide-glyph repaint path, and +// its comment 2 is filed against 1.4.163, whose CompositionHelper is a different +// implementation from HEAD's — measurements here do not transfer to it. +// 2. Measured, not inferred: every expectation below was read off onData against the +// @xterm/xterm this repo installs (src/browser/input/CompositionHelper.ts, +// sha256 10893b3e609b3a1d296e03be03e397b5044308d7f7b6a3da6efd06a545c13a21). +// The comparison bundle is cited, NOT imported — a landed test can only exercise +// code that ships. Stock 6.1.0-beta.287 CompositionHelper.ts, +// sha256 1e935e66830ca171456466987cb45ed0a270553901729f11dfa91f6b702e0845 +// (sha1 ebffd1d354428143d712124f92fbcd846e6e44d4, byte-identical across beta.287, +// .288 and .292, so this is also what VS Code 1.129.1 runs). Against that bundle +// the duplication is version-NEUTRAL — defective on both, in different magnitudes. +// The swallowed insertText is NOT: stock delivers the syllable and this bundle +// drops it, making it the one defect here that is ours rather than inherited. +// 3. Inferred, not measured: that a real macOS IME delivers a compositionend for a +// composition it kept alive across the Cmd. That is ordinary IME behaviour but no +// capture contains the gesture, so the trigger is unverified on hardware. +// 4. Unobserved, and the corpus cannot say more than that. Of 731 retained evidence +// JSONs, 82 carry a keydown-bearing DOM trace; across those, all 3508 keydowns +// during a live composition are 229 (3443) or Shift/16 (65), and none is +// non-exempt. A further 59 bundles use a different trace shape that scan did not +// read — they are SILENT on this branch, not supporting it. And retained captures +// show the branch was never entered; they cannot show it is unenterable. +// The same scan refuted an earlier premise that Space reaches this path: Space +// during composition is keyCode 229 in every capture, and 229 returns early. +// Do not reintroduce a Space arm. +// +// These assertions pin CURRENT broken behaviour. When someone fixes it they will fail — +// update the expectations to the correct values named in each comment. Do not work +// around them. +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)) +} + +function openTerminal(): { emitted: string[]; textarea: HTMLTextAreaElement } { + 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 emitted: string[] = [] + terminal.onData((data) => emitted.push(data)) + return { emitted, textarea } +} + +function dispatchCompositionEvent( + textarea: HTMLTextAreaElement, + type: 'compositionstart' | 'compositionupdate' | 'compositionend', + 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 dispatchProcessKeydown(textarea: HTMLTextAreaElement): void { + const keydown = new KeyboardEvent('keydown', { key: 'Process', isComposing: true, bubbles: true }) + Object.defineProperty(keydown, 'keyCode', { value: 229 }) + textarea.dispatchEvent(keydown) +} + +function dispatchModifierKeydown( + textarea: HTMLTextAreaElement, + modifier: 'Meta' | 'Control' +): void { + const isMeta = modifier === 'Meta' + const keydown = new KeyboardEvent('keydown', { + key: modifier, + code: isMeta ? 'MetaLeft' : 'ControlLeft', + metaKey: isMeta, + ctrlKey: !isMeta, + bubbles: true + }) + Object.defineProperty(keydown, 'keyCode', { value: isMeta ? 91 : 17 }) + textarea.dispatchEvent(keydown) +} + +function dispatchComposedInput(textarea: HTMLTextAreaElement, init: InputEventInit): void { + const input = new InputEvent('input', { ...init, bubbles: true }) + Object.defineProperty(input, 'composed', { value: true }) + textarea.dispatchEvent(input) +} + +function setValue(textarea: HTMLTextAreaElement, value: string): void { + textarea.value = value + textarea.selectionStart = value.length + textarea.selectionEnd = value.length +} + +/** Walk a syllable through its preedits, leaving the composition open. */ +async function preeditSyllable(textarea: HTMLTextAreaElement, steps: string[]): Promise { + for (const step of steps) { + setValue(textarea, step) + dispatchCompositionEvent(textarea, 'compositionupdate', step) + dispatchComposedInput(textarea, { data: step, inputType: 'insertCompositionText' }) + await nextEventLoop() + dispatchProcessKeydown(textarea) + } +} + +/** Compose 한, interrupted by a modifier after the first jamo. */ +async function composeHanInterruptedBy( + textarea: HTMLTextAreaElement, + modifier: 'Meta' | 'Control' +): Promise { + dispatchProcessKeydown(textarea) + dispatchCompositionEvent(textarea, 'compositionstart') + await preeditSyllable(textarea, ['ㅎ']) + dispatchModifierKeydown(textarea, modifier) + await nextEventLoop() + await preeditSyllable(textarea, ['하', '한']) + dispatchCompositionEvent(textarea, 'compositionend', '한') + await nextEventLoop() + await nextEventLoop() +} + +describe('xterm CompositionHelper — overlapping and swallowed commits at onData', () => { + beforeEach(() => { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + measureText: () => ({ width: 10 }) + } as unknown as CanvasRenderingContext2D) + }) + + afterEach(() => { + vi.restoreAllMocks() + document.body.replaceChildren() + }) + + it('commits the syllable twice when Cmd interrupts the composition', async () => { + const { emitted, textarea } = openTerminal() + await composeHanInterruptedBy(textarea, 'Meta') + + // The early commit emits the bare jamo, then compositionend emits the finished + // syllable over the same range. CORRECT would be ['한']. + // Pristine beta.287 is worse here, emitting ['ㅎ', '한', '한']. + expect(emitted).toEqual(['ㅎ', '한']) + }) + + it('commits the syllable twice when Cmd arrives after the last preedit', async () => { + const { emitted, textarea } = openTerminal() + dispatchProcessKeydown(textarea) + dispatchCompositionEvent(textarea, 'compositionstart') + await preeditSyllable(textarea, ['ㅎ', '하', '한']) + dispatchModifierKeydown(textarea, 'Meta') + await nextEventLoop() + dispatchCompositionEvent(textarea, 'compositionend', '한') + await nextEventLoop() + await nextEventLoop() + + // CORRECT would be ['한']. Pristine beta.287 emits the same two commits, which is + // what makes this arm version-neutral. + expect(emitted).toEqual(['한', '한']) + }) + + it('leaves the composition intact when Ctrl interrupts in the same position', async () => { + const { emitted, textarea } = openTerminal() + await composeHanInterruptedBy(textarea, 'Control') + + // Paired negative: keyCode 17 is exempt, so no early commit and no overlap. The + // difference between this arm and the first is the exemption set, nothing else. + expect(emitted).toEqual(['한']) + }) + + it('swallows an uncomposed insertText that lands in the sending window', async () => { + const { emitted, textarea } = openTerminal() + dispatchProcessKeydown(textarea) + dispatchCompositionEvent(textarea, 'compositionstart') + await preeditSyllable(textarea, ['ㅁ', '무', '문']) + dispatchCompositionEvent(textarea, 'compositionend', '문') + // The commit timer has now sent 문 and cleared _pendingCompositionStart, but + // _isSendingComposition stays true for one more macrotask. + await nextEventLoop() + expect(emitted).toEqual(['문']) + + const input = new InputEvent('input', { data: '제', inputType: 'insertText', bubbles: true }) + Object.defineProperty(input, 'composed', { value: false }) + textarea.dispatchEvent(input) + await nextEventLoop() + await nextEventLoop() + + // Whole syllable lost. CORRECT would be ['문', '제'], which is what pristine + // beta.287 emits — this arm, unlike the ones above, is ours. + expect(emitted).toEqual(['문']) + }) + + it('leaves ordinary Latin typing untouched', async () => { + const { emitted, textarea } = openTerminal() + for (const [key, keyCode] of [ + ['a', 65], + ['b', 66] + ] as [string, number][]) { + const keydown = new KeyboardEvent('keydown', { + key, + code: `Key${key.toUpperCase()}`, + bubbles: true + }) + Object.defineProperty(keydown, 'keyCode', { value: keyCode }) + textarea.dispatchEvent(keydown) + await nextEventLoop() + } + dispatchModifierKeydown(textarea, 'Meta') + await nextEventLoop() + + // No composition, so no range to overlap and nothing for Cmd to tear down. + expect(emitted).toEqual(['a', 'b']) + }) +})