From ae813a00c8bc450a61c453152ed70ea165c8fb8a Mon Sep 17 00:00:00 2001 From: Adam Dinwoodie Date: Wed, 18 Feb 2026 10:50:58 +0000 Subject: [PATCH 01/23] nixos-install-tools: handle spaces in mount paths If a btrfs or bcachefs mountpoint has a space or some other special characters, these will mangle the command that nixos-generate-config runs to get information about the mount. Avoid this by switching from the "magic" form of calling the `open` function to the "three-argument" form, where each command argument is specified separately. https://perldoc.perl.org/functions/open#Whitespace-and-special-characters-in-the-filename-argument Fixes #491738 --- nixos/modules/installer/tools/nixos-generate-config.pl | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/nixos/modules/installer/tools/nixos-generate-config.pl b/nixos/modules/installer/tools/nixos-generate-config.pl index 96d0d68e5c93..ac406d2dfce3 100644 --- a/nixos/modules/installer/tools/nixos-generate-config.pl +++ b/nixos/modules/installer/tools/nixos-generate-config.pl @@ -24,8 +24,7 @@ sub uniq { } sub runCommand { - my ($cmd) = @_; - open FILE, "$cmd 2>&1 |" or die "Failed to execute: $cmd\n"; + open FILE, "-|", @_ or die "Failed to execute: @_\n"; my @ret = ; close FILE; return ($?, @ret); @@ -458,7 +457,7 @@ EOF # Is this a btrfs filesystem? if ($fsType eq "btrfs") { - my ($status, @info) = runCommand("@btrfs@ subvol show $rootDir$mountPoint"); + my ($status, @info) = runCommand("@btrfs@", "subvol", "show", "$rootDir$mountPoint"); if ($status != 0 || join("", @info) =~ /ERROR:/) { die "Failed to retrieve subvolume info for $mountPoint\n"; } @@ -505,7 +504,7 @@ EOF # This should work for single and multi-device systems. # still needs subvolume support if ($fsType eq "bcachefs") { - my ($status, @info) = runCommand("@bcachefs@ fs usage $rootDir$mountPoint"); + my ($status, @info) = runCommand("@bcachefs@", "fs", "usage", "$rootDir$mountPoint"); my $UUID = $info[0]; if ($status == 0 && $UUID =~ /^Filesystem:[ \t\n]*([0-9a-z-]+)/) { From 0ef5d5f2f089d8ad578fc17ff7dca4fbd2226966 Mon Sep 17 00:00:00 2001 From: Ihar Hrachyshka Date: Fri, 17 Jul 2026 15:14:08 -0400 Subject: [PATCH 02/23] nixos/test-driver: share graphical helpers Window queries and screenshot/OCR helpers only depend on the common machine interface, but currently live on QemuMachine. Nspawn tests therefore fail type checking with errors such as: NspawnMachine has no attribute wait_for_window Move the helpers to BaseMachine without changing their implementations. Keep a default unsupported screenshot backend so other machine types remain constructible until they provide one. Assisted-by: Codex gpt-5.6-sol xhigh --- .../src/test_driver/machine/__init__.py | 218 +++++++++--------- 1 file changed, 112 insertions(+), 106 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 596627a79491..dd46ca69b563 100644 --- a/nixos/lib/test-driver/src/test_driver/machine/__init__.py +++ b/nixos/lib/test-driver/src/test_driver/machine/__init__.py @@ -596,6 +596,118 @@ class BaseMachine(ABC): with self.nested(f"waiting for file '{filename}'"): retry(check_file, as_timedelta(timeout)) + def get_window_names(self) -> list[str]: + return self.succeed( + r"xwininfo -root -tree | sed 's/.*0x[0-9a-f]* \"\([^\"]*\)\".*/\1/; t; d'" + ).splitlines() + + def wait_for_window( + self, regexp: str, timeout: Duration = dt.timedelta(minutes=15) + ) -> None: + """ + Wait until an X11 window has appeared whose name matches the given + regular expression, e.g., `wait_for_window("Terminal")`. + """ + _warn_if_numeric_duration(timeout, "wait_for_window") + pattern = re.compile(regexp) + + def window_is_visible(last_try: bool) -> bool: + names = self.get_window_names() + if last_try: + self.log( + f"Last chance to match {regexp} on the window list," + + " which currently contains: " + + ", ".join(names) + ) + return any(pattern.search(name) for name in names) + + with self.nested("waiting for a window to appear"): + retry(window_is_visible, as_timedelta(timeout)) + + @contextmanager + def _managed_screenshot(self) -> Generator[Path]: + raise MachineError(f"Screenshots are not supported by {type(self).__name__}") + yield Path() + + def screenshot(self, filename: str) -> None: + """ + Take a picture of the display of the machine, in PNG format. + The screenshot will be available in the derivation output. + """ + if "." not in filename: + filename += ".png" + if "/" not in filename: + filename = os.path.join(self.out_dir, filename) + + with self.nested( + f"making screenshot {filename}", + {"image": os.path.basename(filename)}, + ): + with self._managed_screenshot() as screenshot_path: + ret = subprocess.run( + f"pnmtopng '{screenshot_path}' > '{filename}'", shell=True + ) + if ret.returncode != 0: + raise MachineError( + f"Cannot convert screenshot (pnmtopng returned code {ret.returncode})" + ) + + def get_screen_text_variants(self) -> list[str]: + """ + Return a list of different interpretations of what is currently + visible on the machine's screen using optical character + recognition. The number and order of the interpretations is not + specified and is subject to change, but if no exception is raised at + least one will be returned. + + ::: {.note} + This requires [`enableOCR`](#test-opt-enableOCR) to be set to `true`. + ::: + """ + with self._managed_screenshot() as screenshot_path: + return perform_ocr_variants_on_screenshot(screenshot_path) + + def get_screen_text(self) -> str: + """ + Return a textual representation of what is currently visible on the + machine's screen using optical character recognition. + + ::: {.note} + This requires [`enableOCR`](#test-opt-enableOCR) to be set to `true`. + ::: + """ + with self._managed_screenshot() as screenshot_path: + return perform_ocr_on_screenshot(screenshot_path) + + def wait_for_text( + self, regex: str, timeout: Duration = dt.timedelta(minutes=15) + ) -> None: + """ + Wait until the supplied regular expressions matches the textual + contents of the screen by using optical character recognition (see + `get_screen_text` and `get_screen_text_variants`). + + ::: {.note} + This requires [`enableOCR`](#test-opt-enableOCR) to be set to `true`. + ::: + """ + + _warn_if_numeric_duration(timeout, "wait_for_text") + + def screen_matches(last_try: bool) -> bool: + variants = self.get_screen_text_variants() + for text in variants: + if re.search(regex, text) is not None: + return True + + if last_try: + self.log(f"Last OCR attempt failed. Text was: {variants}") + + return False + + with self.nested(f"waiting for {regex} to appear on screen"): + retry(screen_matches, as_timedelta(timeout)) + def wait_for_open_port( self, port: int, @@ -1199,84 +1311,6 @@ class QemuMachine(BaseMachine): self.send_monitor_command(f"screendump {screenshot_path}") yield screenshot_path - def screenshot(self, filename: str) -> None: - """ - Take a picture of the display of the virtual machine, in PNG format. - The screenshot will be available in the derivation output. - """ - if "." not in filename: - filename += ".png" - if "/" not in filename: - filename = os.path.join(self.out_dir, filename) - - with self.nested( - f"making screenshot {filename}", - {"image": os.path.basename(filename)}, - ): - with self._managed_screenshot() as screenshot_path: - ret = subprocess.run( - f"pnmtopng '{screenshot_path}' > '{filename}'", shell=True - ) - if ret.returncode != 0: - raise MachineError( - f"Cannot convert screenshot (pnmtopng returned code {ret.returncode})" - ) - - def get_screen_text_variants(self) -> list[str]: - """ - Return a list of different interpretations of what is currently - visible on the machine's screen using optical character - recognition. The number and order of the interpretations is not - specified and is subject to change, but if no exception is raised at - least one will be returned. - - ::: {.note} - This requires [`enableOCR`](#test-opt-enableOCR) to be set to `true`. - ::: - """ - with self._managed_screenshot() as screenshot_path: - return perform_ocr_variants_on_screenshot(screenshot_path) - - def get_screen_text(self) -> str: - """ - Return a textual representation of what is currently visible on the - machine's screen using optical character recognition. - - ::: {.note} - This requires [`enableOCR`](#test-opt-enableOCR) to be set to `true`. - ::: - """ - with self._managed_screenshot() as screenshot_path: - return perform_ocr_on_screenshot(screenshot_path) - - def wait_for_text( - self, regex: str, timeout: Duration = dt.timedelta(minutes=15) - ) -> None: - """ - Wait until the supplied regular expressions matches the textual - contents of the screen by using optical character recognition (see - `get_screen_text` and `get_screen_text_variants`). - - ::: {.note} - This requires [`enableOCR`](#test-opt-enableOCR) to be set to `true`. - ::: - """ - _warn_if_numeric_duration(timeout, "wait_for_text") - - def screen_matches(last_try: bool) -> bool: - variants = self.get_screen_text_variants() - for text in variants: - if re.search(regex, text) is not None: - return True - - if last_try: - self.log(f"Last OCR attempt failed. Text was: {variants}") - - return False - - with self.nested(f"waiting for {regex} to appear on screen"): - retry(screen_matches, as_timedelta(timeout)) - def wait_for_console_text( self, regex: str, timeout: Duration | None = None ) -> None: @@ -1487,34 +1521,6 @@ class QemuMachine(BaseMachine): with self.nested("waiting for the X11 server"): retry(check_x, as_timedelta(timeout)) - def get_window_names(self) -> list[str]: - return self.succeed( - r"xwininfo -root -tree | sed 's/.*0x[0-9a-f]* \"\([^\"]*\)\".*/\1/; t; d'" - ).splitlines() - - def wait_for_window( - self, regexp: str, timeout: Duration = dt.timedelta(minutes=15) - ) -> None: - """ - Wait until an X11 window has appeared whose name matches the given - regular expression, e.g., `wait_for_window("Terminal")`. - """ - _warn_if_numeric_duration(timeout, "wait_for_window") - pattern = re.compile(regexp) - - def window_is_visible(last_try: bool) -> bool: - names = self.get_window_names() - if last_try: - self.log( - f"Last chance to match {regexp} on the window list," - + " which currently contains: " - + ", ".join(names) - ) - return any(pattern.search(name) for name in names) - - with self.nested("waiting for a window to appear"): - retry(window_is_visible, as_timedelta(timeout)) - def forward_port(self, host_port: int = 8080, guest_port: int = 80) -> None: """ Forward a TCP port on the host to a TCP port on the guest. From bec7a9e57a7e018af56d92ea6f7a4363c090ce07 Mon Sep 17 00:00:00 2001 From: Ihar Hrachyshka Date: Fri, 17 Jul 2026 15:15:08 -0400 Subject: [PATCH 03/23] nixos/tests: use dummy X driver in nspawn nspawn containers do not get a private DRM or framebuffer device for an X server. Select the dummy video driver and a fixed resolution so graphical tests can run a headless X server. VKMS would require privileged host setup: its DRM device is created by a host kernel module and would have to be configured outside and passed through the test sandbox. ReFrame does not avoid this requirement: it consumes an existing DRM/KMS device. If VKMS setup and passthrough become viable, ReFrame could expose that framebuffer over VNC. https://docs.kernel.org/gpu/vkms.html https://github.com/AlynxZhou/reframe Assisted-by: Codex gpt-5.6-sol xhigh --- nixos/tests/common/x11.nix | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/nixos/tests/common/x11.nix b/nixos/tests/common/x11.nix index 16bdbfa7ae24..99908a12f25a 100644 --- a/nixos/tests/common/x11.nix +++ b/nixos/tests/common/x11.nix @@ -1,19 +1,38 @@ -{ lib, ... }: +{ config, lib, ... }: +let + isNspawn = config.boot.isNspawnContainer; +in { imports = [ ./auto.nix ]; - services.xserver.enable = true; + services.xserver = lib.mkMerge [ + { + enable = true; + + # Use IceWM as the window manager. + windowManager.icewm.enable = true; + } + (lib.mkIf isNspawn { + # nspawn containers have no physical display device, so use a headless + # X server for graphical tests. + videoDrivers = [ "dummy" ]; + resolutions = [ + { + x = 1024; + y = 768; + } + ]; + }) + ]; # Automatically log in. test-support.displayManager.auto.enable = true; - # Use IceWM as the window manager. # Don't use a desktop manager. services.displayManager.defaultSession = lib.mkDefault "none+icewm"; - services.xserver.windowManager.icewm.enable = true; environment.etc = { # Help with OCR From bf01e5132c7f1aa17165790a4a4f99865f4ae169 Mon Sep 17 00:00:00 2001 From: Ihar Hrachyshka Date: Fri, 17 Jul 2026 15:15:34 -0400 Subject: [PATCH 04/23] nixos/tests: let LightDM use nspawn seats Logind reports CanGraphical=no for an nspawn seat because the container has no display hardware. LightDM consequently stops after "Monitoring logind for seats" and never starts X. The dummy X server does not need display hardware, so disable LightDM's graphical-seat check for nspawn tests. Assisted-by: Codex gpt-5.6-sol xhigh --- nixos/tests/common/x11.nix | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/nixos/tests/common/x11.nix b/nixos/tests/common/x11.nix index 99908a12f25a..b5a8dee683a1 100644 --- a/nixos/tests/common/x11.nix +++ b/nixos/tests/common/x11.nix @@ -25,6 +25,12 @@ in y = 768; } ]; + + # logind correctly marks an nspawn seat without display hardware as + # non-graphical. The dummy X server does not depend on that hardware. + displayManager.lightdm.extraConfig = '' + logind-check-graphical = false + ''; }) ]; From 2921316c30726ff2c1cee517e4db854ad14a2de0 Mon Sep 17 00:00:00 2001 From: Ihar Hrachyshka Date: Sun, 13 Sep 2026 23:31:01 -0400 Subject: [PATCH 05/23] nixos/test-driver: export nspawn shell environment The USER and HOME assignments currently only apply to the `source' command. They are consequently absent from the commands run after the profile has been loaded. Export them so the complete nspawn command runs with the intended shell environment. Assisted-by: Codex gpt-5.6-sol xhigh --- nixos/lib/test-driver/src/test_driver/machine/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 dd46ca69b563..f33cbda18f1e 100644 --- a/nixos/lib/test-driver/src/test_driver/machine/__init__.py +++ b/nixos/lib/test-driver/src/test_driver/machine/__init__.py @@ -1783,7 +1783,11 @@ class NspawnMachine(BaseMachine): # NOTE If the test calls switch-to-configuration (with a differently configured specialization) # this will use the /etc/profile of the new specialisation while `QemuMachine` nodes # will continue to use the original /etc/profile. - command = f"set -eo pipefail; USER=root HOME=/root source /etc/profile; set -u; {command}" + command = ( + "set -eo pipefail; " + "export USER=root HOME=/root; " + f"source /etc/profile; set -u; {command}" + ) cp = subprocess.run( [ From 53e04c5f6c3f2a8737263dc334e73a0af986a028 Mon Sep 17 00:00:00 2001 From: Ihar Hrachyshka Date: Sun, 13 Sep 2026 23:31:51 -0400 Subject: [PATCH 06/23] nixos/test-driver: set DISPLAY in nspawn The QEMU backdoor provides DISPLAY=:0.0, but commands run through nspawn have no DISPLAY. X11 clients consequently fail with "unable to open display" even after the X server is running. Set DISPLAY for nspawn commands as well. Assisted-by: Codex gpt-5.6-sol xhigh --- nixos/lib/test-driver/src/test_driver/machine/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f33cbda18f1e..7c53a45e6d0c 100644 --- a/nixos/lib/test-driver/src/test_driver/machine/__init__.py +++ b/nixos/lib/test-driver/src/test_driver/machine/__init__.py @@ -1785,7 +1785,7 @@ class NspawnMachine(BaseMachine): # will continue to use the original /etc/profile. command = ( "set -eo pipefail; " - "export USER=root HOME=/root; " + "export USER=root HOME=/root DISPLAY=:0.0; " f"source /etc/profile; set -u; {command}" ) From edfb099b5b21dd8a012032cae8971c7486a42b96 Mon Sep 17 00:00:00 2001 From: Ihar Hrachyshka Date: Fri, 17 Jul 2026 15:17:47 -0400 Subject: [PATCH 07/23] nixos/test-driver: wait for X in nspawn wait_for_x() is only implemented by QemuMachine, so an nspawn test that uses it fails type checking with: NspawnMachine has no attribute wait_for_x Provide a backend-neutral implementation that probes the X root window. Keep QEMU's existing graphical-target check as its override. Assisted-by: Codex gpt-5.6-sol xhigh --- .../src/test_driver/machine/__init__.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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 7c53a45e6d0c..03e2b1cb5b3a 100644 --- a/nixos/lib/test-driver/src/test_driver/machine/__init__.py +++ b/nixos/lib/test-driver/src/test_driver/machine/__init__.py @@ -1507,6 +1507,8 @@ class QemuMachine(BaseMachine): """ _warn_if_numeric_duration(timeout, "wait_for_x") + # Keep this separate from nspawn's authenticated X probe: QEMU tests may + # install the session user's Xauthority cookie only after this returns. def check_x(_last_try: bool) -> bool: cmd = ( "journalctl -b SYSLOG_IDENTIFIER=systemd | " @@ -1652,6 +1654,19 @@ class NspawnMachine(BaseMachine): self.machine_sock_path = self.tmp_dir / f"{self.name}-nspawn.sock" + def wait_for_x(self, timeout: Duration = dt.timedelta(minutes=15)) -> None: + """ + Wait until it is possible to connect to the X server. + """ + _warn_if_numeric_duration(timeout, "wait_for_x") + + def check_x(_last_try: bool) -> bool: + status, _ = self.execute("xwininfo -root >/dev/null 2>&1") + return status == 0 + + with self.nested("waiting for the X11 server"): + retry(check_x, as_timedelta(timeout)) + def ssh_backdoor_command(self) -> str: # documented in systemd-ssh-generator(8) and https://systemd.io/CONTAINER_INTERFACE/ socket_path = f"/run/systemd/nspawn/unix-export/{self.name}/ssh" From 90ad9c9db40bfd1e52d2239f25778154e98f36b3 Mon Sep 17 00:00:00 2001 From: Ihar Hrachyshka Date: Fri, 17 Jul 2026 15:19:02 -0400 Subject: [PATCH 08/23] nixos/test-driver: capture nspawn screenshots nspawn machines have no QEMU monitor, so the shared screenshot helper currently fails with: Screenshots are not supported by NspawnMachine Capture the root window with xwd into the shared directory, then convert it to PPM on the host for the existing PNG and OCR paths. Install xwd only in instrumented nspawn containers. Assisted-by: Codex gpt-5.6-sol xhigh --- .../src/test_driver/machine/__init__.py | 33 +++++++++++++++++++ .../modules/testing/test-instrumentation.nix | 9 ++++- 2 files changed, 41 insertions(+), 1 deletion(-) 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 03e2b1cb5b3a..265b9fc47dc8 100644 --- a/nixos/lib/test-driver/src/test_driver/machine/__init__.py +++ b/nixos/lib/test-driver/src/test_driver/machine/__init__.py @@ -626,6 +626,10 @@ class BaseMachine(ABC): @contextmanager def _managed_screenshot(self) -> Generator[Path]: + """ + Take a screenshot and yield the path to its PPM file. + The file will be deleted when leaving the generator. + """ raise MachineError(f"Screenshots are not supported by {type(self).__name__}") yield Path() @@ -1667,6 +1671,35 @@ class NspawnMachine(BaseMachine): with self.nested("waiting for the X11 server"): retry(check_x, as_timedelta(timeout)) + @contextmanager + def _managed_screenshot(self) -> Generator[Path]: + # xwd writes inside the container and xwdtopnm reads on the host, so + # the intermediate files must live in their shared directory. + with tempfile.TemporaryDirectory(dir=self.shared_dir) as shared_td: + shared_path = Path(shared_td) + xwd_path = shared_path / "screen.xwd" + ppm_path = shared_path / "screen.ppm" + machine_xwd_path = Path("/tmp/shared") / shared_path.name / xwd_path.name + + self.succeed( + make_command( + [ + "xwd", + "-root", + "-silent", + "-out", + machine_xwd_path, + ] + ) + ) + with ppm_path.open("wb") as ppm: + ret = subprocess.run(["xwdtopnm", xwd_path], stdout=ppm) + if ret.returncode != 0: + raise MachineError( + f"Cannot convert screenshot (xwdtopnm returned code {ret.returncode})" + ) + yield ppm_path + def ssh_backdoor_command(self) -> str: # documented in systemd-ssh-generator(8) and https://systemd.io/CONTAINER_INTERFACE/ socket_path = f"/run/systemd/nspawn/unix-export/{self.name}/ssh" diff --git a/nixos/modules/testing/test-instrumentation.nix b/nixos/modules/testing/test-instrumentation.nix index 47890479a4c9..c050e2dc9381 100644 --- a/nixos/modules/testing/test-instrumentation.nix +++ b/nixos/modules/testing/test-instrumentation.nix @@ -230,7 +230,14 @@ in ]; # `xwininfo' is used by the test driver to query open windows. - environment.systemPackages = [ pkgs.xwininfo ]; + environment.systemPackages = [ + pkgs.xwininfo + ] + ++ lib.optional 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. + pkgs.xwd + ); # Log everything to the serial console. services.journald.settings.Journal = { From da5f434bcccf3dc723d2e8e4492bb6c34b0eb18e Mon Sep 17 00:00:00 2001 From: Ihar Hrachyshka Date: Sun, 13 Sep 2026 21:47:13 -0400 Subject: [PATCH 09/23] 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 = { From d920fcac52fb8259f8449cf2d560b6acfb1fd496 Mon Sep 17 00:00:00 2001 From: Ihar Hrachyshka Date: Sun, 13 Sep 2026 23:32:57 -0400 Subject: [PATCH 10/23] nixos/tests/firefox: use machine keyboard interface The test invokes xdotool directly for two key presses. Use the shared machine input interface so the test does not depend on its backend. Assisted-by: Codex gpt-5.6-sol high --- nixos/tests/firefox.nix | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/nixos/tests/firefox.nix b/nixos/tests/firefox.nix index 44aa0734ca14..2eee50f3396a 100644 --- a/nixos/tests/firefox.nix +++ b/nixos/tests/firefox.nix @@ -12,11 +12,10 @@ }; nodes.machine = - { pkgs, ... }: + { ... }: { imports = [ ./common/x11.nix ]; - environment.systemPackages = [ pkgs.xdotool ]; programs.firefox = { enable = true; @@ -94,10 +93,10 @@ machine.copy_from_machine("/tmp/record.wav") with subtest("Close sound test tab"): - machine.execute("xdotool key ctrl+w") + machine.send_key("ctrl-w") with subtest("Close default browser prompt"): - machine.execute("xdotool key space") + machine.send_key("spc") with subtest("Wait until Firefox draws the developer tool panel"): machine.sleep(10) From a4e04f1a3838427b945440bbc1b87ff644e71848 Mon Sep 17 00:00:00 2001 From: Ihar Hrachyshka Date: Fri, 17 Jul 2026 17:38:48 -0400 Subject: [PATCH 11/23] nixos/tests/firefox: record audio with PulseAudio The ALSA recorder is backed by the snd_aloop kernel module. ALSA devices are not namespaced, so making this device available to nspawn would require loading it in the shared host kernel and passing its host-global /dev/snd nodes into the container. https://man7.org/linux/man-pages/man7/namespaces.7.html Use a system-wide PulseAudio null sink and capture its monitor source. This keeps audio generation and capture in user space and uses the same path independently of the test driver. Assisted-by: Codex gpt-5.6-sol xhigh --- nixos/tests/firefox.nix | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/nixos/tests/firefox.nix b/nixos/tests/firefox.nix index 2eee50f3396a..0e99767ca80b 100644 --- a/nixos/tests/firefox.nix +++ b/nixos/tests/firefox.nix @@ -23,15 +23,35 @@ package = firefoxPackage; }; - hardware.alsa = { - enable = true; - enableRecorder = true; - defaultDevice.playback = "pcm.recorder"; + services.pipewire = { + enable = false; + alsa.enable = false; + pulse.enable = false; }; + services.pulseaudio = { + enable = true; + systemWide = true; + extraConfig = '' + load-module module-null-sink sink_name=recorder + set-default-sink recorder + ''; + }; + + users.users.root.extraGroups = [ "pulse-access" ]; + + systemd.services.pulseaudio.wantedBy = [ "multi-user.target" ]; + systemd.services.audio-recorder = { description = "Record NixOS test audio to /tmp/record.wav"; - script = "${pkgs.alsa-utils}/bin/arecord -Drecorder -fS16_LE -r48000 -c2 /tmp/record.wav"; + after = [ "pulseaudio.service" ]; + requires = [ "pulseaudio.service" ]; + script = '' + ${pkgs.pulseaudio}/bin/parec \ + --device=recorder.monitor \ + --file-format=wav \ + /tmp/record.wav + ''; }; }; From 3db3dd03b9a39ff3996d4872228cc65c5653578b Mon Sep 17 00:00:00 2001 From: Ihar Hrachyshka Date: Fri, 17 Jul 2026 17:52:59 -0400 Subject: [PATCH 12/23] nixos/tests/firefox: run with nspawn Firefox exercises LightDM and X11, plays and records audio, and takes a screenshot. The preceding changes provide these facilities without depending on VM devices. Switch the existing test to nspawn. Non-scientific benchmark: 75.75s -> 56.47s improvement. Assisted-by: Codex gpt-5.6-sol xhigh --- nixos/tests/firefox.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nixos/tests/firefox.nix b/nixos/tests/firefox.nix index 0e99767ca80b..63e0ddd68339 100644 --- a/nixos/tests/firefox.nix +++ b/nixos/tests/firefox.nix @@ -11,7 +11,7 @@ maintainers = [ shlevy ]; }; - nodes.machine = + containers.machine = { ... }: { From 92dfea23cde3760a13c6472397de330371172eb1 Mon Sep 17 00:00:00 2001 From: Ihar Hrachyshka Date: Fri, 17 Jul 2026 19:09:34 -0400 Subject: [PATCH 13/23] nixos/tests/firefoxpwa: run with nspawn The test only requires X11 and does not rely on VM-specific functionality. Run it in a container now that nspawn tests support X11 applications. virtualisation.diskSize only sizes the QEMU disk image. Remove its Jellyfin override because nspawn stores the container root in a directory on the builder filesystem rather than in a fixed-size virtual disk. Assisted-by: Codex gpt-5.6-sol xhigh --- nixos/tests/firefoxpwa.nix | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/nixos/tests/firefoxpwa.nix b/nixos/tests/firefoxpwa.nix index b8cf0578da08..a0bc1d5a5ffe 100644 --- a/nixos/tests/firefoxpwa.nix +++ b/nixos/tests/firefoxpwa.nix @@ -4,7 +4,7 @@ name = "firefoxpwa"; meta.maintainers = with lib.maintainers; [ camillemndn ]; - nodes.machine = + containers.machine = { pkgs, ... }: { imports = [ ./common/x11.nix ]; @@ -19,8 +19,6 @@ }; services.jellyfin.enable = true; - # Jellyfin requires at least 2 GB of disk space - virtualisation.diskSize = 3 * 1024; # 3 GB }; enableOCR = true; From 9d6b12b53129c0d1dfe1ee6693d7ba2a5170a317 Mon Sep 17 00:00:00 2001 From: Ihar Hrachyshka Date: Fri, 17 Jul 2026 20:25:11 -0400 Subject: [PATCH 14/23] nixos/tests/firefox_decrypt: run with nspawn The test exercises Firefox through X11 and its profile files. With input delivered through xdotool, it no longer depends on QEMU-specific functionality. Switch the machine to nspawn. Assisted-by: Codex gpt-5.6-sol xhigh --- nixos/tests/firefox_decrypt.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nixos/tests/firefox_decrypt.nix b/nixos/tests/firefox_decrypt.nix index afc3bfaf485c..f9bea80d84f7 100644 --- a/nixos/tests/firefox_decrypt.nix +++ b/nixos/tests/firefox_decrypt.nix @@ -6,7 +6,7 @@ maintainers = with lib.maintainers; [ schnusch ]; }; - nodes.machine = + containers.machine = { pkgs, ... }: { imports = [ ./common/x11.nix ]; From 11b68c8a87cbd1bda59bd7933f78fff0c1b55337 Mon Sep 17 00:00:00 2001 From: Ihar Hrachyshka Date: Mon, 5 Oct 2026 23:06:04 -0400 Subject: [PATCH 15/23] nixos/test-driver: resolve ty imports from the checked sources Checks run after installation, which adds the installed driver to PYTHONPATH. ty checks local source files but imports the installed copy. The new VNC helper exposes this mismatch. NspawnVnc(self, ...) fails with: Expected `NspawnMachine`, found `Self@start` Prepend the source directory to PYTHONPATH for ty so checked files and imports use the same copy of NspawnMachine. Assisted-by: Codex, GPT-6.1-Sol medium --- nixos/lib/test-driver/default.nix | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nixos/lib/test-driver/default.nix b/nixos/lib/test-driver/default.nix index a18aebc44e5a..ad3a877e07a5 100644 --- a/nixos/lib/test-driver/default.nix +++ b/nixos/lib/test-driver/default.nix @@ -83,7 +83,8 @@ buildPythonApplication { checkPhase = '' echo -e "\x1b[32m## run ty\x1b[0m" - ty check --error-on-warning test_driver extract-docstrings.py + # Installation adds the driver to PYTHONPATH; keep ty imports in the source tree. + PYTHONPATH=".:$PYTHONPATH" ty check --error-on-warning test_driver extract-docstrings.py echo -e "\x1b[32m## run ruff check\x1b[0m" ruff check . echo -e "\x1b[32m## run ruff format\x1b[0m" From 8a8d00e90ea68b2fb5abed87509b2bb13120b55d Mon Sep 17 00:00:00 2001 From: Ihar Hrachyshka Date: Mon, 5 Oct 2026 23:45:37 -0400 Subject: [PATCH 16/23] nixos/test-driver: view nspawn X11 interactively Connect TigerVNC to x11vnc through the existing shared directory, and stop the viewer and server with the container. Assisted-by: Codex, GPT-6.1-Sol medium --- .../test-driver/src/test_driver/__init__.py | 1 + .../lib/test-driver/src/test_driver/driver.py | 5 + .../src/test_driver/machine/__init__.py | 20 ++ .../src/test_driver/machine/nspawn_display.py | 201 ++++++++++++++++++ nixos/lib/testing/driver-configuration.nix | 60 ++++-- nixos/lib/testing/interactive.nix | 6 + 6 files changed, 274 insertions(+), 19 deletions(-) create mode 100644 nixos/lib/test-driver/src/test_driver/machine/nspawn_display.py diff --git a/nixos/lib/test-driver/src/test_driver/__init__.py b/nixos/lib/test-driver/src/test_driver/__init__.py index 568ac5758d05..a63e881bbca3 100644 --- a/nixos/lib/test-driver/src/test_driver/__init__.py +++ b/nixos/lib/test-driver/src/test_driver/__init__.py @@ -178,6 +178,7 @@ def main() -> None: logger=logger, keep_machine_state=args.keep_machine_state, debug=debugger, + interactive=bool(args.interactive), ) as driver: if driver.config.enable_ssh_backdoor: driver.dump_machine_ssh() diff --git a/nixos/lib/test-driver/src/test_driver/driver.py b/nixos/lib/test-driver/src/test_driver/driver.py index a3891313b817..e40dae73c100 100644 --- a/nixos/lib/test-driver/src/test_driver/driver.py +++ b/nixos/lib/test-driver/src/test_driver/driver.py @@ -30,6 +30,7 @@ from test_driver.machine import ( QemuMachine, retry, ) +from test_driver.machine.nspawn_display import NspawnVncConfiguration from test_driver.polling_condition import PollingCondition from test_driver.vlan import VLan @@ -37,6 +38,7 @@ from test_driver.vlan import VLan class NodeConfiguration(BaseModel): name: str start_script: Path + vnc: NspawnVncConfiguration | None = None class DriverConfiguration(BaseModel): @@ -158,6 +160,7 @@ class Driver: logger: AbstractLogger, keep_machine_state: bool = False, debug: DebugAbstract = DebugNop(), + interactive: bool = False, ): self.config = config self.tests = config.test_script.read_text() @@ -166,6 +169,7 @@ class Driver: self.debug = debug self.polling_conditions = [] self.keep_machine_state = keep_machine_state + self.interactive = interactive def __enter__(self) -> "Driver": self.race_timer = threading.Timer( @@ -214,6 +218,7 @@ class Driver: NspawnMachine( name=name, start_command=container_config.start_script.as_posix(), + vnc=container_config.vnc if self.interactive else None, tmp_dir=tmp_dir, logger=self.logger, keep_machine_state=self.keep_machine_state, 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 79d9e3cd459c..7708deb4207c 100644 --- a/nixos/lib/test-driver/src/test_driver/machine/__init__.py +++ b/nixos/lib/test-driver/src/test_driver/machine/__init__.py @@ -32,6 +32,7 @@ from test_driver.duration import ( from test_driver.efi import EfiVariable, EfiVars from test_driver.errors import MachineError, RequestedAssertionFailed from test_driver.logger import AbstractLogger +from test_driver.machine.nspawn_display import NspawnVnc, NspawnVncConfiguration from test_driver.machine.ocr import ( perform_ocr_on_screenshot, perform_ocr_variants_on_screenshot, @@ -1687,6 +1688,7 @@ class NspawnMachine(BaseMachine): machine_sock_path: Path machine_sock: socket.socket | None notify_thread: threading.Thread | None + vnc: NspawnVnc | None @staticmethod def machine_name_from_start_command(start_command: str) -> str: @@ -1703,6 +1705,7 @@ class NspawnMachine(BaseMachine): logger: AbstractLogger, callbacks: list[Callable] | None = None, keep_machine_state: bool = False, + vnc: NspawnVncConfiguration | None = None, ): # TODO: don't compute `name` from `start_command` path, instead thread it down explicitly. # See analogous TODO in `QemuStartCommand::machine_name`. @@ -1716,6 +1719,8 @@ class NspawnMachine(BaseMachine): ) self.start_command = start_command + self.vnc_configuration = vnc + self.vnc = None self.process = None self.notify_thread = None # State maintained by the notify-socket drainer thread (see @@ -1806,6 +1811,8 @@ class NspawnMachine(BaseMachine): return f'ssh -o User=root -o ProxyCommand="{proxy_cmd}" bash' def release(self) -> None: + self._stop_vnc() + if self.process is None: return @@ -2053,6 +2060,18 @@ class NspawnMachine(BaseMachine): journal_thread = threading.Thread(target=self._stream_journal, daemon=True) journal_thread.start() + if self.vnc_configuration is not None: + if any(name in os.environ for name in ("DISPLAY", "WAYLAND_DISPLAY")): + self.vnc = NspawnVnc(self, self.vnc_configuration) + self.vnc.start() + else: + self.log("no graphical host display available; VNC viewer disabled") + + def _stop_vnc(self) -> None: + if self.vnc is not None: + self.vnc.stop() + self.vnc = None + def shutdown(self) -> None: """ Shut down the container, waiting for it to exit. @@ -2075,6 +2094,7 @@ class NspawnMachine(BaseMachine): timeout=timeout.total_seconds() if timeout is not None else None ) self.process = None + self._stop_vnc() class MachineDeprecationWrapper: diff --git a/nixos/lib/test-driver/src/test_driver/machine/nspawn_display.py b/nixos/lib/test-driver/src/test_driver/machine/nspawn_display.py new file mode 100644 index 000000000000..d751eaf0cbfa --- /dev/null +++ b/nixos/lib/test-driver/src/test_driver/machine/nspawn_display.py @@ -0,0 +1,201 @@ +import datetime as dt +import shlex +import socket +import subprocess +import tempfile +import threading +from pathlib import Path +from typing import TYPE_CHECKING, Self + +from pydantic import BaseModel + +if TYPE_CHECKING: + from test_driver.machine import NspawnMachine + + +class NspawnVncConfiguration(BaseModel): + x11vnc: Path + tigervnc: Path + + +def _container_running(machine: "NspawnMachine") -> bool: + process = machine.process + return process is not None and process.poll() is None + + +class VncServer: + unit = "nixos-test-vnc" + + def __init__( + self, + machine: "NspawnMachine", + x11vnc_executable: Path, + socket_path: Path, + stop_event: threading.Event, + ) -> None: + self.machine = machine + self.x11vnc_executable = x11vnc_executable + self.socket_path = socket_path + self.stop_event = stop_event + + def _command(self) -> list[str]: + container_socket = Path("/tmp/shared") / self.socket_path.relative_to( + self.machine.shared_dir + ) + return [ + str(self.x11vnc_executable), + "-display", + ":0", + "-auth", + "/root/.Xauthority", + "-unixsock", + str(container_socket), + "-desktop", + f"{self.machine.name} (nspawn)", + # Suppress the password warning for our private Unix socket. + "-nopw", + # Disable IPv4 and IPv6 TCP listeners; use only the Unix socket. + "-rfbport", + "0", + "-rfbportv6", + "0", + ] + + def __enter__(self) -> Self: + command = shlex.join( + [ + "systemd-run", + "--quiet", + "--collect", + "--service-type=exec", + f"--unit={self.unit}", + *self._command(), + ] + ) + try: + self.machine.succeed(command, timeout=dt.timedelta(seconds=5)) + except BaseException: + self._stop() + raise + return self + + def wait_ready(self) -> bool: + while not self.stop_event.is_set() and _container_running(self.machine): + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: + connection.settimeout(1) + try: + connection.connect(str(self.socket_path)) + return True + except (FileNotFoundError, ConnectionRefusedError, TimeoutError): + pass + + status, _ = self.machine.execute( + f"systemctl is-active --quiet {self.unit}.service", + timeout=dt.timedelta(seconds=5), + ) + if status != 0: + raise RuntimeError("VNC server exited before accepting connections") + self.stop_event.wait(0.5) + return False + + def _stop(self) -> None: + if _container_running(self.machine): + try: + self.machine.execute( + f"systemctl stop {self.unit}.service", + timeout=dt.timedelta(seconds=5), + ) + except Exception as error: + self.machine.log(f"failed to stop the VNC server: {error}") + + def __exit__(self, *_: object) -> None: + self._stop() + + +class VncViewer: + def __init__( + self, + machine: "NspawnMachine", + tigervnc_executable: Path, + socket_path: Path, + stop_event: threading.Event, + ) -> None: + self.machine = machine + self.tigervnc_executable = tigervnc_executable + self.socket_path = socket_path + self.stop_event = stop_event + + def __enter__(self) -> Self: + self.process = subprocess.Popen( + [str(self.tigervnc_executable), str(self.socket_path)], + # Keep terminal Ctrl-C from reaching the viewer; driver cleanup stops it. + start_new_session=True, + ) + self.machine.log(f"VNC viewer running (pid {self.process.pid})") + return self + + def wait(self) -> None: + while not self.stop_event.is_set() and _container_running(self.machine): + if self.process.poll() is not None: + self.machine.log( + f"VNC viewer exited (status {self.process.returncode})" + ) + return + self.stop_event.wait(0.5) + + def __exit__(self, *_: object) -> None: + self.process.terminate() + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait() + + +class NspawnVnc: + """Show a container's default X11 display in TigerVNC.""" + + def __init__( + self, machine: "NspawnMachine", configuration: NspawnVncConfiguration + ) -> None: + self.machine = machine + self.configuration = configuration + self.stop_event = threading.Event() + self.thread = threading.Thread(target=self._run, daemon=True) + + def start(self) -> None: + self.thread.start() + + def stop(self) -> None: + self.stop_event.set() + self.thread.join(timeout=15) + if self.thread.is_alive(): + self.machine.log("VNC worker did not stop within 15 seconds") + + def _run(self) -> None: + try: + self.machine.wait_for_x() + if self.stop_event.is_set(): + return + + with tempfile.TemporaryDirectory(dir=self.machine.shared_dir) as directory: + host_socket = Path(directory) / "vnc.sock" + with VncServer( + self.machine, + self.configuration.x11vnc, + host_socket, + self.stop_event, + ) as server: + if not server.wait_ready() or self.stop_event.is_set(): + return + + with VncViewer( + self.machine, + self.configuration.tigervnc, + host_socket, + self.stop_event, + ) as viewer: + viewer.wait() + except Exception as error: + if not self.stop_event.is_set(): + self.machine.log(f"failed to open the VNC viewer: {error}") diff --git a/nixos/lib/testing/driver-configuration.nix b/nixos/lib/testing/driver-configuration.nix index 222332d51fad..2f30ec0e95db 100644 --- a/nixos/lib/testing/driver-configuration.nix +++ b/nixos/lib/testing/driver-configuration.nix @@ -7,23 +7,26 @@ let inherit (lib) types; - nodeConfigurationAttrs = lib.mkOption { - internal = true; - type = types.attrsOf ( - types.submodule { - options = { - name = lib.mkOption { - internal = true; - type = types.str; - }; - start_script = lib.mkOption { - internal = true; - type = types.path; - }; - }; - } - ); - }; + nodeConfigurationAttrs = + extraOptions: + lib.mkOption { + internal = true; + type = types.attrsOf ( + types.submodule { + options = { + name = lib.mkOption { + internal = true; + type = types.str; + }; + start_script = lib.mkOption { + internal = true; + type = types.path; + }; + } + // extraOptions; + } + ); + }; in { options = { @@ -32,8 +35,27 @@ in internal = true; type = types.submodule { options = { - vms = nodeConfigurationAttrs; - containers = nodeConfigurationAttrs; + vms = nodeConfigurationAttrs { }; + containers = nodeConfigurationAttrs { + vnc = lib.mkOption { + internal = true; + default = null; + type = types.nullOr ( + types.submodule { + options = { + x11vnc = lib.mkOption { + internal = true; + type = types.path; + }; + tigervnc = lib.mkOption { + internal = true; + type = types.path; + }; + }; + } + ); + }; + }; vlans = lib.mkOption { internal = true; type = types.listOf types.ints.unsigned; diff --git a/nixos/lib/testing/interactive.nix b/nixos/lib/testing/interactive.nix index 4088cc2abfc1..49958010c7c4 100644 --- a/nixos/lib/testing/interactive.nix +++ b/nixos/lib/testing/interactive.nix @@ -46,6 +46,12 @@ in config = { interactive.qemu.package = hostPkgs.qemu; interactive.extraDriverArgs = [ "--interactive" ]; + interactive.driverConfiguration.containers = lib.mapAttrs (_: machine: { + vnc = lib.mkIf machine.services.xserver.enable { + x11vnc = lib.getExe hostPkgs.x11vnc; + tigervnc = lib.getExe' hostPkgs.tigervnc "vncviewer"; + }; + }) config.containers; passthru.driverInteractive = config.interactive.driver; }; } From 2d3192e5e50c74268770629406a80cf520a66a61 Mon Sep 17 00:00:00 2001 From: Ihar Hrachyshka Date: Thu, 8 Oct 2026 22:10:14 -0400 Subject: [PATCH 17/23] nixos/test-driver: test interactive nspawn X11 Type through TigerVNC and check that closing it leaves the container running. Use Xvfb to exercise the viewer without a host desktop. Assisted-by: Codex, GPT-6.1-Sol medium --- nixos/lib/test-driver/default.nix | 7 +- nixos/tests/all-tests.nix | 1 + .../nixos-test-driver/nspawn-interactive.nix | 125 ++++++++++++++++++ 3 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 nixos/tests/nixos-test-driver/nspawn-interactive.nix diff --git a/nixos/lib/test-driver/default.nix b/nixos/lib/test-driver/default.nix index ad3a877e07a5..2c06392d065a 100644 --- a/nixos/lib/test-driver/default.nix +++ b/nixos/lib/test-driver/default.nix @@ -71,8 +71,11 @@ buildPythonApplication { tesseract4 ]; - # containers test requires extra nix features that are not available in ofborg. - passthru.tests = removeAttrs nixosTests.nixos-test-driver [ "containers" ]; + # containers tests require extra nix features that are not available in ofborg. + passthru.tests = removeAttrs nixosTests.nixos-test-driver [ + "containers" + "nspawn-interactive" + ]; doCheck = true; diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 582c447f3f24..ba8681f63f23 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -197,6 +197,7 @@ in ssh-backdoor = runTestOn [ "x86_64-linux" ] ./nixos-test-driver/ssh-backdoor.nix; console-log = runTest ./nixos-test-driver/console-log.nix; containers = runTest ./nixos-test-driver/containers.nix; + nspawn-interactive = runTest ./nixos-test-driver/nspawn-interactive.nix; nspawn-daemon-reexec-dbus = runTest ./nspawn-daemon-reexec-dbus.nix; multi-arch-test = runTest ./nixos-test-driver/multi-arch-test.nix; skip-typecheck = runTest ./nixos-test-driver/skip-typecheck.nix; diff --git a/nixos/tests/nixos-test-driver/nspawn-interactive.nix b/nixos/tests/nixos-test-driver/nspawn-interactive.nix new file mode 100644 index 000000000000..0fa23b36ec64 --- /dev/null +++ b/nixos/tests/nixos-test-driver/nspawn-interactive.nix @@ -0,0 +1,125 @@ +{ + config, + hostPkgs, + lib, + ... +}: +let + # This test script loads the nested driver's configuration. + # Give that driver an empty script to avoid a circular store reference. + interactiveConfig = hostPkgs.writers.writeJSON "interactive-driver-config.json" ( + config.interactive.driverConfiguration // { test_script = hostPkgs.emptyFile; } + ); +in +{ + name = "nspawn-interactive"; + meta.teams = [ lib.teams.test-driver ]; + + containers.machine = { + imports = [ ../common/x11.nix ]; + virtualisation.vlans = [ ]; + }; + + # Run a nested interactive driver under Xvfb, type into the container through + # TigerVNC, and check that closing the viewer leaves the container running. + testScript = '' + import datetime as dt + import os + import subprocess + from collections.abc import Iterator + from contextlib import contextmanager + + from test_driver.driver import Driver, load_driver_configuration + + def xdotool(*args: str) -> str: + return subprocess.check_output( + ["${lib.getExe hostPkgs.xdotool}", *args], text=True, timeout=30 + ).strip() + + def wait_for_host_display() -> None: + def can_connect(_last_try: bool) -> bool: + return subprocess.call( + ["${lib.getExe hostPkgs.xdotool}", "getdisplaygeometry"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) == 0 + + retry(can_connect, timeout=dt.timedelta(seconds=30)) + + def wait_for_viewer() -> str: + return xdotool( + "search", "--sync", "--onlyvisible", "--name", + r"machine \(nspawn\)", + ) + + def open_guest_terminal(machine: NspawnMachine) -> None: + # Keep the shell prompt from replacing the terminal's window title. + machine.execute("xterm -title input-test -e bash --noprofile --norc >&2 &") + machine.wait_for_window("input-test", timeout=dt.timedelta(seconds=30)) + + def focus_terminal_in_viewer(machine: NspawnMachine, window: str) -> None: + xdotool("windowraise", window) + xdotool("windowfocus", "--sync", window) + xdotool("mousemove", "--window", window, "150", "200") + xdotool("click", "1") + + machine.wait_until_succeeds( + 'test "$(xdotool getwindowfocus getwindowname)" = input-test', + timeout=dt.timedelta(seconds=30), + ) + + def run_command_in_terminal( + machine: NspawnMachine, window: str, command: str + ) -> None: + focus_terminal_in_viewer(machine, window) + xdotool("type", "--delay", "50", command) + xdotool("key", "Return") + + @contextmanager + def test_display() -> Iterator[None]: + # Keep the viewer visible while debugging the outer test interactively. + if driver.interactive and os.environ.get("DISPLAY"): + yield + return + + previous_display = os.environ.get("DISPLAY") + with subprocess.Popen([ + "${lib.getExe' hostPkgs.xorg-server "Xvfb"}", ":99", "-screen", "0", "1280x1024x24", "-ac", + ]) as display: + os.environ["DISPLAY"] = ":99" + try: + yield + finally: + display.terminate() + if previous_display is None: + os.environ.pop("DISPLAY", None) + else: + os.environ["DISPLAY"] = previous_display + + with test_display(): + wait_for_host_display() + + with Driver( + config=load_driver_configuration("${interactiveConfig}"), + out_dir=driver.out_dir, + logger=driver.logger, + interactive=True, + ) as interactive: + machine = interactive.machines_nspawn[0] + machine.start() + + with subtest("type through the viewer"): + window = wait_for_viewer() + machine.fail("ss -H -lntp | grep -w x11vnc") + open_guest_terminal(machine) + run_command_in_terminal(machine, window, "echo viewer-input > /tmp/viewer-input") + machine.wait_until_succeeds( + "grep -qx viewer-input /tmp/viewer-input", + timeout=dt.timedelta(seconds=30), + ) + + with subtest("closing the viewer leaves the container running"): + xdotool("windowclose", window) + machine.wait_until_fails("pgrep -x x11vnc") + machine.succeed("test -s /tmp/viewer-input") + ''; +} From 237d80d66f6f32ff29515325929181b2de649247 Mon Sep 17 00:00:00 2001 From: Ihar Hrachyshka Date: Thu, 8 Oct 2026 22:10:27 -0400 Subject: [PATCH 18/23] nixos/test-driver: document nspawn X11 support Assisted-by: Codex, GPT-6.1-Sol medium --- ...nning-nixos-tests-interactively.section.md | 26 +++++++++++++++++++ .../writing-nixos-tests.section.md | 6 ++++- nixos/doc/manual/redirects.json | 3 +++ .../manual/release-notes/rl-2611.section.md | 2 ++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/nixos/doc/manual/development/running-nixos-tests-interactively.section.md b/nixos/doc/manual/development/running-nixos-tests-interactively.section.md index 78df8d3d77c7..7dc6a6ce7a19 100644 --- a/nixos/doc/manual/development/running-nixos-tests-interactively.section.md +++ b/nixos/doc/manual/development/running-nixos-tests-interactively.section.md @@ -41,6 +41,32 @@ back into the test driver command line upon its completion. This allows you to inspect the state of the VMs after the test (e.g. to debug the test script). +## Graphical tests {#sec-nixos-test-interactive-graphical} + +For `systemd-nspawn` containers with X11 enabled, `.driverInteractive` opens +TigerVNC once the X server is ready. It connects to the container's default +X11 display through an x11vnc Unix socket in the shared directory and accepts +keyboard and mouse input. +Closing the viewer leaves the container running. + +Because the nspawn driver runs as root, preserve the host graphical-session +variables when starting it. For X11, use: + +```ShellSession +$ sudo --preserve-env=DISPLAY,XAUTHORITY \ + ./result/bin/nixos-test-driver +``` + +For a Wayland host session, use: + +```ShellSession +$ sudo --preserve-env=WAYLAND_DISPLAY,XDG_RUNTIME_DIR \ + ./result/bin/nixos-test-driver +``` + +If neither `DISPLAY` nor `WAYLAND_DISPLAY` is set, the driver skips the viewer. +Ordinary test runs do not start a VNC server or viewer. + ## Shell access to VMs in interactive mode {#sec-nixos-test-shell-access} ::: {.warning} diff --git a/nixos/doc/manual/development/writing-nixos-tests.section.md b/nixos/doc/manual/development/writing-nixos-tests.section.md index a6c16f02077f..1ea011792a26 100644 --- a/nixos/doc/manual/development/writing-nixos-tests.section.md +++ b/nixos/doc/manual/development/writing-nixos-tests.section.md @@ -150,12 +150,16 @@ Some advantages of virtual machines over containers are: - Virtual machines run a separate kernel, which allows testing kernel features (kernel modules, etc.). -- Virtual machines support testing graphical applications on X11. - Virtual machines allow testing NixOS modules that use systemd's namespacing options (such as `ProtectSystem=` or `MountAPIVFS=`). - Virtual machines allow testing [`specialisation`](options.html#opt-specialisation). (Switching to a specialisation requires the creation of SUID/SGID wrappers, which is disallowed in `systemd-nspawn` within the Nix sandbox.) - Virtual machines allow the execution of `setuid` binaries. +Both backends support testing graphical applications on X11. Virtual machines +provide emulated display hardware, while containers use a headless X server. +See [running tests interactively](#sec-nixos-test-interactive-graphical) for how +to view container displays during debugging. + Refer to the sections on [QEMU virtual machines](#ssec-nixos-test-qemu-vms) and [systemd-nspawn containers](#ssec-nixos-test-nspawn-containers) below for more details on configuring each type of machine. diff --git a/nixos/doc/manual/redirects.json b/nixos/doc/manual/redirects.json index eddf3ace6215..dc9e51d89e21 100644 --- a/nixos/doc/manual/redirects.json +++ b/nixos/doc/manual/redirects.json @@ -2447,6 +2447,9 @@ "sec-running-nixos-tests-interactively": [ "index.html#sec-running-nixos-tests-interactively" ], + "sec-nixos-test-interactive-graphical": [ + "index.html#sec-nixos-test-interactive-graphical" + ], "sec-nixos-test-shell-access": [ "index.html#sec-nixos-test-shell-access" ], diff --git a/nixos/doc/manual/release-notes/rl-2611.section.md b/nixos/doc/manual/release-notes/rl-2611.section.md index 8a006ed90670..bf7b29a82b86 100644 --- a/nixos/doc/manual/release-notes/rl-2611.section.md +++ b/nixos/doc/manual/release-notes/rl-2611.section.md @@ -388,6 +388,8 @@ - The `shell_interact()` function on interactive runs of NixOS VM tests has been deprecated. Use the SSH backdoor instead. +- NixOS tests using `systemd-nspawn` containers now support graphical X11 applications, including keyboard input, screenshots, and OCR. The [interactive test driver](#sec-nixos-test-interactive-graphical) opens each container's default X11 display in TigerVNC for observation and input. + - The {option}`programs.fish.shellFunctions` option can now be used to create custom fish functions in a structured manner, as opposed to concatenating strings with {option}`program.fish.interactiveShellInit`. :::{.example} # Migrating fish functions to `programs.fish.shellFunctions` From 620ddff3025b3b071a37b9f850206b8a6f93b3d6 Mon Sep 17 00:00:00 2001 From: Bart Oostveen Date: Wed, 21 Oct 2026 13:52:05 +0200 Subject: [PATCH 19/23] bind: 9.20.29 -> 9.20.30 Changelog: https://downloads.isc.org/isc/bind9/cur/9.20/doc/arm/html/notes.html#notes-for-bind-9-20-30 Diff: https://gitlab.isc.org/isc-projects/bind9/-/compare/v9.20.29...v9.20.30 --- pkgs/by-name/bi/bind/package.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/by-name/bi/bind/package.nix b/pkgs/by-name/bi/bind/package.nix index 3f0529b79dfa..bfdc26b7ef63 100644 --- a/pkgs/by-name/bi/bind/package.nix +++ b/pkgs/by-name/bi/bind/package.nix @@ -30,11 +30,11 @@ stdenv.mkDerivation (finalAttrs: { pname = "bind"; - version = "9.20.29"; + version = "9.20.30"; src = fetchurl { url = "https://downloads.isc.org/isc/bind9/${finalAttrs.version}/bind-${finalAttrs.version}.tar.xz"; - hash = "sha256-WHApUIs7G0Min65BbJflVDq6RYCc76ypilAEoCpXNsE="; + hash = "sha256-nhRDpApyowJyy8MZbQIKay4mk3ERyD5Z4QHoLh47T+8="; }; outputs = [ From 8b7c4dec8f8c14e969797166d2b698bd92d45489 Mon Sep 17 00:00:00 2001 From: Sergei Trofimovich Date: Wed, 21 Oct 2026 21:23:00 +0100 Subject: [PATCH 20/23] nano: 9.2 -> 9.3 Changes: https://www.nano-editor.org/news.php --- pkgs/by-name/na/nano/package.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/by-name/na/nano/package.nix b/pkgs/by-name/na/nano/package.nix index 69c06cece607..2221057b3b38 100644 --- a/pkgs/by-name/na/nano/package.nix +++ b/pkgs/by-name/na/nano/package.nix @@ -31,11 +31,11 @@ let in stdenv.mkDerivation rec { pname = "nano"; - version = "9.2"; + version = "9.3"; src = fetchurl { url = "mirror://gnu/nano/nano-${version}.tar.xz"; - hash = "sha256-Bey5kke3guils6Je1BAd0DSwI2kC90SbyXlbcXZC9+k="; + hash = "sha256-qa/8UslZbuBoK83UzrPAsjXrIh2D1zPf2bDf9FiXyw0="; }; nativeBuildInputs = [ texinfo ] ++ lib.optional enableNls gettext; From 19f931d784b811e65fa87422144c7b397ae35a03 Mon Sep 17 00:00:00 2001 From: r-vdp Date: Thu, 22 Oct 2026 19:31:39 +0200 Subject: [PATCH 21/23] nixos/udev: fix PATH in initrd udev rules udevPath is expected to be the root of an env containing bin and sbin, but for the initrd we passed the bin directory itself. This made the initrd udev PATH point to .../initrd-bin-env/bin/bin and .../initrd-bin-env/bin/sbin, which do not exist. --- nixos/modules/services/hardware/udev.nix | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nixos/modules/services/hardware/udev.nix b/nixos/modules/services/hardware/udev.nix index 65663997f590..fc7c7d02317f 100644 --- a/nixos/modules/services/hardware/udev.nix +++ b/nixos/modules/services/hardware/udev.nix @@ -87,7 +87,7 @@ let --replace-quiet /usr/bin/cat ${pkgs.coreutils}/bin/cat \ --replace-quiet /usr/bin/basename ${pkgs.coreutils}/bin/basename 2>/dev/null ${lib.optionalString (initrdBin != null) '' - substituteInPlace $i --replace-quiet '/run/current-system/systemd' "${lib.removeSuffix "/bin" initrdBin}" + substituteInPlace $i --replace-quiet '/run/current-system/systemd' "${initrdBin}" ''} done @@ -475,9 +475,9 @@ in boot.initrd.systemd.contents = { "/etc/udev/rules.d".source = udevRulesFor { name = "initrd-udev-rules"; - initrdBin = config.boot.initrd.systemd.contents."/bin".source; + initrdBin = lib.removeSuffix "/bin" config.boot.initrd.systemd.contents."/bin".source; udevPackages = config.boot.initrd.services.udev.packages; - udevPath = config.boot.initrd.systemd.contents."/bin".source; + udevPath = lib.removeSuffix "/bin" config.boot.initrd.systemd.contents."/bin".source; udev = config.boot.initrd.systemd.package; systemd = config.boot.initrd.systemd.package; binPackages = config.boot.initrd.services.udev.binPackages ++ [ From 1795b109969fbe73b605a0ddd5b3fd5ce837723b Mon Sep 17 00:00:00 2001 From: Jost Alemann Date: Thu, 22 Oct 2026 22:28:07 +0200 Subject: [PATCH 22/23] ty: 0.0.90 -> 0.0.91 Changelog: https://github.com/astral-sh/ty/releases/tag/0.0.91 Diff: https://github.com/astral-sh/ty/compare/0.0.90...0.0.91 --- pkgs/by-name/ty/ty/package.nix | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkgs/by-name/ty/ty/package.nix b/pkgs/by-name/ty/ty/package.nix index f49197490a36..543bac8a2516 100644 --- a/pkgs/by-name/ty/ty/package.nix +++ b/pkgs/by-name/ty/ty/package.nix @@ -18,7 +18,7 @@ rustPlatform.buildRustPackage (finalAttrs: { pname = "ty"; - version = "0.0.90"; + version = "0.0.91"; __structuredAttrs = true; src = fetchFromGitHub { @@ -26,7 +26,7 @@ rustPlatform.buildRustPackage (finalAttrs: { repo = "ty"; tag = finalAttrs.version; fetchSubmodules = true; - hash = "sha256-IvyIOqaQZWuIu9+NJwDGf3gQBwZa50s/gZNMJockxH8="; + hash = "sha256-HI3POcbJYDFJpgTg6Y1q8amUNDUrCcrz43MNwzxUb/A="; }; # For Darwin platforms, remove the integration test for file notifications, @@ -40,7 +40,7 @@ rustPlatform.buildRustPackage (finalAttrs: { cargoBuildFlags = [ "--package=ty" ]; - cargoHash = "sha256-80Xt1nL3/yw4RNsVFIyayc1MxzX+Ybs8YGgGGTIodhQ="; + cargoHash = "sha256-lzy/H9l6liudRY41zSpAF8I1QWoooASsWFoxvOso1Zo="; nativeBuildInputs = [ installShellFiles ]; buildInputs = [ rust-jemalloc-sys ]; From 13c88f9f2b9ba649cb92ef71b9e434d805ccfaf1 Mon Sep 17 00:00:00 2001 From: Jost Alemann Date: Thu, 22 Oct 2026 22:44:14 +0200 Subject: [PATCH 23/23] ruff: 0.17.1 -> 0.17.2 Changelog: https://github.com/astral-sh/ruff/releases/tag/0.17.2 Diff: https://github.com/astral-sh/ruff/compare/0.17.1...0.17.2 --- pkgs/by-name/ru/ruff/package.nix | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkgs/by-name/ru/ruff/package.nix b/pkgs/by-name/ru/ruff/package.nix index a1f35453466c..01afbcb67d65 100644 --- a/pkgs/by-name/ru/ruff/package.nix +++ b/pkgs/by-name/ru/ruff/package.nix @@ -16,7 +16,7 @@ rustPlatform.buildRustPackage (finalAttrs: { pname = "ruff"; - version = "0.17.1"; + version = "0.17.2"; __structuredAttrs = true; @@ -24,12 +24,12 @@ rustPlatform.buildRustPackage (finalAttrs: { owner = "astral-sh"; repo = "ruff"; tag = finalAttrs.version; - hash = "sha256-jpdEdVpp5PWIxn64End1duumm6YJ9EaesBKEBgWoU7Q="; + hash = "sha256-pnEBvVElyBKTc4tsZIMD48H3RQESlZaHR+8TLE6bCDs="; }; cargoBuildFlags = [ "--package=ruff" ]; - cargoHash = "sha256-JrkjRbZ5z0DoUdAWtns6xfKiQLxEhRQSuzYmEngMCBI="; + cargoHash = "sha256-CqLZr1VHgwF4a8d5zQku5Xrl/K/ARYnsSlQriYIieWk="; nativeBuildInputs = [ installShellFiles ];