From a3d8f4067a6fbed4d888e87ddcf70329f166c816 Mon Sep 17 00:00:00 2001
From: naoNao89 <90588855+naoNao89@users.noreply.github.com>
Date: Fri, 11 Sep 2026 12:03:46 +0700
Subject: [PATCH 01/22] test(browser): add X11 Playwright E2E harness with
xdotool input injection
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Local HTTP server fixture with input, textarea, and contenteditable
- Event instrumentation recording keydown, keyup, beforeinput, input, compositionstart/update/end
- OS-level input injection via xdotool XTEST to exercise X11 -> Fcitx5 -> Lotus -> Browser DOM
- Positive and negative Telex control tests (dd -> đ vs dd -> dd)
- Sequential Chromium and Firefox smoke and rapid input typing test suites
- Fully isolated temporary runtime environment with private D-Bus busconfig and explicit PID tracking
---
test/browser/.gitignore | 5 +
test/browser/fixtures/index.html | 168 +++++++++++++++++++++
test/browser/fixtures/server.mjs | 75 ++++++++++
test/browser/helpers/events.ts | 56 +++++++
test/browser/helpers/fcitx5.ts | 54 +++++++
test/browser/helpers/x11-input.ts | 119 +++++++++++++++
test/browser/package-lock.json | 93 ++++++++++++
test/browser/package.json | 18 +++
test/browser/playwright.config.ts | 70 +++++++++
test/browser/scripts/run-xvfb.sh | 222 ++++++++++++++++++++++++++++
test/browser/scripts/setup-fcitx.sh | 85 +++++++++++
test/browser/tests/control.spec.ts | 52 +++++++
test/browser/tests/smoke.spec.ts | 87 +++++++++++
test/browser/tests/stress.spec.ts | 101 +++++++++++++
test/browser/tsconfig.json | 20 +++
15 files changed, 1225 insertions(+)
create mode 100644 test/browser/.gitignore
create mode 100644 test/browser/fixtures/index.html
create mode 100644 test/browser/fixtures/server.mjs
create mode 100644 test/browser/helpers/events.ts
create mode 100644 test/browser/helpers/fcitx5.ts
create mode 100644 test/browser/helpers/x11-input.ts
create mode 100644 test/browser/package-lock.json
create mode 100644 test/browser/package.json
create mode 100644 test/browser/playwright.config.ts
create mode 100755 test/browser/scripts/run-xvfb.sh
create mode 100755 test/browser/scripts/setup-fcitx.sh
create mode 100644 test/browser/tests/control.spec.ts
create mode 100644 test/browser/tests/smoke.spec.ts
create mode 100644 test/browser/tests/stress.spec.ts
create mode 100644 test/browser/tsconfig.json
diff --git a/test/browser/.gitignore b/test/browser/.gitignore
new file mode 100644
index 00000000..a9ba0ed2
--- /dev/null
+++ b/test/browser/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+playwright-report/
+test-results/
+.playwright/
+bun.lock
diff --git a/test/browser/fixtures/index.html b/test/browser/fixtures/index.html
new file mode 100644
index 00000000..8b6799fc
--- /dev/null
+++ b/test/browser/fixtures/index.html
@@ -0,0 +1,168 @@
+
+
+
+
+ Fcitx5 Lotus Browser E2E Fixture
+
+
+
+ Fcitx5 Lotus Browser E2E Fixture
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/browser/fixtures/server.mjs b/test/browser/fixtures/server.mjs
new file mode 100644
index 00000000..908d17af
--- /dev/null
+++ b/test/browser/fixtures/server.mjs
@@ -0,0 +1,75 @@
+import http from 'node:http';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const PORT = parseInt(process.env.PORT || '3000', 10);
+const HOST = process.env.HOST || '127.0.0.1';
+
+const MIME_TYPES = {
+ '.html': 'text/html; charset=utf-8',
+ '.js': 'application/javascript; charset=utf-8',
+ '.mjs': 'application/javascript; charset=utf-8',
+ '.css': 'text/css; charset=utf-8',
+ '.json': 'application/json; charset=utf-8',
+ '.ico': 'image/x-icon',
+};
+
+const server = http.createServer((req, res) => {
+ const parsedUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
+ let pathname = parsedUrl.pathname;
+
+ if (pathname === '/' || pathname === '') {
+ pathname = '/index.html';
+ }
+
+ // Prevent directory traversal
+ const safePath = path.normalize(pathname).replace(/^(\.\.[\/\\])+/, '');
+ const filePath = path.join(__dirname, safePath);
+
+ if (!filePath.startsWith(__dirname)) {
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
+ res.end('Forbidden');
+ return;
+ }
+
+ fs.readFile(filePath, (err, data) => {
+ if (err) {
+ if (err.code === 'ENOENT') {
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
+ res.end('Not Found');
+ } else {
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
+ res.end('Internal Server Error');
+ }
+ return;
+ }
+
+ const ext = path.extname(filePath).toLowerCase();
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
+
+ res.writeHead(200, {
+ 'Content-Type': contentType,
+ 'Cache-Control': 'no-store',
+ });
+ res.end(data);
+ });
+});
+
+server.listen(PORT, HOST, () => {
+ console.log(`Server listening on http://127.0.0.1:${PORT}`);
+});
+
+function handleShutdown() {
+ server.close(() => {
+ process.exit(0);
+ });
+}
+
+process.on('SIGINT', handleShutdown);
+process.on('SIGTERM', handleShutdown);
+
+export default server;
diff --git a/test/browser/helpers/events.ts b/test/browser/helpers/events.ts
new file mode 100644
index 00000000..833b6787
--- /dev/null
+++ b/test/browser/helpers/events.ts
@@ -0,0 +1,56 @@
+import type { Page, TestInfo } from '@playwright/test';
+
+export interface RecordedInputEvent {
+ type: string;
+ key?: string | null;
+ code?: string | null;
+ data?: string | null;
+ inputType?: string | null;
+ isComposing?: boolean;
+ targetId?: string;
+ selectionStart?: number | null;
+ selectionEnd?: number | null;
+ domValue?: string | null;
+ activeElementId?: string | null;
+ timestamp: number;
+}
+
+declare global {
+ interface Window {
+ __inputEvents?: RecordedInputEvent[];
+ __resetEvents?: () => void;
+ }
+}
+
+/**
+ * Retrieves the list of recorded input, key, and composition events from the page.
+ */
+export async function getEventLog(page: Page): Promise {
+ return await page.evaluate(() => window.__inputEvents || []);
+}
+
+/**
+ * Resets the recorded input events array on the page.
+ */
+export async function resetEventLog(page: Page): Promise {
+ await page.evaluate(() => {
+ if (typeof window.__resetEvents === 'function') {
+ window.__resetEvents();
+ }
+ });
+}
+/**
+ * Attaches recorded events as a JSON diagnostic artifact to Playwright's TestInfo.
+ */
+export async function attachEventLog(
+ page: Page,
+ testInfo: TestInfo
+): Promise {
+ const events = await getEventLog(page).catch(() => []);
+ if (events.length > 0) {
+ await testInfo.attach('input-events.json', {
+ body: JSON.stringify(events, null, 2),
+ contentType: 'application/json',
+ });
+ }
+}
diff --git a/test/browser/helpers/fcitx5.ts b/test/browser/helpers/fcitx5.ts
new file mode 100644
index 00000000..bd61520d
--- /dev/null
+++ b/test/browser/helpers/fcitx5.ts
@@ -0,0 +1,54 @@
+import { execFile } from 'node:child_process';
+import { promisify } from 'node:util';
+import { setTimeout } from 'node:timers/promises';
+
+const execFileAsync = promisify(execFile);
+
+/**
+ * Returns the currently active input method name (e.g. 'lotus', 'keyboard-us').
+ */
+export async function getActiveIM(): Promise {
+ const { stdout } = await execFileAsync('fcitx5-remote', ['-n']);
+ return stdout.trim();
+}
+
+/**
+ * Switches the active input method to the given name (e.g. 'lotus' or 'keyboard-us').
+ */
+export async function switchIM(name: string): Promise {
+ await execFileAsync('fcitx5-remote', ['-s', name]);
+ if (name === 'lotus') {
+ await execFileAsync('fcitx5-remote', ['-o']).catch(() => {});
+ }
+ // Small delay to allow fcitx5 to switch its active engine
+ await setTimeout(100);
+}
+/**
+ * Activates the input method engine (equivalent to fcitx5-remote -o).
+ */
+export async function activateIM(): Promise {
+ await execFileAsync('fcitx5-remote', ['-o']);
+ await setTimeout(100);
+}
+
+/**
+ * Inactivates the input method engine (equivalent to fcitx5-remote -c).
+ */
+export async function inactivateIM(): Promise {
+ await execFileAsync('fcitx5-remote', ['-c']);
+ await setTimeout(100);
+}
+
+/**
+ * Checks if Fcitx5 is currently running and responsive.
+ * `fcitx5-remote` returns 1 (inactive) or 2 (active) when running, or 0 / error when not.
+ */
+export async function isFcitxRunning(): Promise {
+ try {
+ const { stdout } = await execFileAsync('fcitx5-remote', []);
+ const code = parseInt(stdout.trim(), 10);
+ return code === 1 || code === 2;
+ } catch {
+ return false;
+ }
+}
diff --git a/test/browser/helpers/x11-input.ts b/test/browser/helpers/x11-input.ts
new file mode 100644
index 00000000..7f5fbe02
--- /dev/null
+++ b/test/browser/helpers/x11-input.ts
@@ -0,0 +1,119 @@
+import { expect } from '@playwright/test';
+import type { Page, Locator } from '@playwright/test';
+import { execFile } from 'node:child_process';
+import { promisify } from 'node:util';
+
+const execFileAsync = promisify(execFile);
+
+/**
+ * Executes `xdotool key --delay ` to inject X11 XTEST key events.
+ */
+export async function typeXdotool(
+ keys: string | string[],
+ delayMs = 60
+): Promise {
+ const keyList = Array.isArray(keys) ? keys : [keys];
+ if (keyList.length === 0) {
+ return;
+ }
+
+ // Normalize common key names for xdotool
+ const normalizedKeys = keyList.map((k) => (k === ' ' ? 'space' : k));
+
+ await execFileAsync('xdotool', [
+ 'key',
+ '--delay',
+ String(delayMs),
+ ...normalizedKeys,
+ ]);
+}
+
+/**
+ * Returns the active X11 window ID and title via xdotool.
+ */
+export async function getActiveX11Window(): Promise<{ id: string; name: string }> {
+ try {
+ const { stdout: idOut } = await execFileAsync('xdotool', ['getactivewindow']);
+ const id = idOut.trim();
+ const { stdout: nameOut } = await execFileAsync('xdotool', ['getwindowname', id]).catch(() => ({ stdout: '' }));
+ return { id, name: nameOut.trim() };
+ } catch {
+ return { id: '', name: '' };
+ }
+}
+
+/**
+ * Ensures the target locator is clicked, focused, and waits for X11 window focus to settle.
+ */
+export async function ensureActive(
+ page: Page,
+ locator: Locator
+): Promise {
+ await page.bringToFront();
+ await locator.click();
+ await expect(locator).toBeFocused();
+
+ // Verify that the active X11 window belongs to the browser fixture
+ await expect
+ .poll(
+ async () => {
+ const win = await getActiveX11Window();
+ return win.name;
+ },
+ { timeout: 2000 }
+ )
+ .toContain('Fcitx5 Lotus Browser E2E Fixture');
+
+ // Settle delay for browser focus and input context
+ await page.waitForTimeout(100);
+}
+
+/**
+ * Clears an input, textarea, or contenteditable element using X11 select-all and backspace.
+ */
+export async function clearInput(
+ page: Page,
+ locator: Locator
+): Promise {
+ await ensureActive(page, locator);
+ await typeXdotool('ctrl+a', 50);
+ await typeXdotool('BackSpace', 50);
+ await expect
+ .poll(async () => {
+ return await locator.evaluate((el: HTMLElement) => {
+ if ('value' in el && typeof (el as HTMLInputElement).value === 'string') {
+ return (el as HTMLInputElement).value;
+ }
+ return (el.textContent || '').trim();
+ });
+ }, { timeout: 2000 })
+ .toBe('');
+}
+
+/**
+ * Direct DOM reset for fixture initialization outside of input method testing.
+ * MUST NOT be used as a fallback for user-level keyboard interactions.
+ */
+export async function resetFixtureDirectly(locator: Locator): Promise {
+ await locator.evaluate((el: HTMLElement) => {
+ if ('value' in el && typeof (el as HTMLInputElement).value === 'string') {
+ (el as HTMLInputElement).value = '';
+ } else {
+ el.textContent = '';
+ }
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+}
+
+/**
+ * Focuses locator and types the given sequence of keys through xdotool.
+ */
+export async function typeWithLotus(
+ page: Page,
+ locator: Locator,
+ keys: string[],
+ delayMs = 60
+): Promise {
+ await ensureActive(page, locator);
+ await typeXdotool(keys, delayMs);
+}
diff --git a/test/browser/package-lock.json b/test/browser/package-lock.json
new file mode 100644
index 00000000..1f360ce8
--- /dev/null
+++ b/test/browser/package-lock.json
@@ -0,0 +1,93 @@
+{
+ "name": "fcitx5-lotus-browser-e2e",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "fcitx5-lotus-browser-e2e",
+ "version": "1.0.0",
+ "devDependencies": {
+ "@playwright/test": "1.63.0",
+ "@types/node": "20.10.0",
+ "typescript": "5.3.3"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
+ "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "20.10.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.0.tgz",
+ "integrity": "sha512-D0WfRmU9TQ8I9PFx9Yc+EBHw+vSpIub4IDvQivcp26PtPrdMGAq5SDcpXEo/epqa/DXotVpekHiLNTg3iaKXBQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~5.26.4"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
+ "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
+ "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz",
+ "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "5.26.5",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/test/browser/package.json b/test/browser/package.json
new file mode 100644
index 00000000..6c9e14c4
--- /dev/null
+++ b/test/browser/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "fcitx5-lotus-browser-e2e",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "typecheck": "tsc -p tsconfig.json --noEmit",
+ "test": "playwright test",
+ "test:chromium": "playwright test --project=chromium",
+ "test:firefox": "playwright test --project=firefox",
+ "serve": "node fixtures/server.mjs"
+ },
+ "devDependencies": {
+ "@playwright/test": "1.63.0",
+ "@types/node": "20.10.0",
+ "typescript": "5.3.3"
+ }
+}
diff --git a/test/browser/playwright.config.ts b/test/browser/playwright.config.ts
new file mode 100644
index 00000000..958fa077
--- /dev/null
+++ b/test/browser/playwright.config.ts
@@ -0,0 +1,70 @@
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './tests',
+ timeout: 30000,
+ expect: {
+ timeout: 5000,
+ },
+ fullyParallel: false,
+ workers: 1,
+ retries: 0,
+ forbidOnly: !!process.env.CI,
+ reporter: process.env.CI
+ ? [
+ ['dot'],
+ ['html', { outputFolder: 'playwright-report', open: 'never' }],
+ ]
+ : [
+ ['list'],
+ ['html', { outputFolder: 'playwright-report', open: 'never' }],
+ ],
+ use: {
+ baseURL: 'http://127.0.0.1:3000',
+ headless: false,
+ trace: 'retain-on-failure',
+ screenshot: 'only-on-failure',
+ video: 'retain-on-failure',
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: {
+ ...devices['Desktop Chrome'],
+ launchOptions: {
+ args: [
+ '--no-sandbox',
+ '--disable-setuid-sandbox',
+ '--enable-features=UseOzonePlatform',
+ '--ozone-platform=x11',
+ '--gtk-version=3',
+ ],
+ },
+ },
+ },
+ {
+ name: 'firefox',
+ use: {
+ ...devices['Desktop Firefox'],
+ launchOptions: {
+ firefoxUserPrefs: {
+ 'focusmanager.testmode': false,
+ 'dom.input_events.dispatch_before_compositionend': true,
+ },
+ env: {
+ ...process.env,
+ MOZ_ENABLE_WAYLAND: '0',
+ GTK_IM_MODULE: 'fcitx',
+ QT_IM_MODULE: 'fcitx',
+ XMODIFIERS: '@im=fcitx',
+ },
+ },
+ },
+ },
+ ],
+ webServer: {
+ command: 'node fixtures/server.mjs',
+ port: 3000,
+ reuseExistingServer: !process.env.CI,
+ },
+});
diff --git a/test/browser/scripts/run-xvfb.sh b/test/browser/scripts/run-xvfb.sh
new file mode 100755
index 00000000..48f6a678
--- /dev/null
+++ b/test/browser/scripts/run-xvfb.sh
@@ -0,0 +1,222 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [ -z "${TEST_HOME:-}" ]; then
+ echo "error: TEST_HOME environment variable is not set. Run setup-fcitx.sh first or export TEST_HOME." >&2
+ exit 1
+fi
+
+DISPLAY="${DISPLAY:-:99}"
+export DISPLAY
+
+PID_FILE="${TEST_HOME}/run-xvfb.pids"
+XVFB_LOG="${TEST_HOME}/xvfb.log"
+OPENBOX_LOG="${TEST_HOME}/openbox.log"
+FCITX_LOG="${TEST_HOME}/fcitx5.log"
+
+if [ "${1:-}" = "--stop" ]; then
+ if [ -f "${PID_FILE}" ]; then
+ while read -r pid; do
+ kill "$pid" 2>/dev/null || true
+ done < "${PID_FILE}"
+ rm -f "${PID_FILE}"
+ fi
+ # Also gracefully terminate dbus session if we started it
+ if [ -f "${TEST_HOME}/dbus.pid" ]; then
+ kill "$(cat "${TEST_HOME}/dbus.pid")" 2>/dev/null || true
+ fi
+ exit 0
+fi
+
+touch "${PID_FILE}"
+
+# Create an isolated, hermetic D-Bus session for our test environment.
+# By omitting , this private bus never scans
+# /usr/share/dbus-1/services/ or auto-activates services behind our back.
+# This prevents race conditions without modifying any system-wide files.
+DBUS_CONF="${TEST_HOME}/dbus-session.conf"
+cat < "${DBUS_CONF}"
+
+
+ session
+ unix:tmpdir=${TEST_HOME}
+ EXTERNAL
+
+
+
+
+
+
+EOF
+
+if ! command -v dbus-daemon >/dev/null 2>&1; then
+ echo "error: dbus-daemon binary not found in PATH" >&2
+ exit 1
+fi
+
+# Launch dbus-daemon with explicit file descriptors:
+# --print-address 1 writes address to stdout (redirected to dbus.addr)
+# --print-pid 3 writes PID to fd 3 (redirected to dbus.pid)
+dbus-daemon --config-file="${DBUS_CONF}" --fork --print-address 1 --print-pid 3 > "${TEST_HOME}/dbus.addr" 3> "${TEST_HOME}/dbus.pid"
+
+DBUS_SESSION_BUS_ADDRESS=$(cat "${TEST_HOME}/dbus.addr" 2>/dev/null || true)
+DBUS_PID=$(cat "${TEST_HOME}/dbus.pid" 2>/dev/null || true)
+export DBUS_SESSION_BUS_ADDRESS
+
+if [ -z "${DBUS_SESSION_BUS_ADDRESS}" ] || [ -z "${DBUS_PID}" ] || ! kill -0 "${DBUS_PID}" 2>/dev/null; then
+ echo "error: dbus-daemon failed to start or write valid address/PID" >&2
+ [ -f "${TEST_HOME}/dbus.addr" ] && cat "${TEST_HOME}/dbus.addr" >&2
+ [ -f "${TEST_HOME}/dbus.pid" ] && cat "${TEST_HOME}/dbus.pid" >&2
+ exit 1
+fi
+
+echo "${DBUS_PID}" >> "${PID_FILE}"
+
+if [ -n "${GITHUB_ENV:-}" ]; then
+ echo "DBUS_SESSION_BUS_ADDRESS=${DBUS_SESSION_BUS_ADDRESS}" >> "$GITHUB_ENV"
+ echo "DISPLAY=${DISPLAY}" >> "$GITHUB_ENV"
+fi
+# Start Xvfb virtual framebuffer if not already running
+if ! (command -v xdpyinfo >/dev/null 2>&1 && xdpyinfo -display "${DISPLAY}" >/dev/null 2>&1); then
+ if ! command -v Xvfb >/dev/null 2>&1; then
+ echo "error: Xvfb binary not found in PATH" >&2
+ exit 1
+ fi
+ Xvfb "${DISPLAY}" -screen 0 1920x1080x24 -ac +extension GLX +render -noreset > "${XVFB_LOG}" 2>&1 &
+ echo $! >> "${PID_FILE}"
+
+ xvfb_ready=0
+ for _ in $(seq 1 50); do
+ if [ -S "/tmp/.X11-unix/X${DISPLAY#:}" ] || (command -v xdpyinfo >/dev/null 2>&1 && xdpyinfo -display "${DISPLAY}" >/dev/null 2>&1); then
+ xvfb_ready=1
+ break
+ fi
+ sleep 0.1
+ done
+
+ if [ "$xvfb_ready" -ne 1 ]; then
+ echo "error: Xvfb failed to start on ${DISPLAY} within 5s" >&2
+ [ -f "${XVFB_LOG}" ] && tail -n 50 "${XVFB_LOG}" >&2
+ exit 1
+ fi
+fi
+
+# Start Openbox window manager
+if ! command -v openbox >/dev/null 2>&1; then
+ echo "error: openbox binary not found in PATH" >&2
+ exit 1
+fi
+openbox --sm-disable > "${OPENBOX_LOG}" 2>&1 &
+echo $! >> "${PID_FILE}"
+
+openbox_ready=0
+for _ in $(seq 1 30); do
+ if kill -0 "$(tail -1 "${PID_FILE}")" 2>/dev/null; then
+ openbox_ready=1
+ break
+ fi
+ sleep 0.1
+done
+
+if [ "$openbox_ready" -ne 1 ]; then
+ echo "error: openbox failed to start within 3s" >&2
+ [ -f "${OPENBOX_LOG}" ] && tail -n 50 "${OPENBOX_LOG}" >&2
+ exit 1
+fi
+
+# Isolated environment for Fcitx5
+export HOME="${TEST_HOME}"
+export XDG_CONFIG_HOME="${TEST_HOME}/.config"
+export XDG_DATA_HOME="${TEST_HOME}/.local/share"
+
+# Input method environment
+export GTK_IM_MODULE=fcitx
+export QT_IM_MODULE=fcitx
+export XMODIFIERS=@im=fcitx
+export SDL_IM_MODULE=fcitx
+
+# Start Fcitx5 daemon (with retry for transient D-Bus issues)
+if ! command -v fcitx5 >/dev/null 2>&1; then
+ echo "error: fcitx5 binary not found in PATH" >&2
+ exit 1
+fi
+
+echo "Starting fcitx5 with DBUS_SESSION_BUS_ADDRESS=${DBUS_SESSION_BUS_ADDRESS}"
+echo "XDG_CONFIG_HOME=${XDG_CONFIG_HOME}"
+
+fcitx5_started=0
+for attempt in 1 2 3; do
+ if [ "${BROWSER_E2E_DEBUG:-0}" = "1" ]; then
+ fcitx5 -r --disable=wayland,waylandim --verbose '*=5' > "${FCITX_LOG}" 2>&1 &
+ else
+ fcitx5 -r --disable=wayland,waylandim > "${FCITX_LOG}" 2>&1 &
+ fi
+ FCITX_PID=$!
+ echo "$FCITX_PID" >> "${PID_FILE}"
+
+ # Wait for Fcitx5 daemon to initialize
+ fcitx_ready=0
+ for _ in $(seq 1 30); do
+ if ! kill -0 "$FCITX_PID" 2>/dev/null; then
+ echo "warning: fcitx5 (PID $FCITX_PID) exited prematurely on attempt $attempt" >&2
+ [ -f "${FCITX_LOG}" ] && tail -n 20 "${FCITX_LOG}" >&2
+ break
+ fi
+ if command -v fcitx5-remote >/dev/null 2>&1 && fcitx5-remote >/dev/null 2>&1; then
+ fcitx_ready=1
+ break
+ fi
+ sleep 0.5
+ done
+
+ if [ "$fcitx_ready" -eq 1 ]; then
+ fcitx5_started=1
+ break
+ fi
+
+ # Kill the failed fcitx5 before retrying
+ kill "$FCITX_PID" 2>/dev/null || true
+ sleep 1
+done
+
+if [ "$fcitx5_started" -ne 1 ]; then
+ echo "error: fcitx5 daemon failed to start after 3 attempts" >&2
+ [ -f "${FCITX_LOG}" ] && tail -n 50 "${FCITX_LOG}" >&2
+ exit 1
+fi
+
+# Verify lotus input method addon activates successfully
+lotus_ready=0
+for _ in $(seq 1 30); do
+ fcitx5-remote -s lotus >/dev/null 2>&1 || true
+ fcitx5-remote -o >/dev/null 2>&1 || true
+ if [ "$(fcitx5-remote -n 2>/dev/null || true)" = "lotus" ]; then
+ lotus_ready=1
+ break
+ fi
+ sleep 0.2
+done
+
+if [ "$lotus_ready" -ne 1 ]; then
+ echo "error: fcitx5 failed to activate lotus addon within 6s (current IM: $(fcitx5-remote -n 2>/dev/null || echo 'none'))" >&2
+ [ -f "${FCITX_LOG}" ] && tail -n 50 "${FCITX_LOG}" >&2
+ exit 1
+fi
+
+cat < /tmp/x11-env.sh
+export DISPLAY="${DISPLAY}"
+export DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS}"
+export HOME="${TEST_HOME}"
+export XDG_CONFIG_HOME="${TEST_HOME}/.config"
+export XDG_DATA_HOME="${TEST_HOME}/.local/share"
+export GTK_IM_MODULE=fcitx
+export QT_IM_MODULE=fcitx
+export XMODIFIERS=@im=fcitx
+EOF
+
+if [ -n "${GITHUB_ENV:-}" ]; then
+ echo "HOME=${TEST_HOME}" >> "$GITHUB_ENV"
+ echo "XDG_CONFIG_HOME=${TEST_HOME}/.config" >> "$GITHUB_ENV"
+ echo "XDG_DATA_HOME=${TEST_HOME}/.local/share" >> "$GITHUB_ENV"
+fi
diff --git a/test/browser/scripts/setup-fcitx.sh b/test/browser/scripts/setup-fcitx.sh
new file mode 100755
index 00000000..c4675f67
--- /dev/null
+++ b/test/browser/scripts/setup-fcitx.sh
@@ -0,0 +1,85 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [ -z "${TEST_HOME:-}" ]; then
+ TEST_HOME="$(mktemp -d -t fcitx5-browser-e2e-XXXXXX)"
+ echo "WARNING: TEST_HOME not set. Created temporary directory: $TEST_HOME" >&2
+fi
+
+export HOME="${TEST_HOME}"
+export XDG_CONFIG_HOME="${TEST_HOME}/.config"
+CONFIG_ROOT="${XDG_CONFIG_HOME}"
+FCITX5_CONFIG_DIR="${CONFIG_ROOT}/fcitx5"
+GTK3_CONFIG_DIR="${CONFIG_ROOT}/gtk-3.0"
+GTK4_CONFIG_DIR="${CONFIG_ROOT}/gtk-4.0"
+OPENBOX_CONFIG_DIR="${CONFIG_ROOT}/openbox"
+
+mkdir -p "${FCITX5_CONFIG_DIR}/conf" "${GTK3_CONFIG_DIR}" "${GTK4_CONFIG_DIR}" "${OPENBOX_CONFIG_DIR}"
+
+# Default to lotus with keyboard-us fallback in standard Fcitx5 order (layout at index 0)
+cat <<'EOF' > "${FCITX5_CONFIG_DIR}/profile"
+[Groups/0]
+Name=Default
+Default Layout=us
+DefaultIM=lotus
+
+[Groups/0/Items/0]
+Name=keyboard-us
+Layout=
+
+[Groups/0/Items/1]
+Name=lotus
+Layout=
+
+[GroupOrder]
+0=Default
+EOF
+
+# Production default: Telex in Preedit mode
+cat <<'EOF' > "${FCITX5_CONFIG_DIR}/conf/lotus.conf"
+[InputMethod]
+InputMethod=Telex
+Mode=Preedit
+SpellCheck=True
+AutoNonVnRestore=True
+DdFreeStyle=True
+EOF
+
+# GTK IM module routing
+cat <<'EOF' > "${GTK3_CONFIG_DIR}/settings.ini"
+[Settings]
+gtk-im-module=fcitx
+EOF
+
+cat <<'EOF' > "${GTK4_CONFIG_DIR}/settings.ini"
+[Settings]
+gtk-im-module=fcitx
+EOF
+
+# ShareInputState=All and ActiveByDefault ensure fcitx5-remote CLI commands
+# immediately affect browser windows without focus-in state resets.
+cat <<'EOF' > "${FCITX5_CONFIG_DIR}/config"
+[Behavior]
+ActiveByDefault=True
+ShareInputState=All
+resetStateWhenFocusIn=No
+
+[Hotkey]
+EnumerateForwardKeys=
+EnumerateBackwardKeys=
+EOF
+
+# Ensure Openbox automatically focuses and raises new browser windows on Xvfb
+cat <<'EOF' > "${OPENBOX_CONFIG_DIR}/rc.xml"
+
+
+
+ yes
+ no
+ yes
+ no
+ 0
+ yes
+
+
+EOF
diff --git a/test/browser/tests/control.spec.ts b/test/browser/tests/control.spec.ts
new file mode 100644
index 00000000..3e968586
--- /dev/null
+++ b/test/browser/tests/control.spec.ts
@@ -0,0 +1,52 @@
+import { test, expect } from '@playwright/test';
+import { clearInput, ensureActive, typeWithLotus } from '../helpers/x11-input';
+import { getActiveIM, switchIM, activateIM } from '../helpers/fcitx5';
+import { resetEventLog, getEventLog, attachEventLog } from '../helpers/events';
+
+test.describe('Fcitx5 Lotus Control Tests', () => {
+ test.beforeEach(async ({ page }) => {
+ await activateIM();
+ await page.goto('/');
+ });
+
+ test.afterEach(async ({ page }, testInfo) => {
+ if (testInfo.status !== testInfo.expectedStatus) {
+ await attachEventLog(page, testInfo);
+ }
+ });
+
+ test('positive and negative control: lotus (dd -> đ) vs keyboard-us (dd -> dd) vs lotus restoration', async ({
+ page,
+ }) => {
+ const input = page.locator('#test-input');
+ await ensureActive(page, input);
+
+ // Lotus active: Telex input produces composed character
+ await switchIM('lotus');
+ await expect.poll(async () => await getActiveIM(), { timeout: 3000 }).toBe('lotus');
+ await clearInput(page, input);
+ await resetEventLog(page);
+ await typeWithLotus(page, input, ['d', 'd']);
+ await expect(input).toHaveValue('đ');
+ const positiveEvents = await getEventLog(page);
+ expect(positiveEvents.length).toBeGreaterThan(0);
+
+ // Switch to English layout: raw keys bypass input method
+ await switchIM('keyboard-us');
+ await expect
+ .poll(async () => await getActiveIM(), { timeout: 3000 })
+ .toBe('keyboard-us');
+ await clearInput(page, input);
+ await resetEventLog(page);
+ await typeWithLotus(page, input, ['d', 'd']);
+ await expect(input).toHaveValue('dd');
+
+ // Restore Lotus: composition resumes
+ await switchIM('lotus');
+ await expect.poll(async () => await getActiveIM(), { timeout: 3000 }).toBe('lotus');
+ await clearInput(page, input);
+ await resetEventLog(page);
+ await typeWithLotus(page, input, ['d', 'd']);
+ await expect(input).toHaveValue('đ');
+ });
+});
diff --git a/test/browser/tests/smoke.spec.ts b/test/browser/tests/smoke.spec.ts
new file mode 100644
index 00000000..6ce82c48
--- /dev/null
+++ b/test/browser/tests/smoke.spec.ts
@@ -0,0 +1,87 @@
+import { test, expect } from '@playwright/test';
+import { clearInput, ensureActive, typeWithLotus, typeXdotool } from '../helpers/x11-input';
+import { getActiveIM, switchIM, activateIM } from '../helpers/fcitx5';
+import { attachEventLog } from '../helpers/events';
+
+test.describe('Fcitx5 Lotus Smoke Tests', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/');
+ await activateIM();
+ await switchIM('lotus');
+ });
+
+ test.afterEach(async ({ page }, testInfo) => {
+ if (testInfo.status !== testInfo.expectedStatus) {
+ await attachEventLog(page, testInfo);
+ }
+ });
+
+ test('types telex single character on text input', async ({ page }) => {
+ const input = page.locator('#test-input');
+ await clearInput(page, input);
+ await typeWithLotus(page, input, ['d', 'd']);
+ await expect(input).toHaveValue('đ');
+ });
+
+ test('types telex acute accent tone on text input', async ({ page }) => {
+ const input = page.locator('#test-input');
+ await clearInput(page, input);
+ await typeWithLotus(page, input, ['a', 's']);
+ await expect(input).toHaveValue('á');
+ });
+
+ test('types multi-word phrase with telex tones', async ({ page }) => {
+ const input = page.locator('#test-input');
+ await clearInput(page, input);
+ await typeWithLotus(page, input, [
+ 't', 'i', 'e', 'e', 'n', 'g', 's',
+ 'space',
+ 'v', 'i', 'e', 'e', 't', 'j',
+ ]);
+ await expect(input).toHaveValue('tiếng việt');
+ });
+
+ test('types telex phrase in textarea', async ({ page }) => {
+ const textarea = page.locator('#test-textarea');
+ await clearInput(page, textarea);
+ await typeWithLotus(page, textarea, [
+ 'x', 'i', 'n',
+ 'space',
+ 'c', 'h', 'a', 'o', 'f',
+ ]);
+ await expect(textarea).toHaveValue('xin chào');
+ });
+
+ test('types telex phrase in contenteditable element', async ({ page }) => {
+ const contenteditable = page.locator('#test-contenteditable');
+ await clearInput(page, contenteditable);
+ await typeWithLotus(page, contenteditable, ['v', 'i', 'e', 'e', 't', 'j']);
+ await expect(contenteditable).toHaveText('việt');
+ });
+
+ test('preserves committed text and resumes typing across blur and refocus', async ({
+ page,
+ }) => {
+ const input1 = page.locator('#test-input');
+ const input2 = page.locator('#test-input-2');
+
+ // Type first word
+ await clearInput(page, input1);
+ await typeWithLotus(page, input1, ['t', 'i', 'e', 'e', 'n', 'g', 's']);
+ await expect(input1).toHaveValue('tiếng');
+
+ // Blur by focusing second input
+ await ensureActive(page, input2);
+ await expect(input2).toBeFocused();
+
+ // Refocus first input
+ await ensureActive(page, input1);
+ await expect(input1).toBeFocused();
+ // Move caret to end to ensure typing appends cleanly
+ await typeXdotool('End', 50);
+
+ // Type remaining phrase
+ await typeXdotool(['space', 'v', 'i', 'e', 'e', 't', 'j']);
+ await expect(input1).toHaveValue('tiếng việt');
+ });
+});
diff --git a/test/browser/tests/stress.spec.ts b/test/browser/tests/stress.spec.ts
new file mode 100644
index 00000000..a6b54f3d
--- /dev/null
+++ b/test/browser/tests/stress.spec.ts
@@ -0,0 +1,101 @@
+import { test, expect } from '@playwright/test';
+import { clearInput, typeWithLotus, typeXdotool } from '../helpers/x11-input';
+import { getActiveIM, switchIM, activateIM } from '../helpers/fcitx5';
+import { attachEventLog } from '../helpers/events';
+
+test.describe('Fcitx5 Lotus Stress Tests', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/');
+ await activateIM();
+ await switchIM('lotus');
+ });
+
+ test.afterEach(async ({ page }, testInfo) => {
+ if (testInfo.status !== testInfo.expectedStatus) {
+ await attachEventLog(page, testInfo);
+ }
+ });
+
+ test('rapid typing with low key delay does not drop characters', async ({
+ page,
+ }) => {
+ const input = page.locator('#test-input');
+ await clearInput(page, input);
+
+ // Rapid typing at 20ms delay between key events
+ await typeWithLotus(
+ page,
+ input,
+ ['v', 'i', 'e', 'e', 't', 'j', 'space', 'n', 'a', 'm'],
+ 20
+ );
+ await expect(input).toHaveValue('việt nam');
+ });
+
+ test('rapid backspace deletion followed by new composition', async ({
+ page,
+ }) => {
+ const input = page.locator('#test-input');
+ await clearInput(page, input);
+
+ // Rapid sequence: 'd' + 'd' -> 'đ', BackSpace -> deletes 'đ', 'd' + 'd' -> 'đ'
+ await typeWithLotus(page, input, ['d', 'd', 'BackSpace', 'd', 'd'], 40);
+ await expect(input).toHaveValue('đ');
+ });
+
+ // Note: Current MVP runs Lotus in default Preedit mode. Word editing tests
+ // verify engine state transitions on committed text followed by new composition.
+ test('edits committed text with backspace and tone modification', async ({
+ page,
+ }) => {
+ const input = page.locator('#test-input');
+ await clearInput(page, input);
+
+ await typeWithLotus(page, input, [
+ 't', 'o', 'o', 'i',
+ 'space',
+ 'l', 'a', 'f',
+ ]);
+ await expect(input).toHaveValue('tôi là');
+
+ // Backspace once to delete 'à' (leaving "tôi l"), then retype with acute tone
+ await typeXdotool('BackSpace', 50);
+ await typeXdotool(['a', 's'], 50);
+ await expect(input).toHaveValue('tôi lá');
+ });
+
+ // Note: Historical #215 fixed a Gecko async surrounding-text race in SurroundingText mode.
+ // In this Preedit MVP, this test stresses repeated rapid Telex composition across cycles
+ // to ensure browser event dispatch and Fcitx preedit do not drop or scramble characters.
+ test('repeated rapid composition cycles remain stable without dropping keys', async ({
+ page,
+ }) => {
+ const input = page.locator('#test-input');
+
+ // Repeat typing "nhieeuf" -> "nhiều" across multiple cycles
+ for (let cycle = 0; cycle < 5; cycle++) {
+ await clearInput(page, input);
+ await typeWithLotus(
+ page,
+ input,
+ ['n', 'h', 'i', 'e', 'e', 'u', 'f'],
+ 25
+ );
+ await expect(input).toHaveValue('nhiều');
+ }
+
+ // Verify multi-word composition in a single session without clearing
+ await clearInput(page, input);
+ await typeWithLotus(
+ page,
+ input,
+ [
+ 'n', 'h', 'i', 'e', 'e', 'u', 'f',
+ 'space',
+ 'n', 'h', 'i', 'e', 'e', 'u', 'f',
+ ],
+ 25
+ );
+ await expect(input).toHaveValue('nhiều nhiều');
+ });
+});
diff --git a/test/browser/tsconfig.json b/test/browser/tsconfig.json
new file mode 100644
index 00000000..12b86b72
--- /dev/null
+++ b/test/browser/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "isolatedModules": true,
+ "types": ["node", "@playwright/test"]
+ },
+ "include": [
+ "**/*.ts",
+ "fixtures/**/*.mjs"
+ ]
+}
From c8afb2f40531bc468513dae040ad8ba5b29f14e0 Mon Sep 17 00:00:00 2001
From: naoNao89 <90588855+naoNao89@users.noreply.github.com>
Date: Fri, 11 Sep 2026 12:03:51 +0700
Subject: [PATCH 02/22] ci: add isolated browser E2E workflow on X11/Xvfb
- Dedicated workflow triggered on PR and workflow_dispatch
- Separate job from GCC/Clang core CTest matrix to preserve build optimization
- Runs on ubuntu-24.04 with Xvfb, Openbox, private D-Bus, Fcitx5, Chromium, and Firefox
- Exercises sequential E2E tests across both browsers
- Uploads complete diagnostics, Playwright reports, and server logs on failure
---
.github/workflows/browser-e2e.yml | 139 ++++++++++++++++++++++++++++++
1 file changed, 139 insertions(+)
create mode 100644 .github/workflows/browser-e2e.yml
diff --git a/.github/workflows/browser-e2e.yml b/.github/workflows/browser-e2e.yml
new file mode 100644
index 00000000..25d87aa6
--- /dev/null
+++ b/.github/workflows/browser-e2e.yml
@@ -0,0 +1,139 @@
+name: Browser E2E
+
+on:
+ workflow_dispatch:
+ push:
+ paths:
+ - 'test/browser/**'
+ - '.github/workflows/browser-e2e.yml'
+ - 'src/**'
+ - 'bamboo/**'
+ - 'data/**'
+ - 'server/**'
+ - '**/CMakeLists.txt'
+ pull_request:
+ paths:
+ - 'test/browser/**'
+ - '.github/workflows/browser-e2e.yml'
+ - 'src/**'
+ - 'bamboo/**'
+ - 'data/**'
+ - 'server/**'
+ - '**/CMakeLists.txt'
+concurrency:
+ group: browser-e2e-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ browser-e2e:
+ name: Browser E2E (X11 / Xvfb)
+ runs-on: ubuntu-24.04
+ env:
+ DISPLAY: ':99'
+ GTK_IM_MODULE: fcitx
+ QT_IM_MODULE: fcitx
+ XMODIFIERS: '@im=fcitx'
+ PLAYWRIGHT_BROWSERS_PATH: /home/runner/.cache/ms-playwright
+ steps:
+ - name: Setup test environment
+ run: echo "TEST_HOME=$(mktemp -d -t fcitx5-browser-e2e-XXXXXX)" >> "$GITHUB_ENV"
+
+ - name: Checkout fcitx5-lotus
+ uses: actions/checkout@v7
+ with:
+ submodules: recursive
+
+ - name: Setup Go
+ uses: actions/setup-go@v7
+ with:
+ go-version: '1.18'
+ cache: false
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v7
+ with:
+ node-version: 22
+ - name: Install system dependencies
+ run: |
+ sudo apt update
+ sudo apt install -y --no-install-recommends \
+ xvfb \
+ openbox \
+ xdotool \
+ dbus-x11 \
+ fcitx5 \
+ fcitx5-frontend-all \
+ fcitx5-frontend-gtk3 \
+ fcitx5-frontend-gtk4 \
+ libfcitx5core-dev \
+ libfcitx5config-dev \
+ libfcitx5utils-dev \
+ fcitx5-modules-dev \
+ extra-cmake-modules \
+ cmake \
+ ninja-build \
+ gettext \
+ libx11-dev \
+ libfmt-dev \
+ librsvg2-bin \
+ libinput-dev \
+ libudev-dev
+
+ - name: Build and install fcitx5-lotus
+ run: |
+ cmake -B build -S . -GNinja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr
+ sudo ninja -C build install
+
+ - name: Install test dependencies and Playwright browsers
+ working-directory: test/browser
+ run: |
+ npm ci
+ npx playwright install --with-deps chromium firefox
+
+ - name: Typecheck browser test suite
+ working-directory: test/browser
+ run: npm run typecheck
+ - name: Setup Fcitx configuration
+ run: |
+ chmod +x test/browser/scripts/setup-fcitx.sh test/browser/scripts/run-xvfb.sh
+ ./test/browser/scripts/setup-fcitx.sh
+
+ - name: Start X11 Desktop environment and Fcitx5
+ run: |
+ ./test/browser/scripts/run-xvfb.sh
+
+ - name: Run Chromium E2E tests
+ working-directory: test/browser
+ run: npm run test:chromium
+
+ - name: Run Firefox E2E tests
+ working-directory: test/browser
+ run: npm run test:firefox
+
+ - name: Collect diagnostics on failure
+ if: failure()
+ run: |
+ fcitx5-diagnose > /tmp/fcitx5-diagnose.log 2>&1 || true
+ # Copy logs from isolated TEST_HOME to /tmp for artifact upload
+ cp "${TEST_HOME}/fcitx5.log" /tmp/fcitx5.log 2>/dev/null || true
+ cp "${TEST_HOME}/xvfb.log" /tmp/xvfb.log 2>/dev/null || true
+ cp "${TEST_HOME}/openbox.log" /tmp/openbox.log 2>/dev/null || true
+
+ - name: Upload diagnostics on failure
+ if: failure()
+ uses: actions/upload-artifact@v7
+ with:
+ name: browser-e2e-failure-artifacts
+ path: |
+ test/browser/playwright-report/
+ test/browser/test-results/
+ /tmp/fcitx5.log
+ /tmp/fcitx5-diagnose.log
+ /tmp/xvfb.log
+ /tmp/openbox.log
+ if-no-files-found: ignore
+
+ - name: Stop X11 and Fcitx5
+ if: always()
+ run: |
+ ./test/browser/scripts/run-xvfb.sh --stop || true
From e540db1615f82567e54db04b04cc7e6511b6e362 Mon Sep 17 00:00:00 2001
From: naoNao89 <90588855+naoNao89@users.noreply.github.com>
Date: Sat, 12 Sep 2026 00:21:16 +0700
Subject: [PATCH 03/22] fix(browser): own the X11 display and make harness
waits deterministic
- Display selection moved to BROWSER_E2E_DISPLAY (default :99); the harness
never inherits the host DISPLAY and fails closed when the display is
occupied by a live server or stale socket instead of reusing it
- Xvfb is always started unconditionally; readiness requires the process to
stay alive AND xdpyinfo to succeed, so stale sockets cannot pass
- D-Bus PID terminated exactly once via the PID file; dbus.pid and dbus.addr
are removed on --stop so stale PIDs can never be re-killed
- x11-env.sh now lives under the isolated TEST_HOME instead of global /tmp
- setup-fcitx.sh requires TEST_HOME; new scripts/run-browser-e2e.sh is the
local lifecycle entrypoint that creates it and traps EXIT for cleanup
- fcitx5.ts: fixed 100ms settle sleeps replaced by fcitx5-remote state
polling (active IM name, active/inactive state codes)
- x11-input.ts: ensureActive settle sleep replaced by polling the fixture
event log until the browser records the focus event for the target
---
.github/workflows/browser-e2e.yml | 1 -
test/browser/helpers/fcitx5.ts | 68 +++++++++++++++++++++----
test/browser/helpers/x11-input.ts | 18 +++++--
test/browser/scripts/run-browser-e2e.sh | 24 +++++++++
test/browser/scripts/run-xvfb.sh | 64 +++++++++++++----------
test/browser/scripts/setup-fcitx.sh | 4 +-
6 files changed, 134 insertions(+), 45 deletions(-)
create mode 100755 test/browser/scripts/run-browser-e2e.sh
diff --git a/.github/workflows/browser-e2e.yml b/.github/workflows/browser-e2e.yml
index 25d87aa6..215f2ed5 100644
--- a/.github/workflows/browser-e2e.yml
+++ b/.github/workflows/browser-e2e.yml
@@ -29,7 +29,6 @@ jobs:
name: Browser E2E (X11 / Xvfb)
runs-on: ubuntu-24.04
env:
- DISPLAY: ':99'
GTK_IM_MODULE: fcitx
QT_IM_MODULE: fcitx
XMODIFIERS: '@im=fcitx'
diff --git a/test/browser/helpers/fcitx5.ts b/test/browser/helpers/fcitx5.ts
index bd61520d..9a822070 100644
--- a/test/browser/helpers/fcitx5.ts
+++ b/test/browser/helpers/fcitx5.ts
@@ -4,6 +4,43 @@ import { setTimeout } from 'node:timers/promises';
const execFileAsync = promisify(execFile);
+const POLL_INTERVAL_MS = 50;
+const POLL_DEADLINE_MS = 3000;
+
+/**
+ * Polls `check` every ~50 ms until it returns true, throwing once the deadline
+ * expires. Replaces fixed settle sleeps with deterministic state polling.
+ */
+async function waitForState(
+ check: () => Promise,
+ description: string
+): Promise {
+ const deadline = Date.now() + POLL_DEADLINE_MS;
+ for (;;) {
+ if (await check()) {
+ return;
+ }
+ if (Date.now() >= deadline) {
+ throw new Error(`Timed out waiting for ${description}`);
+ }
+ await setTimeout(POLL_INTERVAL_MS);
+ }
+}
+
+/**
+ * Runs `fcitx5-remote` with no args and parses the integer exit-state code
+ * (0 = not connected, 1 = inactive, 2 = active); 0 on error.
+ */
+async function fcitx5State(): Promise {
+ try {
+ const { stdout } = await execFileAsync('fcitx5-remote', []);
+ const code = parseInt(stdout.trim(), 10);
+ return Number.isNaN(code) ? 0 : code;
+ } catch {
+ return 0;
+ }
+}
+
/**
* Returns the currently active input method name (e.g. 'lotus', 'keyboard-us').
*/
@@ -17,18 +54,29 @@ export async function getActiveIM(): Promise {
*/
export async function switchIM(name: string): Promise {
await execFileAsync('fcitx5-remote', ['-s', name]);
+ await waitForState(
+ async () => (await getActiveIM()) === name,
+ `active input method to become '${name}'`
+ );
if (name === 'lotus') {
+ // Best-effort: -o opens the input context; not all builds require it
+ // after the switch.
await execFileAsync('fcitx5-remote', ['-o']).catch(() => {});
+ await waitForState(
+ async () => (await fcitx5State()) === 2,
+ 'fcitx5 to report active state'
+ );
}
- // Small delay to allow fcitx5 to switch its active engine
- await setTimeout(100);
}
/**
* Activates the input method engine (equivalent to fcitx5-remote -o).
*/
export async function activateIM(): Promise {
await execFileAsync('fcitx5-remote', ['-o']);
- await setTimeout(100);
+ await waitForState(
+ async () => (await fcitx5State()) === 2,
+ 'fcitx5 to report active state'
+ );
}
/**
@@ -36,7 +84,10 @@ export async function activateIM(): Promise {
*/
export async function inactivateIM(): Promise {
await execFileAsync('fcitx5-remote', ['-c']);
- await setTimeout(100);
+ await waitForState(
+ async () => (await fcitx5State()) === 1,
+ 'fcitx5 to report inactive state'
+ );
}
/**
@@ -44,11 +95,6 @@ export async function inactivateIM(): Promise {
* `fcitx5-remote` returns 1 (inactive) or 2 (active) when running, or 0 / error when not.
*/
export async function isFcitxRunning(): Promise {
- try {
- const { stdout } = await execFileAsync('fcitx5-remote', []);
- const code = parseInt(stdout.trim(), 10);
- return code === 1 || code === 2;
- } catch {
- return false;
- }
+ const state = await fcitx5State();
+ return state === 1 || state === 2;
}
diff --git a/test/browser/helpers/x11-input.ts b/test/browser/helpers/x11-input.ts
index 7f5fbe02..a04b9099 100644
--- a/test/browser/helpers/x11-input.ts
+++ b/test/browser/helpers/x11-input.ts
@@ -2,6 +2,7 @@ import { expect } from '@playwright/test';
import type { Page, Locator } from '@playwright/test';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
+import { getEventLog } from './events';
const execFileAsync = promisify(execFile);
@@ -43,7 +44,8 @@ export async function getActiveX11Window(): Promise<{ id: string; name: string }
}
/**
- * Ensures the target locator is clicked, focused, and waits for X11 window focus to settle.
+ * Ensures the target locator is clicked, focused, and waits until the X11
+ * window is active and the browser has processed the focus (IM context ready).
*/
export async function ensureActive(
page: Page,
@@ -64,8 +66,18 @@ export async function ensureActive(
)
.toContain('Fcitx5 Lotus Browser E2E Fixture');
- // Settle delay for browser focus and input context
- await page.waitForTimeout(100);
+ // Poll the fixture event log until the browser processed the X11 focus and
+ // created the input context: a focus event must be recorded for this element.
+ const targetId = await locator.evaluate((el: HTMLElement) => el.id);
+ await expect
+ .poll(
+ async () =>
+ (await getEventLog(page)).some(
+ (e) => e.type === 'focus' && e.targetId === targetId
+ ),
+ { timeout: 2000 }
+ )
+ .toBe(true);
}
/**
diff --git a/test/browser/scripts/run-browser-e2e.sh b/test/browser/scripts/run-browser-e2e.sh
new file mode 100755
index 00000000..2524e144
--- /dev/null
+++ b/test/browser/scripts/run-browser-e2e.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+# TEST_HOME is required by every script in this harness. The local entrypoint
+# owns its creation so it can be cleaned up deterministically via run-xvfb.sh --stop.
+if [ -z "${TEST_HOME:-}" ]; then
+ TEST_HOME="$(mktemp -d -t fcitx5-browser-e2e-XXXXXX)"
+ export TEST_HOME
+ echo "TEST_HOME=${TEST_HOME}"
+fi
+
+cleanup() {
+ "${SCRIPT_DIR}/run-xvfb.sh" --stop || true
+}
+trap cleanup EXIT
+
+"${SCRIPT_DIR}/setup-fcitx.sh"
+"${SCRIPT_DIR}/run-xvfb.sh"
+
+cd "${SCRIPT_DIR}/.."
+npm run test:chromium
+npm run test:firefox
diff --git a/test/browser/scripts/run-xvfb.sh b/test/browser/scripts/run-xvfb.sh
index 48f6a678..232ff742 100755
--- a/test/browser/scripts/run-xvfb.sh
+++ b/test/browser/scripts/run-xvfb.sh
@@ -2,11 +2,13 @@
set -euo pipefail
if [ -z "${TEST_HOME:-}" ]; then
- echo "error: TEST_HOME environment variable is not set. Run setup-fcitx.sh first or export TEST_HOME." >&2
+ echo "error: TEST_HOME environment variable is not set. Use scripts/run-browser-e2e.sh, or export TEST_HOME before calling this script." >&2
exit 1
fi
-DISPLAY="${DISPLAY:-:99}"
+# The harness must own its X server: never inherit the host DISPLAY.
+: "${BROWSER_E2E_DISPLAY:=:99}"
+DISPLAY="${BROWSER_E2E_DISPLAY}"
export DISPLAY
PID_FILE="${TEST_HOME}/run-xvfb.pids"
@@ -21,13 +23,17 @@ if [ "${1:-}" = "--stop" ]; then
done < "${PID_FILE}"
rm -f "${PID_FILE}"
fi
- # Also gracefully terminate dbus session if we started it
- if [ -f "${TEST_HOME}/dbus.pid" ]; then
- kill "$(cat "${TEST_HOME}/dbus.pid")" 2>/dev/null || true
- fi
+ rm -f "${TEST_HOME}/dbus.pid" "${TEST_HOME}/dbus.addr"
exit 0
fi
+# Fail closed if the display is already in use (live server or stale socket):
+# the harness owns its X server and never reuses an existing one.
+if [ -S "/tmp/.X11-unix/X${DISPLAY#:}" ] || { command -v xdpyinfo >/dev/null 2>&1 && xdpyinfo -display "${DISPLAY}" >/dev/null 2>&1; }; then
+ echo "error: display ${DISPLAY} is already in use; set BROWSER_E2E_DISPLAY to a free display (e.g. :98) and retry" >&2
+ exit 1
+fi
+
touch "${PID_FILE}"
# Create an isolated, hermetic D-Bus session for our test environment.
@@ -77,29 +83,31 @@ if [ -n "${GITHUB_ENV:-}" ]; then
echo "DBUS_SESSION_BUS_ADDRESS=${DBUS_SESSION_BUS_ADDRESS}" >> "$GITHUB_ENV"
echo "DISPLAY=${DISPLAY}" >> "$GITHUB_ENV"
fi
-# Start Xvfb virtual framebuffer if not already running
-if ! (command -v xdpyinfo >/dev/null 2>&1 && xdpyinfo -display "${DISPLAY}" >/dev/null 2>&1); then
- if ! command -v Xvfb >/dev/null 2>&1; then
- echo "error: Xvfb binary not found in PATH" >&2
- exit 1
- fi
- Xvfb "${DISPLAY}" -screen 0 1920x1080x24 -ac +extension GLX +render -noreset > "${XVFB_LOG}" 2>&1 &
- echo $! >> "${PID_FILE}"
-
- xvfb_ready=0
- for _ in $(seq 1 50); do
- if [ -S "/tmp/.X11-unix/X${DISPLAY#:}" ] || (command -v xdpyinfo >/dev/null 2>&1 && xdpyinfo -display "${DISPLAY}" >/dev/null 2>&1); then
- xvfb_ready=1
- break
- fi
- sleep 0.1
- done
+# Start Xvfb: the harness always owns a fresh X server on DISPLAY.
+if ! command -v Xvfb >/dev/null 2>&1; then
+ echo "error: Xvfb binary not found in PATH" >&2
+ exit 1
+fi
+Xvfb "${DISPLAY}" -screen 0 1920x1080x24 -ac +extension GLX +render -noreset > "${XVFB_LOG}" 2>&1 &
+XVFB_PID=$!
+echo "${XVFB_PID}" >> "${PID_FILE}"
- if [ "$xvfb_ready" -ne 1 ]; then
- echo "error: Xvfb failed to start on ${DISPLAY} within 5s" >&2
- [ -f "${XVFB_LOG}" ] && tail -n 50 "${XVFB_LOG}" >&2
- exit 1
+xvfb_ready=0
+for _ in $(seq 1 50); do
+ if ! kill -0 "${XVFB_PID}" 2>/dev/null; then
+ break
+ fi
+ if command -v xdpyinfo >/dev/null 2>&1 && xdpyinfo -display "${DISPLAY}" >/dev/null 2>&1; then
+ xvfb_ready=1
+ break
fi
+ sleep 0.1
+done
+
+if [ "$xvfb_ready" -ne 1 ]; then
+ echo "error: Xvfb failed to start on ${DISPLAY} within 5s" >&2
+ [ -f "${XVFB_LOG}" ] && tail -n 50 "${XVFB_LOG}" >&2
+ exit 1
fi
# Start Openbox window manager
@@ -204,7 +212,7 @@ if [ "$lotus_ready" -ne 1 ]; then
exit 1
fi
-cat < /tmp/x11-env.sh
+cat < "${TEST_HOME}/x11-env.sh"
export DISPLAY="${DISPLAY}"
export DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS}"
export HOME="${TEST_HOME}"
diff --git a/test/browser/scripts/setup-fcitx.sh b/test/browser/scripts/setup-fcitx.sh
index c4675f67..66385287 100755
--- a/test/browser/scripts/setup-fcitx.sh
+++ b/test/browser/scripts/setup-fcitx.sh
@@ -2,8 +2,8 @@
set -euo pipefail
if [ -z "${TEST_HOME:-}" ]; then
- TEST_HOME="$(mktemp -d -t fcitx5-browser-e2e-XXXXXX)"
- echo "WARNING: TEST_HOME not set. Created temporary directory: $TEST_HOME" >&2
+ echo 'error: TEST_HOME is not set. Use scripts/run-browser-e2e.sh, or export TEST_HOME=$(mktemp -d) before calling this script.' >&2
+ exit 1
fi
export HOME="${TEST_HOME}"
From bc179c3247b2a21c376f708a38384415ed9629d3 Mon Sep 17 00:00:00 2001
From: naoNao89 <90588855+naoNao89@users.noreply.github.com>
Date: Sat, 12 Sep 2026 00:41:37 +0700
Subject: [PATCH 04/22] fix(ci): install x11-utils for xdpyinfo display
readiness check
The hardened Xvfb readiness check requires xdpyinfo to succeed (process
alive AND display responsive); xvfb alone does not ship xdpyinfo, so the
previously socket-only readiness silently passed without it.
---
.github/workflows/browser-e2e.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/browser-e2e.yml b/.github/workflows/browser-e2e.yml
index 215f2ed5..f125d311 100644
--- a/.github/workflows/browser-e2e.yml
+++ b/.github/workflows/browser-e2e.yml
@@ -57,6 +57,7 @@ jobs:
sudo apt update
sudo apt install -y --no-install-recommends \
xvfb \
+ x11-utils \
openbox \
xdotool \
dbus-x11 \
From 09614d2868bff917097c312e324a5fc3ab2de323 Mon Sep 17 00:00:00 2001
From: naoNao89 <90588855+naoNao89@users.noreply.github.com>
Date: Sat, 12 Sep 2026 00:55:32 +0700
Subject: [PATCH 05/22] fix(browser): force real focus transition in
ensureActive
Clicking an already-focused element fires no focus event, and clearInput
resets the fixture event log beforehand, so the focus-event readiness poll
could never observe a new event for the target. Blur first, then click, so
a fresh genuine focus event is always recorded and the IM-context readiness
poll is meaningful.
---
test/browser/helpers/x11-input.ts | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/test/browser/helpers/x11-input.ts b/test/browser/helpers/x11-input.ts
index a04b9099..0e966f68 100644
--- a/test/browser/helpers/x11-input.ts
+++ b/test/browser/helpers/x11-input.ts
@@ -66,8 +66,12 @@ export async function ensureActive(
)
.toContain('Fcitx5 Lotus Browser E2E Fixture');
- // Poll the fixture event log until the browser processed the X11 focus and
- // created the input context: a focus event must be recorded for this element.
+ // Force a genuine focus transition: clicking an already-focused element
+ // fires no focus event (and prior clearInput calls may have reset the
+ // event log), so blur first to guarantee a fresh, real focus event that
+ // proves the browser processed the X11 focus and created the IM context.
+ await locator.evaluate((el: HTMLElement) => el.blur());
+ await locator.click();
const targetId = await locator.evaluate((el: HTMLElement) => el.id);
await expect
.poll(
From c917f29409b863d24f118e854b856ad80104ee95 Mon Sep 17 00:00:00 2001
From: naoNao89 <90588855+naoNao89@users.noreply.github.com>
Date: Sat, 12 Sep 2026 01:37:53 +0700
Subject: [PATCH 06/22] fix(browser): record fcitx5 PID only after readiness
succeeds
Failed startup attempts were appended to run-xvfb.pids before readiness
and killed during retry, leaving stale PIDs that cleanup would re-kill.
Record ownership only for a daemon that is alive and responsive; also
correct the wrapper comment: --stop tears down processes and keeps
TEST_HOME for post-mortem logs.
---
test/browser/scripts/run-browser-e2e.sh | 3 ++-
test/browser/scripts/run-xvfb.sh | 4 +++-
2 files changed, 5 insertions(+), 2 deletions(-)
diff --git a/test/browser/scripts/run-browser-e2e.sh b/test/browser/scripts/run-browser-e2e.sh
index 2524e144..f29338f3 100755
--- a/test/browser/scripts/run-browser-e2e.sh
+++ b/test/browser/scripts/run-browser-e2e.sh
@@ -4,7 +4,8 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# TEST_HOME is required by every script in this harness. The local entrypoint
-# owns its creation so it can be cleaned up deterministically via run-xvfb.sh --stop.
+# owns its creation; run-xvfb.sh --stop tears down the managed processes
+# while keeping the directory for post-mortem logs.
if [ -z "${TEST_HOME:-}" ]; then
TEST_HOME="$(mktemp -d -t fcitx5-browser-e2e-XXXXXX)"
export TEST_HOME
diff --git a/test/browser/scripts/run-xvfb.sh b/test/browser/scripts/run-xvfb.sh
index 232ff742..eb0c924b 100755
--- a/test/browser/scripts/run-xvfb.sh
+++ b/test/browser/scripts/run-xvfb.sh
@@ -161,7 +161,6 @@ for attempt in 1 2 3; do
fcitx5 -r --disable=wayland,waylandim > "${FCITX_LOG}" 2>&1 &
fi
FCITX_PID=$!
- echo "$FCITX_PID" >> "${PID_FILE}"
# Wait for Fcitx5 daemon to initialize
fcitx_ready=0
@@ -179,6 +178,9 @@ for attempt in 1 2 3; do
done
if [ "$fcitx_ready" -eq 1 ]; then
+ # Record ownership only for a daemon that is actually alive and
+ # responsive, so the PID file never contains killed retry attempts.
+ echo "$FCITX_PID" >> "${PID_FILE}"
fcitx5_started=1
break
fi
From cf936aa71932e6b0fb0a6cbf0b0ee180fc5ab983 Mon Sep 17 00:00:00 2001
From: naoNao89 <90588855+naoNao89@users.noreply.github.com>
Date: Mon, 14 Sep 2026 04:51:16 +0700
Subject: [PATCH 07/22] fix(browser): load openbox rc.xml and harden process
teardown
- export HOME/XDG_* before spawning openbox so setup-fcitx.sh's rc.xml is
actually read (suite previously passed on stock-openbox focus defaults)
- capture OPENBOX_PID and poll it directly instead of tail -1 of the PID file
- --stop tears down newest-first (consumers before bus/display), verifies
each PID is alive before SIGTERM, waits up to 3s, then escalates SIGKILL
- truncate the PID file on start so a reused TEST_HOME never carries stale
PIDs into a blind kill
- delete the never-sourced x11-env.sh generator
---
test/browser/scripts/run-xvfb.sh | 57 +++++++++++++++++---------------
1 file changed, 31 insertions(+), 26 deletions(-)
diff --git a/test/browser/scripts/run-xvfb.sh b/test/browser/scripts/run-xvfb.sh
index eb0c924b..a50e0e45 100755
--- a/test/browser/scripts/run-xvfb.sh
+++ b/test/browser/scripts/run-xvfb.sh
@@ -17,10 +17,25 @@ OPENBOX_LOG="${TEST_HOME}/openbox.log"
FCITX_LOG="${TEST_HOME}/fcitx5.log"
if [ "${1:-}" = "--stop" ]; then
+ pids=()
if [ -f "${PID_FILE}" ]; then
while read -r pid; do
- kill "$pid" 2>/dev/null || true
+ [ -n "${pid}" ] && pids+=("${pid}")
done < "${PID_FILE}"
+ # Tear down newest-first: consumers (fcitx5, openbox) die before the
+ # bus and display they depend on, and every PID is verified alive
+ # before killing, then waited on, so recycled PIDs are never hit.
+ for ((i=${#pids[@]}-1; i>=0; i--)); do
+ pid="${pids[i]}"
+ if kill -0 "${pid}" 2>/dev/null; then
+ kill "${pid}" 2>/dev/null || true
+ for _ in $(seq 1 30); do
+ kill -0 "${pid}" 2>/dev/null || break
+ sleep 0.1
+ done
+ kill -9 "${pid}" 2>/dev/null || true
+ fi
+ done
rm -f "${PID_FILE}"
fi
rm -f "${TEST_HOME}/dbus.pid" "${TEST_HOME}/dbus.addr"
@@ -34,7 +49,7 @@ if [ -S "/tmp/.X11-unix/X${DISPLAY#:}" ] || { command -v xdpyinfo >/dev/null 2>&
exit 1
fi
-touch "${PID_FILE}"
+: > "${PID_FILE}"
# Create an isolated, hermetic D-Bus session for our test environment.
# By omitting , this private bus never scans
@@ -110,17 +125,29 @@ if [ "$xvfb_ready" -ne 1 ]; then
exit 1
fi
+# Isolated environment for Fcitx5
+export HOME="${TEST_HOME}"
+export XDG_CONFIG_HOME="${TEST_HOME}/.config"
+export XDG_DATA_HOME="${TEST_HOME}/.local/share"
+
+# Input method environment
+export GTK_IM_MODULE=fcitx
+export QT_IM_MODULE=fcitx
+export XMODIFIERS=@im=fcitx
+export SDL_IM_MODULE=fcitx
+
# Start Openbox window manager
if ! command -v openbox >/dev/null 2>&1; then
echo "error: openbox binary not found in PATH" >&2
exit 1
fi
openbox --sm-disable > "${OPENBOX_LOG}" 2>&1 &
-echo $! >> "${PID_FILE}"
+OPENBOX_PID=$!
+echo "${OPENBOX_PID}" >> "${PID_FILE}"
openbox_ready=0
for _ in $(seq 1 30); do
- if kill -0 "$(tail -1 "${PID_FILE}")" 2>/dev/null; then
+ if kill -0 "${OPENBOX_PID}" 2>/dev/null; then
openbox_ready=1
break
fi
@@ -133,17 +160,6 @@ if [ "$openbox_ready" -ne 1 ]; then
exit 1
fi
-# Isolated environment for Fcitx5
-export HOME="${TEST_HOME}"
-export XDG_CONFIG_HOME="${TEST_HOME}/.config"
-export XDG_DATA_HOME="${TEST_HOME}/.local/share"
-
-# Input method environment
-export GTK_IM_MODULE=fcitx
-export QT_IM_MODULE=fcitx
-export XMODIFIERS=@im=fcitx
-export SDL_IM_MODULE=fcitx
-
# Start Fcitx5 daemon (with retry for transient D-Bus issues)
if ! command -v fcitx5 >/dev/null 2>&1; then
echo "error: fcitx5 binary not found in PATH" >&2
@@ -214,17 +230,6 @@ if [ "$lotus_ready" -ne 1 ]; then
exit 1
fi
-cat < "${TEST_HOME}/x11-env.sh"
-export DISPLAY="${DISPLAY}"
-export DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS}"
-export HOME="${TEST_HOME}"
-export XDG_CONFIG_HOME="${TEST_HOME}/.config"
-export XDG_DATA_HOME="${TEST_HOME}/.local/share"
-export GTK_IM_MODULE=fcitx
-export QT_IM_MODULE=fcitx
-export XMODIFIERS=@im=fcitx
-EOF
-
if [ -n "${GITHUB_ENV:-}" ]; then
echo "HOME=${TEST_HOME}" >> "$GITHUB_ENV"
echo "XDG_CONFIG_HOME=${TEST_HOME}/.config" >> "$GITHUB_ENV"
From 65ed1f223e2a21dd8bd66c463c47d6b1991aea3f Mon Sep 17 00:00:00 2001
From: naoNao89 <90588855+naoNao89@users.noreply.github.com>
Date: Mon, 14 Sep 2026 04:51:16 +0700
Subject: [PATCH 08/22] fix(ci): pin least-privilege token and make
Playwright's CI branch real
- permissions: contents: read matching build.yml convention
- CI=true in job env so forbidOnly/reuseExistingServer evaluate correctly
on Actions (GH runners do not export CI)
- cancel-in-progress skips main/dev/tags like build.yml
---
.github/workflows/browser-e2e.yml | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/browser-e2e.yml b/.github/workflows/browser-e2e.yml
index f125d311..e5f39188 100644
--- a/.github/workflows/browser-e2e.yml
+++ b/.github/workflows/browser-e2e.yml
@@ -20,9 +20,12 @@ on:
- 'data/**'
- 'server/**'
- '**/CMakeLists.txt'
+permissions:
+ contents: read
+
concurrency:
group: browser-e2e-${{ github.ref }}
- cancel-in-progress: true
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' && !startsWith(github.ref, 'refs/tags/') }}
jobs:
browser-e2e:
@@ -32,6 +35,7 @@ jobs:
GTK_IM_MODULE: fcitx
QT_IM_MODULE: fcitx
XMODIFIERS: '@im=fcitx'
+ CI: "true"
PLAYWRIGHT_BROWSERS_PATH: /home/runner/.cache/ms-playwright
steps:
- name: Setup test environment
From 7b102085a7a7fda6643bcf73b9311a70519fcb20 Mon Sep 17 00:00:00 2001
From: naoNao89 <90588855+naoNao89@users.noreply.github.com>
Date: Mon, 14 Sep 2026 04:51:34 +0700
Subject: [PATCH 09/22] test(browser): close the stale-focus poll and make the
control test discriminative
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- ensureActive records an event-log watermark before the blur and only
accepts focus events after it, so a leftover event from a previous call
can no longer satisfy the wait
- replace the tautological positiveEvents.length>0 check (the harness's own
click/blur manufactures those events) with an input/compositionend event
carrying the committed 'đ' on test-input, plus the mirror assertion that
the keyboard-us phase never produces one
- drop dead resetFixtureDirectly helper
---
test/browser/helpers/x11-input.ts | 27 ++++++---------------------
test/browser/tests/control.spec.ts | 19 ++++++++++++++++++-
2 files changed, 24 insertions(+), 22 deletions(-)
diff --git a/test/browser/helpers/x11-input.ts b/test/browser/helpers/x11-input.ts
index 0e966f68..dba7d2b8 100644
--- a/test/browser/helpers/x11-input.ts
+++ b/test/browser/helpers/x11-input.ts
@@ -67,18 +67,18 @@ export async function ensureActive(
.toContain('Fcitx5 Lotus Browser E2E Fixture');
// Force a genuine focus transition: clicking an already-focused element
- // fires no focus event (and prior clearInput calls may have reset the
- // event log), so blur first to guarantee a fresh, real focus event that
- // proves the browser processed the X11 focus and created the IM context.
+ // fires no focus event, so blur first and only accept focus events recorded
+ // AFTER a watermark — a stale event from a previous call must not pass.
+ const watermark = (await getEventLog(page)).length;
await locator.evaluate((el: HTMLElement) => el.blur());
await locator.click();
const targetId = await locator.evaluate((el: HTMLElement) => el.id);
await expect
.poll(
async () =>
- (await getEventLog(page)).some(
- (e) => e.type === 'focus' && e.targetId === targetId
- ),
+ (await getEventLog(page))
+ .slice(watermark)
+ .some((e) => e.type === 'focus' && e.targetId === targetId),
{ timeout: 2000 }
)
.toBe(true);
@@ -106,21 +106,6 @@ export async function clearInput(
.toBe('');
}
-/**
- * Direct DOM reset for fixture initialization outside of input method testing.
- * MUST NOT be used as a fallback for user-level keyboard interactions.
- */
-export async function resetFixtureDirectly(locator: Locator): Promise {
- await locator.evaluate((el: HTMLElement) => {
- if ('value' in el && typeof (el as HTMLInputElement).value === 'string') {
- (el as HTMLInputElement).value = '';
- } else {
- el.textContent = '';
- }
- el.dispatchEvent(new Event('input', { bubbles: true }));
- });
-}
-
/**
* Focuses locator and types the given sequence of keys through xdotool.
*/
diff --git a/test/browser/tests/control.spec.ts b/test/browser/tests/control.spec.ts
index 3e968586..b3da4241 100644
--- a/test/browser/tests/control.spec.ts
+++ b/test/browser/tests/control.spec.ts
@@ -28,8 +28,17 @@ test.describe('Fcitx5 Lotus Control Tests', () => {
await resetEventLog(page);
await typeWithLotus(page, input, ['d', 'd']);
await expect(input).toHaveValue('đ');
+ // A real commit must surface as an input/compositionend event carrying the
+ // composed character — something a tautological length check never proved.
const positiveEvents = await getEventLog(page);
- expect(positiveEvents.length).toBeGreaterThan(0);
+ expect(
+ positiveEvents.some(
+ (e) =>
+ (e.type === 'input' || e.type === 'compositionend') &&
+ e.data === 'đ' &&
+ e.targetId === 'test-input'
+ )
+ ).toBe(true);
// Switch to English layout: raw keys bypass input method
await switchIM('keyboard-us');
@@ -40,6 +49,14 @@ test.describe('Fcitx5 Lotus Control Tests', () => {
await resetEventLog(page);
await typeWithLotus(page, input, ['d', 'd']);
await expect(input).toHaveValue('dd');
+ const negativeEvents = await getEventLog(page);
+ expect(
+ negativeEvents.some(
+ (e) =>
+ (e.type === 'input' || e.type === 'compositionend') &&
+ e.data === 'đ'
+ )
+ ).toBe(false);
// Restore Lotus: composition resumes
await switchIM('lotus');
From b63f91ff4f53dc165adf4af1c2e2767901495361 Mon Sep 17 00:00:00 2001
From: naoNao89 <90588855+naoNao89@users.noreply.github.com>
Date: Mon, 14 Sep 2026 04:51:35 +0700
Subject: [PATCH 10/22] test(browser): keep failure diagnostics when the page
is dead; harden IM query
- attachEventLog always attaches: an unreachable event log becomes a JSON
{error,url} artifact instead of silently no-oping exactly when triage
matters most
- getActiveIM returns '' on exec failure (mirrors fcitx5State) so a
transient D-Bus blip reads as a poll mismatch, not a raw Command-failed
reject inside expect.poll
- remove unused inactivateIM/isFcitxRunning helpers
---
test/browser/helpers/events.ts | 24 ++++++++++++++++--------
test/browser/helpers/fcitx5.ts | 27 ++++++---------------------
2 files changed, 22 insertions(+), 29 deletions(-)
diff --git a/test/browser/helpers/events.ts b/test/browser/helpers/events.ts
index 833b6787..e123d268 100644
--- a/test/browser/helpers/events.ts
+++ b/test/browser/helpers/events.ts
@@ -40,17 +40,25 @@ export async function resetEventLog(page: Page): Promise {
});
}
/**
- * Attaches recorded events as a JSON diagnostic artifact to Playwright's TestInfo.
+ * Attaches recorded events as a JSON diagnostic artifact to Playwright's
+ * TestInfo. Always attaches — empty logs and dead pages (where the log
+ * cannot be read) are exactly the failures that need artifacts.
*/
export async function attachEventLog(
page: Page,
testInfo: TestInfo
): Promise {
- const events = await getEventLog(page).catch(() => []);
- if (events.length > 0) {
- await testInfo.attach('input-events.json', {
- body: JSON.stringify(events, null, 2),
- contentType: 'application/json',
- });
- }
+ const events = await getEventLog(page).catch(() => null);
+ const body =
+ events === null
+ ? JSON.stringify(
+ { error: 'event log unavailable', url: page.url() },
+ null,
+ 2
+ )
+ : JSON.stringify(events, null, 2);
+ await testInfo.attach('input-events.json', {
+ body,
+ contentType: 'application/json',
+ });
}
diff --git a/test/browser/helpers/fcitx5.ts b/test/browser/helpers/fcitx5.ts
index 9a822070..fa85276f 100644
--- a/test/browser/helpers/fcitx5.ts
+++ b/test/browser/helpers/fcitx5.ts
@@ -45,8 +45,12 @@ async function fcitx5State(): Promise {
* Returns the currently active input method name (e.g. 'lotus', 'keyboard-us').
*/
export async function getActiveIM(): Promise {
- const { stdout } = await execFileAsync('fcitx5-remote', ['-n']);
- return stdout.trim();
+ try {
+ const { stdout } = await execFileAsync('fcitx5-remote', ['-n']);
+ return stdout.trim();
+ } catch {
+ return '';
+ }
}
/**
@@ -79,22 +83,3 @@ export async function activateIM(): Promise {
);
}
-/**
- * Inactivates the input method engine (equivalent to fcitx5-remote -c).
- */
-export async function inactivateIM(): Promise {
- await execFileAsync('fcitx5-remote', ['-c']);
- await waitForState(
- async () => (await fcitx5State()) === 1,
- 'fcitx5 to report inactive state'
- );
-}
-
-/**
- * Checks if Fcitx5 is currently running and responsive.
- * `fcitx5-remote` returns 1 (inactive) or 2 (active) when running, or 0 / error when not.
- */
-export async function isFcitxRunning(): Promise {
- const state = await fcitx5State();
- return state === 1 || state === 2;
-}
From 93a119cf995356395cb797063f24ac43ec4253c8 Mon Sep 17 00:00:00 2001
From: naoNao89 <90588855+naoNao89@users.noreply.github.com>
Date: Mon, 14 Sep 2026 04:51:35 +0700
Subject: [PATCH 11/22] test(browser): trim fixture plumbing gaps
- drop the unused getActiveIM imports
- tsconfig: remove fixtures/**/*.mjs from include; without allowJs it never
typechecked server.mjs and only looked like it did
- webServer: probe /index.html so reuseExistingServer validates the fixture
instead of any listener on port 3000
- server.mjs: malformed request targets answer 400 instead of an
uncaughtException killing the webServer mid-suite; shutdown closes
keep-alive connections with a force-exit backstop
---
test/browser/fixtures/server.mjs | 67 +++++++++++++++++--------------
test/browser/playwright.config.ts | 1 +
test/browser/tests/smoke.spec.ts | 2 +-
test/browser/tests/stress.spec.ts | 2 +-
test/browser/tsconfig.json | 3 +-
5 files changed, 41 insertions(+), 34 deletions(-)
diff --git a/test/browser/fixtures/server.mjs b/test/browser/fixtures/server.mjs
index 908d17af..81d0b9ba 100644
--- a/test/browser/fixtures/server.mjs
+++ b/test/browser/fixtures/server.mjs
@@ -19,44 +19,49 @@ const MIME_TYPES = {
};
const server = http.createServer((req, res) => {
- const parsedUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
- let pathname = parsedUrl.pathname;
+ try {
+ const parsedUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
+ let pathname = parsedUrl.pathname;
- if (pathname === '/' || pathname === '') {
- pathname = '/index.html';
- }
+ if (pathname === '/' || pathname === '') {
+ pathname = '/index.html';
+ }
- // Prevent directory traversal
- const safePath = path.normalize(pathname).replace(/^(\.\.[\/\\])+/, '');
- const filePath = path.join(__dirname, safePath);
+ // Prevent directory traversal
+ const safePath = path.normalize(pathname).replace(/^(\.\.[\/\\])+/, '');
+ const filePath = path.join(__dirname, safePath);
- if (!filePath.startsWith(__dirname)) {
- res.writeHead(403, { 'Content-Type': 'text/plain' });
- res.end('Forbidden');
- return;
- }
-
- fs.readFile(filePath, (err, data) => {
- if (err) {
- if (err.code === 'ENOENT') {
- res.writeHead(404, { 'Content-Type': 'text/plain' });
- res.end('Not Found');
- } else {
- res.writeHead(500, { 'Content-Type': 'text/plain' });
- res.end('Internal Server Error');
- }
+ if (!filePath.startsWith(__dirname)) {
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
+ res.end('Forbidden');
return;
}
- const ext = path.extname(filePath).toLowerCase();
- const contentType = MIME_TYPES[ext] || 'application/octet-stream';
+ fs.readFile(filePath, (err, data) => {
+ if (err) {
+ if (err.code === 'ENOENT') {
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
+ res.end('Not Found');
+ } else {
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
+ res.end('Internal Server Error');
+ }
+ return;
+ }
+
+ const ext = path.extname(filePath).toLowerCase();
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
- res.writeHead(200, {
- 'Content-Type': contentType,
- 'Cache-Control': 'no-store',
+ res.writeHead(200, {
+ 'Content-Type': contentType,
+ 'Cache-Control': 'no-store',
+ });
+ res.end(data);
});
- res.end(data);
- });
+ } catch {
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
+ res.end('Bad Request');
+ }
});
server.listen(PORT, HOST, () => {
@@ -67,6 +72,8 @@ function handleShutdown() {
server.close(() => {
process.exit(0);
});
+ server.closeAllConnections();
+ setTimeout(() => process.exit(0), 2000).unref();
}
process.on('SIGINT', handleShutdown);
diff --git a/test/browser/playwright.config.ts b/test/browser/playwright.config.ts
index 958fa077..0f9ef0bd 100644
--- a/test/browser/playwright.config.ts
+++ b/test/browser/playwright.config.ts
@@ -65,6 +65,7 @@ export default defineConfig({
webServer: {
command: 'node fixtures/server.mjs',
port: 3000,
+ url: 'http://127.0.0.1:3000/index.html',
reuseExistingServer: !process.env.CI,
},
});
diff --git a/test/browser/tests/smoke.spec.ts b/test/browser/tests/smoke.spec.ts
index 6ce82c48..bb627b9d 100644
--- a/test/browser/tests/smoke.spec.ts
+++ b/test/browser/tests/smoke.spec.ts
@@ -1,6 +1,6 @@
import { test, expect } from '@playwright/test';
import { clearInput, ensureActive, typeWithLotus, typeXdotool } from '../helpers/x11-input';
-import { getActiveIM, switchIM, activateIM } from '../helpers/fcitx5';
+import { switchIM, activateIM } from '../helpers/fcitx5';
import { attachEventLog } from '../helpers/events';
test.describe('Fcitx5 Lotus Smoke Tests', () => {
diff --git a/test/browser/tests/stress.spec.ts b/test/browser/tests/stress.spec.ts
index a6b54f3d..a2758406 100644
--- a/test/browser/tests/stress.spec.ts
+++ b/test/browser/tests/stress.spec.ts
@@ -1,6 +1,6 @@
import { test, expect } from '@playwright/test';
import { clearInput, typeWithLotus, typeXdotool } from '../helpers/x11-input';
-import { getActiveIM, switchIM, activateIM } from '../helpers/fcitx5';
+import { switchIM, activateIM } from '../helpers/fcitx5';
import { attachEventLog } from '../helpers/events';
test.describe('Fcitx5 Lotus Stress Tests', () => {
diff --git a/test/browser/tsconfig.json b/test/browser/tsconfig.json
index 12b86b72..cc23ca74 100644
--- a/test/browser/tsconfig.json
+++ b/test/browser/tsconfig.json
@@ -14,7 +14,6 @@
"types": ["node", "@playwright/test"]
},
"include": [
- "**/*.ts",
- "fixtures/**/*.mjs"
+ "**/*.ts"
]
}
From 64140ed65d3a02ec7559aba1c1915b91f480014b Mon Sep 17 00:00:00 2001
From: naoNao89 <90588855+naoNao89@users.noreply.github.com>
Date: Mon, 14 Sep 2026 04:56:18 +0700
Subject: [PATCH 12/22] fix(browser): webServer takes port OR url, not both
Playwright 1.63 rejects a config that specifies both; keep the url probe so
reuseExistingServer validates the fixture rather than any listener on :3000.
---
test/browser/playwright.config.ts | 1 -
1 file changed, 1 deletion(-)
diff --git a/test/browser/playwright.config.ts b/test/browser/playwright.config.ts
index 0f9ef0bd..e82b8b03 100644
--- a/test/browser/playwright.config.ts
+++ b/test/browser/playwright.config.ts
@@ -64,7 +64,6 @@ export default defineConfig({
],
webServer: {
command: 'node fixtures/server.mjs',
- port: 3000,
url: 'http://127.0.0.1:3000/index.html',
reuseExistingServer: !process.env.CI,
},
From f5282e3f376b93da16cffb10394936d746450775 Mon Sep 17 00:00:00 2001
From: naoNao89 <90588855+naoNao89@users.noreply.github.com>
Date: Mon, 14 Sep 2026 05:33:58 +0700
Subject: [PATCH 13/22] fix(browser): don't let a blank PID-file line abort
--stop under set -e
`[ -n $pid ] && pids+=($pid)` returns 1 on the final blank line, which
kills the script before any teardown; use an if-block so --stop always
reaches the kill loop.
---
test/browser/scripts/run-xvfb.sh | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/test/browser/scripts/run-xvfb.sh b/test/browser/scripts/run-xvfb.sh
index a50e0e45..3fd349f3 100755
--- a/test/browser/scripts/run-xvfb.sh
+++ b/test/browser/scripts/run-xvfb.sh
@@ -20,7 +20,9 @@ if [ "${1:-}" = "--stop" ]; then
pids=()
if [ -f "${PID_FILE}" ]; then
while read -r pid; do
- [ -n "${pid}" ] && pids+=("${pid}")
+ if [ -n "${pid}" ]; then
+ pids+=("${pid}")
+ fi
done < "${PID_FILE}"
# Tear down newest-first: consumers (fcitx5, openbox) die before the
# bus and display they depend on, and every PID is verified alive
From 254fd62dd64c324d74e49dd2612964c5f8dbd557 Mon Sep 17 00:00:00 2001
From: naoNao89 <90588855+naoNao89@users.noreply.github.com>
Date: Mon, 14 Sep 2026 07:06:26 +0700
Subject: [PATCH 14/22] test(browser): clamp watermark against mid-poll log
replacement
slice(watermark) on a log that was reset/reloaded between capture and the
next poll is empty forever; Math.min keeps the poll recoverable instead of
guaranteed-timeout red.
---
test/browser/helpers/x11-input.ts | 13 +++++++++----
1 file changed, 9 insertions(+), 4 deletions(-)
diff --git a/test/browser/helpers/x11-input.ts b/test/browser/helpers/x11-input.ts
index dba7d2b8..a63f3d5b 100644
--- a/test/browser/helpers/x11-input.ts
+++ b/test/browser/helpers/x11-input.ts
@@ -75,10 +75,15 @@ export async function ensureActive(
const targetId = await locator.evaluate((el: HTMLElement) => el.id);
await expect
.poll(
- async () =>
- (await getEventLog(page))
- .slice(watermark)
- .some((e) => e.type === 'focus' && e.targetId === targetId),
+ async () => {
+ const log = await getEventLog(page);
+ // Math.min clamps the watermark if the log was ever replaced
+ // (reset/reload) mid-poll; without it slice(watermark) of a fresh
+ // short array is [] forever and the poll can only time out.
+ return log
+ .slice(Math.min(watermark, log.length))
+ .some((e) => e.type === 'focus' && e.targetId === targetId);
+ },
{ timeout: 2000 }
)
.toBe(true);
From 73cdb3618820311214877aec34b8ca3666fd2753 Mon Sep 17 00:00:00 2001
From: naoNao89 <90588855+naoNao89@users.noreply.github.com>
Date: Mon, 14 Sep 2026 07:18:55 +0700
Subject: [PATCH 15/22] fix(browser): fail loudly on missing xdpyinfo; wait for
openbox to claim the display
- xdpyinfo absence used to silently degrade the Xvfb readiness loop to a
liveness check; hard-fail with an actionable message instead (this exact
guard already failed open once via commit 23a512d's fix)
- openbox 'readiness' was a one-shot kill -0 that always passed microseconds
after fork; poll _NET_SUPPORTING_WM_CHECK via xprop (x11-utils, already
installed) so rc.xml focus/raise policies are proven active before
browser windows map
---
test/browser/scripts/run-xvfb.sh | 17 +++++++++++++++--
1 file changed, 15 insertions(+), 2 deletions(-)
diff --git a/test/browser/scripts/run-xvfb.sh b/test/browser/scripts/run-xvfb.sh
index 3fd349f3..aa1931d7 100755
--- a/test/browser/scripts/run-xvfb.sh
+++ b/test/browser/scripts/run-xvfb.sh
@@ -44,6 +44,14 @@ if [ "${1:-}" = "--stop" ]; then
exit 0
fi
+# Readiness polling depends on it; fail with a truthful error instead of
+# letting the loop below report "Xvfb failed to start" when the real problem
+# is the missing probe.
+if ! command -v xdpyinfo >/dev/null 2>&1; then
+ echo "error: xdpyinfo not found (apt install x11-utils)" >&2
+ exit 1
+fi
+
# Fail closed if the display is already in use (live server or stale socket):
# the harness owns its X server and never reuses an existing one.
if [ -S "/tmp/.X11-unix/X${DISPLAY#:}" ] || { command -v xdpyinfo >/dev/null 2>&1 && xdpyinfo -display "${DISPLAY}" >/dev/null 2>&1; }; then
@@ -149,7 +157,12 @@ echo "${OPENBOX_PID}" >> "${PID_FILE}"
openbox_ready=0
for _ in $(seq 1 30); do
- if kill -0 "${OPENBOX_PID}" 2>/dev/null; then
+ if ! kill -0 "${OPENBOX_PID}" 2>/dev/null; then
+ break
+ fi
+ # Liveness proves nothing: poll until openbox actually claims the
+ # display (_NET_SUPPORTING_WM_CHECK resolves to a window id).
+ if xprop -root -notype _NET_SUPPORTING_WM_CHECK 2>/dev/null | grep -q '0x'; then
openbox_ready=1
break
fi
@@ -157,7 +170,7 @@ for _ in $(seq 1 30); do
done
if [ "$openbox_ready" -ne 1 ]; then
- echo "error: openbox failed to start within 3s" >&2
+ echo "error: openbox did not claim the display within 3s" >&2
[ -f "${OPENBOX_LOG}" ] && tail -n 50 "${OPENBOX_LOG}" >&2
exit 1
fi
From 9bf512d1c486ce14cf7b7d753711db54532aa044 Mon Sep 17 00:00:00 2001
From: naoNao89 <90588855+naoNao89@users.noreply.github.com>
Date: Mon, 14 Sep 2026 07:25:20 +0700
Subject: [PATCH 16/22] test(browser): make red runs and future flakes
attributable
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- fcitx5 now logs at '*=4' always: inter-event timing survives in the
uploaded log, so a dropped-key flake isn't invisible retroactively
- collect /proc/pressure alongside the logs: red run next to high cpu
pressure is a runner-statistics event, not a Lotus regression
- record 'fcitx5 --version' on every run — green must name the distro
build it validated when noble SRUs move the 5.1.x series
- json reporter (per-browser outputFile via E2E_JSON_OUTPUT) + timing
artifact uploaded on green runs too; the dot alone can't tell a 50ms
poll from a 1.9s one, and poll headroom is the flake leading indicator
---
.github/workflows/browser-e2e.yml | 22 ++++++++++++++++++++++
test/browser/playwright.config.ts | 1 +
test/browser/scripts/run-xvfb.sh | 4 +++-
3 files changed, 26 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/browser-e2e.yml b/.github/workflows/browser-e2e.yml
index e5f39188..dc025147 100644
--- a/.github/workflows/browser-e2e.yml
+++ b/.github/workflows/browser-e2e.yml
@@ -104,20 +104,41 @@ jobs:
- name: Start X11 Desktop environment and Fcitx5
run: |
+ # Record which distro fcitx5 this run validated — green suites
+ # must be attributable when noble SRUs move the 5.1.x series.
+ fcitx5 --version
./test/browser/scripts/run-xvfb.sh
- name: Run Chromium E2E tests
working-directory: test/browser
+ env:
+ E2E_JSON_OUTPUT: test-results/results-chromium.json
run: npm run test:chromium
- name: Run Firefox E2E tests
working-directory: test/browser
+ env:
+ E2E_JSON_OUTPUT: test-results/results-firefox.json
run: npm run test:firefox
+ # Timing data is the flake-rate experiment's raw material; the dot
+ # reporter alone can't tell a 50ms poll from a 1.9s one.
+ - name: Upload timing results
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: browser-e2e-timing
+ path: test/browser/test-results/*.json
+ if-no-files-found: ignore
+
- name: Collect diagnostics on failure
if: failure()
run: |
fcitx5-diagnose > /tmp/fcitx5-diagnose.log 2>&1 || true
+ # Runner stall vs engine bug, decided from the artifact: a red run
+ # next to high cpu pressure is a runner-statistics event, not a
+ # Lotus regression.
+ cat /proc/pressure/* > /tmp/pressure.txt 2>/dev/null || true
# Copy logs from isolated TEST_HOME to /tmp for artifact upload
cp "${TEST_HOME}/fcitx5.log" /tmp/fcitx5.log 2>/dev/null || true
cp "${TEST_HOME}/xvfb.log" /tmp/xvfb.log 2>/dev/null || true
@@ -135,6 +156,7 @@ jobs:
/tmp/fcitx5-diagnose.log
/tmp/xvfb.log
/tmp/openbox.log
+ /tmp/pressure.txt
if-no-files-found: ignore
- name: Stop X11 and Fcitx5
diff --git a/test/browser/playwright.config.ts b/test/browser/playwright.config.ts
index e82b8b03..22eb1e1f 100644
--- a/test/browser/playwright.config.ts
+++ b/test/browser/playwright.config.ts
@@ -13,6 +13,7 @@ export default defineConfig({
reporter: process.env.CI
? [
['dot'],
+ ['json', { outputFile: process.env.E2E_JSON_OUTPUT || 'test-results/results.json' }],
['html', { outputFolder: 'playwright-report', open: 'never' }],
]
: [
diff --git a/test/browser/scripts/run-xvfb.sh b/test/browser/scripts/run-xvfb.sh
index aa1931d7..dbcb0e6a 100755
--- a/test/browser/scripts/run-xvfb.sh
+++ b/test/browser/scripts/run-xvfb.sh
@@ -186,10 +186,12 @@ echo "XDG_CONFIG_HOME=${XDG_CONFIG_HOME}"
fcitx5_started=0
for attempt in 1 2 3; do
+ # '*=4' keeps inter-event timing visible in the uploaded log at
+ # negligible cost (~2s/test); '*=5' stays opt-in for local debugging.
if [ "${BROWSER_E2E_DEBUG:-0}" = "1" ]; then
fcitx5 -r --disable=wayland,waylandim --verbose '*=5' > "${FCITX_LOG}" 2>&1 &
else
- fcitx5 -r --disable=wayland,waylandim > "${FCITX_LOG}" 2>&1 &
+ fcitx5 -r --disable=wayland,waylandim --verbose '*=4' > "${FCITX_LOG}" 2>&1 &
fi
FCITX_PID=$!
From db9f29ec6ebda7065c3d68be7bef2738fcde7267 Mon Sep 17 00:00:00 2001
From: naoNao89 <90588855+naoNao89@users.noreply.github.com>
Date: Mon, 14 Sep 2026 07:31:15 +0700
Subject: [PATCH 17/22] ci(browser): gate on harness changes only; nightly
carries the drift signal
Panel consensus (4/7 lenses converged independently):
- pull_request trigger now fires only on test/browser/** + the workflow
file; engine PRs are gated by the headless ctest suite, which actually
covers the historical regression classes. Promotion to a blocking
src/** gate requires >=20 clean consecutive nightly runs; 3 consecutive
reds without a triage issue demotes to workflow_dispatch-only. Rule
written into the workflow header so it survives the author.
- nightly schedule on dev catches apt/runner-image drift before it
surfaces as a mystery red on someone else's PR
- drop libx11-dev (dev-tip removed X11 linkage in #496)
- encode the secret-free invariant: this job executes PR-authored code;
no repository/environment secrets, ever (contents: read + ephemeral
hosted runners complete the fork-PR trust model)
- dependabot npm entry for /test/browser: the Playwright pin transitively
pins browser builds and should not age silently
---
.github/dependabot.yml | 5 +++++
.github/workflows/browser-e2e.yml | 19 +++++++++++++------
2 files changed, 18 insertions(+), 6 deletions(-)
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index f24e4a42..3c58af6b 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -10,3 +10,8 @@ updates:
schedule:
interval: "weekly"
target-branch: "dev"
+ - package-ecosystem: "npm" # Playwright pins its browser builds; keep that pin from aging silently
+ directory: "/test/browser"
+ schedule:
+ interval: "weekly"
+ target-branch: "dev"
diff --git a/.github/workflows/browser-e2e.yml b/.github/workflows/browser-e2e.yml
index dc025147..f73ca124 100644
--- a/.github/workflows/browser-e2e.yml
+++ b/.github/workflows/browser-e2e.yml
@@ -1,7 +1,15 @@
+# Browser-level E2E for the X11/Preedit key-to-DOM plumbing (canary), not a
+# general regression net: engine logic is guarded by the headless ctest suite.
+# Gate rule: PRs run only when the harness itself changes; engine PRs see
+# this lane via nightly + dev pushes. Promotion to a blocking gate on
+# src/** requires >=20 clean consecutive nightly runs; 3 consecutive reds
+# without a linked triage issue demote it to workflow_dispatch-only.
name: Browser E2E
on:
workflow_dispatch:
+ schedule:
+ - cron: '23 3 * * *'
push:
paths:
- 'test/browser/**'
@@ -15,11 +23,11 @@ on:
paths:
- 'test/browser/**'
- '.github/workflows/browser-e2e.yml'
- - 'src/**'
- - 'bamboo/**'
- - 'data/**'
- - 'server/**'
- - '**/CMakeLists.txt'
+
+# INVARIANT: this job executes PR-authored code (scripts, lockfile, and this
+# file itself) and must stay secret-free — no repository/environment secrets,
+# ever; that plus `contents: read` and ephemeral hosted runners (the X11 -ac /
+# XTEST / --no-sandbox trust model) is what makes fork-PR execution safe.
permissions:
contents: read
@@ -77,7 +85,6 @@ jobs:
cmake \
ninja-build \
gettext \
- libx11-dev \
libfmt-dev \
librsvg2-bin \
libinput-dev \
From 6090c989f36a64cdd472d95a75a90951956c38c2 Mon Sep 17 00:00:00 2001
From: naoNao89 <90588855+naoNao89@users.noreply.github.com>
Date: Mon, 14 Sep 2026 08:32:46 +0700
Subject: [PATCH 18/22] test(browser): give the canary a spine; document the
harness contract
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- smoke blur/refocus now asserts the commit event (input/compositionend
data='tiếng') AFTER the blur watermark — value equality alone cannot tell
'committed on focus-out' from 'preedit silently discarded and recomposed',
and a FocusOut-commit mutation would pass 11/11 today
- README.md: the invariants that live only in shell comments today
(HOME-before-openbox ordering, _NET_SUPPORTING_WM_CHECK wait, private bus,
workers=1, retries=0, version attribution), the gate contract summary, and
the red-run triage path (PSI + '*=4' fcitx log + timing artifact)
- workflow header promotion rule tightened per flake math: >=20 consecutive
clean nightlies AND >=5 uncontaminated by src changes, demote at >1/10 —
20-clean alone would promote a 10%-flake suite ~12% of the time
---
.github/workflows/browser-e2e.yml | 9 ++++--
test/browser/README.md | 49 +++++++++++++++++++++++++++++++
test/browser/tests/smoke.spec.ts | 22 ++++++++++++--
3 files changed, 75 insertions(+), 5 deletions(-)
create mode 100644 test/browser/README.md
diff --git a/.github/workflows/browser-e2e.yml b/.github/workflows/browser-e2e.yml
index f73ca124..f5e6ae0e 100644
--- a/.github/workflows/browser-e2e.yml
+++ b/.github/workflows/browser-e2e.yml
@@ -1,9 +1,12 @@
# Browser-level E2E for the X11/Preedit key-to-DOM plumbing (canary), not a
# general regression net: engine logic is guarded by the headless ctest suite.
# Gate rule: PRs run only when the harness itself changes; engine PRs see
-# this lane via nightly + dev pushes. Promotion to a blocking gate on
-# src/** requires >=20 clean consecutive nightly runs; 3 consecutive reds
-# without a linked triage issue demote it to workflow_dispatch-only.
+# this lane via nightly + dev pushes. Promotion to a blocking gate on src/**
+# requires >=20 consecutive clean nightlies with >=5 of them unbroken by any
+# src/**/bamboo/** change (so the streak can't accrue against 20 different
+# engines); demotion back to workflow_dispatch-only at 3 consecutive reds
+# without a linked triage issue OR >1 failure per rolling 10 — 20-clean
+# alone would still promote a 10%-flake suite ~12% of the time.
name: Browser E2E
on:
diff --git a/test/browser/README.md b/test/browser/README.md
new file mode 100644
index 00000000..fc47e616
--- /dev/null
+++ b/test/browser/README.md
@@ -0,0 +1,49 @@
+# Browser E2E harness
+
+Real `fcitx5` session under Xvfb; keys injected via `xdotool` (XTEST); results
+asserted in Chromium/Firefox DOM via Playwright. What this lane guards is the
+key-to-DOM plumbing in `Mode=Preedit` (XTEST → fcitx5 → XIM/GTK path → browser
+→ committed text). Engine logic (Telex/VNI rules, macro, per-app mode rules,
+surrounding text) is guarded by the headless ctest suite in `test/` —
+deliberately NOT here, and the workflow header records the same scope.
+
+## Invariants (violating any of these produces green-but-meaningless runs)
+
+- The harness owns its X display: `DISPLAY` is never inherited, the run aborts
+ if the display is occupied (live server or stale socket), and readiness polls
+ `xdpyinfo` (hard dependency — no silent socket-only fallback).
+- `HOME`/`XDG_*` are exported BEFORE openbox starts, and openbox readiness
+ waits for `_NET_SUPPORTING_WM_CHECK` — the focus/raise policy in `rc.xml` is
+ load-bearing for `ensureActive()`; without the ordering, tests pass on
+ stock-default luck.
+- D-Bus is a private bus from `dbus-session.conf` (no
+ `standard_session_servicedirs`) — fcitx5 name ownership (`-r`) is scoped to
+ it, so the harness can coexist with a developer's running desktop.
+- `workers=1`/`fullyParallel=false`: one display, one focused window. Parallel
+ keystroke injection into the same X server is undefined behavior, not speed.
+- `retries=0`: red runs are data. Do not add retries; open a triage issue.
+- `fcitx5` comes from apt and its version is printed per run — a green run
+ only attests to the (addon SHA × fcitx5 version × runner image) triple it
+ actually executed.
+
+## CI gate contract
+
+Lives in `.github/workflows/browser-e2e.yml` header; summary: PRs are gated on
+harness changes only; nightlies + dev pushes carry the engine-facing signal.
+Promotion to a blocking `src/**` gate needs ≥20 consecutive clean nightlies
+with ≥5 uncontaminated by `src/**`/`bamboo/**` changes; demotion on 3 reds
+without a linked triage issue, or >1 failure per rolling 10.
+
+## Local use (Linux/X11 only)
+
+```bash
+scripts/run-browser-e2e.sh # creates TEST_HOME, starts stack, runs both browsers
+BROWSER_E2E_DISPLAY=:98 scripts/run-browser-e2e.sh # if :99 is taken
+```
+
+Triage on red: the failure artifact carries `input-events.json` (DOM-side),
+`fcitx5.log` at `*=4` (inter-event timing), xvfb/openbox logs, and
+`/proc/pressure` — high cpu pressure + failed poll ⇒ runner statistics event,
+not a Lotus regression. The json reporter timing artifact (`*.json`) is
+uploaded on green runs too; compare p95 poll margins against the 2 s
+`expect.poll` deadlines before blaming the engine.
diff --git a/test/browser/tests/smoke.spec.ts b/test/browser/tests/smoke.spec.ts
index bb627b9d..6f4f94d0 100644
--- a/test/browser/tests/smoke.spec.ts
+++ b/test/browser/tests/smoke.spec.ts
@@ -1,7 +1,7 @@
import { test, expect } from '@playwright/test';
import { clearInput, ensureActive, typeWithLotus, typeXdotool } from '../helpers/x11-input';
import { switchIM, activateIM } from '../helpers/fcitx5';
-import { attachEventLog } from '../helpers/events';
+import { attachEventLog, getEventLog } from '../helpers/events';
test.describe('Fcitx5 Lotus Smoke Tests', () => {
test.beforeEach(async ({ page }) => {
@@ -70,9 +70,27 @@ test.describe('Fcitx5 Lotus Smoke Tests', () => {
await typeWithLotus(page, input1, ['t', 'i', 'e', 'e', 'n', 'g', 's']);
await expect(input1).toHaveValue('tiếng');
- // Blur by focusing second input
+ // Blur by focusing second input. Lotus must commit the pending preedit
+ // as focus leaves input1; the value check alone cannot tell 'committed
+ // on blur' from 'preedit silently discarded/recomposed', so watermark
+ // the event log and require the commit event AFTER the blur.
+ const watermark = (await getEventLog(page)).length;
await ensureActive(page, input2);
await expect(input2).toBeFocused();
+ await expect
+ .poll(
+ async () =>
+ (await getEventLog(page))
+ .slice(watermark)
+ .some(
+ (e) =>
+ (e.type === 'input' || e.type === 'compositionend') &&
+ e.data === 'tiếng' &&
+ e.targetId === 'test-input'
+ ),
+ { timeout: 2000 }
+ )
+ .toBe(true);
// Refocus first input
await ensureActive(page, input1);
From ae09790a7cd25423366ef60c93ed06f8ffd4d6a9 Mon Sep 17 00:00:00 2001
From: naoNao89 <90588855+naoNao89@users.noreply.github.com>
Date: Mon, 14 Sep 2026 09:35:49 +0700
Subject: [PATCH 19/22] ci(browser): fix the timing artifact that silently
uploaded only Firefox
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Playwright wipes test-results/ between the two project runs, so the
chromium results.json never reached the artifact (confirmed in run
34792979491: 1 file uploaded, if-no-files-found: ignore hid the miss).
Move reporter output to reports/ (not wiped), upload both files, and make
absence an error instead of a shrug — a claim about per-browser timing
histograms has to carry its own receipts.
---
.github/workflows/browser-e2e.yml | 8 ++++----
test/browser/.gitignore | 1 +
test/browser/playwright.config.ts | 2 +-
3 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/.github/workflows/browser-e2e.yml b/.github/workflows/browser-e2e.yml
index f5e6ae0e..51bd02d5 100644
--- a/.github/workflows/browser-e2e.yml
+++ b/.github/workflows/browser-e2e.yml
@@ -122,13 +122,13 @@ jobs:
- name: Run Chromium E2E tests
working-directory: test/browser
env:
- E2E_JSON_OUTPUT: test-results/results-chromium.json
+ E2E_JSON_OUTPUT: reports/results-chromium.json
run: npm run test:chromium
- name: Run Firefox E2E tests
working-directory: test/browser
env:
- E2E_JSON_OUTPUT: test-results/results-firefox.json
+ E2E_JSON_OUTPUT: reports/results-firefox.json
run: npm run test:firefox
# Timing data is the flake-rate experiment's raw material; the dot
@@ -138,8 +138,8 @@ jobs:
uses: actions/upload-artifact@v7
with:
name: browser-e2e-timing
- path: test/browser/test-results/*.json
- if-no-files-found: ignore
+ path: test/browser/reports/*.json
+ if-no-files-found: error
- name: Collect diagnostics on failure
if: failure()
diff --git a/test/browser/.gitignore b/test/browser/.gitignore
index a9ba0ed2..80c5eaa0 100644
--- a/test/browser/.gitignore
+++ b/test/browser/.gitignore
@@ -1,5 +1,6 @@
node_modules/
playwright-report/
test-results/
+reports/
.playwright/
bun.lock
diff --git a/test/browser/playwright.config.ts b/test/browser/playwright.config.ts
index 22eb1e1f..77da55ad 100644
--- a/test/browser/playwright.config.ts
+++ b/test/browser/playwright.config.ts
@@ -13,7 +13,7 @@ export default defineConfig({
reporter: process.env.CI
? [
['dot'],
- ['json', { outputFile: process.env.E2E_JSON_OUTPUT || 'test-results/results.json' }],
+ ['json', { outputFile: process.env.E2E_JSON_OUTPUT || 'reports/results.json' }],
['html', { outputFolder: 'playwright-report', open: 'never' }],
]
: [
From d7be50a67045e7f99ec1fc360a70f4b5cc14064a Mon Sep 17 00:00:00 2001
From: naoNao89 <90588855+naoNao89@users.noreply.github.com>
Date: Mon, 14 Sep 2026 09:50:13 +0700
Subject: [PATCH 20/22] test(browser): fail up front when run locally off Linux
Xvfb/dbus/openbox/fcitx5 aren't startable on macOS; an upfront uname guard
beats a mid-script 'command not found' that looks like a harness bug.
---
test/browser/scripts/run-browser-e2e.sh | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/test/browser/scripts/run-browser-e2e.sh b/test/browser/scripts/run-browser-e2e.sh
index f29338f3..bcaca78e 100755
--- a/test/browser/scripts/run-browser-e2e.sh
+++ b/test/browser/scripts/run-browser-e2e.sh
@@ -3,6 +3,14 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# The stack this starts (Xvfb, private dbus session, openbox, fcitx5) is
+# X11/Linux-only; fail up front instead of mid-script with a 'command not
+# found' that reads like a harness bug.
+if [ "$(uname -s)" != "Linux" ]; then
+ echo "error: run-browser-e2e.sh requires Linux/X11 (use a container or a Linux box)" >&2
+ exit 1
+fi
+
# TEST_HOME is required by every script in this harness. The local entrypoint
# owns its creation; run-xvfb.sh --stop tears down the managed processes
# while keeping the directory for post-mortem logs.
From 6e711d9173b56d66e95f8c6386b13a034af14093 Mon Sep 17 00:00:00 2001
From: naoNao89 <90588855+naoNao89@users.noreply.github.com>
Date: Mon, 14 Sep 2026 15:36:34 +0700
Subject: [PATCH 21/22] test(browser): widen the canary's wrong-char coverage
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The 11 assertions pinned ~15 Telex compositions; a regression in any other
shipped rule (vowel hats aa/oo/ee, capital DD->Đ) stayed green on both
browsers. Adds a table-driven corpus for the hat rules and the uppercase
commit path — one test, four more discriminating value pairs.
---
test/browser/tests/smoke.spec.ts | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/test/browser/tests/smoke.spec.ts b/test/browser/tests/smoke.spec.ts
index 6f4f94d0..ce814090 100644
--- a/test/browser/tests/smoke.spec.ts
+++ b/test/browser/tests/smoke.spec.ts
@@ -41,6 +41,21 @@ test.describe('Fcitx5 Lotus Smoke Tests', () => {
await expect(input).toHaveValue('tiếng việt');
});
+ test('telex vowel-hat and capitalization rules compose', async ({ page }) => {
+ const input = page.locator('#test-input');
+ const cases: Array<[string[], string]> = [
+ [['a', 'a'], 'â'],
+ [['o', 'o'], 'ô'],
+ [['e', 'e'], 'ê'],
+ [['D', 'D'], 'Đ'],
+ ];
+ for (const [keys, expected] of cases) {
+ await clearInput(page, input);
+ await typeWithLotus(page, input, keys);
+ await expect(input, `keys=${keys.join('+')}`).toHaveValue(expected);
+ }
+ });
+
test('types telex phrase in textarea', async ({ page }) => {
const textarea = page.locator('#test-textarea');
await clearInput(page, textarea);
From 966ae37bbda4f4b75464eec9b30d7eb634df9303 Mon Sep 17 00:00:00 2001
From: naoNao89 <90588855+naoNao89@users.noreply.github.com>
Date: Mon, 14 Sep 2026 15:42:25 +0700
Subject: [PATCH 22/22] docs(browser): record the fidelity caveats the
cross-model audit surfaced
ShareInputState=All + resetStateWhenFocusIn=No buy CLI-switch determinism at
the cost of per-window realism (cross-field isolation is out of scope by
design); corpus pins Telex hat/DD/tones, not VNI or spellcheck edges. Naming
these in the durable doc prevents the green badge from being read wider than
it is.
---
test/browser/README.md | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/test/browser/README.md b/test/browser/README.md
index fc47e616..53e5bc68 100644
--- a/test/browser/README.md
+++ b/test/browser/README.md
@@ -26,6 +26,12 @@ deliberately NOT here, and the workflow header records the same scope.
only attests to the (addon SHA × fcitx5 version × runner image) triple it
actually executed.
+Known fidelity caveats: `ShareInputState=All` + `resetStateWhenFocusIn=No`
+are set so `fcitx5-remote` switching works without focus games — real users
+mostly run per-window state, so cross-field isolation bugs are NOT covered
+here by design; and the corpus pins one Telex configuration (hats/DD/tones),
+not VNI or spellcheck edges. Widen both in the phase-2 issue, not ad hoc.
+
## CI gate contract
Lives in `.github/workflows/browser-e2e.yml` header; summary: PRs are gated on