From da5f434bcccf3dc723d2e8e4492bb6c34b0eb18e Mon Sep 17 00:00:00 2001 From: Ihar Hrachyshka Date: Sun, 13 Sep 2026 21:47:13 -0400 Subject: [PATCH] nixos/test-driver: implement nspawn keyboard input Keyboard input helpers currently live on QemuMachine and send keys through the QEMU monitor. Tests moved to nspawn must consequently know about the backend and invoke xdotool themselves. uinput would require exposing the host's /dev/uinput to the sandbox. Its device belongs to the shared kernel input subsystem rather than to a container-private kernel, adding a privileged host dependency. https://docs.kernel.org/input/uinput.html Define send_key on BaseMachine and move the backend-neutral send_chars helper there. Keep QEMU input unchanged, and translate the portable key vocabulary to X11 key names for nspawn. Install xdotool in instrumented nspawn containers. This lets tests continue to express keyboard input through the machine interface regardless of backend. Assisted-by: Codex gpt-5.6-sol high --- .../src/test_driver/machine/__init__.py | 139 +++++++++++++++--- .../modules/testing/test-instrumentation.nix | 8 +- 2 files changed, 124 insertions(+), 23 deletions(-) diff --git a/nixos/lib/test-driver/src/test_driver/machine/__init__.py b/nixos/lib/test-driver/src/test_driver/machine/__init__.py index 265b9fc47dc8..79d9e3cd459c 100644 --- a/nixos/lib/test-driver/src/test_driver/machine/__init__.py +++ b/nixos/lib/test-driver/src/test_driver/machine/__init__.py @@ -101,6 +101,56 @@ CHAR_TO_KEY = { ")": "shift-0x0B", } +# send_key uses QEMU key names, with "\n" as a driver alias for Return. +# QEMU names: https://www.qemu.org/docs/master/interop/qemu-qmp-ref.html#enum-QKeyCode +# xdotool names: https://github.com/jordansissel/xdotool/blob/main/xdotool.pod#keyboard-commands +# QEMU key name (or "\n") -> X11 keysym name. +XDOTOOL_KEY_ALIASES = { + "\n": "Return", + "alt": "Alt_L", + "alt_r": "Alt_R", + "backspace": "BackSpace", + "delete": "Delete", + "down": "Down", + "esc": "Escape", + "kp_enter": "KP_Enter", + "left": "Left", + "meta_l": "Super_L", + "meta_r": "Super_R", + "ret": "Return", + "right": "Right", + "shift": "Shift_L", + "spc": "space", + "tab": "Tab", + "up": "Up", +} + +# QEMU modifier name -> xdotool modifier name. +XDOTOOL_MODIFIER_ALIASES = { + "alt": "alt", + "ctrl": "ctrl", + "meta_l": "super", + "meta_r": "super", + "shift": "shift", +} + + +def xdotool_key_sequence(key: str) -> str: + """Translate a QEMU-style key chord to xdotool syntax. + + For example, "ctrl-alt-ret" becomes "ctrl+alt+Return". + """ + parts = key.split("-") + modifiers = [] + while len(parts) > 1 and parts[0] in XDOTOOL_MODIFIER_ALIASES: + modifiers.append(XDOTOOL_MODIFIER_ALIASES[parts.pop(0)]) + + base = "-".join(parts) + base = XDOTOOL_KEY_ALIASES.get(base, base) + if re.fullmatch(r"f\d+", base): + base = base.upper() + return "+".join([*modifiers, base]) + def make_command(args: list) -> str: return " ".join(map(shlex.quote, (map(str, args)))) @@ -624,6 +674,44 @@ class BaseMachine(ABC): with self.nested("waiting for a window to appear"): retry(window_is_visible, as_timedelta(timeout)) + @abstractmethod + def send_key( + self, + key: str, + delay: Duration | None = dt.timedelta(milliseconds=10), + log: bool | None = True, + ) -> None: + """ + Simulate pressing a key or key chord, e.g., + `send_key("ctrl-alt-delete")`. + + Key names follow QEMU's sendkey vocabulary: `ret` means Enter and + `spc` means Space. Named keys and modifiers are case-sensitive; + use lowercase names, e.g. `f1` or `ctrl-alt-f1`. Literal characters + preserve case: `F` types uppercase F, while `f` types lowercase f. + See https://www.qemu.org/docs/master/interop/qemu-qmp-ref.html#enum-QKeyCode. + + Portable key names include printable ASCII characters, function keys, + `tab`, `ret`, `esc`, `spc`, `backspace`, `delete`, `left`, `right`, + `up`, `down`, and `kp_enter`. Chords may use the `ctrl`, `alt`, + `shift`, `meta_l`, and `meta_r` modifiers. Machine backends may accept + additional key names. + """ + ... + + def send_chars( + self, chars: str, delay: Duration | None = dt.timedelta(milliseconds=10) + ) -> None: + r""" + Simulate typing a sequence of characters on the virtual keyboard, + e.g., `send_chars("foobar\n")` will type the string `foobar` + followed by the Enter key. + """ + _warn_if_numeric_duration(delay, "send_chars") + with self.nested(f"sending keys {repr(chars)}"): + for char in chars: + self.send_key(char, delay, log=False) + @contextmanager def _managed_screenshot(self) -> Generator[Path]: """ @@ -1226,19 +1314,6 @@ class QemuMachine(BaseMachine): if elapsed >= timeout: raise TimeoutError - def send_chars( - self, chars: str, delay: Duration | None = dt.timedelta(milliseconds=10) - ) -> None: - r""" - Simulate typing a sequence of characters on the virtual keyboard, - e.g., `send_chars("foobar\n")` will type the string `foobar` - followed by the Enter key. - """ - _warn_if_numeric_duration(delay, "send_chars") - with self.nested(f"sending keys {repr(chars)}"): - for char in chars: - self.send_key(char, delay, log=False) - def wait_for_file( self, filename: str, timeout: Duration = dt.timedelta(minutes=15) ) -> None: @@ -1363,13 +1438,6 @@ class QemuMachine(BaseMachine): delay: Duration | None = dt.timedelta(milliseconds=10), log: bool | None = True, ) -> None: - """ - Simulate pressing keys on the virtual keyboard, e.g., - `send_key("ctrl-alt-delete")`. - - Please also refer to the QEMU documentation for more information on the - input syntax: https://en.wikibooks.org/wiki/QEMU/Monitor#sendkey_keys - """ _warn_if_numeric_duration(delay, "send_key") key = CHAR_TO_KEY.get(key, key) context = self.nested(f"sending key {repr(key)}") if log else nullcontext() @@ -1671,6 +1739,37 @@ class NspawnMachine(BaseMachine): with self.nested("waiting for the X11 server"): retry(check_x, as_timedelta(timeout)) + def send_key( + self, + key: str, + delay: Duration | None = dt.timedelta(milliseconds=10), + log: bool | None = True, + ) -> None: + _warn_if_numeric_duration(delay, "send_key") + context = self.nested(f"sending key {repr(key)}") if log else nullcontext() + with context: + # Type literal characters to avoid key syntax, e.g. "+" as a chord separator. + if len(key) == 1 and key.isprintable(): + command = [ + "xdotool", + "type", + "--clearmodifiers", + "--delay", + 0, + "--", + key, + ] + else: + command = [ + "xdotool", + "key", + "--clearmodifiers", + xdotool_key_sequence(key), + ] + self.succeed(make_command(command)) + if delay is not None: + time.sleep(as_seconds(delay)) + @contextmanager def _managed_screenshot(self) -> Generator[Path]: # xwd writes inside the container and xwdtopnm reads on the host, so diff --git a/nixos/modules/testing/test-instrumentation.nix b/nixos/modules/testing/test-instrumentation.nix index c050e2dc9381..085b6265548d 100644 --- a/nixos/modules/testing/test-instrumentation.nix +++ b/nixos/modules/testing/test-instrumentation.nix @@ -233,11 +233,13 @@ in environment.systemPackages = [ pkgs.xwininfo ] - ++ lib.optional config.boot.isNspawnContainer ( + ++ lib.optionals config.boot.isNspawnContainer [ # Unlike a QEMU machine, an nspawn container has no monitor that can - # capture its display, so the test driver uses `xwd' inside the container. + # capture its display or inject keyboard input, so the test driver uses + # `xwd' and `xdotool' inside the container instead. + pkgs.xdotool pkgs.xwd - ); + ]; # Log everything to the serial console. services.journald.settings.Journal = {