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 = {