Files
orca/tests/e2e/native-chat-composer-linux-sogou-commit.spec.ts
T
Neil 09681f02ba test(native-chat): replay recorded Linux Sogou commits into the chat composer (#28610)
* test(native-chat): replay recorded Linux Sogou commits into the chat composer

Guards #26722: keyup-only pinyin, plain Space/digit selector picks (GTK IM
and XIM shapes), Process/229 phrase picks, and a client-preedit commit all
land in the composer. The recorded fcitx4 shapes do not reproduce the issue.

* test(native-chat): read the composer event log through a typed dataset value
2026-10-14 01:58:45 -07:00

220 lines
7.5 KiB
TypeScript

// Replays the fcitx + Sogou X11 DOM shape (terminal-pane/__fixtures__/fcitx4-sogou-*-trace.json)
// into the native chat composer: pinyin letters arrive as keyups only, a single-character pick is a
// plain Space/digit keydown whose keypress and insertText carry the candidate, a phrase pick is a
// Process/229 keydown plus insertText, and no composition event fires at all.
import { randomUUID } from 'node:crypto'
import { mkdtempSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import type { CDPSession, Locator } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import { waitForActivePaneHookDescriptor, waitForActiveTerminalManager } from './helpers/terminal'
type Key = { key: string; code: string; keyCode: number }
function claudeTranscript(sessionId: string): string {
const startedAt = Date.now() - 60_000
return `${[
['user', 'hello'],
['assistant', 'Ready for input.']
]
.map(([role, text], index) =>
JSON.stringify({
sessionId,
uuid: `${sessionId}-${index}`,
timestamp: new Date(startedAt + index * 1_000).toISOString(),
type: role,
message: {
role,
model: 'claude-opus-4',
content: [{ type: 'text', text }]
}
})
)
.join('\n')}\n`
}
function draft(composer: Locator): Promise<string> {
return composer.evaluate((element) =>
Array.from(element.querySelectorAll('p'), (line) => line.textContent ?? '').join('\n')
)
}
async function pinyinKeyUps(session: CDPSession, letters: string): Promise<void> {
// Why: the IME grabs each pinyin keydown; the page only sees its release.
for (const letter of letters) {
const keyCode = letter.toUpperCase().charCodeAt(0)
await session.send('Input.dispatchKeyEvent', {
type: 'keyUp',
key: letter,
code: `Key${letter.toUpperCase()}`,
windowsVirtualKeyCode: keyCode,
nativeVirtualKeyCode: keyCode
})
}
}
async function plainSelectorPick(
session: CDPSession,
selector: Key,
text: string,
// XIM reports the selector keydown as key NUL / keyCode 0; its keyup is the real key.
down: Pick<Key, 'key' | 'keyCode'> = selector
): Promise<void> {
// A plain keyDown whose text is the candidate yields keydown(selector) → keypress(candidate) →
// insertText(candidate), the recorded single-character pick.
await session.send('Input.dispatchKeyEvent', {
type: 'keyDown',
key: down.key,
code: selector.code,
windowsVirtualKeyCode: down.keyCode,
nativeVirtualKeyCode: down.keyCode,
text,
unmodifiedText: text
})
await session.send('Input.dispatchKeyEvent', {
type: 'keyUp',
key: selector.key,
code: selector.code,
windowsVirtualKeyCode: selector.keyCode,
nativeVirtualKeyCode: selector.keyCode
})
}
async function processPhrasePick(session: CDPSession, selector: Key, text: string): Promise<void> {
await session.send('Input.dispatchKeyEvent', {
type: 'rawKeyDown',
key: 'Process',
code: selector.code,
windowsVirtualKeyCode: 229,
nativeVirtualKeyCode: 229
})
await session.send('Input.insertText', { text })
await session.send('Input.dispatchKeyEvent', {
type: 'keyUp',
key: selector.key,
code: selector.code,
windowsVirtualKeyCode: selector.keyCode,
nativeVirtualKeyCode: selector.keyCode
})
}
test('Linux Sogou commits land in the native chat composer', async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const descriptor = await waitForActivePaneHookDescriptor(orcaPage)
const [tabId] = descriptor.paneKey.split(':')
const sessionId = `e2e-sogou-composer-${randomUUID()}`
const transcriptPath = path.join(
mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-sogou-composer-')),
`${sessionId}.jsonl`
)
writeFileSync(transcriptPath, claudeTranscript(sessionId))
await orcaPage.evaluate(
async ({ paneKey, worktreeId, tabId, sessionId, transcriptPath }) => {
const settings = await window.api.settings.set({
experimentalNativeChat: true
})
const store = window.__store
if (!store) {
throw new Error('Store unavailable')
}
store.setState({ settings })
const state = store.getState()
state.setAgentStatus(
paneKey,
{ state: 'done', prompt: 'e2e sogou composer', agentType: 'claude' },
'Claude',
undefined,
{ worktreeId },
{
providerSession: { key: 'session_id', id: sessionId, transcriptPath }
}
)
const tab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find(
(candidate) => candidate.contentType === 'terminal' && candidate.entityId === tabId
)
if (!tab) {
throw new Error('Terminal tab unavailable')
}
state.toggleTabViewMode(tab.id)
},
{ ...descriptor, tabId, sessionId, transcriptPath }
)
const chat = orcaPage.locator('[data-native-chat-root="true"]')
await expect(chat.getByText('Ready for input.')).toBeVisible()
const composer = chat.getByRole('textbox')
await composer.click()
await expect(composer).toBeFocused()
const session = await orcaPage.context().newCDPSession(orcaPage)
await composer.evaluate((element) => {
const log: string[] = []
for (const type of ['keydown', 'keypress', 'keyup', 'beforeinput', 'compositionstart']) {
element.addEventListener(
type,
(event) => {
const detail =
event instanceof KeyboardEvent
? `${event.key}/${event.keyCode}/${event.charCode}`
: event instanceof InputEvent
? `${event.inputType}/${event.data}`
: ''
log.push(`${type}:${detail}`)
document.documentElement.dataset.sogouEventLog = JSON.stringify(log)
},
true
)
}
})
await pinyinKeyUps(session, 'ni')
await plainSelectorPick(session, { key: ' ', code: 'Space', keyCode: 32 }, '你')
await expect.poll(() => draft(composer)).toBe('你')
// Guards the replay itself: it must match the recorded fixture shape.
expect(
await orcaPage.evaluate((): unknown =>
JSON.parse(document.documentElement.dataset.sogouEventLog ?? '[]')
)
).toEqual([
'keyup:n/78/0',
'keyup:i/73/0',
'keydown: /32/0',
`keypress: /20320/20320`,
'beforeinput:insertText/你',
'keyup: /32/0'
])
await pinyinKeyUps(session, 'hao')
await plainSelectorPick(session, { key: '3', code: 'Digit3', keyCode: 51 }, '浩')
await expect.poll(() => draft(composer)).toBe('你浩')
await pinyinKeyUps(session, 'nihao')
await processPhrasePick(session, { key: ' ', code: 'Space', keyCode: 32 }, '你好')
await expect.poll(() => draft(composer)).toBe('你浩你好')
await pinyinKeyUps(session, 'ni')
await plainSelectorPick(session, { key: ' ', code: 'Space', keyCode: 32 }, '你', {
key: '\u0000',
keyCode: 0
})
await expect.poll(() => draft(composer)).toBe('你浩你好你')
// Client-preedit engines compose instead; the commit must land the same way.
await session.send('Input.imeSetComposition', {
text: '好',
selectionStart: 1,
selectionEnd: 1
})
await session.send('Input.insertText', { text: '好' })
await expect.poll(() => draft(composer)).toBe('你浩你好你好')
await composer.pressSequentially(' ok')
await expect.poll(() => draft(composer)).toBe('你浩你好你好 ok')
})