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" + ] +}