test(e2e): headless preedit-geometry coverage for Korean and CJK terminal input (#14500)

* test(e2e): headless preedit-geometry coverage for Korean and CJK terminal input

Both IME defects that shipped and were reverted passed a suite of ~3000 IME
assertions, because every one of them checked bytes reaching the pty and a
preedit rendered into a hidden overlay satisfies all of them while the user
composes blind. The one arm that asserted real geometry was headful-gated and
macOS-only, so it never ran in CI.

Drives composition through CDP Input.imeSetComposition rather than a native
input source, which removes the accessibility grant, the system input source
and the visible window that forced that gate, so this runs in the ordinary
electron-headless project.

The load-bearing assertion is the composition overlay's real bounding rect.
Verified to have teeth: with max-width 0 and overflow hidden injected, the
active class, the textContent, display block and checkVisibility all still
pass, and only the rect assertion fails.

* test(e2e): restore the CDP composition drivers the preedit specs need

The trimmed copy on main kept only the key-dispatch helpers, so the composition
drivers the geometry specs import were missing. Adds them back: setImeComposition,
commitImeText, dispatchImeProcessKey, composeHangulSyllable and
dispatchResumedCompositionUpdate. The shared helpers are unchanged.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-08-14 01:47:03 -07:00
committed by GitHub
co-authored by Orca
parent 92b6ffd17d
commit 41ba29d79a
6 changed files with 3185 additions and 1 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,312 @@
/**
* Headless end-to-end coverage for Japanese and Chinese terminal input.
*
* Three shapes are covered, and the second is the one the whole suite used to be blind to:
*
* 1. Phrase-level composition — a Japanese preedit that grows to several characters and converts
* to kanji, and a pinyin preedit that spends most of its life as multi-letter romanisation.
* Both are asserted on the overlay's real geometry rather than on the bytes they later emit.
* 2. Committed text that arrives with **no composition session at all**. Full-width punctuation
* (`` `。` `、`) and full-width digits are typed as a single keystroke that the input source
* rewrites; there is no compositionstart/update/end around them. Every IME test in the repo
* was composition-session-shaped, so this shape was invisible to the suite by construction —
* which is how `` reaching the shell as an ASCII `,` shipped to users. This is the macOS
* shape, and the tests for it pin the macOS ownership policy.
* 3. The same full-width punctuation arriving **inside** a composition session, which is how the
* Windows and Linux frameworks deliver it. Same user-facing guarantee, different ordering.
*
* The assertion for shapes 2 and 3 is deliberately "the ASCII byte never appears" rather than "the
* substitution was applied": the correct design never manufactures the ASCII byte in the first
* place, so pinning its absence stays true under a forwarder rewrite as well as under a
* structural one.
*/
import type { CDPSession } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
import { closeTerminalImePaneArena, openTerminalImePaneArena } from './terminal-ime-pane-arena'
import { readTerminalImeBoundaryTrace } from './terminal-ime-boundary-probe'
import {
commitImeText,
dispatchImeProcessKey,
dispatchImeRewrittenPrintableKey,
dispatchImeSubstitutedTextKey,
dispatchPlainEnter,
setImeComposition,
type ImeKeyIdentity
} from './terminal-ime-cdp-composition'
import {
createTerminalImeByteReader,
removeTerminalImeByteReader,
startTerminalImeByteReader,
waitForTerminalImeBytes
} from './terminal-ime-byte-reader'
import { expectPreeditHidden, expectPreeditRendered } from './terminal-ime-preedit-overlay-probe'
import { applyImePlatformPolicy, type ImePlatformPolicy } from './terminal-ime-platform-policy'
/** Frames a Japanese IME shows while typing にほんご and converting it to 日本語. */
const JAPANESE_FRAMES = ['に', 'にほ', 'にほん', 'にほんご', '日本語'] as const
/**
* Substitutions an East Asian input source commits from one keystroke, with no composition session
* around them. `ascii` is the character the physical key carries in the Latin layout — the byte the
* user must never see — and `glyph` is what the input source actually committed.
*/
type SubstitutedKeystroke = ImeKeyIdentity & { glyph: string; ascii: string }
const FULL_WIDTH_PUNCTUATION: readonly SubstitutedKeystroke[] = [
{ key: ',', code: 'Comma', keyCode: 188, glyph: '', ascii: ',' },
{ key: '.', code: 'Period', keyCode: 190, glyph: '。', ascii: '.' },
{ key: ',', code: 'Comma', keyCode: 188, glyph: '、', ascii: ',' }
]
const FULL_WIDTH_DIGITS: readonly SubstitutedKeystroke[] = [
{ key: '1', code: 'Digit1', keyCode: 49, glyph: '', ascii: '1' },
{ key: '2', code: 'Digit2', keyCode: 50, glyph: '', ascii: '2' },
{ key: '3', code: 'Digit3', keyCode: 51, glyph: '', ascii: '3' }
]
const SUBSTITUTION_GROUPS = [
{ label: 'punctuation', keystrokes: FULL_WIDTH_PUNCTUATION },
{ label: 'digits', keystrokes: FULL_WIDTH_DIGITS }
] as const
/**
* The two ways a substituted keystroke can reach the renderer. Both are real; only the second one
* regressed, and only the second one can regress, which is why running both is the point.
*/
const SUBSTITUTION_SHAPES: readonly {
name: string
slug: string
dispatch: (session: CDPSession, keystroke: SubstitutedKeystroke) => Promise<void>
}[] = [
{
// Green, and honest about its limits: xterm's own key handler emits `event.key`, so this shape
// survives with or without a forwarder and would have passed throughout the regression. It
// guards the frameworks that do rewrite `key`; it is not the regression guard.
name: 'the keydown already carries the substituted glyph',
slug: 'rewritten-keydown',
dispatch: (session, keystroke) =>
dispatchImeRewrittenPrintableKey(session, {
key: keystroke.glyph,
code: keystroke.code,
keyCode: keystroke.keyCode
})
},
{
// The regression guard. Red on `main`, green from the structural-forwarder layer down.
//
// The keydown carries the plain Latin `,` and the `` exists only in the following `input`
// event, so anything that produces bytes from the keydown emits the ASCII form and destroys
// the real one. Today the bypass that would prevent that is gated on the OS reporting an
// input source whose id matches a 23-term allowlist. That read returns null on every
// non-macOS host and `com.apple.keylayout.*` on a macOS runner with no CJK source selected,
// and the preload API surface is frozen so no spec can stub it — meaning correctness here is
// not expressible as a test until the gate goes away.
//
// Closed by the structural rule one layer below: bytes for printable characters come only from
// the `input` event, decided on the event's own shape with no input-source read. Verified on
// real macOS hardware — with an Apple pinyin source selected, the pty receives ef bc 8c e3 80 82
// (,。) where main sends ASCII. Note main can pass this by luck: an input source whose id
// happens to contain an allowlist term, such as Sogou's, satisfies the old gate.
name: 'the keydown still carries the ASCII layout key',
slug: 'substituted-insert-text',
dispatch: (session, keystroke) =>
dispatchImeSubstitutedTextKey(session, keystroke, keystroke.glyph)
}
]
/**
* The two substitution shapes above are macOS-only, and deliberately so: the keydown bypass that
* owns single-keystroke IME commits installs only when the renderer reports macOS, because only
* the macOS text system delivers a substituted glyph with the plain layout key still on the
* keydown. Windows and Linux frameworks claim the same keystroke as VK_PROCESSKEY and route the
* glyph through a composition session instead, which the separate composition-session test below
* covers. Running the macOS shapes under a Linux policy would assert a sequence no Linux input
* framework produces.
*/
const FULL_WIDTH_SESSION_PUNCTUATION = [
{ key: ',', code: 'Comma', keyCode: 188, glyph: '', ascii: ',' },
{ key: '.', code: 'Period', keyCode: 190, glyph: '。', ascii: '.' }
] as const
test.describe('Terminal CJK IME committed text', () => {
test('shows a growing Japanese phrase preedit and commits the converted kanji', async ({
orcaPage,
testRepoPath
}, testInfo) => {
const arena = await openTerminalImePaneArena(orcaPage)
const reader = createTerminalImeByteReader(testRepoPath, 1)
let completed = false
try {
await startTerminalImeByteReader(orcaPage, arena.ptyId, reader)
await expectPreeditHidden(orcaPage, 'before composing')
await dispatchImeProcessKey(arena.session, { key: 'Process', code: 'KeyN' })
const widthByFrame = new Map<string, number>()
for (const frame of JAPANESE_FRAMES) {
await setImeComposition(arena.session, frame)
const sample = await expectPreeditRendered(orcaPage, frame, `composing ${frame}`)
widthByFrame.set(frame, sample.rect.width)
}
// A phrase-level preedit must widen as it grows. An overlay pinned to one cell renders only
// the first character, which is a shape every non-geometric assertion reports as correct.
expect(widthByFrame.get('にほんご')!).toBeGreaterThan(widthByFrame.get('に')!)
expect(widthByFrame.get('日本語')!).toBeGreaterThan(widthByFrame.get('に')!)
await commitImeText(arena.session, '日本語')
await expectPreeditHidden(orcaPage, 'after committing 日本語')
await dispatchPlainEnter(arena.session)
const received = await waitForTerminalImeBytes(orcaPage, reader)
expect(received).toEqual([Buffer.from('日本語\n').toString('hex')])
completed = true
} finally {
await closeTerminalImePaneArena(arena, testInfo, 'japanese-phrase-preedit', !completed)
removeTerminalImeByteReader(reader)
}
})
for (const shape of SUBSTITUTION_SHAPES) {
for (const group of SUBSTITUTION_GROUPS) {
test(`sends full-width ${group.label} and never their ASCII form when ${shape.name}`, async ({
orcaPage,
testRepoPath
}, testInfo) => {
await applyImePlatformPolicy(orcaPage, 'mac')
const arena = await openTerminalImePaneArena(orcaPage)
const reader = createTerminalImeByteReader(testRepoPath, 1)
const expected = group.keystrokes.map((keystroke) => keystroke.glyph).join('')
let completed = false
try {
await startTerminalImeByteReader(orcaPage, arena.ptyId, reader)
for (const keystroke of group.keystrokes) {
await shape.dispatch(arena.session, keystroke)
await orcaPage.waitForTimeout(60)
}
await dispatchPlainEnter(arena.session)
const sent = (await readTerminalImeBoundaryTrace(orcaPage)).onData.join('')
for (const keystroke of group.keystrokes) {
expect(
sent,
`${keystroke.glyph} reached the PTY as ASCII ${keystroke.ascii}`
).not.toContain(keystroke.ascii)
}
expect(sent).toBe(`${expected}\r`)
const received = await waitForTerminalImeBytes(orcaPage, reader)
expect(received).toEqual([Buffer.from(`${expected}\n`).toString('hex')])
completed = true
} finally {
await closeTerminalImePaneArena(
arena,
testInfo,
`full-width-${group.label}-${shape.slug}`,
!completed
)
removeTerminalImeByteReader(reader)
}
})
}
}
test('forwards Chinese pinyin conversions and their trailing full-width stop together', async ({
orcaPage,
testRepoPath
}, testInfo) => {
await applyImePlatformPolicy(orcaPage, 'mac')
const arena = await openTerminalImePaneArena(orcaPage)
const reader = createTerminalImeByteReader(testRepoPath, 1)
let completed = false
try {
await startTerminalImeByteReader(orcaPage, arena.ptyId, reader)
await dispatchImeProcessKey(arena.session, { key: 'Process', code: 'KeyN' })
const widthByFrame = new Map<string, number>()
for (const frame of ['n', 'ni', 'niha', 'nihao', '你好']) {
await setImeComposition(arena.session, frame)
const sample = await expectPreeditRendered(orcaPage, frame, `composing ${frame}`)
widthByFrame.set(frame, sample.rect.width)
}
// Pinyin spends most of its life as a multi-letter romanisation before any Chinese appears,
// so an overlay pinned to a single cell shows the user only the first letter of what they
// typed. Width is the only property that catches that; text content looks correct.
expect(widthByFrame.get('nihao')!).toBeGreaterThan(widthByFrame.get('n')!)
expect(widthByFrame.get('你好')!).toBeGreaterThan(widthByFrame.get('n')!)
await commitImeText(arena.session, '你好')
await expectPreeditHidden(orcaPage, 'after committing 你好')
// The distinct risk here is the adjacency, not the substitution: the stop arrives with no
// composition session immediately after one closed, so a tracker that still believes a
// composition is open swallows it. Dispatched in the glyph-carrying shape so this stays a
// test of the transition rather than a second copy of the known-broken case above.
await dispatchImeRewrittenPrintableKey(arena.session, {
key: '。',
code: 'Period',
keyCode: 190
})
await orcaPage.waitForTimeout(60)
await dispatchPlainEnter(arena.session)
const trace = await readTerminalImeBoundaryTrace(orcaPage)
expect(trace.onData.join('')).toBe('你好。\r')
const received = await waitForTerminalImeBytes(orcaPage, reader)
expect(received).toEqual([Buffer.from('你好。\n').toString('hex')])
completed = true
} finally {
await closeTerminalImePaneArena(arena, testInfo, 'pinyin-with-full-width-stop', !completed)
removeTerminalImeByteReader(reader)
}
})
for (const policy of ['windows', 'linux'] as const satisfies readonly ImePlatformPolicy[]) {
test(`sends full-width punctuation committed through a composition session on ${policy}`, async ({
orcaPage,
testRepoPath
}, testInfo) => {
// SYNTHESISED, and the reason is worth stating: the recorded corpus contains no Windows or
// Linux capture of full-width punctuation, only of Hangul and pinyin. What is not synthesised
// is the shape — on these platforms the framework claims the punctuation key as
// VK_PROCESSKEY and routes the substituted glyph through a real composition arena.session, which is
// what `Input.imeSetComposition` opens, rather than through the macOS insertText path the
// tests above cover. The ASCII form is asserted absent rather than the substitution asserted
// present, so this stays true of any design that never manufactures the ASCII byte.
await applyImePlatformPolicy(orcaPage, policy)
const arena = await openTerminalImePaneArena(orcaPage)
const reader = createTerminalImeByteReader(testRepoPath, 1)
const expected = FULL_WIDTH_SESSION_PUNCTUATION.map((entry) => entry.glyph).join('')
let completed = false
try {
await startTerminalImeByteReader(orcaPage, arena.ptyId, reader)
for (const entry of FULL_WIDTH_SESSION_PUNCTUATION) {
await dispatchImeProcessKey(arena.session, { key: 'Process', code: entry.code })
await setImeComposition(arena.session, entry.glyph)
await expectPreeditRendered(orcaPage, entry.glyph, `composing ${entry.glyph}`)
await commitImeText(arena.session, entry.glyph)
await expectPreeditHidden(orcaPage, `after committing ${entry.glyph}`)
}
await dispatchPlainEnter(arena.session)
const sent = (await readTerminalImeBoundaryTrace(orcaPage)).onData.join('')
for (const entry of FULL_WIDTH_SESSION_PUNCTUATION) {
expect(sent, `${entry.glyph} reached the PTY as ASCII ${entry.ascii}`).not.toContain(
entry.ascii
)
}
expect(sent).toBe(`${expected}\r`)
const received = await waitForTerminalImeBytes(orcaPage, reader)
expect(received).toEqual([Buffer.from(`${expected}\n`).toString('hex')])
completed = true
} finally {
await closeTerminalImePaneArena(
arena,
testInfo,
`full-width-punctuation-session-${policy}`,
!completed
)
removeTerminalImeByteReader(reader)
}
})
}
})
+87 -1
View File
@@ -1,4 +1,4 @@
import type { CDPSession } from '@stablyai/playwright-test'
import type { CDPSession, Page } from '@stablyai/playwright-test'
/**
* Dispatches the key shapes an input source or a system text substitution produces, through CDP.
@@ -87,3 +87,89 @@ export async function dispatchPlainEnter(session: CDPSession): Promise<void> {
})
}
}
/**
* `Input.imeSetComposition` opens a genuine Chromium composition session — the same one a native
* input source would — so preedit geometry can be asserted with no system input source at all.
*/
export async function setImeComposition(session: CDPSession, text: string): Promise<void> {
const length = Array.from(text).length
await session.send('Input.imeSetComposition', {
text,
selectionStart: length,
selectionEnd: length
})
}
export async function commitImeText(session: CDPSession, text: string): Promise<void> {
await session.send('Input.insertText', { text })
}
/** IME preedit keystrokes reach the renderer as VK_PROCESSKEY (229) with no text payload. */
export async function dispatchImeProcessKey(
session: CDPSession,
identity: Pick<ImeKeyIdentity, 'key' | 'code'>
): Promise<void> {
for (const type of ['rawKeyDown', 'keyUp'] as const) {
await session.send('Input.dispatchKeyEvent', {
type,
key: identity.key,
code: identity.code,
windowsVirtualKeyCode: 229,
nativeVirtualKeyCode: 229,
text: '',
unmodifiedText: ''
})
}
}
/** Drives one syllable's jamo frames; `pauseMs` 0 models key repeat reaching the renderer. */
export async function composeHangulSyllable(
session: CDPSession,
page: Page,
frames: readonly { jamoKey: ImeKeyIdentity; preedit: string }[],
pauseMs = 60
): Promise<void> {
for (const frame of frames) {
await dispatchImeProcessKey(session, frame.jamoKey)
await setImeComposition(session, frame.preedit)
if (pauseMs > 0) {
await page.waitForTimeout(pauseMs)
}
}
}
/**
* Emits a bare `compositionupdate` with no `compositionstart` ahead of it.
*
* SYNTHESISED, not replayed, and the reason is worth stating: the recorded Windows/WSL Hangul
* capture in `fixtures/windows-wsl-2set-hangul-dom-trace.json` does **not** contain this ordering
* — every one of its 37 composition updates sits inside an open start/end pair. The shape is
* nevertheless reachable by construction, because xterm adds `.active` to the overlay only in its
* `compositionstart` handler and its `compositionupdate` handler writes `textContent` without
* ever re-adding it. CDP cannot produce the ordering either: `Input.imeSetComposition` always
* opens a session first. So this is dispatched directly.
*/
export async function dispatchResumedCompositionUpdate(page: Page, data: string): Promise<void> {
await page.evaluate((preedit: string) => {
const textarea = document.querySelector<HTMLTextAreaElement>('.xterm-helper-textarea:focus')
if (!textarea) {
throw new Error('xterm helper textarea is not focused')
}
textarea.dispatchEvent(
new CompositionEvent('compositionupdate', {
bubbles: true,
data: preedit
})
)
textarea.dispatchEvent(
new InputEvent('input', {
bubbles: true,
composed: true,
data: preedit,
inputType: 'insertCompositionText',
isComposing: true
})
)
}, data)
}
@@ -0,0 +1,102 @@
import type { Page } from '@stablyai/playwright-test'
import { expect } from '@stablyai/playwright-test'
/**
* Samples the xterm preedit overlay's real geometry.
*
* Why geometry and not the `active` class: an overlay forced to
* `max-width: 0; overflow: hidden` is invisible on screen, yet keeps its class, its
* `textContent`, `display: block` and `checkVisibility() === true`. The bounding rect is the
* only property that discriminates, and it is also the only one a DOM emulator cannot produce —
* happy-dom reports all-zero rects in every state, so a unit-level arm passes over an overlay
* that never renders.
*/
export type PreeditOverlaySample = {
found: boolean
active: boolean
text: string
rect: { width: number; height: number }
checkVisibility: boolean | null
display: string
visibility: string
opacity: string
maxWidth: string
overflow: string
}
export function readPreeditOverlay(): PreeditOverlaySample {
const textarea =
document.querySelector<HTMLTextAreaElement>('.xterm-helper-textarea:focus') ??
document.querySelector<HTMLTextAreaElement>('.xterm-helper-textarea')
const view = textarea?.parentElement?.querySelector<HTMLElement>('.composition-view') ?? null
if (!view) {
return {
found: false,
active: false,
text: '',
rect: { width: 0, height: 0 },
checkVisibility: null,
display: '',
visibility: '',
opacity: '',
maxWidth: '',
overflow: ''
}
}
const style = getComputedStyle(view)
const rect = view.getBoundingClientRect()
return {
found: true,
active: view.classList.contains('active'),
// The overlay wraps its text in LRM marks; strip them so assertions read as the user sees it.
text: (view.textContent ?? '').replaceAll('', ''),
rect: { width: rect.width, height: rect.height },
checkVisibility: typeof view.checkVisibility === 'function' ? view.checkVisibility() : null,
display: style.display,
visibility: style.visibility,
opacity: style.opacity,
maxWidth: style.maxWidth,
overflow: style.overflow
}
}
export async function samplePreeditOverlay(page: Page): Promise<PreeditOverlaySample> {
return page.evaluate(readPreeditOverlay)
}
export async function expectPreeditRendered(
page: Page,
expectedText: string,
message: string
): Promise<PreeditOverlaySample> {
await expect
.poll(async () => (await samplePreeditOverlay(page)).text, { message })
.toBe(expectedText)
const sample = await samplePreeditOverlay(page)
assertPreeditRendered(sample, expectedText, message)
return sample
}
export function assertPreeditRendered(
sample: PreeditOverlaySample,
expectedText: string,
message: string
): void {
expect(sample.found, `${message}: no composition overlay exists`).toBe(true)
expect(sample.text, `${message}: overlay text`).toBe(expectedText)
expect(sample.active, `${message}: overlay is not marked active`).toBe(true)
expect(sample.display, `${message}: overlay is display:none`).not.toBe('none')
expect(sample.visibility, `${message}: overlay is not visible`).toBe('visible')
expect(sample.checkVisibility, `${message}: overlay fails checkVisibility`).not.toBe(false)
// The load-bearing pair. Do not weaken these to a class or visibility check.
expect(sample.rect.width, `${message}: overlay has zero width`).toBeGreaterThan(0)
expect(sample.rect.height, `${message}: overlay has zero height`).toBeGreaterThan(0)
expect(sample.maxWidth, `${message}: overlay is clipped to zero width`).not.toBe('0px')
}
export async function expectPreeditHidden(page: Page, message: string): Promise<void> {
await expect.poll(async () => (await samplePreeditOverlay(page)).active, { message }).toBe(false)
const sample = await samplePreeditOverlay(page)
expect(sample.rect.width, `${message}: overlay still occupies width`).toBe(0)
expect(sample.rect.height, `${message}: overlay still occupies height`).toBe(0)
}
@@ -0,0 +1,198 @@
import type { Page } from '@stablyai/playwright-test'
import {
samplePreeditOverlay,
type PreeditOverlaySample
} from './terminal-ime-preedit-overlay-probe'
/**
* Replays a recorded IME DOM trace against the live terminal one event at a time, sampling the
* preedit overlay after every composition event.
*
* Each event costs a CDP round-trip. That is deliberate: it lets xterm's deferred composition
* timers and the renderer's layout run between events the way they do under a real IME, so a
* per-event geometry sample measures an overlay that has actually been positioned.
*/
export type RecordedImeDomEvent = {
type: string
data?: string
inputType?: string
key?: string
code?: string
keyCode?: number
isComposing?: boolean
value?: string
selectionStart?: number
selectionEnd?: number
}
export type RecordedImeDomTrace = {
recordedFrom: string
inputFramework: string
engine: string
/** Present on the Linux captures, where X11 and Wayland emit different orderings. */
displayServer?: string
note: string
onData?: { data: string }[]
dom: RecordedImeDomEvent[]
}
export type ReplayedCompositionSample = {
index: number
type: string
data: string
compositionOpen: boolean
overlay: PreeditOverlaySample
}
export type RecordedTraceReplay = {
samples: ReplayedCompositionSample[]
onData: string
}
const COMPOSITION_EVENT_TYPES = new Set(['compositionstart', 'compositionupdate', 'compositionend'])
async function startRecordedTraceOnDataCapture(page: Page): Promise<void> {
await page.evaluate(() => {
const target = window as unknown as { __recordedTraceOnData: string[] }
target.__recordedTraceOnData = []
const state = window.__store?.getState()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!pane) {
throw new Error('No active terminal pane for the recorded trace replay')
}
pane.terminal.onData((data) => target.__recordedTraceOnData.push(data))
})
}
async function dispatchRecordedEvents(
page: Page,
recorded: readonly RecordedImeDomEvent[]
): Promise<void> {
await page.evaluate((events: RecordedImeDomEvent[]) => {
const textarea = document.querySelector<HTMLTextAreaElement>('.xterm-helper-textarea:focus')
if (!textarea) {
throw new Error('xterm helper textarea is not focused')
}
for (const event of events) {
// The recorded value/selection is what the recorder observed *during* this event, so it has
// to be in place before dispatch. xterm reads `textarea.value` inside its own handlers rather
// than off the event, so applying it afterwards hands every handler the previous event's
// state.
if (event.value !== undefined) {
textarea.value = event.value
}
if (event.selectionStart !== undefined && event.selectionEnd !== undefined) {
textarea.setSelectionRange(event.selectionStart, event.selectionEnd)
}
// keypress is recorded on Windows, where the IME lets Enter through to the textarea's own
// default action; replaying it as a CompositionEvent would invent an event no IME ever sent.
if (event.type === 'keydown' || event.type === 'keyup' || event.type === 'keypress') {
const keyboard = new KeyboardEvent(event.type, {
key: event.key,
code: event.code,
isComposing: event.isComposing,
bubbles: true,
cancelable: true
})
Object.defineProperty(keyboard, 'keyCode', { value: event.keyCode })
textarea.dispatchEvent(keyboard)
} else if (event.type === 'input' || event.type === 'beforeinput') {
textarea.dispatchEvent(
new InputEvent(event.type, {
bubbles: true,
cancelable: event.type === 'beforeinput',
composed: true,
data: event.data ?? null,
inputType: event.inputType ?? '',
isComposing: event.isComposing
})
)
} else {
textarea.dispatchEvent(
new CompositionEvent(event.type, {
bubbles: true,
data: event.data ?? ''
})
)
}
}
}, recorded as RecordedImeDomEvent[])
}
/**
* Groups a `compositionend` with the `beforeinput`/`input` events that immediately follow it.
*
* Chromium dispatches a commit's `compositionend` and the `input` carrying the committed text
* inside one task. The replay's per-event round-trip inserts a task boundary the IME never
* produced, and xterm arms a deferred finalizer on `compositionend` that reads the textarea when it
* runs — so a boundary there lets the finalizer settle the commit against a textarea the committed
* text has not reached yet. On IBus, whose `compositionend` is empty and whose text arrives only in
* the following `insertText`, that swallowed every syllable and made a working build look broken.
*
* Only the commit tail is fused. Composition updates keep their own round-trip, which is what lets
* xterm's deferred overlay positioning run before each geometry sample.
*/
function nextRecordedEventGroup(
dom: readonly RecordedImeDomEvent[],
start: number
): RecordedImeDomEvent[] {
const group = [dom[start]]
if (dom[start].type !== 'compositionend') {
return group
}
for (let index = start + 1; index < dom.length; index += 1) {
if (dom[index].type !== 'input' && dom[index].type !== 'beforeinput') {
break
}
group.push(dom[index])
}
return group
}
export async function replayRecordedImeDomTrace(
page: Page,
trace: RecordedImeDomTrace
): Promise<RecordedTraceReplay> {
await startRecordedTraceOnDataCapture(page)
const samples: ReplayedCompositionSample[] = []
let compositionOpen = false
for (let index = 0; index < trace.dom.length; ) {
const group = nextRecordedEventGroup(trace.dom, index)
const recorded = group[0]
await dispatchRecordedEvents(page, group)
index += group.length
if (!COMPOSITION_EVENT_TYPES.has(recorded.type)) {
continue
}
if (recorded.type === 'compositionstart') {
compositionOpen = true
}
samples.push({
index: index - group.length,
type: recorded.type,
data: recorded.data ?? '',
compositionOpen,
overlay: await samplePreeditOverlay(page)
})
if (recorded.type === 'compositionend') {
compositionOpen = false
}
}
const onData = await page.evaluate(() =>
((window as unknown as { __recordedTraceOnData?: string[] }).__recordedTraceOnData ?? []).join(
''
)
)
return { samples, onData }
}
@@ -0,0 +1,207 @@
/**
* Headless end-to-end coverage for 2-Set Korean terminal input, asserting what the user sees.
*
* Two IME defects shipped past a suite of ~3000 passing IME assertions. Both were invisible to it
* for the same reason: every assertion was about bytes reaching the PTY, and a preedit rendered
* into a hidden overlay satisfies all of them while the user composes blind. The real-geometry
* coverage that would have caught it existed, but was `@headful`, env-gated and macOS-only, so it
* never ran in CI.
*
* This file closes that gap. Composition is driven through CDP `Input.imeSetComposition`, which
* opens a genuine Chromium composition session with no native IME, no accessibility grant and no
* system input source — so the geometry assertions run in the normal headless CI project.
*/
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { expect, test } from './helpers/orca-app'
import { closeTerminalImePaneArena, openTerminalImePaneArena } from './terminal-ime-pane-arena'
import {
commitImeText,
composeHangulSyllable,
dispatchPlainEnter,
dispatchResumedCompositionUpdate,
type ImeKeyIdentity
} from './terminal-ime-cdp-composition'
import { readTerminalImeBoundaryTrace } from './terminal-ime-boundary-probe'
import {
createTerminalImeByteReader,
removeTerminalImeByteReader,
startTerminalImeByteReader,
waitForTerminalImeBytes
} from './terminal-ime-byte-reader'
import {
expectPreeditHidden,
expectPreeditRendered,
samplePreeditOverlay
} from './terminal-ime-preedit-overlay-probe'
import { applyImePlatformPolicy } from './terminal-ime-platform-policy'
import {
replayRecordedImeDomTrace,
type RecordedImeDomTrace
} from './terminal-ime-recorded-dom-trace-replay'
/** 2-Set Korean maps each jamo to a QWERTY position; the IME rewrites `key` to the jamo itself. */
const JAMO: Record<string, ImeKeyIdentity> = {
: { key: 'ㅎ', code: 'KeyG', keyCode: 71 },
: { key: 'ㅏ', code: 'KeyK', keyCode: 75 },
: { key: 'ㄴ', code: 'KeyS', keyCode: 83 },
: { key: 'ㄱ', code: 'KeyR', keyCode: 82 },
: { key: 'ㅡ', code: 'KeyM', keyCode: 77 },
: { key: 'ㄹ', code: 'KeyF', keyCode: 70 }
}
/** ㅎ → ㅏ → ㄴ assembles 한; the preedit shows the partially assembled syllable at each step. */
const HAN_FRAMES = [
{ jamoKey: JAMO['ㅎ'], preedit: 'ㅎ' },
{ jamoKey: JAMO['ㅏ'], preedit: '하' },
{ jamoKey: JAMO['ㄴ'], preedit: '한' }
] as const
/** ㄱ → ㅡ → ㄹ assembles 글. */
const GEUL_FRAMES = [
{ jamoKey: JAMO['ㄱ'], preedit: 'ㄱ' },
{ jamoKey: JAMO['ㅡ'], preedit: '그' },
{ jamoKey: JAMO['ㄹ'], preedit: '글' }
] as const
const RECORDED_TRACE = JSON.parse(
readFileSync(path.join(__dirname, 'fixtures', 'windows-wsl-2set-hangul-dom-trace.json'), 'utf8')
) as RecordedImeDomTrace
test.describe('Terminal 2-Set Korean preedit visibility', () => {
test('shows every assembling jamo at non-zero size and commits the syllable ahead of the newline', async ({
orcaPage,
testRepoPath
}, testInfo) => {
const arena = await openTerminalImePaneArena(orcaPage)
const reader = createTerminalImeByteReader(testRepoPath, 1)
let completed = false
try {
await startTerminalImeByteReader(orcaPage, arena.ptyId, reader)
await expectPreeditHidden(orcaPage, 'before composing')
for (const frame of HAN_FRAMES) {
await composeHangulSyllable(arena.session, orcaPage, [frame])
await expectPreeditRendered(orcaPage, frame.preedit, `composing ${frame.preedit}`)
}
await commitImeText(arena.session, '한')
await expectPreeditHidden(orcaPage, 'after committing 한')
await dispatchPlainEnter(arena.session)
const received = await waitForTerminalImeBytes(orcaPage, reader)
expect(received).toEqual([Buffer.from('한\n').toString('hex')])
const trace = await readTerminalImeBoundaryTrace(orcaPage)
// The ordering the user reported as broken: the syllable must precede the newline.
expect(trace.onData.join('')).toBe('한\r')
completed = true
} finally {
await closeTerminalImePaneArena(arena, testInfo, 'korean-preedit-visibility', !completed)
removeTerminalImeByteReader(reader)
}
})
test('keeps a preedit the IME resumes without a compositionstart visible', async ({
orcaPage
}, testInfo) => {
// Red on `main`, by design. xterm adds `.active` to the overlay only in its `compositionstart`
// handler, so a preedit resumed by a bare `compositionupdate` is written into a hidden element
// and the user composes blind while the committed bytes still land correctly — which is why no
// byte-level assertion ever saw it. Pre-existing and broken in every shipped build; closed by
// the visibility fix in xterm's own composition helper one layer below this one.
const arena = await openTerminalImePaneArena(orcaPage)
let completed = false
try {
// Synthesised, not replayed — see dispatchResumedCompositionUpdate for why the recorded
// corpus cannot supply this ordering and why it is still reachable in production.
await dispatchResumedCompositionUpdate(orcaPage, '한')
const sample = await samplePreeditOverlay(orcaPage)
expect(sample.found, 'no composition overlay exists').toBe(true)
expect(sample.text, 'the resumed preedit text never reached the overlay').toBe('한')
expect(
sample.rect.width,
'the resumed preedit was written into an overlay with zero width — the user composes blind'
).toBeGreaterThan(0)
expect(sample.rect.height, 'the resumed preedit overlay has zero height').toBeGreaterThan(0)
completed = true
} finally {
await closeTerminalImePaneArena(arena, testInfo, 'korean-resumed-preedit', !completed)
}
})
test('renders the preedit at every update of a recorded Windows/WSL Hangul session', async ({
orcaPage
}, testInfo) => {
// Pinned to the Windows policy because the trace is a Windows recording. Without the pin it
// ran under whatever the runner reported — macOS locally, Linux on the CI shards — so the one
// platform it was named for was the one platform it never exercised.
await applyImePlatformPolicy(orcaPage, 'windows')
const arena = await openTerminalImePaneArena(orcaPage)
let completed = false
try {
const replay = await replayRecordedImeDomTrace(orcaPage, RECORDED_TRACE)
const updates = replay.samples.filter(
(sample) => sample.type === 'compositionupdate' && sample.data.length > 0
)
// If the fixture is ever replaced with one that carries no updates this assertion keeps the
// rest of the test from passing vacuously.
expect(updates.length, 'the recorded trace carries no composition updates').toBe(37)
const invisible = updates.filter(
(sample) => sample.overlay.rect.width === 0 || sample.overlay.rect.height === 0
)
expect(
invisible.map((sample) => ({ index: sample.index, data: sample.data })),
'these recorded preedit frames were written into an overlay with no size'
).toEqual([])
const committed = replay.samples
.filter((sample) => sample.type === 'compositionend' && sample.data.length > 0)
.map((sample) => sample.data)
expect(committed.join('')).toBe('문제모르겠네안녕하세요')
// The capture's own byte stream, asserted rather than carried unused: it is the only thing
// in this test that would notice a syllable being dropped between the overlay and the PTY,
// and the trailing ASCII `hello` pins that a plain word after a Korean session is unharmed.
expect(replay.onData).toBe((RECORDED_TRACE.onData ?? []).map((entry) => entry.data).join(''))
expect(replay.onData).toBe('문제\r모르겠네\r안녕하세요\rhello\r')
completed = true
} finally {
await closeTerminalImePaneArena(arena, testInfo, 'korean-recorded-trace-preedit', !completed)
}
})
test('loses and duplicates nothing when back-to-back syllables commit at full speed', async ({
orcaPage,
testRepoPath
}, testInfo) => {
const arena = await openTerminalImePaneArena(orcaPage)
const reader = createTerminalImeByteReader(testRepoPath, 1)
let completed = false
try {
await startTerminalImeByteReader(orcaPage, arena.ptyId, reader)
// No settle time between frames or between syllables: the cadence a fast typist produces,
// and the one that used to drop or double a syllable at the boundary.
for (let repetition = 0; repetition < 4; repetition += 1) {
await composeHangulSyllable(arena.session, orcaPage, HAN_FRAMES, 0)
await commitImeText(arena.session, '한')
await composeHangulSyllable(arena.session, orcaPage, GEUL_FRAMES, 0)
await commitImeText(arena.session, '글')
}
await dispatchPlainEnter(arena.session)
const received = await waitForTerminalImeBytes(orcaPage, reader)
expect(received).toEqual([Buffer.from(`${'한글'.repeat(4)}\n`).toString('hex')])
const trace = await readTerminalImeBoundaryTrace(orcaPage)
expect(trace.onData.join('')).toBe(`${'한글'.repeat(4)}\r`)
completed = true
} finally {
await closeTerminalImePaneArena(arena, testInfo, 'korean-fast-cadence', !completed)
removeTerminalImeByteReader(reader)
}
})
})