"""Opt-in real Wayland/GTK/Rime integration checks against the build-tree preview. FERESE_TEST_RIME=1 python3 scripts/tests/test_rime_native_isolated.py Only disposable fixture entries are modified. The probe refuses host displays. """ import importlib.util import json import os from pathlib import Path import subprocess import time import unittest ROOT = Path(__file__).resolve().parents[2] spec = importlib.util.spec_from_file_location("preview", ROOT / "scripts/rime-preview.py") preview = importlib.util.module_from_spec(spec) spec.loader.exec_module(preview) @unittest.skipUnless(os.environ.get("FERESE_TEST_RIME") == "1", "opt-in native Rime validation") class NativeRimeTest(unittest.TestCase): def test_native_pipeline_and_process_boundary(self): preview.start() preview.start_ime() results = [] def state(): return json.loads((preview.DATA / "entry-state.json").read_text()) def expect(text): preview.wait_for(lambda: state()["normal"] == text, f"Expected {text!r}; latest state {state()}", timeout=4) def probe(*args): preview.probe(list(args)) time.sleep(.12) def clear(): preview.focus_entry() probe("--key", "Escape") preview.action("clear") expect("") def screenshot(name): c = preview.context() p = subprocess.run([str(ROOT / "target/debug/feresectl"), "--socket", c["socket"], "screenshot"], capture_output=True, check=True) (preview.DATA / name).write_bytes(p.stdout) clear() baseline_windows={window["id"] for window in preview.windows()} probe("--keys", "nihao") expect("") # Preedit is displayed, not prematurely committed. screenshot("candidate-normal.png") self.assertEqual({window["id"] for window in preview.windows()},baseline_windows) probe("--keys", " ") expect("你好") results.append("native GTK preedit, candidate popup and space commit") clear() probe("--keys", "nihao1") expect("你好") results.append("numeric candidate selection") clear() probe("--keys", "ni") pid = preview.prop(preview.IME, "MainPID") def composition(): lines = preview.run(["journalctl", "--user", f"_PID={pid}", "--no-pager", "-o", "cat"]).splitlines() return [json.loads(line) for line in lines if line.startswith('{') and '"event":"composition"' in line][-1] self.assertEqual(composition()["page"], 0) probe("--keys", "=") self.assertEqual(composition()["page"], 1) screenshot("candidate-horizontal-page2.png") probe("--keys", "-") self.assertEqual(composition()["page"], 0) probe("--chord", "Shift_L+equal") self.assertEqual(composition()["page"], 1) self.assertFalse(composition()["ascii"]) probe("--keys", "-1") expect("你") results.append("horizontal candidates page with number-row minus, equal and Shift+equal") clear() probe("--keys", "nihao", "--key", "BackSpace", "--keys", "o ") expect("你好") results.append("composition backspace and repair") clear() probe("--keys", "nihao", "--key", "Escape") expect("") probe("--key", "Shift_L", "--keys", "abc123") expect("abc123") time.sleep(.8) expect("abc123") # Rejected keys must receive paired releases. probe("--keys", "-=") expect("abc123-=") probe("--chord", "Control_L+a", "--key", "BackSpace") expect("") probe("--key", "Shift_L", "--keys", "nihao ") expect("你好") results.append("escape, Shift mode switching, ASCII and Ctrl+A release pairing") clear() preview.action("password") time.sleep(.2) pid = preview.prop(preview.IME, "MainPID") def compositions(): return preview.run(["journalctl", "--user", f"_PID={pid}", "--no-pager", "-o", "cat"]).count('"event":"composition"') before = compositions() probe("--keys", "nihao") preview.wait_for(lambda: state()["password_length"] == 5, "Sensitive field did not receive literal keys") self.assertEqual(compositions(), before) screenshot("sensitive-no-candidates.png") results.append("sensitive field bypasses Rime and has no candidates") clear() c = preview.context() control = [str(ROOT / "target/debug/feresectl"), "--socket", c["socket"]] subprocess.run(control + ["toggle-maximized"], check=True) time.sleep(.4) preview.action("bottom") time.sleep(.2) probe("--keys", "nihao") screenshot("candidate-bottom-flipped.png") probe("--keys", " ") expect("你好") preview.action("top") subprocess.run(control + ["toggle-maximized"], check=True) time.sleep(.3) results.append("bottom-edge native candidate composition and commit; screenshot for placement inspection") clear() probe("--keys", "nihao") theme = json.loads(preview.run(control + ["--json", "theme", "get"])) original_mode = theme["mode"] subprocess.run(control + ["theme", "mode", "light"], check=True, capture_output=True) preview.wait_for(lambda: json.loads(preview.run(control + ["--json", "theme", "get"]))["mode"] == "light", "Light theme did not finish resolving") time.sleep(.35) screenshot("candidate-theme-light.png") subprocess.run(control + ["theme", "mode", original_mode], check=True, capture_output=True) preview.wait_for(lambda: json.loads(preview.run(control + ["--json", "theme", "get"]))["mode"] == original_mode, "Original theme did not finish resolving") time.sleep(.35) results.append("candidate UI follows Ferese light/dark theme through its existing IPC") clear() probe("--keys", "nihao") compositor_pid = preview.prop(preview.MAIN, "MainPID") old_ime_pid = preview.prop(preview.IME, "MainPID") subprocess.run(["systemctl", "--user", "kill", "--signal=KILL", preview.IME], check=True) preview.wait_for(lambda: preview.prop(preview.IME, "MainPID") not in ("", "0", old_ime_pid) and preview.prop(preview.IME, "ActiveState") == "active", "IME did not automatically recover") self.assertEqual(preview.prop(preview.MAIN, "ActiveState"), "active") self.assertEqual(preview.prop(preview.MAIN, "MainPID"), compositor_pid) preview.wait_for(lambda: '"event":"ready"' in preview.run( ["journalctl", "--user", f"_PID={preview.prop(preview.IME, 'MainPID')}", "--no-pager", "-o", "cat"]), "Recovered IME was not ready") time.sleep(.3) screenshot("ime-crash-desktop-survives.png") probe("--keys", "nihao ") expect("你好") results.append("IME SIGKILL automatically restarts with desktop alive and Chinese restored") # The validation driver must refuse the real host before creating a keyboard. host = Path(os.environ.get("WAYLAND_DISPLAY", "wayland-0")) if not host.is_absolute(): host = Path(os.environ["XDG_RUNTIME_DIR"]) / host p = subprocess.run([str(ROOT / "target/debug/ferese-rime-probe"), "--keys", "x"], env={**os.environ, "WAYLAND_DISPLAY": str(host), "FERESE_TEST_INPUT": "1"}, text=True, capture_output=True) self.assertNotEqual(p.returncode, 0) self.assertIn("not an isolated Ferese", p.stderr) results.append("test driver refuses host desktop") pid = preview.prop(preview.IME, "MainPID") maps = (Path("/proc") / pid / "maps").read_text() self.assertIn("librime.so", maps) self.assertNotIn("libibus", maps) self.assertNotIn("libfcitx", maps) results.append("independent process links librime, with no IBus or Fcitx library") clear() probe("--keys", "nihao") (preview.DATA / "validation.json").write_text(json.dumps({"status": "passed", "checks": results}, ensure_ascii=False, indent=2) + "\n") if __name__ == "__main__": unittest.main()