diff --git a/.github/workflows/terminal-ime-e2e.yml b/.github/workflows/terminal-ime-e2e.yml index 31318bdff12..eadff00272e 100644 --- a/.github/workflows/terminal-ime-e2e.yml +++ b/.github/workflows/terminal-ime-e2e.yml @@ -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 diff --git a/config/scripts/run-terminal-ibus-hangul-e2e.mjs b/config/scripts/run-terminal-ibus-engine-e2e.mjs similarity index 73% rename from config/scripts/run-terminal-ibus-hangul-e2e.mjs rename to config/scripts/run-terminal-ibus-engine-e2e.mjs index 9e8eb07cf47..11d8a7b69c0 100644 --- a/config/scripts/run-terminal-ibus-hangul-e2e.mjs +++ b/config/scripts/run-terminal-ibus-engine-e2e.mjs @@ -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 diff --git a/config/scripts/terminal-ibus-engine-profiles.mjs b/config/scripts/terminal-ibus-engine-profiles.mjs new file mode 100644 index 00000000000..8cad67f213e --- /dev/null +++ b/config/scripts/terminal-ibus-engine-profiles.mjs @@ -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} */ +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 +} diff --git a/config/scripts/terminal-ime-e2e-workflow.test.mjs b/config/scripts/terminal-ime-e2e-workflow.test.mjs index 96062ebe706..ed5125d5a91 100644 --- a/config/scripts/terminal-ime-e2e-workflow.test.mjs +++ b/config/scripts/terminal-ime-e2e-workflow.test.mjs @@ -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' ) diff --git a/package.json b/package.json index bcf7efafeb5..2a2708c44d7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/e2e/terminal-ibus-engine-input-profiles.ts b/tests/e2e/terminal-ibus-engine-input-profiles.ts new file mode 100644 index 00000000000..2f940b7bb2a --- /dev/null +++ b/tests/e2e/terminal-ibus-engine-input-profiles.ts @@ -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 = { + 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 +} diff --git a/tests/e2e/terminal-ibus-hangul-native.spec.ts b/tests/e2e/terminal-ibus-engine-native.spec.ts similarity index 59% rename from tests/e2e/terminal-ibus-hangul-native.spec.ts rename to tests/e2e/terminal-ibus-engine-native.spec.ts index ce4f5dd5aa3..033e1bb8753 100644 --- a/tests/e2e/terminal-ibus-hangul-native.spec.ts +++ b/tests/e2e/terminal-ibus-engine-native.spec.ts @@ -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 { +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 { await focusActiveTerminalInput(page) const title = `ORCA_NATIVE_IBUS_${randomUUID()}` await page.evaluate((nextTitle) => { @@ -64,50 +85,24 @@ async function focusNativeTerminalWindow(page: Page): Promise { 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 { 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) + }) + } })