diff --git a/nixos/doc/manual/development/developing-the-test-driver.chapter.md b/nixos/doc/manual/development/developing-the-test-driver.chapter.md index d64574fa62aaa..414e202458d23 100644 --- a/nixos/doc/manual/development/developing-the-test-driver.chapter.md +++ b/nixos/doc/manual/development/developing-the-test-driver.chapter.md @@ -23,6 +23,51 @@ Beyond the test driver itself, its integration into NixOS and Nixpkgs is importa Finally, we have legacy entrypoints that users should move away from, but are cared for on a best effort basis. These include `pkgs.nixosTest`, `testing-python.nix` and `make-test-python.nix`. +## Interactive display architecture {#sec-test-driver-display-architecture} + +QEMU owns its emulated display hardware and opens its display window directly. +`systemd-nspawn` containers have no corresponding window, so the test driver +uses a pipeline to present their displays during interactive test runs: + +```text +machine declaration host presentation + +┌────────────────┐ ┌────────────── DisplaySession ──────────────┐ +│ display target │ │ │ +│ backend: X11 │────▶│ exporter ───▶ endpoint ───▶ viewer │ +│ display: :0 │ │ X11 → VNC vnc://… VNC │ +└────────────────┘ └────────────────────────────────────────────┘ + backend selects exporter protocol selects viewer +``` + +A **display target** describes a display provided by a machine. Its backend +identifies the display technology, while backend-specific fields identify the +display and any authentication data. Tests normally declare targets through a +shared graphical-test module rather than selecting a presentation mechanism. + +A **display exporter** understands one target backend and makes that display +available over a transport protocol. It returns a **display endpoint**, which +contains only the protocol and the URI needed to connect. A **display viewer** +is selected by that protocol and opens the endpoint on the host. A display +session connects these components and owns their asynchronous startup and +shutdown. + +Keeping these roles separate means that tests do not choose a transport or a +host application. Exporters and viewers are supplied by the interactive driver +configuration, so regular test runs neither start them nor depend on their +packages. Multiple exporters can also reuse a viewer when they produce the +same protocol. + +The current nspawn exporter starts an X11 VNC server inside the container and +relays a host-loopback endpoint into the container's network namespace. The +viewer remains in the host network namespace so it retains access to the +host's graphical session. These details are private to the exporter. + +New display backends should fit the same boundary. For example, if an isolated +virtual DRM/KMS device can eventually be passed to the container, a +[ReFrame](https://github.com/AlynxZhou/reframe)-based exporter could expose its +framebuffer over VNC without changing the VNC viewer or test configuration. + ## Testing changes to the test framework {#sec-test-the-test-framework} We currently have limited unit tests for the framework itself. You may run these with `nix-build -A nixosTests.nixos-test-driver`. 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 78df8d3d77c71..e6f08eb719035 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,34 @@ 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} + +When a graphical host display is available, QEMU machines open their regular +display window. For `systemd-nspawn` containers, `.driverInteractive` opens a +VNC viewer for each X11 display declared by the test. The viewer shows the same +display used by the test and accepts keyboard and mouse input. + +Because the nspawn driver must run 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 Wayland, 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 does not open +graphical viewers. + +See the [interactive display architecture](#sec-test-driver-display-architecture) +for how display targets, exporters, and viewers are connected. + ## 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 ef5dc6984c2d2..3042fe4a0673c 100644 --- a/nixos/doc/manual/development/writing-nixos-tests.section.md +++ b/nixos/doc/manual/development/writing-nixos-tests.section.md @@ -144,12 +144,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-running-nixos-tests-interactively) for how +their displays are presented 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 9901e543a3460..3ae4d9d0777bb 100644 --- a/nixos/doc/manual/redirects.json +++ b/nixos/doc/manual/redirects.json @@ -2420,6 +2420,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" ], @@ -2447,6 +2450,9 @@ "chap-developing-the-test-driver": [ "index.html#chap-developing-the-test-driver" ], + "sec-test-driver-display-architecture": [ + "index.html#sec-test-driver-display-architecture" + ], "sec-test-the-test-framework": [ "index.html#sec-test-the-test-framework" ], diff --git a/nixos/doc/manual/release-notes/rl-2611.section.md b/nixos/doc/manual/release-notes/rl-2611.section.md index 71d52633731c6..065e143a8a3f5 100644 --- a/nixos/doc/manual/release-notes/rl-2611.section.md +++ b/nixos/doc/manual/release-notes/rl-2611.section.md @@ -257,6 +257,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 window queries, screenshots, and OCR. The [interactive test driver](#sec-running-nixos-tests-interactively) opens each container display in a VNC viewer for observation and input. + - NixOS VM tests now prefer to express durations and timeouts as `datetime.timedelta` values instead of bare numbers. Methods such as `machine.wait_until_succeeds`, `machine.sleep`, `retry`, and `polling_condition` now accept a `timedelta` (e.g., `machine.wait_for_unit("sshd.service", timeout=datetime.timedelta(minutes=1))`). Passing an `int`/`float` as seconds still works but now emits a deprecation warning. Argument names that explicitly defined units were preserved but have had `timedelta` equivalents introduced (`timeout_seconds` → `timeout`, `secs` → `duration`, `seconds_interval` → `interval`). - `darwin.linux-builder-vz` has been added: a variant of `darwin.linux-builder` that runs the builder guest on Apple's Virtualization.framework via the new `vzvm` package, translating `x86_64-linux` builds with Rosetta instead of emulating them. Apple silicon hosts only. As part of this, the `nixos/modules/profiles/nix-builder-vm.nix` profile has been split into the backend-neutral `nixos/modules/profiles/nix-builder.nix` and a QEMU-specific part. Existing imports of `nix-builder-vm.nix` keep working unchanged. diff --git a/nixos/lib/test-driver/src/test_driver/__init__.py b/nixos/lib/test-driver/src/test_driver/__init__.py index 568ac5758d051..29bb3d5629670 100644 --- a/nixos/lib/test-driver/src/test_driver/__init__.py +++ b/nixos/lib/test-driver/src/test_driver/__init__.py @@ -9,8 +9,9 @@ import ptpython.repl from colorama import Fore, Style +from test_driver.config import load_driver_configuration from test_driver.debug import Debug, DebugAbstract, DebugNop -from test_driver.driver import Driver, load_driver_configuration +from test_driver.driver import Driver from test_driver.logger import ( CompositeLogger, JunitXMLLogger, @@ -178,6 +179,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/config.py b/nixos/lib/test-driver/src/test_driver/config.py new file mode 100644 index 0000000000000..49a5af98fcb25 --- /dev/null +++ b/nixos/lib/test-driver/src/test_driver/config.py @@ -0,0 +1,69 @@ +import datetime as dt +import json +from pathlib import Path +from typing import Literal + +from pydantic import BaseModel, Field + +DisplayBackend = Literal["x11"] +DisplayProtocol = Literal["vnc"] + + +class X11DisplayTargetConfiguration(BaseModel): + backend: DisplayBackend + display: str = ":0" + xauthority: Path = Path("/root/.Xauthority") + + +DisplayTargetConfiguration = X11DisplayTargetConfiguration + + +class VncDisplayViewerConfiguration(BaseModel): + kind: Literal["vnc"] + executable: Path + + +DisplayViewerConfiguration = VncDisplayViewerConfiguration + + +class NspawnX11VncExporterConfiguration(BaseModel): + kind: Literal["x11-vnc"] + server: Path + relay: Path + + +NspawnDisplayExporterConfiguration = NspawnX11VncExporterConfiguration + + +class MachineConfiguration(BaseModel): + name: str + start_script: Path + + +class QemuMachineConfiguration(MachineConfiguration): + pass + + +class NspawnMachineConfiguration(MachineConfiguration): + display_targets: list[DisplayTargetConfiguration] = Field(default_factory=list) + display_exporters: dict[DisplayBackend, NspawnDisplayExporterConfiguration] = Field( + default_factory=dict + ) + + +class DriverConfiguration(BaseModel): + vms: dict[str, QemuMachineConfiguration] + containers: dict[str, NspawnMachineConfiguration] + display_viewers: dict[DisplayProtocol, DisplayViewerConfiguration] = Field( + default_factory=dict + ) + vlans: list[int] + global_timeout: dt.timedelta + enable_ssh_backdoor: bool + test_script: Path + + +def load_driver_configuration(file_path: str) -> DriverConfiguration: + with open(file_path) as file: + data = json.load(file) + return DriverConfiguration.model_validate(data) diff --git a/nixos/lib/test-driver/src/test_driver/display.py b/nixos/lib/test-driver/src/test_driver/display.py new file mode 100644 index 0000000000000..b435588f8dd08 --- /dev/null +++ b/nixos/lib/test-driver/src/test_driver/display.py @@ -0,0 +1,208 @@ +import os +import platform +import signal +import subprocess +import threading +import time +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from typing import Protocol + +from test_driver.config import ( + DisplayProtocol, + DisplayViewerConfiguration, + VncDisplayViewerConfiguration, +) + + +def graphical_display_available() -> bool: + if platform.system() == "Darwin": + # We have no DISPLAY variables on macOS and seemingly no better way + # to find out. + return "TERM_PROGRAM" in os.environ + + return any(name in os.environ for name in ("DISPLAY", "WAYLAND_DISPLAY")) + + +@dataclass(frozen=True) +class DisplayEndpoint: + protocol: DisplayProtocol + uri: str + + +class DisplayViewer(Protocol): + def start(self) -> None: ... + + def is_running(self) -> bool: ... + + @property + def returncode(self) -> int | None: ... + + def stop(self) -> None: ... + + +class DisplayExporter(Protocol): + description: str + protocol: DisplayProtocol + + def open(self, stop_event: threading.Event) -> DisplayEndpoint | None: ... + + def stop(self) -> None: ... + + +def terminate_process_groups( + processes: Iterable[subprocess.Popen[bytes]], + description: str, + log: Callable[[str], None], +) -> None: + running = [process for process in processes if process.poll() is None] + for process in running: + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + pass + + deadline = time.monotonic() + 5 + for process in running: + try: + process.wait(timeout=max(0, deadline - time.monotonic())) + except subprocess.TimeoutExpired: + log(f"{description} did not exit after SIGTERM; sending SIGKILL") + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + + for process in running: + process.wait() + + +class VncDisplayViewer: + def __init__( + self, + configuration: VncDisplayViewerConfiguration, + endpoint: DisplayEndpoint, + log: Callable[[str], None], + ) -> None: + if endpoint.protocol != "vnc": + raise ValueError( + f"VNC viewer cannot open a {endpoint.protocol} display endpoint" + ) + + self.configuration = configuration + self.endpoint = endpoint + self.log = log + self.process: subprocess.Popen[bytes] | None = None + self.cleanup_lock = threading.Lock() + + def start(self) -> None: + self.process = subprocess.Popen( + [str(self.configuration.executable), self.endpoint.uri], + start_new_session=True, + ) + self.log(f"VNC viewer running (pid {self.process.pid})") + + def is_running(self) -> bool: + return self.process is not None and self.process.poll() is None + + @property + def returncode(self) -> int | None: + if self.process is None: + return None + return self.process.poll() + + def stop(self) -> None: + with self.cleanup_lock: + process = self.process + if process is None: + return + terminate_process_groups([process], "VNC viewer", self.log) + + +def create_display_viewer( + configuration: DisplayViewerConfiguration, + endpoint: DisplayEndpoint, + log: Callable[[str], None], +) -> DisplayViewer: + if configuration.kind == "vnc": + return VncDisplayViewer(configuration, endpoint, log) + + raise ValueError(f"unsupported display viewer kind {configuration.kind}") + + +class DisplaySession: + def __init__( + self, + *, + exporter: DisplayExporter, + viewer_configuration: DisplayViewerConfiguration, + machine_running: Callable[[], bool], + log: Callable[[str], None], + ) -> None: + self.exporter = exporter + self.viewer_configuration = viewer_configuration + self.machine_running = machine_running + self.log = log + + self.stop_event = threading.Event() + self.thread: threading.Thread | None = None + self.viewer: DisplayViewer | None = None + self.lock = threading.Lock() + self.cleanup_lock = threading.Lock() + + def start(self) -> None: + self.thread = threading.Thread(target=self._run, daemon=True) + self.thread.start() + + def stop(self) -> None: + with self.cleanup_lock: + self.stop_event.set() + with self.lock: + viewer = self.viewer + if viewer is not None: + viewer.stop() + self.exporter.stop() + + def join(self) -> None: + thread = self.thread + if thread is not None: + thread.join(timeout=5) + if thread.is_alive(): + self.log("display session did not stop within 5 seconds") + return + self.thread = None + + def _run(self) -> None: + try: + endpoint = self.exporter.open(self.stop_event) + if endpoint is None: + return + + viewer = create_display_viewer( + self.viewer_configuration, endpoint, self.log + ) + with self.lock: + if self.stop_event.is_set(): + return + self.viewer = viewer + viewer.start() + + while ( + not self.stop_event.is_set() + and self.machine_running() + and viewer.is_running() + ): + self.stop_event.wait(0.5) + + if not self.stop_event.is_set() and not viewer.is_running(): + self.log( + f"{self.viewer_configuration.kind.upper()} viewer exited " + f"(status {viewer.returncode})" + ) + except Exception as error: + if not self.stop_event.is_set(): + self.log( + f"failed to open the viewer for {self.exporter.description}: {error}" + ) + finally: + self.stop() diff --git a/nixos/lib/test-driver/src/test_driver/driver.py b/nixos/lib/test-driver/src/test_driver/driver.py index a3891313b8177..0d44d31b9032c 100644 --- a/nixos/lib/test-driver/src/test_driver/driver.py +++ b/nixos/lib/test-driver/src/test_driver/driver.py @@ -1,5 +1,4 @@ import datetime as dt -import json import os import re import signal @@ -17,8 +16,8 @@ from unittest import TestCase from colorama import Style -from pydantic import BaseModel +from test_driver.config import DriverConfiguration from test_driver.debug import DebugAbstract, DebugNop from test_driver.duration import as_timedelta from test_driver.errors import MachineError, RequestedAssertionFailed @@ -34,26 +33,6 @@ from test_driver.vlan import VLan -class NodeConfiguration(BaseModel): - name: str - start_script: Path - - -class DriverConfiguration(BaseModel): - vms: dict[str, NodeConfiguration] - containers: dict[str, NodeConfiguration] - vlans: list[int] - global_timeout: dt.timedelta - enable_ssh_backdoor: bool - test_script: Path - - -def load_driver_configuration(file_path: str) -> DriverConfiguration: - with open(file_path) as f: - data = json.load(f) - return DriverConfiguration.model_validate(data) - - class AssertionTester(TestCase): """ Subclass of `unittest.TestCase` which is used in the @@ -147,6 +126,7 @@ class Driver: polling_conditions: list[PollingCondition] race_timer: threading.Timer keep_machine_state: bool + interactive: bool logger: AbstractLogger debug: DebugAbstract vhost_vsock: VHostDeviceVsock | None = None @@ -158,6 +138,7 @@ def __init__( 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 +147,7 @@ def __init__( 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( @@ -219,6 +201,10 @@ def __enter__(self) -> "Driver": keep_machine_state=self.keep_machine_state, callbacks=[self.check_polling_conditions], out_dir=self.out_dir, + interactive=self.interactive, + display_targets=container_config.display_targets, + display_exporters=container_config.display_exporters, + display_viewers=self.config.display_viewers, ) for name, container_config in self.config.containers.items() ] 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 ffc7c75171656..7a82e952f6a37 100644 --- a/nixos/lib/test-driver/src/test_driver/machine/__init__.py +++ b/nixos/lib/test-driver/src/test_driver/machine/__init__.py @@ -2,7 +2,6 @@ import datetime as dt import io import os -import platform import queue import re import select @@ -23,6 +22,14 @@ from queue import Queue from typing import Any +from test_driver.config import ( + DisplayBackend, + DisplayProtocol, + DisplayTargetConfiguration, + DisplayViewerConfiguration, + NspawnDisplayExporterConfiguration, +) +from test_driver.display import DisplaySession, graphical_display_available from test_driver.duration import ( Duration, _warn_if_numeric_duration, @@ -32,6 +39,7 @@ 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 create_nspawn_display_exporter from test_driver.machine.ocr import ( perform_ocr_on_screenshot, perform_ocr_variants_on_screenshot, @@ -101,6 +109,48 @@ ")": "shift-0x0B", } +X11_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", +} + +X11_MODIFIER_ALIASES = { + "alt": "alt", + "ctrl": "ctrl", + "meta_l": "super", + "meta_r": "super", + "shift": "shift", +} + + +def x11_key_name(key: str) -> str: + """Translate a key from the test driver vocabulary to an X11 key chord.""" + parts = key.split("-") + modifiers = [] + while len(parts) > 1 and parts[0] in X11_MODIFIER_ALIASES: + modifiers.append(X11_MODIFIER_ALIASES[parts.pop(0)]) + + base = "-".join(parts) + base = X11_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)))) @@ -183,13 +233,7 @@ def cmd( ) -> str: display_opts = "" - display_available = any(x in os.environ for x in ["DISPLAY", "WAYLAND_DISPLAY"]) - if platform.system() == "Darwin": - # We have no DISPLAY variables on macOS and seemingly no better way - # to find out - display_available = "TERM_PROGRAM" in os.environ - - if not display_available: + if not graphical_display_available(): display_opts += " -nographic" # qemu options @@ -596,6 +640,154 @@ def check_file(_last_try: bool) -> bool: 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)) + + @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")`. + + 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]: + """ + 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() + + 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, @@ -1108,19 +1300,6 @@ def wait_for_qmp_event( 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: @@ -1197,84 +1376,6 @@ def _managed_screenshot(self) -> Generator[Path]: 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: @@ -1323,13 +1424,6 @@ def send_key( 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() @@ -1471,6 +1565,8 @@ def wait_for_x(self, timeout: Duration = dt.timedelta(minutes=15)) -> None: """ _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 | " @@ -1485,34 +1581,6 @@ def check_x(_last_try: bool) -> bool: 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. @@ -1606,6 +1674,12 @@ class NspawnMachine(BaseMachine): machine_sock: socket.socket | None notify_thread: threading.Thread | None + interactive: bool + display_targets: list[DisplayTargetConfiguration] + display_exporters: dict[DisplayBackend, NspawnDisplayExporterConfiguration] + display_viewers: dict[DisplayProtocol, DisplayViewerConfiguration] + display_sessions: list[DisplaySession] + @staticmethod def machine_name_from_start_command(start_command: str) -> str: match = re.search("run-(.+)-nspawn", os.path.basename(start_command)) @@ -1621,6 +1695,14 @@ def __init__( logger: AbstractLogger, callbacks: list[Callable] | None = None, keep_machine_state: bool = False, + interactive: bool = False, + display_targets: list[DisplayTargetConfiguration] | None = None, + display_exporters: ( + dict[DisplayBackend, NspawnDisplayExporterConfiguration] | None + ) = None, + display_viewers: ( + dict[DisplayProtocol, DisplayViewerConfiguration] | None + ) = None, ): # TODO: don't compute `name` from `start_command` path, instead thread it down explicitly. # See analogous TODO in `QemuStartCommand::machine_name`. @@ -1634,6 +1716,11 @@ def __init__( ) self.start_command = start_command + self.interactive = interactive + self.display_targets = display_targets or [] + self.display_exporters = display_exporters or {} + self.display_viewers = display_viewers or {} + self.display_sessions = [] self.process = None self.notify_thread = None # State maintained by the notify-socket drainer thread (see @@ -1644,14 +1731,140 @@ def __init__( 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 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: + if len(key) == 1 and key.isprintable(): + command = [ + "xdotool", + "type", + "--clearmodifiers", + "--delay", + 0, + "--", + key, + ] + else: + command = [ + "xdotool", + "key", + "--clearmodifiers", + x11_key_name(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 + # 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" proxy_cmd = f"socat - UNIX-CLIENT:{socket_path}" return f'ssh -o User=root -o ProxyCommand="{proxy_cmd}" bash' + def _stop_display_sessions(self) -> None: + for session in self.display_sessions: + session.stop() + + def _join_display_sessions(self) -> None: + for session in self.display_sessions: + session.join() + self.display_sessions = [] + + def _is_container_running(self) -> bool: + return self.process is not None and self.process.poll() is None + + def _start_display_sessions(self) -> None: + if not self.interactive or not self.display_targets: + return + if not graphical_display_available(): + self.log("no graphical host display available; display viewers disabled") + return + + for index, target in enumerate(self.display_targets): + exporter_configuration = self.display_exporters.get(target.backend) + if exporter_configuration is None: + self.log( + f"display export for backend {target.backend} is not configured" + ) + continue + display_exporter = create_nspawn_display_exporter( + target=target, + configuration=exporter_configuration, + port=5900 + index, + unit=f"nixos-test-display-{index}", + execute=self._execute, + wait_for_container_pid=lambda: self.get_systemd_process, + container_running=self._is_container_running, + log=self.log, + ) + viewer = self.display_viewers.get(display_exporter.protocol) + if viewer is None: + self.log( + f"display viewer for protocol {display_exporter.protocol} is not configured" + ) + continue + session = DisplaySession( + exporter=display_exporter, + viewer_configuration=viewer, + machine_running=self._is_container_running, + log=self.log, + ) + self.display_sessions.append(session) + session.start() + def release(self) -> None: + self._stop_display_sessions() + if self.process is None: + self._join_display_sessions() return if self.machine_sock: @@ -1671,6 +1884,7 @@ def release(self) -> None: self.process.kill() self.process.wait() self.process = None + self._join_display_sessions() def is_up(self) -> bool: return self.process is not None @@ -1775,7 +1989,11 @@ def _execute( # 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 DISPLAY=:0.0; " + f"source /etc/profile; set -u; {command}" + ) cp = subprocess.run( [ @@ -1893,6 +2111,7 @@ def start(self) -> None: journal_thread = threading.Thread(target=self._stream_journal, daemon=True) journal_thread.start() + self._start_display_sessions() def shutdown(self) -> None: """ @@ -1914,6 +2133,8 @@ def wait_for_shutdown(self) -> None: with self.nested("waiting for the container to power off"): self.process.wait() self.process = None + self._stop_display_sessions() + self._join_display_sessions() 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 0000000000000..3188344cbc665 --- /dev/null +++ b/nixos/lib/test-driver/src/test_driver/machine/nspawn_display.py @@ -0,0 +1,302 @@ +import datetime as dt +import shlex +import shutil +import socket +import subprocess +import threading +import time +from collections.abc import Callable +from typing import Protocol + +from test_driver.config import ( + DisplayProtocol, + DisplayTargetConfiguration, + NspawnDisplayExporterConfiguration, + NspawnX11VncExporterConfiguration, + X11DisplayTargetConfiguration, +) +from test_driver.display import ( + DisplayEndpoint, + DisplayExporter, + terminate_process_groups, +) + + +class ExecuteCommand(Protocol): + def __call__( + self, + command: str, + check_return: bool = True, + check_output: bool = True, + timeout: dt.timedelta | None = dt.timedelta(minutes=15), + ) -> tuple[int, str]: ... + + +class NspawnX11VncExporter: + protocol: DisplayProtocol = "vnc" + + def __init__( + self, + *, + target: X11DisplayTargetConfiguration, + configuration: NspawnX11VncExporterConfiguration, + port: int, + unit: str, + execute: ExecuteCommand, + wait_for_container_pid: Callable[[], int], + container_running: Callable[[], bool], + log: Callable[[str], None], + ) -> None: + self.target = target + self.configuration = configuration + self.port = port + self.unit = unit + self.execute = execute + self.wait_for_container_pid = wait_for_container_pid + self.container_running = container_running + self.log = log + self.description = f"X display {self.target.display}" + + self.listener: socket.socket | None = None + self.relay_thread: threading.Thread | None = None + self.relays: list[subprocess.Popen[bytes]] = [] + self.server_started = False + self.lock = threading.Lock() + self.cleanup_lock = threading.Lock() + + def _wait_for_x(self, stop_event: threading.Event) -> bool: + command = shlex.join( + [ + "env", + f"XAUTHORITY={self.target.xauthority}", + "xwininfo", + "-display", + self.target.display, + "-root", + ] + ) + last_warning = time.monotonic() + while not stop_event.is_set(): + if not self.container_running(): + return False + try: + status, _ = self.execute( + f"{command} >/dev/null 2>&1", + timeout=dt.timedelta(seconds=5), + ) + except subprocess.TimeoutExpired: + status = 1 + if status == 0: + return True + now = time.monotonic() + if now - last_warning >= 10: + self.log( + f"still waiting for X display {self.target.display} before opening the viewer..." + ) + last_warning = now + stop_event.wait(1) + return False + + def _start_server(self) -> bool: + command = shlex.join( + [ + "systemd-run", + "--quiet", + "--service-type=exec", + f"--unit={self.unit}", + str(self.configuration.server), + "-display", + self.target.display, + "-auth", + str(self.target.xauthority), + "-localhost", + "-nopw", + "-forever", + "-shared", + "-rfbport", + str(self.port), + ] + ) + status, output = self.execute(command) + if status != 0: + self.log( + f"failed to start VNC server for X display {self.target.display}: {output.strip()}" + ) + return False + self.server_started = True + return True + + def _wait_for_server(self, stop_event: threading.Event) -> bool: + command = shlex.join( + [ + str(self.configuration.relay), + "-u", + "/dev/null", + f"TCP:127.0.0.1:{self.port},connect-timeout=1", + ] + ) + last_warning = time.monotonic() + while not stop_event.is_set(): + if not self.container_running(): + return False + try: + status, _ = self.execute( + f"{command} >/dev/null 2>&1", + timeout=dt.timedelta(seconds=2), + ) + except subprocess.TimeoutExpired: + status = 1 + if status == 0: + return True + service_failed, _ = self.execute( + f"systemctl is-failed --quiet {self.unit}.service" + ) + if service_failed == 0: + self.log( + f"VNC server for X display {self.target.display} exited before accepting connections" + ) + return False + now = time.monotonic() + if now - last_warning >= 10: + self.log( + f"still waiting for the VNC server for X display {self.target.display}..." + ) + last_warning = now + stop_event.wait(1) + return False + + def _relay_connections( + self, + nsenter: str, + container_pid: int, + listener: socket.socket, + stop_event: threading.Event, + ) -> None: + while not stop_event.is_set() and self.container_running(): + with self.lock: + self.relays = [relay for relay in self.relays if relay.poll() is None] + try: + connection, _ = listener.accept() + except TimeoutError: + continue + except OSError: + break + + with connection: + if stop_event.is_set(): + break + connection_fd = connection.fileno() + relay = subprocess.Popen( + [ + nsenter, + "--target", + str(container_pid), + "--net", + str(self.configuration.relay), + "-", + f"TCP:127.0.0.1:{self.port}", + ], + stdin=connection_fd, + stdout=connection_fd, + pass_fds=[connection_fd], + start_new_session=True, + ) + with self.lock: + self.relays.append(relay) + + def open(self, stop_event: threading.Event) -> DisplayEndpoint | None: + if stop_event.is_set(): + return None + nsenter = shutil.which("nsenter") + if nsenter is None: + raise RuntimeError("nsenter is required for nspawn display forwarding") + container_pid = self.wait_for_container_pid() + if not self._wait_for_x(stop_event): + return None + if not self._start_server(): + return None + if not self._wait_for_server(stop_event): + return None + + # Keep the viewer in the host network namespace. In particular, an SSH + # forwarded DISPLAY commonly points at host loopback and would stop + # working if the viewer itself entered the container's namespace. + # Instead, relay each viewer connection through nsenter and socat. + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(("127.0.0.1", 0)) + listener.listen() + listener.settimeout(0.5) + with self.lock: + if stop_event.is_set(): + listener.close() + return None + self.listener = listener + + self.relay_thread = threading.Thread( + target=self._relay_connections, + args=(nsenter, container_pid, listener, stop_event), + daemon=True, + ) + self.relay_thread.start() + host_port = listener.getsockname()[1] + return DisplayEndpoint(protocol="vnc", uri=f"vnc://127.0.0.1:{host_port}") + + def stop(self) -> None: + with self.cleanup_lock: + with self.lock: + if self.listener is not None: + self.listener.close() + self.listener = None + relays = list(self.relays) + + terminate_process_groups(relays, "VNC relay", self.log) + + relay_thread = self.relay_thread + if relay_thread is not None: + relay_thread.join(timeout=5) + if relay_thread.is_alive(): + self.log("VNC relay thread did not stop within 5 seconds") + else: + self.relay_thread = None + + if self.server_started and self.container_running(): + try: + self.execute( + shlex.join(["systemctl", "stop", f"{self.unit}.service"]), + check_return=False, + timeout=dt.timedelta(seconds=5), + ) + except subprocess.TimeoutExpired: + self.log( + f"timed out stopping the VNC server for X display {self.target.display}" + ) + self.server_started = False + + +def create_nspawn_display_exporter( + *, + target: DisplayTargetConfiguration, + configuration: NspawnDisplayExporterConfiguration, + port: int, + unit: str, + execute: ExecuteCommand, + wait_for_container_pid: Callable[[], int], + container_running: Callable[[], bool], + log: Callable[[str], None], +) -> DisplayExporter: + if target.backend == "x11" and configuration.kind == "x11-vnc": + return NspawnX11VncExporter( + target=target, + configuration=configuration, + port=port, + unit=unit, + execute=execute, + wait_for_container_pid=wait_for_container_pid, + container_running=container_running, + log=log, + ) + + raise ValueError( + f"unsupported nspawn display export {target.backend} via {configuration.kind}" + ) diff --git a/nixos/lib/testing/driver-configuration.nix b/nixos/lib/testing/driver-configuration.nix index 222332d51fad6..5d5eddf3cbd64 100644 --- a/nixos/lib/testing/driver-configuration.nix +++ b/nixos/lib/testing/driver-configuration.nix @@ -7,23 +7,73 @@ 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; - }; - }; - } - ); + displayTarget = types.submodule { + options = { + backend = lib.mkOption { + internal = true; + type = types.enum [ "x11" ]; + }; + display = lib.mkOption { + internal = true; + type = types.str; + }; + xauthority = lib.mkOption { + internal = true; + type = types.str; + }; + }; }; + + displayViewer = types.submodule { + options = { + kind = lib.mkOption { + internal = true; + type = types.enum [ "vnc" ]; + }; + executable = lib.mkOption { + internal = true; + type = types.path; + }; + }; + }; + + nspawnDisplayExporter = types.submodule { + options = { + kind = lib.mkOption { + internal = true; + type = types.enum [ "x11-vnc" ]; + }; + server = lib.mkOption { + internal = true; + type = types.path; + }; + relay = lib.mkOption { + internal = true; + type = types.path; + }; + }; + }; + + machineConfigurationAttrs = + 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 +82,24 @@ in internal = true; type = types.submodule { options = { - vms = nodeConfigurationAttrs; - containers = nodeConfigurationAttrs; + vms = machineConfigurationAttrs { }; + containers = machineConfigurationAttrs { + display_targets = lib.mkOption { + internal = true; + type = types.listOf displayTarget; + default = [ ]; + }; + display_exporters = lib.mkOption { + internal = true; + type = types.attrsOf nspawnDisplayExporter; + default = { }; + }; + }; + display_viewers = lib.mkOption { + internal = true; + type = types.attrsOf displayViewer; + default = { }; + }; vlans = lib.mkOption { internal = true; type = types.listOf types.ints.unsigned; @@ -68,6 +134,7 @@ in containers = lib.mapAttrs (name: value: { inherit name; start_script = lib.getExe value.system.build.nspawn; + display_targets = value.testing.displayTargets; }) config.containers; vlans = lib.unique ( lib.concatMap ( diff --git a/nixos/lib/testing/interactive.nix b/nixos/lib/testing/interactive.nix index 4088cc2abfc1f..a7c8a609a07cb 100644 --- a/nixos/lib/testing/interactive.nix +++ b/nixos/lib/testing/interactive.nix @@ -7,6 +7,9 @@ }: let inherit (lib) mkOption; + x11Containers = lib.filterAttrs ( + _: machine: lib.any (target: target.backend == "x11") machine.testing.displayTargets + ) config.containers; in { options = { @@ -46,6 +49,21 @@ in config = { interactive.qemu.package = hostPkgs.qemu; interactive.extraDriverArgs = [ "--interactive" ]; + interactive.driverConfiguration = lib.mkIf hostPkgs.stdenv.hostPlatform.isLinux { + containers = lib.mapAttrs (_: _: { + display_exporters.x11 = { + kind = "x11-vnc"; + server = lib.getExe hostPkgs.x11vnc; + relay = lib.getExe hostPkgs.socat; + }; + }) x11Containers; + display_viewers = lib.mkIf (x11Containers != { }) { + vnc = { + kind = "vnc"; + executable = lib.getExe' hostPkgs.virt-viewer "remote-viewer"; + }; + }; + }; passthru.driverInteractive = config.interactive.driver; }; } diff --git a/nixos/modules/testing/test-instrumentation.nix b/nixos/modules/testing/test-instrumentation.nix index 47890479a4c93..5c99e462fa39b 100644 --- a/nixos/modules/testing/test-instrumentation.nix +++ b/nixos/modules/testing/test-instrumentation.nix @@ -92,6 +92,29 @@ in default = !config.boot.isContainer; }; + displayTargets = lib.mkOption { + internal = true; + default = [ ]; + description = "Displays provided by this test machine."; + type = lib.types.listOf ( + lib.types.submodule { + options = { + backend = lib.mkOption { + type = lib.types.enum [ "x11" ]; + }; + display = lib.mkOption { + type = lib.types.str; + default = ":0"; + }; + xauthority = lib.mkOption { + type = lib.types.str; + default = "/root/.Xauthority"; + }; + }; + } + ); + }; + initrdBackdoor = lib.mkEnableOption '' backdoor.service in initrd. Requires boot.initrd.systemd.enable to be enabled. Boot will pause in @@ -230,7 +253,16 @@ in ]; # `xwininfo' is used by the test driver to query open windows. - environment.systemPackages = [ pkgs.xwininfo ]; + environment.systemPackages = [ + pkgs.xwininfo + ] + ++ lib.optionals config.boot.isNspawnContainer [ + # Unlike a QEMU machine, an nspawn container has no monitor that can + # 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 = { diff --git a/nixos/tests/common/x11.nix b/nixos/tests/common/x11.nix index 16bdbfa7ae24d..e2983bb459b9f 100644 --- a/nixos/tests/common/x11.nix +++ b/nixos/tests/common/x11.nix @@ -1,19 +1,48 @@ -{ lib, ... }: +{ config, lib, ... }: +let + isNspawn = config.boot.isNspawnContainer; +in { imports = [ ./auto.nix ]; - services.xserver.enable = true; + testing.displayTargets = lib.mkDefault [ + { backend = "x11"; } + ]; + + 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; + } + ]; + + # 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 + ''; + }) + ]; # 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 diff --git a/nixos/tests/firefox.nix b/nixos/tests/firefox.nix index 44aa0734ca144..63e0ddd683397 100644 --- a/nixos/tests/firefox.nix +++ b/nixos/tests/firefox.nix @@ -11,12 +11,11 @@ maintainers = [ shlevy ]; }; - nodes.machine = - { pkgs, ... }: + containers.machine = + { ... }: { imports = [ ./common/x11.nix ]; - environment.systemPackages = [ pkgs.xdotool ]; programs.firefox = { enable = true; @@ -24,15 +23,35 @@ package = firefoxPackage; }; - hardware.alsa = { + services.pipewire = { + enable = false; + alsa.enable = false; + pulse.enable = false; + }; + + services.pulseaudio = { enable = true; - enableRecorder = true; - defaultDevice.playback = "pcm.recorder"; + 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 + ''; }; }; @@ -94,10 +113,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) diff --git a/nixos/tests/firefox_decrypt.nix b/nixos/tests/firefox_decrypt.nix index afc3bfaf485ce..f9bea80d84f73 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 ]; diff --git a/nixos/tests/firefoxpwa.nix b/nixos/tests/firefoxpwa.nix index b8cf0578da08b..a0bc1d5a5ffe5 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;