diff --git a/tests/e2e/terminal-ime-cdp-composition.ts b/tests/e2e/terminal-ime-cdp-composition.ts new file mode 100644 index 00000000000..e035db8bef0 --- /dev/null +++ b/tests/e2e/terminal-ime-cdp-composition.ts @@ -0,0 +1,89 @@ +import type { CDPSession } from '@stablyai/playwright-test' + +/** + * Dispatches the key shapes an input source or a system text substitution produces, through CDP. + * + * Driving these from the browser protocol rather than a native input source is what removes the + * accessibility grant and the system input source that would otherwise force a `@headful`, + * macOS-only gate, so the specs they feed run in the normal headless project on CI. + */ +export type ImeKeyIdentity = { + key: string + code: string + keyCode: number +} + +/** + * A printable keydown whose `key` the input source has already rewritten to the glyph it will + * commit — the shape a CJK source produces for punctuation and full-width digits, which arrive + * with no composition session at all. + */ +export async function dispatchImeRewrittenPrintableKey( + session: CDPSession, + identity: ImeKeyIdentity +): Promise { + await session.send('Input.dispatchKeyEvent', { + type: 'keyDown', + key: identity.key, + code: identity.code, + windowsVirtualKeyCode: identity.keyCode, + nativeVirtualKeyCode: identity.keyCode, + text: identity.key, + unmodifiedText: identity.key + }) + await session.send('Input.dispatchKeyEvent', { + type: 'keyUp', + key: identity.key, + code: identity.code, + windowsVirtualKeyCode: identity.keyCode, + nativeVirtualKeyCode: identity.keyCode + }) +} + +/** + * A printable keydown that still carries the **physical layout key**, followed by the substituted + * glyph arriving through the text system. + * + * This is the macOS shape for full-width punctuation and digits, and for a + * `DefaultKeyBinding.dict` remap: the substitution happens inside `insertText:`, not on the + * keydown, so the keydown Chromium delivers is the plain layout character and the substituted one + * only ever appears in the `input` event. Anything that produces terminal bytes from the keydown + * emits the layout form and destroys the real one. + */ +export async function dispatchImeSubstitutedTextKey( + session: CDPSession, + identity: ImeKeyIdentity, + committedText: string +): Promise { + // rawKeyDown carries no `text`, so Chromium generates no character of its own and the only + // committed text is the one the text system supplies below. + await session.send('Input.dispatchKeyEvent', { + type: 'rawKeyDown', + key: identity.key, + code: identity.code, + windowsVirtualKeyCode: identity.keyCode, + nativeVirtualKeyCode: identity.keyCode, + text: '', + unmodifiedText: '' + }) + await session.send('Input.insertText', { text: committedText }) + await session.send('Input.dispatchKeyEvent', { + type: 'keyUp', + key: identity.key, + code: identity.code, + windowsVirtualKeyCode: identity.keyCode, + nativeVirtualKeyCode: identity.keyCode + }) +} + +export async function dispatchPlainEnter(session: CDPSession): Promise { + for (const type of ['rawKeyDown', 'keyUp'] as const) { + await session.send('Input.dispatchKeyEvent', { + type, + key: 'Enter', + code: 'Enter', + windowsVirtualKeyCode: 13, + nativeVirtualKeyCode: 13 + }) + } +} diff --git a/tests/e2e/terminal-ime-pane-arena.ts b/tests/e2e/terminal-ime-pane-arena.ts new file mode 100644 index 00000000000..4462814d06a --- /dev/null +++ b/tests/e2e/terminal-ime-pane-arena.ts @@ -0,0 +1,50 @@ +import type { CDPSession, Page, TestInfo } from '@stablyai/playwright-test' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + focusActiveTerminalInput, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { + attachTerminalImeBoundaryEvidence, + disposeTerminalImeBoundaryProbe, + installTerminalImeBoundaryProbe +} from './terminal-ime-boundary-probe' + +/** A focused terminal pane with a CDP session and the boundary probe already attached. */ +export type TerminalImePaneArena = { + page: Page + session: CDPSession + ptyId: string +} + +export async function openTerminalImePaneArena(page: Page): Promise { + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + const ptyId = await waitForActivePanePtyId(page) + const session = await page.context().newCDPSession(page) + await focusActiveTerminalInput(page) + await installTerminalImeBoundaryProbe(page) + return { page, session, ptyId } +} + +/** + * `interrupt` sends Ctrl+C only when the test did not reach its end, so a spec that failed + * mid-composition cannot leave a byte reader holding the pane for the next test in the worker. + */ +export async function closeTerminalImePaneArena( + arena: TerminalImePaneArena, + testInfo: TestInfo, + evidenceName: string, + interrupt: boolean +): Promise { + await attachTerminalImeBoundaryEvidence(arena.page, testInfo, evidenceName).catch(() => undefined) + await disposeTerminalImeBoundaryProbe(arena.page).catch(() => undefined) + await arena.session.detach().catch(() => undefined) + if (interrupt) { + await sendToTerminal(arena.page, arena.ptyId, '\x03').catch(() => undefined) + } +} diff --git a/tests/e2e/terminal-ime-platform-policy.ts b/tests/e2e/terminal-ime-platform-policy.ts new file mode 100644 index 00000000000..04adb66f0ea --- /dev/null +++ b/tests/e2e/terminal-ime-platform-policy.ts @@ -0,0 +1,51 @@ +import type { Page } from '@stablyai/playwright-test' + +/** + * Pins the renderer's IME ownership policy to one platform, from any runner. + * + * Every platform-dependent IME decision in the terminal reads `navigator.userAgent` and nothing + * else — the forwarder that owns printable keydowns installs only when the UA reports macOS, the + * candidate-key guards only when it reports desktop Linux, and the standalone `keyCode 229` + * keydown reaches xterm on macOS and Linux but not on Windows. Overriding the UA is therefore the + * whole platform decision, which is what lets the Windows and Linux shapes run headless on the + * Linux CI shards instead of needing three runners. + * + * What it does NOT simulate is the input framework itself. That comes from the recorded traces + * replayed against the pinned policy, because the frameworks disagree on ordering in ways no + * hand-authored sequence would have predicted. + */ +export type ImePlatformPolicy = 'mac' | 'windows' | 'linux' + +const USER_AGENT_BY_POLICY: Record = { + mac: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/146 Safari/537.36', + windows: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/146 Safari/537.36', + // X11 rather than Wayland in the UA string: Chromium reports the same token for both, so the + // Wayland traces run under this policy too. + linux: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/146 Safari/537.36' +} + +export async function applyImePlatformPolicy(page: Page, policy: ImePlatformPolicy): Promise { + await page.addInitScript((userAgent) => { + Object.defineProperty(navigator, 'userAgent', { + get: () => userAgent, + configurable: true + }) + }, USER_AGENT_BY_POLICY[policy]) + await page.reload({ waitUntil: 'domcontentloaded' }) + await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 }) +} + +/** Fails loudly if the override did not take, so a policy-scoped spec cannot pass on the wrong one. */ +export async function expectImePlatformPolicy( + page: Page, + policy: ImePlatformPolicy +): Promise { + const observed = await page.evaluate(() => ({ + mac: navigator.userAgent.includes('Mac'), + windows: navigator.userAgent.includes('Windows'), + linux: navigator.userAgent.includes('Linux') && !/Android|CrOS/.test(navigator.userAgent) + })) + if (!observed[policy] || (policy !== 'mac' && observed.mac)) { + throw new Error(`IME platform policy '${policy}' did not take: ${JSON.stringify(observed)}`) + } +}