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
new file mode 100644
index 00000000..51bd02d5
--- /dev/null
+++ b/.github/workflows/browser-e2e.yml
@@ -0,0 +1,175 @@
+# 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 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:
+ workflow_dispatch:
+ schedule:
+ - cron: '23 3 * * *'
+ 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'
+
+# 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
+
+concurrency:
+ group: browser-e2e-${{ github.ref }}
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' && !startsWith(github.ref, 'refs/tags/') }}
+
+jobs:
+ browser-e2e:
+ name: Browser E2E (X11 / Xvfb)
+ runs-on: ubuntu-24.04
+ env:
+ 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
+ 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 \
+ x11-utils \
+ 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 \
+ 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: |
+ # 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: reports/results-chromium.json
+ run: npm run test:chromium
+
+ - name: Run Firefox E2E tests
+ working-directory: test/browser
+ env:
+ E2E_JSON_OUTPUT: reports/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/reports/*.json
+ if-no-files-found: error
+
+ - 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
+ 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
+ /tmp/pressure.txt
+ if-no-files-found: ignore
+
+ - name: Stop X11 and Fcitx5
+ if: always()
+ run: |
+ ./test/browser/scripts/run-xvfb.sh --stop || true
diff --git a/test/browser/.gitignore b/test/browser/.gitignore
new file mode 100644
index 00000000..80c5eaa0
--- /dev/null
+++ b/test/browser/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+playwright-report/
+test-results/
+reports/
+.playwright/
+bun.lock
diff --git a/test/browser/README.md b/test/browser/README.md
new file mode 100644
index 00000000..53e5bc68
--- /dev/null
+++ b/test/browser/README.md
@@ -0,0 +1,55 @@
+# 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.
+
+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
+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/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..81d0b9ba
--- /dev/null
+++ b/test/browser/fixtures/server.mjs
@@ -0,0 +1,82 @@
+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) => {
+ try {
+ 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);
+ });
+ } catch {
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
+ res.end('Bad Request');
+ }
+});
+
+server.listen(PORT, HOST, () => {
+ console.log(`Server listening on http://127.0.0.1:${PORT}`);
+});
+
+function handleShutdown() {
+ server.close(() => {
+ process.exit(0);
+ });
+ server.closeAllConnections();
+ setTimeout(() => process.exit(0), 2000).unref();
+}
+
+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..e123d268
--- /dev/null
+++ b/test/browser/helpers/events.ts
@@ -0,0 +1,64 @@
+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. 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(() => 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
new file mode 100644
index 00000000..fa85276f
--- /dev/null
+++ b/test/browser/helpers/fcitx5.ts
@@ -0,0 +1,85 @@
+import { execFile } from 'node:child_process';
+import { promisify } from 'node:util';
+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').
+ */
+export async function getActiveIM(): Promise {
+ try {
+ const { stdout } = await execFileAsync('fcitx5-remote', ['-n']);
+ return stdout.trim();
+ } catch {
+ return '';
+ }
+}
+
+/**
+ * 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]);
+ 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'
+ );
+ }
+}
+/**
+ * Activates the input method engine (equivalent to fcitx5-remote -o).
+ */
+export async function activateIM(): Promise {
+ await execFileAsync('fcitx5-remote', ['-o']);
+ await waitForState(
+ async () => (await fcitx5State()) === 2,
+ 'fcitx5 to report active state'
+ );
+}
+
diff --git a/test/browser/helpers/x11-input.ts b/test/browser/helpers/x11-input.ts
new file mode 100644
index 00000000..a63f3d5b
--- /dev/null
+++ b/test/browser/helpers/x11-input.ts
@@ -0,0 +1,125 @@
+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);
+
+/**
+ * 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 until the X11
+ * window is active and the browser has processed the focus (IM context ready).
+ */
+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');
+
+ // Force a genuine focus transition: clicking an already-focused element
+ // 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 () => {
+ 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);
+}
+
+/**
+ * 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('');
+}
+
+/**
+ * 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..77da55ad
--- /dev/null
+++ b/test/browser/playwright.config.ts
@@ -0,0 +1,71 @@
+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'],
+ ['json', { outputFile: process.env.E2E_JSON_OUTPUT || 'reports/results.json' }],
+ ['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',
+ url: 'http://127.0.0.1:3000/index.html',
+ reuseExistingServer: !process.env.CI,
+ },
+});
diff --git a/test/browser/scripts/run-browser-e2e.sh b/test/browser/scripts/run-browser-e2e.sh
new file mode 100755
index 00000000..bcaca78e
--- /dev/null
+++ b/test/browser/scripts/run-browser-e2e.sh
@@ -0,0 +1,33 @@
+#!/usr/bin/env bash
+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.
+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
new file mode 100755
index 00000000..dbcb0e6a
--- /dev/null
+++ b/test/browser/scripts/run-xvfb.sh
@@ -0,0 +1,254 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [ -z "${TEST_HOME:-}" ]; then
+ 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
+
+# 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"
+XVFB_LOG="${TEST_HOME}/xvfb.log"
+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
+ 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
+ # 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"
+ 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
+ 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
+
+: > "${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: 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}"
+
+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
+
+# 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 &
+OPENBOX_PID=$!
+echo "${OPENBOX_PID}" >> "${PID_FILE}"
+
+openbox_ready=0
+for _ in $(seq 1 30); do
+ 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
+ sleep 0.1
+done
+
+if [ "$openbox_ready" -ne 1 ]; then
+ echo "error: openbox did not claim the display within 3s" >&2
+ [ -f "${OPENBOX_LOG}" ] && tail -n 50 "${OPENBOX_LOG}" >&2
+ exit 1
+fi
+
+# 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
+ # '*=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 --verbose '*=4' > "${FCITX_LOG}" 2>&1 &
+ fi
+ FCITX_PID=$!
+
+ # 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
+ # 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
+
+ # 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
+
+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..66385287
--- /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
+ 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}"
+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..b3da4241
--- /dev/null
+++ b/test/browser/tests/control.spec.ts
@@ -0,0 +1,69 @@
+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('đ');
+ // 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.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');
+ 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');
+ 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');
+ 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..ce814090
--- /dev/null
+++ b/test/browser/tests/smoke.spec.ts
@@ -0,0 +1,120 @@
+import { test, expect } from '@playwright/test';
+import { clearInput, ensureActive, typeWithLotus, typeXdotool } from '../helpers/x11-input';
+import { switchIM, activateIM } from '../helpers/fcitx5';
+import { attachEventLog, getEventLog } 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('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);
+ 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. 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);
+ 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..a2758406
--- /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 { 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..cc23ca74
--- /dev/null
+++ b/test/browser/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "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"
+ ]
+}