import { spawn, spawnSync } from 'node:child_process' import { closeSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, openSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' const projectDir = path.resolve(import.meta.dirname, '../..') const scriptPath = import.meta.filename const insideSessionFlag = '--inside-session' const processStopTimeoutMs = 5_000 const processKillTimeoutMs = 1_000 const inputFramework = process.env.ORCA_E2E_NATIVE_IME ?? 'ibus' const displayServer = process.env.ORCA_E2E_NATIVE_DISPLAY_SERVER ?? 'x11' const isWayland = displayServer === 'wayland' if (!['ibus', 'fcitx5'].includes(inputFramework)) { throw new Error(`Unsupported native IME framework: ${inputFramework}`) } if (!['wayland', 'x11'].includes(displayServer)) { throw new Error(`Unsupported native display server: ${displayServer}`) } if (isWayland && inputFramework !== 'fcitx5') { throw new Error('Native Wayland coverage currently requires Fcitx5') } function delay(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) } function waitForExit(child) { return new Promise((resolve, reject) => { child.once('error', reject) child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0))) }) } function processGroupMembers(processGroupId) { const result = spawnSync('ps', ['-o', 'pid=,ppid=,pgid=,comm=', '-g', String(processGroupId)], { encoding: 'utf8' }) if (result.status !== 0) { return [] } return result.stdout .split('\n') .map((line) => line.trim()) .filter(Boolean) } async function stopOwnedProcessGroup(processGroupId) { let members = processGroupMembers(processGroupId) if (members.length === 0) { return [] } console.error( `[terminal-ime] stopping owned process group ${processGroupId}: ${members.join('; ')}` ) try { process.kill(-processGroupId, 'SIGTERM') } catch (error) { if (error?.code !== 'ESRCH') { throw error } } const deadline = Date.now() + processStopTimeoutMs while (Date.now() < deadline) { members = processGroupMembers(processGroupId) if (members.length === 0) { return [] } await delay(100) } try { process.kill(-processGroupId, 'SIGKILL') } catch (error) { if (error?.code !== 'ESRCH') { throw error } } const killDeadline = Date.now() + processKillTimeoutMs do { members = processGroupMembers(processGroupId) if (members.length === 0) { return [] } await delay(100) } while (Date.now() < killDeadline) return members } function commandOutput(command, args) { const result = spawnSync(command, args, { encoding: 'utf8' }) 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' } ) if (result.status !== 0) { throw new Error(`Failed to configure IBus Hangul ${key}: ${result.stderr.trim()}`) } } } function configureFcitxProfile(evidenceDir) { const fcitxConfigDir = path.join(evidenceDir, 'config', 'fcitx5') mkdirSync(fcitxConfigDir, { recursive: true }) writeFileSync( path.join(fcitxConfigDir, 'profile'), `[Groups/0] Name=Default Default Layout=us DefaultIM=hangul [Groups/0/Items/0] Name=keyboard-us Layout= [Groups/0/Items/1] Name=hangul Layout= [Groups/0/Items/2] Name=pinyin Layout= [GroupOrder] 0=Default ` ) } async function waitForIbusEngine(ibusProcess, engine) { const busDeadline = Date.now() + 15_000 while (Date.now() < busDeadline) { if (ibusProcess.exitCode !== null) { throw new Error(`ibus-daemon exited early with code ${ibusProcess.exitCode}`) } const result = spawnSync('ibus', ['engine'], { encoding: 'utf8' }) if (result.status === 0) { break } await delay(100) } spawnSync('ibus', ['engine', engine], { stdio: 'pipe' }) const engineDeadline = Date.now() + 15_000 while (Date.now() < engineDeadline) { const result = spawnSync('ibus', ['engine'], { encoding: 'utf8' }) if (result.status === 0 && result.stdout.trim() === engine) { return } await delay(100) } throw new Error(`Timed out while selecting the IBus ${engine} engine`) } async function waitForFcitx(fcitxProcess) { const deadline = Date.now() + 15_000 while (Date.now() < deadline) { if (fcitxProcess.exitCode !== null) { throw new Error(`fcitx5 exited early with code ${fcitxProcess.exitCode}`) } const owner = spawnSync( 'gdbus', [ 'call', '--session', '--dest', 'org.freedesktop.DBus', '--object-path', '/org/freedesktop/DBus', '--method', 'org.freedesktop.DBus.NameHasOwner', 'org.fcitx.Fcitx5' ], { encoding: 'utf8' } ) if (owner.status === 0 && owner.stdout.includes('true')) { for (const engine of ['hangul', 'pinyin']) { const addon = spawnSync('fcitx5-remote', ['-m', engine], { encoding: 'utf8' }) if (addon.status !== 0 || addon.stdout.trim().length === 0) { throw new Error(`Fcitx5 input method is unavailable: ${engine}`) } } return } await delay(100) } throw new Error('Timed out while starting Fcitx5') } async function waitForWaylandCompositor(compositorProcess) { const runtimeDir = process.env.XDG_RUNTIME_DIR const display = process.env.WAYLAND_DISPLAY if (!runtimeDir || !display) { throw new Error('Native Wayland coverage requires XDG_RUNTIME_DIR and WAYLAND_DISPLAY') } const socketPath = path.isAbsolute(display) ? display : path.join(runtimeDir, display) const deadline = Date.now() + 15_000 while (Date.now() < deadline) { if (compositorProcess.exitCode !== null) { throw new Error(`sway exited early with code ${compositorProcess.exitCode}`) } if (existsSync(socketPath)) { return } await delay(100) } throw new Error(`Timed out while waiting for Wayland socket: ${socketPath}`) } async function runInsideSession(evidenceDir) { const inputMethodLogPath = path.join(evidenceDir, `${inputFramework}-daemon.log`) const inputMethodLogFd = openSync(inputMethodLogPath, 'w') const windowManagerName = isWayland ? 'sway' : 'xfwm4' const displayEvidenceSuffix = isWayland ? '-wayland' : '' const windowManagerLogPath = path.join(evidenceDir, `${windowManagerName}.log`) const windowManagerLogFd = openSync(windowManagerLogPath, 'w') const evidence = { display: process.env.DISPLAY ?? null, displayServer, inputFramework, inputMethodDaemonPid: null, inputMethodGroupBeforeCleanup: [], inputMethodGroupAfterCleanup: [], playwrightPid: null, swaySocket: null, waylandDisplay: process.env.WAYLAND_DISPLAY ?? null, windowManagerPid: null, windowManagerGroupAfterCleanup: [] } let inputMethodProcess let windowManagerProcess let testExitCode = 1 try { if (inputFramework === 'ibus') { configureHangulEngine() } else { configureFcitxProfile(evidenceDir) } windowManagerProcess = spawn( windowManagerName, isWayland ? ['-c', '/dev/null'] : ['--compositor=off'], { detached: true, env: process.env, stdio: ['ignore', windowManagerLogFd, windowManagerLogFd] } ) if (!windowManagerProcess.pid) { throw new Error(`${windowManagerName} did not return a PID`) } evidence.windowManagerPid = windowManagerProcess.pid console.error(`[terminal-ime] started ${windowManagerName} PID ${windowManagerProcess.pid}`) if (isWayland) { process.env.SWAYSOCK = path.join( process.env.XDG_RUNTIME_DIR, `sway-ipc.${process.getuid()}.${windowManagerProcess.pid}.sock` ) evidence.swaySocket = process.env.SWAYSOCK await waitForWaylandCompositor(windowManagerProcess) } const inputMethodCommand = inputFramework === 'ibus' ? 'ibus-daemon' : 'fcitx5' const inputMethodArgs = inputFramework === 'ibus' ? ['--xim', '--verbose', '--panel=disable', '--emoji-extension=disable'] : isWayland ? [] : ['--disable=wayland'] inputMethodProcess = spawn(inputMethodCommand, inputMethodArgs, { detached: true, env: process.env, stdio: ['ignore', inputMethodLogFd, inputMethodLogFd] }) if (!inputMethodProcess.pid) { throw new Error(`${inputMethodCommand} did not return a PID`) } evidence.inputMethodDaemonPid = inputMethodProcess.pid console.error(`[terminal-ime] started ${inputMethodCommand} PID ${inputMethodProcess.pid}`) if (inputFramework === 'ibus') { await waitForIbusEngine(inputMethodProcess, 'hangul') await waitForIbusEngine(inputMethodProcess, 'libpinyin') await waitForIbusEngine(inputMethodProcess, 'hangul') 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' ])}` ) } else { await waitForFcitx(inputMethodProcess) console.error(`[terminal-ime] Fcitx5 version: ${commandOutput('fcitx5', ['--version'])}`) } evidence.inputMethodGroupBeforeCleanup = processGroupMembers(inputMethodProcess.pid) console.error( `[terminal-ime] owned ${inputFramework} group: ${evidence.inputMethodGroupBeforeCleanup.join('; ')}` ) const testProcess = spawn( process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm', [ 'run', 'test:e2e:headful', '--workers=1', '--', 'tests/e2e/terminal-linux-ime-native.spec.ts' ], { cwd: projectDir, env: { ...process.env, ORCA_E2E_FORWARD_APP_LOGS: '1', ORCA_E2E_NATIVE_IME: inputFramework }, stdio: 'inherit' } ) if (!testProcess.pid) { throw new Error('Playwright did not return a PID') } evidence.playwrightPid = testProcess.pid console.error(`[terminal-ime] started Playwright PID ${testProcess.pid}`) testExitCode = await waitForExit(testProcess) } finally { if (inputMethodProcess?.pid) { evidence.inputMethodGroupBeforeCleanup = processGroupMembers(inputMethodProcess.pid) evidence.inputMethodGroupAfterCleanup = await stopOwnedProcessGroup(inputMethodProcess.pid) } if (windowManagerProcess?.pid) { evidence.windowManagerGroupAfterCleanup = await stopOwnedProcessGroup( windowManagerProcess.pid ) } closeSync(inputMethodLogFd) closeSync(windowManagerLogFd) mkdirSync(path.join(projectDir, 'test-results'), { recursive: true }) copyFileSync( inputMethodLogPath, path.join(projectDir, 'test-results', `terminal-${inputFramework}-native-daemon.log`) ) copyFileSync( windowManagerLogPath, path.join( projectDir, 'test-results', `terminal-${inputFramework}-native${displayEvidenceSuffix}-${windowManagerName}.log` ) ) writeFileSync( path.join( projectDir, 'test-results', `terminal-${inputFramework}-native${displayEvidenceSuffix}-processes.json` ), `${JSON.stringify(evidence, null, 2)}\n` ) } if (evidence.inputMethodGroupAfterCleanup.length > 0) { throw new Error( `Owned ${inputFramework} processes survived cleanup: ${evidence.inputMethodGroupAfterCleanup.join('; ')}` ) } if (evidence.windowManagerGroupAfterCleanup.length > 0) { throw new Error( `Owned window-manager processes survived cleanup: ${evidence.windowManagerGroupAfterCleanup.join('; ')}` ) } return testExitCode } async function runOuter() { if (process.platform !== 'linux') { throw new Error('The native Linux IME E2E runner requires Linux') } const evidenceDir = mkdtempSync(path.join(os.tmpdir(), 'orca-terminal-ime-e2e-')) const runtimeDir = path.join(evidenceDir, 'runtime') mkdirSync(runtimeDir, { mode: 0o700 }) mkdirSync(path.join(evidenceDir, 'config')) mkdirSync(path.join(evidenceDir, 'cache')) console.error(`[terminal-ime] evidence directory: ${evidenceDir}`) const sessionCommand = isWayland ? 'dbus-run-session' : 'xvfb-run' const sessionArgs = isWayland ? ['--', process.execPath, scriptPath, insideSessionFlag, evidenceDir] : [ '--auto-servernum', 'dbus-run-session', '--', process.execPath, scriptPath, insideSessionFlag, evidenceDir ] const { DISPLAY: _display, GTK_IM_MODULE: _gtkImModule, QT_IM_MODULE: _qtImModule, XMODIFIERS: _xModifiers, ...waylandBaseEnv } = process.env void _display void _gtkImModule void _qtImModule void _xModifiers const sessionProcess = spawn(sessionCommand, sessionArgs, { cwd: projectDir, detached: true, env: { ...(isWayland ? waylandBaseEnv : process.env), ...(isWayland ? { ELECTRON_OZONE_PLATFORM_HINT: 'wayland', WAYLAND_DISPLAY: 'wayland-1', WLR_BACKENDS: 'headless', WLR_HEADLESS_OUTPUTS: '1', WLR_LIBINPUT_NO_DEVICES: '1', XDG_SESSION_TYPE: 'wayland' } : { GTK_IM_MODULE: inputFramework === 'fcitx5' ? 'fcitx' : 'ibus', ...(inputFramework === 'ibus' ? { IBUS_ENABLE_SYNC_MODE: '1' } : {}), QT_IM_MODULE: inputFramework === 'fcitx5' ? 'fcitx' : 'ibus', XMODIFIERS: inputFramework === 'fcitx5' ? '@im=fcitx' : '@im=ibus' }), LANG: process.env.LANG || 'C.UTF-8', XDG_CACHE_HOME: path.join(evidenceDir, 'cache'), XDG_CONFIG_HOME: path.join(evidenceDir, 'config'), XDG_RUNTIME_DIR: runtimeDir }, stdio: 'inherit' }) if (!sessionProcess.pid) { throw new Error(`${sessionCommand} did not return a PID`) } console.error( `[terminal-ime] started isolated ${displayServer} session PID ${sessionProcess.pid}` ) const exitCode = await waitForExit(sessionProcess) const remaining = await stopOwnedProcessGroup(sessionProcess.pid) if (remaining.length > 0) { throw new Error( `Owned ${displayServer} session processes survived cleanup: ${remaining.join('; ')}` ) } return exitCode } const insideSession = process.argv[2] === insideSessionFlag try { if (insideSession && !process.argv[3]) { throw new Error(`${insideSessionFlag} requires an evidence directory argument`) } process.exitCode = insideSession ? await runInsideSession(process.argv[3]) : await runOuter() } catch (error) { console.error(`[terminal-ime] ${error instanceof Error ? error.message : String(error)}`) process.exitCode = 1 }