mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
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: '1', ascii: '1' },
|
||||
{ key: '2', code: 'Digit2', keyCode: 50, glyph: '2', ascii: '2' },
|
||||
{ key: '3', code: 'Digit3', keyCode: 51, glyph: '3', 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user