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
This commit is contained in:
Ihar Hrachyshka
2026-09-13 21:47:13 -04:00
parent 90ad9c9db4
commit da5f434bcc
2 changed files with 124 additions and 23 deletions

View File

@@ -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

View File

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