From c69903c2d59d8bfacf8f8532fde17c16d8005e11 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 6 Aug 2026 08:02:17 -0700 Subject: [PATCH] test(terminal): pin macOS period substitution landing after the composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #11504's reporter published a DOM trace showing insertText ". " arriving 149ms after compositionend, when two spaces are typed with a CJK input source and NSAutomaticPeriodSubstitutionEnabled is on. This replays that trace against a real Terminal and asserts what reaches onData — bytes to the PTY, not anything visual. The owner is stock upstream CoreBrowserTerminal._inputEvent, not an Orca module, confirmed at the resolved install and in the shipped bundle Vite loads rather than in the TypeScript source. Three mutations against that install, predictions written before the runs, each failing exactly the arms predicted: dropping the composed/keyDownSeen guard fails two, dropping Orca's intercept fails the one arm where the payload arrives before the send window drains, and flipping || to && — the candidate-fix shape — fails the arm that pins the defect itself. CHARACTERIZATION: arm 1 asserts the broken behaviour and will fail the moment #11504 is fixed. Update it to the new contract rather than working around it. composed is absent from every recorded bundle, so composed: true is the spec-required value rather than a captured one; the test asserts it before dispatching so a harness that dropped the field fails loudly. --- ...ue-11504-macos-period-substitution.test.ts | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 src/renderer/src/components/terminal-pane/terminal-stock-composition.issue-11504-macos-period-substitution.test.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-stock-composition.issue-11504-macos-period-substitution.test.ts b/src/renderer/src/components/terminal-pane/terminal-stock-composition.issue-11504-macos-period-substitution.test.ts new file mode 100644 index 00000000000..f7a6a094f88 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-stock-composition.issue-11504-macos-period-substitution.test.ts @@ -0,0 +1,214 @@ +// @vitest-environment happy-dom +import { Terminal } from '@xterm/xterm' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Source: GitHub #11504, reporter `pythonstrup`, comment IC_kwDORpCz1s8AAAABMZuYXQ +// ("Correction to my previous comment"), macOS + Apple 2-Set Korean + Orca 1.4.161. +// Transcribed at .tmp/ime-handoff/swarm-scratch/lane7-11504-macos-period/MANIFEST.md +// SHA-256 e2ed51b1add37c366475413590043b948bfb5738ec849ce6bdeac1e57c9bdb9e, and independently at +// .tmp/ime-handoff/swarm-scratch/lane-9738-composer/COMMENT-SWEEP.md +// SHA-256 086a3bc5d36db0a58a9c23970a762423d73e5e90e7c07eeb1a3081a014c51920. Both agree line for line. +// +// keydown key:" " code:Space keyCode:229 isComposing:true val:"아" +// compositionupdate data:"아 " val:"아" +// input data:"아 " inputType:insertCompositionText val:"아 " +// compositionend data:"아 " val:"아 " +// keyup key:" " code:Space keyCode:32 isComposing:false val:"아 " +// input data:". " inputType:insertText val:"아. " <- +149ms +// +// The reporter's own words on the trigger: "There is no second press at all. One space is enough" — +// the composition commit supplies the first slot of macOS `NSAutomaticPeriodSubstitutionEnabled`, +// so with a Hangul source every word-separating space is a candidate. +// +// NOT recorded anywhere in the corpus: `composed` and `timeStamp`. `composed` is the field the +// stock guard branches on, so it is set to `true` here — the value the UI Events spec requires of +// a browser-dispatched `input` event. It is the harder case for the guard: the +149 ms arm below +// passes with `composed` either way, and only the keydown-in-flight arm depends on it. +// +// These tests pin CURRENT, DEFECTIVE behaviour. #11504 is unfixed at HEAD (PR #11506 is open and +// unmerged) and the first test asserts the unwanted period reaches the PTY. A fix must update it. + +const HANGUL = '아' +const COMMITTED = '아 ' +const SUBSTITUTION = '. ' +const SPACE_KEYCODE = 32 +const IME_KEYCODE = 229 + +function openTerminal(): { + emitted: string[] + terminal: Terminal + textarea: HTMLTextAreaElement +} { + const container = document.createElement('div') + document.body.appendChild(container) + const terminal = new Terminal() + terminal.open(container) + if (!terminal.textarea) { + throw new Error('xterm helper textarea was not created') + } + const emitted: string[] = [] + terminal.onData((data) => emitted.push(data)) + return { emitted, terminal, textarea: terminal.textarea } +} + +function composition( + textarea: HTMLTextAreaElement, + type: 'compositionstart' | 'compositionupdate' | 'compositionend', + data = '' +): void { + const event = new CompositionEvent(type, { bubbles: true }) + Object.defineProperty(event, 'data', { value: data }) + textarea.dispatchEvent(event) +} + +function keydown(textarea: HTMLTextAreaElement, keyCode: number, isComposing = false): void { + const event = new KeyboardEvent('keydown', { + bubbles: true, + code: 'Space', + isComposing, + key: ' ' + }) + Object.defineProperty(event, 'keyCode', { value: keyCode }) + textarea.dispatchEvent(event) +} + +function keyup(textarea: HTMLTextAreaElement): void { + const event = new KeyboardEvent('keyup', { bubbles: true, code: 'Space', key: ' ' }) + Object.defineProperty(event, 'keyCode', { value: SPACE_KEYCODE }) + textarea.dispatchEvent(event) +} + +// The OS substitution: an `insertText` carrying no keydown, keypress or charCode of its own. The +// reporter's `beforeinput` capture records `key/code/keyCode: undefined` and a listener-only stack. +function substitutionInput(textarea: HTMLTextAreaElement, value: string): void { + textarea.value = value + textarea.setSelectionRange(value.length, value.length) + const event = new InputEvent('input', { + bubbles: true, + composed: true, + data: SUBSTITUTION, + inputType: 'insertText', + isComposing: false + }) + // The guard reads `composed`; a harness that dropped it would make the suppression arms below + // pass vacuously, so fail here instead of there. + expect(event.composed).toBe(true) + textarea.dispatchEvent(event) +} + +function nextTask(): Promise { + return new Promise((resolve) => window.setTimeout(resolve, 0)) +} + +// Replays every line of the recorded trace above except its last, leaving the terminal in the state +// the reporter's `+149ms` substitution arrives into. +function replayRecordedCommit(textarea: HTMLTextAreaElement): void { + composition(textarea, 'compositionstart') + composition(textarea, 'compositionupdate', HANGUL) + textarea.value = HANGUL + textarea.setSelectionRange(1, 1) + + keydown(textarea, IME_KEYCODE, true) + composition(textarea, 'compositionupdate', COMMITTED) + textarea.value = COMMITTED + textarea.setSelectionRange(2, 2) + textarea.dispatchEvent( + new InputEvent('input', { + bubbles: true, + composed: true, + data: COMMITTED, + inputType: 'insertCompositionText', + isComposing: true + }) + ) + composition(textarea, 'compositionend', COMMITTED) + keyup(textarea) +} + +describe('#11504 macOS automatic period substitution after a Hangul space commit', () => { + beforeEach(() => { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + measureText: () => ({ width: 10 }) + } as unknown as CanvasRenderingContext2D) + }) + + afterEach(() => { + vi.restoreAllMocks() + document.body.replaceChildren() + }) + + it('sends the reporter delayed period substitution to the PTY as extra input', async () => { + const { emitted, terminal, textarea } = openTerminal() + replayRecordedCommit(textarea) + // Two drains: the commit send and the window that closes it. The reporter's +149 ms is far past + // both, which is precisely why the substitution reaches the stock branch. + await nextTask() + await nextTask() + expect(emitted).toEqual([COMMITTED]) + + substitutionInput(textarea, '아. ') + await nextTask() + + expect(emitted).toEqual([COMMITTED, SUBSTITUTION]) + // The textarea replaced the space; the terminal appended. That divergence is the reported bug — + // a period the user never typed, sitting in the shell's input. + expect(textarea.value).toBe('아. ') + expect(emitted.join('')).toBe('아 . ') + terminal.dispose() + }) + + it('suppresses the identical payload while a keydown is still in flight', async () => { + const { emitted, terminal, textarea } = openTerminal() + replayRecordedCommit(textarea) + await nextTask() + await nextTask() + + // Space is keyCode 32, below xterm's `>= 48` printable floor, so the keydown itself emits + // nothing — it only raises `_keyDownSeen`, which is the half of the guard under test. + keydown(textarea, SPACE_KEYCODE) + substitutionInput(textarea, '아 . ') + await nextTask() + + expect(emitted).toEqual([COMMITTED]) + keyup(textarea) + + // Precondition for the absence above: the same payload does get through once keyup lands. + substitutionInput(textarea, '아 . ') + await nextTask() + + expect(emitted).toEqual([COMMITTED, SUBSTITUTION]) + terminal.dispose() + }) + + it('absorbs a substitution that lands before the commit send window drains', async () => { + const { emitted, terminal, textarea } = openTerminal() + replayRecordedCommit(textarea) + await nextTask() + expect(emitted).toEqual([COMMITTED]) + + // Same payload, same guards, only earlier: Orca's `handleCompositionInput` intercept still owns + // the path here and swallows it. The delay in the report is a trigger condition, not a detail. + substitutionInput(textarea, '아. ') + await nextTask() + await nextTask() + + expect(emitted).toEqual([COMMITTED]) + terminal.dispose() + }) + + it('does not double a period the user actually typed', async () => { + const { emitted, terminal, textarea } = openTerminal() + const period = new KeyboardEvent('keydown', { bubbles: true, code: 'Period', key: '.' }) + Object.defineProperty(period, 'keyCode', { value: 190 }) + textarea.dispatchEvent(period) + textarea.value = '.' + textarea.setSelectionRange(1, 1) + textarea.dispatchEvent( + new InputEvent('input', { bubbles: true, composed: true, data: '.', inputType: 'insertText' }) + ) + await nextTask() + + expect(emitted).toEqual(['.']) + terminal.dispose() + }) +})