test(ime): run the native IBus E2E suite across four input engines

The native suite only ever exercised ibus-hangul, so every non-Korean IME
regression had to be caught by hand. Generalize the runner and spec over an
engine profile and add ibus-libpinyin (Pinyin), ibus-anthy (Romaji/kana) and
ibus-unikey (Telex) as matrix legs, covering the candidate-window, multi-commit
and rule-based rewriting shapes that Hangul alone does not produce.

Session config (apt package, GSettings, `ibus engine` name) and the keystroke
scripts live in separate files because one is loaded by the Node runner and the
other by the Playwright spec. They are joined only by the engine id, so the
contract test asserts the two sets agree and that the workflow matrix covers
exactly them.

Only the hangul expectations have been recorded from a real run. The other three
engines' committed glyphs are predicted from their input rules, so the exact-text
oracle is `expect.soft` for them while the structural oracle stays hard: every
repetition must produce identical bytes, in the engine's script, with xterm's
onData matching what the PTY received. That is the half that actually detects
the duplication and leaked-ASCII bugs, and it holds without knowing the glyphs.
A first green run turns each `expectationsVerified` to true.

fail-fast is off so a drifting libpinyin candidate cannot mask a hangul
regression, and evidence uploads are per-engine because upload-artifact rejects
a duplicate name.
This commit is contained in:
Neil
2026-08-01 21:47:23 -07:00
parent c801b88762
commit 985ec6d74c
7 changed files with 426 additions and 122 deletions
+21 -5
View File
@@ -10,10 +10,26 @@ permissions:
jobs:
linux-x11:
name: Linux X11 terminal IME
name: Linux X11 terminal IME (${{ matrix.engine }})
runs-on: ubuntu-22.04
timeout-minutes: 25
strategy:
# Each engine is its own signal. A libpinyin candidate drifting must not
# hide a hangul regression, which is the one leg with recorded
# expectations.
fail-fast: false
matrix:
include:
- engine: hangul
apt-package: ibus-hangul
- engine: libpinyin
apt-package: ibus-libpinyin
- engine: anthy
apt-package: ibus-anthy
- engine: unikey
apt-package: ibus-unikey
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -28,7 +44,7 @@ jobs:
dbus-x11
dconf-gsettings-backend
ibus
ibus-hangul
${{ matrix.apt-package }}
libglib2.0-bin
python3
xdotool
@@ -64,16 +80,16 @@ jobs:
tests/e2e/terminal-ime-exact-byte.spec.ts
--workers=1
- name: Run native IBus Hangul exact-byte tests
- name: Run native IBus ${{ matrix.engine }} exact-byte tests
env:
SKIP_BUILD: '1'
run: pnpm run test:e2e:terminal-ime-native
run: pnpm run test:e2e:terminal-ime-native -- --engine=${{ matrix.engine }}
- name: Upload terminal IME evidence
if: always()
uses: actions/upload-artifact@v7
with:
name: terminal-ime-evidence
name: terminal-ime-evidence-${{ matrix.engine }}
path: test-results/
retention-days: 7
if-no-files-found: ignore
@@ -2,10 +2,17 @@ import { spawn, spawnSync } from 'node:child_process'
import { closeSync, copyFileSync, mkdirSync, mkdtempSync, openSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import {
defaultTerminalIbusEngineId,
resolveTerminalIbusEngineProfile,
terminalIbusEngineIds
} from './terminal-ibus-engine-profiles.mjs'
const projectDir = path.resolve(import.meta.dirname, '../..')
const scriptPath = import.meta.filename
const insideSessionFlag = '--inside-session'
const engineFlagPrefix = '--engine='
const nativeSpecPath = 'tests/e2e/terminal-ibus-engine-native.spec.ts'
const processStopTimeoutMs = 5_000
const processKillTimeoutMs = 1_000
@@ -81,44 +88,61 @@ function commandOutput(command, args) {
return result.status === 0 ? result.stdout.trim() : result.stderr.trim()
}
function configureHangulEngine() {
for (const [key, value] of [
['initial-input-mode', 'hangul'],
['hangul-keyboard', '2']
]) {
const result = spawnSync(
'gsettings',
['set', 'org.freedesktop.ibus.engine.hangul', key, value],
{ encoding: 'utf8' }
)
function selectedEngineId() {
const flag = process.argv.find((argument) => argument.startsWith(engineFlagPrefix))
const engineId =
flag?.slice(engineFlagPrefix.length) ||
process.env.ORCA_E2E_NATIVE_IBUS_ENGINE ||
defaultTerminalIbusEngineId
resolveTerminalIbusEngineProfile(engineId)
return engineId
}
function configureEngine(profile) {
for (const [schemaId, key, value] of profile.gsettings) {
const result = spawnSync('gsettings', ['set', schemaId, key, value], { encoding: 'utf8' })
if (result.status !== 0) {
throw new Error(`Failed to configure IBus Hangul ${key}: ${result.stderr.trim()}`)
throw new Error(`Failed to configure ${schemaId} ${key}: ${result.stderr.trim()}`)
}
}
}
async function waitForHangulEngine(ibusProcess) {
async function waitForEngine(ibusProcess, profile) {
const deadline = Date.now() + 15_000
let lastFailure = ''
while (Date.now() < deadline) {
if (ibusProcess.exitCode !== null) {
throw new Error(`ibus-daemon exited early with code ${ibusProcess.exitCode}`)
}
const result = spawnSync('ibus', ['engine', 'hangul'], { stdio: 'pipe' })
const result = spawnSync('ibus', ['engine', profile.ibusEngineName], {
encoding: 'utf8',
stdio: 'pipe'
})
if (result.status === 0) {
return
}
lastFailure = (result.stderr || result.stdout || '').trim()
await delay(100)
}
throw new Error('Timed out while selecting the IBus Hangul engine')
// Why: the engine name a package registers is not always its apt suffix, so the
// available list is the only thing that tells you which of the two is wrong.
const available = commandOutput('ibus', ['list-engine'])
throw new Error(
`Timed out while selecting the IBus ${profile.ibusEngineName} engine.\n` +
`Last 'ibus engine' failure: ${lastFailure || '(no output)'}\n` +
`Engines ibus reports as available:\n${available}`
)
}
async function runInsideSession(evidenceDir) {
async function runInsideSession(engineId, evidenceDir) {
const profile = resolveTerminalIbusEngineProfile(engineId)
const ibusLogPath = path.join(evidenceDir, 'ibus-daemon.log')
const ibusLogFd = openSync(ibusLogPath, 'w')
const windowManagerLogPath = path.join(evidenceDir, 'xfwm4.log')
const windowManagerLogFd = openSync(windowManagerLogPath, 'w')
const evidence = {
display: process.env.DISPLAY ?? null,
engineId,
ibusDaemonPid: null,
ibusGroupBeforeCleanup: [],
ibusGroupAfterCleanup: [],
@@ -131,7 +155,7 @@ async function runInsideSession(evidenceDir) {
let testExitCode = 1
try {
configureHangulEngine()
configureEngine(profile)
windowManagerProcess = spawn('xfwm4', ['--compositor=off'], {
detached: true,
env: process.env,
@@ -157,41 +181,26 @@ async function runInsideSession(evidenceDir) {
}
evidence.ibusDaemonPid = ibusProcess.pid
console.error(`[terminal-ime] started ibus-daemon PID ${ibusProcess.pid}`)
await waitForHangulEngine(ibusProcess)
await waitForEngine(ibusProcess, profile)
console.error(`[terminal-ime] IBus version: ${commandOutput('ibus', ['version'])}`)
console.error(`[terminal-ime] IBus engine: ${commandOutput('ibus', ['engine'])}`)
console.error(
`[terminal-ime] Hangul initial mode: ${commandOutput('gsettings', [
'get',
'org.freedesktop.ibus.engine.hangul',
'initial-input-mode'
])}`
)
console.error(
`[terminal-ime] Hangul keyboard: ${commandOutput('gsettings', [
'get',
'org.freedesktop.ibus.engine.hangul',
'hangul-keyboard'
])}`
)
for (const [schemaId, key] of profile.gsettings) {
console.error(
`[terminal-ime] ${schemaId} ${key}: ${commandOutput('gsettings', ['get', schemaId, key])}`
)
}
evidence.ibusGroupBeforeCleanup = processGroupMembers(ibusProcess.pid)
console.error(`[terminal-ime] owned IBus group: ${evidence.ibusGroupBeforeCleanup.join('; ')}`)
const testProcess = spawn(
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
[
'run',
'test:e2e:headful',
'--workers=1',
'--',
'tests/e2e/terminal-ibus-hangul-native.spec.ts'
],
['run', 'test:e2e:headful', '--workers=1', '--', nativeSpecPath],
{
cwd: projectDir,
env: {
...process.env,
ORCA_E2E_FORWARD_APP_LOGS: '1',
ORCA_E2E_NATIVE_IBUS_HANGUL: '1'
ORCA_E2E_NATIVE_IBUS_ENGINE: engineId
},
stdio: 'inherit'
}
@@ -217,14 +226,14 @@ async function runInsideSession(evidenceDir) {
mkdirSync(path.join(projectDir, 'test-results'), { recursive: true })
copyFileSync(
ibusLogPath,
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-ibus.log')
path.join(projectDir, 'test-results', `terminal-ibus-${engineId}-native-ibus.log`)
)
copyFileSync(
windowManagerLogPath,
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-xfwm4.log')
path.join(projectDir, 'test-results', `terminal-ibus-${engineId}-native-xfwm4.log`)
)
writeFileSync(
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-processes.json'),
path.join(projectDir, 'test-results', `terminal-ibus-${engineId}-native-processes.json`),
`${JSON.stringify(evidence, null, 2)}\n`
)
}
@@ -242,10 +251,13 @@ async function runInsideSession(evidenceDir) {
return testExitCode
}
async function runOuter() {
async function runOuter(engineId) {
if (process.platform !== 'linux') {
throw new Error('The native IBus Hangul E2E runner requires Linux/X11')
throw new Error('The native IBus E2E runner requires Linux/X11')
}
console.error(
`[terminal-ime] engine: ${engineId} (available: ${terminalIbusEngineIds().join(', ')})`
)
const evidenceDir = mkdtempSync(path.join(os.tmpdir(), 'orca-terminal-ime-e2e-'))
const runtimeDir = path.join(evidenceDir, 'runtime')
@@ -273,6 +285,7 @@ async function runOuter() {
GTK_IM_MODULE: 'ibus',
IBUS_ENABLE_SYNC_MODE: '1',
LANG: process.env.LANG || 'C.UTF-8',
ORCA_E2E_NATIVE_IBUS_ENGINE: engineId,
QT_IM_MODULE: 'ibus',
XDG_CACHE_HOME: path.join(evidenceDir, 'cache'),
XDG_CONFIG_HOME: path.join(evidenceDir, 'config'),
@@ -296,10 +309,13 @@ async function runOuter() {
const insideSession = process.argv[2] === insideSessionFlag
try {
const engineId = selectedEngineId()
if (insideSession && !process.argv[3]) {
throw new Error(`${insideSessionFlag} requires an evidence directory argument`)
}
process.exitCode = insideSession ? await runInsideSession(process.argv[3]) : await runOuter()
process.exitCode = insideSession
? await runInsideSession(engineId, process.argv[3])
: await runOuter(engineId)
} catch (error) {
console.error(`[terminal-ime] ${error instanceof Error ? error.message : String(error)}`)
process.exitCode = 1
@@ -0,0 +1,97 @@
// Session-level configuration for each IBus engine the native terminal IME E2E
// runner can drive. Keystrokes and expected text live with the Playwright spec
// (tests/e2e/terminal-ibus-engine-input-profiles.ts); the two lists must agree,
// which config/scripts/terminal-ime-e2e-workflow.test.mjs enforces.
//
// Only the hangul entry has been run. Every other engine's schema id, key names
// and value encoding are transcribed from that engine's upstream source for the
// version Ubuntu 22.04 (jammy) ships, not read off an installed schema, so treat
// them as unverified until CI has run green. They are not inferred from
// ibus-hangul: the four engines share no namespace and no key names, and a wrong
// id fails loudly in configureEngine rather than silently mis-configuring.
/**
* @typedef {object} TerminalIbusEngineProfile
* @property {string} aptPackage Package installed by the workflow matrix.
* @property {string} ibusEngineName Argument to `ibus engine`; case-sensitive.
* @property {Array<[string, string, string]>} gsettings schema id, key, value.
* @property {boolean} expectationsVerified False while the committed text is a
* prediction from engine source rather than a recorded CI run.
*/
/** @type {Record<string, TerminalIbusEngineProfile>} */
export const terminalIbusEngineProfiles = {
// ibus-hangul 1.5.4-1build2. Unchanged from the original single-engine runner.
hangul: {
aptPackage: 'ibus-hangul',
ibusEngineName: 'hangul',
gsettings: [
['org.freedesktop.ibus.engine.hangul', 'initial-input-mode', 'hangul'],
['org.freedesktop.ibus.engine.hangul', 'hangul-keyboard', '2']
],
expectationsVerified: true
},
// ibus-libpinyin 1.12.1-2ubuntu2. Schema id comes from PinyinConfig's
// g_settings_new argument, not from the org.freedesktop.ibus namespace.
// Cloud input, emoji candidates and post-commit suggestions are switched off
// so the candidate list is a pure function of the bundled libpinyin model.
libpinyin: {
aptPackage: 'ibus-libpinyin',
ibusEngineName: 'libpinyin',
gsettings: [
['com.github.libpinyin.ibus-libpinyin.libpinyin', 'init-chinese', 'true'],
['com.github.libpinyin.ibus-libpinyin.libpinyin', 'init-full', 'false'],
['com.github.libpinyin.ibus-libpinyin.libpinyin', 'init-full-punct', 'false'],
['com.github.libpinyin.ibus-libpinyin.libpinyin', 'double-pinyin', 'false'],
['com.github.libpinyin.ibus-libpinyin.libpinyin', 'enable-cloud-input', 'false'],
['com.github.libpinyin.ibus-libpinyin.libpinyin', 'emoji-candidate', 'false'],
['com.github.libpinyin.ibus-libpinyin.libpinyin', 'show-suggestion', 'false']
],
expectationsVerified: false
},
// ibus-anthy 1.5.14-1. input-mode defaults to 3 (Latin), so leaving it alone
// would type ASCII and never start a composition; 0 is Hiragana and 0 for
// typing-method is Romaji.
anthy: {
aptPackage: 'ibus-anthy',
ibusEngineName: 'anthy',
gsettings: [
['org.freedesktop.ibus.engine.anthy.common', 'input-mode', '0'],
['org.freedesktop.ibus.engine.anthy.common', 'typing-method', '0']
],
expectationsVerified: false
},
// ibus-unikey 0.7.0~beta1-1build2. Telex and Unicode are already the schema
// defaults; they are pinned so a distro patch cannot silently retarget the
// keystroke script.
unikey: {
aptPackage: 'ibus-unikey',
ibusEngineName: 'Unikey',
gsettings: [
['org.freedesktop.ibus.engine.unikey', 'input-method', 'telex'],
['org.freedesktop.ibus.engine.unikey', 'output-charset', 'unicode'],
['org.freedesktop.ibus.engine.unikey', 'spell-check', 'true'],
['org.freedesktop.ibus.engine.unikey', 'macro-enabled', 'false']
],
expectationsVerified: false
}
}
export const defaultTerminalIbusEngineId = 'hangul'
export function terminalIbusEngineIds() {
return Object.keys(terminalIbusEngineProfiles)
}
export function resolveTerminalIbusEngineProfile(engineId) {
const profile = terminalIbusEngineProfiles[engineId]
if (!profile) {
throw new Error(
`Unknown IBus engine "${engineId}"; expected one of ${terminalIbusEngineIds().join(', ')}`
)
}
return profile
}
@@ -2,6 +2,8 @@ import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { nativeIbusEngineInputProfiles } from '../../tests/e2e/terminal-ibus-engine-input-profiles'
import { terminalIbusEngineProfiles } from './terminal-ibus-engine-profiles.mjs'
const projectDir = resolve(import.meta.dirname, '../..')
@@ -9,6 +11,7 @@ describe('terminal IME e2e workflow', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/terminal-ime-e2e.yml'), 'utf8')
)
const matrix = workflow.jobs['linux-x11'].strategy.matrix.include
it('runs only on schedule or manual dispatch', () => {
expect(workflow.on.pull_request).toBeUndefined()
@@ -16,14 +19,40 @@ describe('terminal IME e2e workflow', () => {
expect(workflow.on.schedule).toEqual([{ cron: '30 9 * * *' }])
})
it('installs native IBus Hangul and X11 input tools', () => {
// The session config and the keystroke scripts live in different files and are
// joined only by this id at runtime, so a rename in one is invisible until CI.
it('describes the same engines in the session and input profiles', () => {
expect(Object.keys(nativeIbusEngineInputProfiles).sort()).toEqual(
Object.keys(terminalIbusEngineProfiles).sort()
)
for (const [engineId, profile] of Object.entries(terminalIbusEngineProfiles)) {
expect(nativeIbusEngineInputProfiles[engineId].ibusEngineName).toBe(profile.ibusEngineName)
expect(nativeIbusEngineInputProfiles[engineId].expectationsVerified).toBe(
profile.expectationsVerified
)
}
})
it('runs every profiled engine as its own matrix leg', () => {
expect(matrix.map((leg) => leg.engine).sort()).toEqual(
Object.keys(terminalIbusEngineProfiles).sort()
)
for (const leg of matrix) {
expect(leg['apt-package']).toBe(terminalIbusEngineProfiles[leg.engine].aptPackage)
}
// One unverified engine's candidate drifting must not mask a regression in
// hangul, the only leg with recorded expectations.
expect(workflow.jobs['linux-x11'].strategy['fail-fast']).toBe(false)
})
it('installs the leg engine and X11 input tools', () => {
const runs = workflow.jobs['linux-x11'].steps
.map((step) => step.run)
.filter((run) => typeof run === 'string')
const installRun = runs.find((run) => run.includes('apt-get install'))
expect(installRun).toBeDefined()
expect(installRun).toContain('ibus-hangul')
expect(installRun).toContain('${{ matrix.apt-package }}')
expect(installRun).toContain('xdotool')
expect(installRun).toContain('xfwm4')
expect(installRun).toContain('xvfb')
@@ -43,11 +72,22 @@ describe('terminal IME e2e workflow', () => {
expect(deterministicIndex).toBeGreaterThanOrEqual(0)
expect(nativeIndex).toBeGreaterThan(deterministicIndex)
expect(runs[nativeIndex]).toContain('--engine=${{ matrix.engine }}')
})
// upload-artifact rejects a duplicate name, so a shared one fails every leg
// after the first.
it('uploads evidence under a per-engine artifact name', () => {
const upload = workflow.jobs['linux-x11'].steps.find((step) =>
step.uses?.startsWith('actions/upload-artifact@')
)
expect(upload.with.name).toContain('${{ matrix.engine }}')
})
it('keeps IBus lifecycle scoped to owned processes', () => {
const runner = readFileSync(
join(projectDir, 'config/scripts/run-terminal-ibus-hangul-e2e.mjs'),
join(projectDir, 'config/scripts/run-terminal-ibus-engine-e2e.mjs'),
'utf8'
)
@@ -55,22 +95,26 @@ describe('terminal IME e2e workflow', () => {
"['--xim', '--verbose', '--panel=disable', '--emoji-extension=disable']"
)
expect(runner).toContain("spawn('xfwm4', ['--compositor=off']")
expect(runner).toContain("['initial-input-mode', 'hangul']")
expect(runner).toContain("['hangul-keyboard', '2']")
expect(runner).toContain("process.kill(-processGroupId, 'SIGTERM')")
expect(runner).toContain("process.kill(-processGroupId, 'SIGKILL')")
expect(runner).toContain('const killDeadline = Date.now() + processKillTimeoutMs')
expect(runner).toMatch(
/'test:e2e:headful',\s*'--workers=1',\s*'--',\s*'tests\/e2e\/terminal-ibus-hangul-native\.spec\.ts'/
)
expect(runner).toMatch(/'test:e2e:headful',\s*'--workers=1',\s*'--',\s*nativeSpecPath/)
expect(runner).not.toContain("'--replace'")
expect(runner).not.toContain('killall')
expect(runner).not.toContain('pkill')
})
it('keeps the hangul session config that the recorded expectations were taken under', () => {
expect(terminalIbusEngineProfiles.hangul.gsettings).toEqual([
['org.freedesktop.ibus.engine.hangul', 'initial-input-mode', 'hangul'],
['org.freedesktop.ibus.engine.hangul', 'hangul-keyboard', '2']
])
expect(terminalIbusEngineProfiles.hangul.expectationsVerified).toBe(true)
})
it('bounds blocking native input commands', () => {
const nativeSpec = readFileSync(
join(projectDir, 'tests/e2e/terminal-ibus-hangul-native.spec.ts'),
join(projectDir, 'tests/e2e/terminal-ibus-engine-native.spec.ts'),
'utf8'
)
+1 -1
View File
@@ -102,7 +102,7 @@
"win-crash-survival-e2e": "node tests/tools/win-crash-survival-e2e/run.mjs",
"test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs",
"test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful",
"test:e2e:terminal-ime-native": "node config/scripts/run-terminal-ibus-hangul-e2e.mjs",
"test:e2e:terminal-ime-native": "node config/scripts/run-terminal-ibus-engine-e2e.mjs",
"test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts",
"bench:idle-cpu": "pnpm run ensure:electron-runtime && node config/scripts/run-idle-cpu-benchmark.mjs",
"bench:macos-computer-helper-owner-loss": "node config/scripts/macos-computer-helper-owner-loss-benchmark.mjs",
@@ -0,0 +1,132 @@
// Keystroke scripts and committed-text expectations for the native IBus E2E
// suite. Session configuration (apt package, GSettings, `ibus engine` name)
// lives in config/scripts/terminal-ibus-engine-profiles.mjs; the engine ids in
// both files must match, which the workflow contract test enforces.
export type NativeIbusInputDriver = {
key: (keyName: string) => void
type: (text: string) => void
typeClearingModifiers: (text: string) => void
}
export type NativeIbusEngineScenario = {
title: string
/** Text the engine is expected to hand to the PTY, excluding the newline. */
expectedText: string
drive: (driver: NativeIbusInputDriver) => void
}
export type NativeIbusEngineInputProfile = {
/** Argument to `ibus engine`; case-sensitive. */
ibusEngineName: string
/**
* Every committed line must match this. It is the engine-independent half of
* the oracle: it holds even when the exact glyphs depend on a dictionary.
*/
committedScriptPattern: RegExp
/**
* False while `expectedText` is derived from engine source rather than from a
* recorded CI run. Unverified expectations are asserted softly so one
* observational run reports both the structural verdict and the real text.
*/
expectationsVerified: boolean
scenarios: NativeIbusEngineScenario[]
}
export const nativeIbusEngineInputProfiles: Record<string, NativeIbusEngineInputProfile> = {
hangul: {
ibusEngineName: 'hangul',
committedScriptPattern: /[\uac00-\ud7af]/,
expectationsVerified: true,
scenarios: [
{
title: 'forwards the issue exact-byte sequence without loss or duplication',
expectedText: '한abc글',
drive: ({ key, type, typeClearingModifiers }) => {
typeClearingModifiers('gks')
key('Hangul')
type('abc')
key('Hangul')
type('rmf')
key('Return')
}
},
{
title: 'forwards the issue sentence stress sequence without leaked ASCII',
expectedText: '테스트를 하고 있는데 여전히 그러네',
drive: ({ key, typeClearingModifiers }) => {
typeClearingModifiers('xptmxmfmf gkrh dlTsmsep duwjsgl rmfjsp')
key('Return')
}
}
]
},
// Space selects the highlighted candidate and is consumed; Enter is only
// forwarded once the editor text is empty, so the newline needs no extra key.
// The candidate itself comes from the bundled libpinyin model, so the glyphs
// are a prediction until CI records them.
libpinyin: {
ibusEngineName: 'libpinyin',
committedScriptPattern: /[\u4e00-\u9fff]/,
expectationsVerified: false,
scenarios: [
{
title: 'commits one Pinyin candidate per repetition without leaked Latin',
expectedText: '你好',
drive: ({ key, typeClearingModifiers }) => {
typeClearingModifiers('nihao')
key('space')
key('Return')
}
}
]
},
// Romaji with no space, so Anthy never enters conversion and the committed
// text is the kana transliteration rather than a dictionary lookup. The first
// Enter commits the preedit and is consumed; the second reaches the PTY.
// "hiragana" is four plain CV syllables, avoiding the ambiguous romaji `n`.
anthy: {
ibusEngineName: 'anthy',
committedScriptPattern: /[\u3040-\u309f]/,
expectationsVerified: false,
scenarios: [
{
title: 'commits the romaji-to-kana preedit without leaked Latin',
expectedText: 'ひらがな',
drive: ({ key, typeClearingModifiers }) => {
typeClearingModifiers('hiragana')
key('Return')
key('Return')
}
}
]
},
// Telex is rule-based, so no dictionary decides the glyphs. Space is a word
// break: it is committed with the first word. Enter commits the buffer and is
// forwarded, so it both flushes and sends the newline. All lowercase, because
// a Shift press is Unikey's restore-keystrokes trigger.
unikey: {
ibusEngineName: 'Unikey',
committedScriptPattern: /[\u1ea0-\u1ef9]/,
expectationsVerified: false,
scenarios: [
{
title: 'commits Telex diacritics without leaked keystroke Latin',
expectedText: 'tiếng việt',
drive: ({ key, typeClearingModifiers }) => {
typeClearingModifiers('tieengs vieejt')
key('Return')
}
}
]
}
}
export function resolveNativeIbusEngineInputProfile(
engineId: string | undefined
): NativeIbusEngineInputProfile | undefined {
return engineId ? nativeIbusEngineInputProfiles[engineId] : undefined
}
@@ -9,6 +9,12 @@ import {
waitForActivePanePtyId,
waitForActiveTerminalManager
} from './helpers/terminal'
import {
resolveNativeIbusEngineInputProfile,
type NativeIbusEngineInputProfile,
type NativeIbusEngineScenario,
type NativeIbusInputDriver
} from './terminal-ibus-engine-input-profiles'
import {
attachTerminalImeBoundaryEvidence,
disposeTerminalImeBoundaryProbe,
@@ -28,6 +34,9 @@ const DEFAULT_KEY_DELAY_MS = 1
const MAX_KEY_DELAY_MS = 100
const NATIVE_COMMAND_TIMEOUT_MS = 10_000
const engineId = process.env.ORCA_E2E_NATIVE_IBUS_ENGINE
const profile = resolveNativeIbusEngineInputProfile(engineId)
test.use({
orcaAppExtraEnv: {
GTK_IM_MODULE: 'ibus',
@@ -55,7 +64,19 @@ function runXdotool(...args: string[]): void {
execFileSync('xdotool', args, { stdio: 'pipe', timeout: NATIVE_COMMAND_TIMEOUT_MS })
}
async function focusNativeTerminalWindow(page: Page): Promise<string> {
function createInputDriver(): NativeIbusInputDriver {
const delay = String(nativeKeyDelayMs())
return {
key: (keyName) => runXdotool('key', keyName),
type: (text) => runXdotool('type', '--delay', delay, text),
typeClearingModifiers: (text) => runXdotool('type', '--delay', delay, '--clearmodifiers', text)
}
}
async function focusNativeTerminalWindow(
page: Page,
engine: NativeIbusEngineInputProfile
): Promise<string> {
await focusActiveTerminalInput(page)
const title = `ORCA_NATIVE_IBUS_${randomUUID()}`
await page.evaluate((nextTitle) => {
@@ -64,50 +85,24 @@ async function focusNativeTerminalWindow(page: Page): Promise<string> {
await expect.poll(() => page.title(), { timeout: 5_000 }).toBe(title)
runXdotool('search', '--onlyvisible', '--name', title, 'windowfocus', '--sync')
execFileSync('ibus', ['engine', 'hangul'], {
execFileSync('ibus', ['engine', engine.ibusEngineName], {
stdio: 'pipe',
timeout: NATIVE_COMMAND_TIMEOUT_MS
})
const engine = execFileSync('ibus', ['engine'], {
const active = execFileSync('ibus', ['engine'], {
encoding: 'utf8',
timeout: NATIVE_COMMAND_TIMEOUT_MS
}).trim()
expect(engine).toBe('hangul')
expect(active).toBe(engine.ibusEngineName)
return title
}
function typeExactByteSequence(repetitions: number): void {
const delay = String(nativeKeyDelayMs())
for (let index = 0; index < repetitions; index += 1) {
runXdotool('type', '--delay', delay, '--clearmodifiers', 'gks')
runXdotool('key', 'Hangul')
runXdotool('type', '--delay', delay, 'abc')
runXdotool('key', 'Hangul')
runXdotool('type', '--delay', delay, 'rmf')
runXdotool('key', 'Return')
}
}
function typeSentenceSequence(repetitions: number): void {
const delay = String(nativeKeyDelayMs())
for (let index = 0; index < repetitions; index += 1) {
runXdotool(
'type',
'--delay',
delay,
'--clearmodifiers',
'xptmxmfmf gkrh dlTsmsep duwjsgl rmfjsp'
)
runXdotool('key', 'Return')
}
}
async function runNativeIbusScenario(
page: Page,
testInfo: TestInfo,
testRepoPath: string,
expectedText: string,
driveInput: (repetitions: number) => void
engine: NativeIbusEngineInputProfile,
scenario: NativeIbusEngineScenario
): Promise<void> {
await waitForSessionReady(page)
await waitForActiveWorktree(page)
@@ -117,13 +112,17 @@ async function runNativeIbusScenario(
const repetitions = nativeRepetitions()
const ptyId = await waitForActivePanePtyId(page)
const reader = createTerminalImeByteReader(testRepoPath, repetitions)
const driver = createInputDriver()
let completed = false
let receivedBytes: string[] = []
let observedText = ''
try {
await startTerminalImeByteReader(page, ptyId, reader)
await focusNativeTerminalWindow(page)
await focusNativeTerminalWindow(page, engine)
await installTerminalImeBoundaryProbe(page)
driveInput(repetitions)
for (let index = 0; index < repetitions; index += 1) {
scenario.drive(driver)
}
receivedBytes = await waitForTerminalImeBytes(page, reader, 30_000)
const trace = await readTerminalImeBoundaryTrace(page)
@@ -133,20 +132,32 @@ async function runNativeIbusScenario(
(event) =>
(event.type === 'compositionupdate' ||
(event.type === 'input' && event.inputType === 'insertText')) &&
/[\uac00-\ud7af]/.test(event.data ?? '')
engine.committedScriptPattern.test(event.data ?? '')
)
).toBe(true)
const expectedBytes = Buffer.from(`${expectedText}\n`).toString('hex')
expect(receivedBytes).toEqual(Array.from({ length: repetitions }, () => expectedBytes))
// Engine-independent oracle: identical bytes every repetition, in the
// engine's script, and xterm emitted exactly what the PTY received. This
// holds even where the glyphs depend on a dictionary.
const [firstLine = ''] = receivedBytes
expect(receivedBytes).toEqual(Array.from({ length: repetitions }, () => firstLine))
observedText = Buffer.from(firstLine, 'hex').toString('utf8').replace(/\n$/, '')
expect(observedText).toMatch(engine.committedScriptPattern)
expect(trace.onData.join('')).toBe(`${observedText}\r`.repeat(repetitions))
expect(trace.onData.join('')).toBe(`${expectedText}\r`.repeat(repetitions))
// Exact oracle. Soft for engines whose expected text is still a prediction,
// so one observational run reports the structural verdict and the real text.
const assertExpectedText = engine.expectationsVerified ? expect : expect.soft
assertExpectedText(observedText).toBe(scenario.expectedText)
completed = true
} finally {
await attachTerminalImeBoundaryEvidence(page, testInfo, 'native-ibus-boundaries', {
display: process.env.DISPLAY,
expectedText,
engine: engine.ibusEngineName,
expectationsVerified: engine.expectationsVerified,
expectedText: scenario.expectedText,
keyDelayMs: nativeKeyDelayMs(),
observedText,
receivedBytes,
repetitions
}).catch(() => undefined)
@@ -158,29 +169,17 @@ async function runNativeIbusScenario(
}
}
test.describe('Native IBus Hangul terminal input @headful', () => {
test.skip(
process.env.ORCA_E2E_NATIVE_IBUS_HANGUL !== '1',
'Run through config/scripts/run-terminal-ibus-hangul-e2e.mjs'
)
test.describe(`Native IBus ${profile?.ibusEngineName ?? 'engine'} terminal input @headful`, () => {
test.skip(!profile, 'Run through config/scripts/run-terminal-ibus-engine-e2e.mjs')
test('forwards the issue exact-byte sequence without loss or duplication', async ({
orcaPage,
testRepoPath
}, testInfo) => {
await runNativeIbusScenario(orcaPage, testInfo, testRepoPath, '한abc글', typeExactByteSequence)
})
if (!profile) {
test('is driven by the native IBus engine runner', () => {})
return
}
test('forwards the issue sentence stress sequence without leaked ASCII', async ({
orcaPage,
testRepoPath
}, testInfo) => {
await runNativeIbusScenario(
orcaPage,
testInfo,
testRepoPath,
'테스트를 하고 있는데 여전히 그러네',
typeSentenceSequence
)
})
for (const scenario of profile.scenarios) {
test(scenario.title, async ({ orcaPage, testRepoPath }, testInfo) => {
await runNativeIbusScenario(orcaPage, testInfo, testRepoPath, profile, scenario)
})
}
})