Testing desktop notifications with NixOS
Desktop notifications are a nice way to be informed immediately about important
changes on your system. In a terminal or interactive shell script this is easy
enough: trap 'notify-send "đź’Ą"' ERR. But in a non-interactive or restricted
context, such as a systemd service, we need more setup for desktop notifications
to work. And with more setup comes more chances to mess up, so it would be good
to test that this actually works on a real desktop. Enter NixOS tests!
- Nix makes it easy to write reproducible tests.
- The tests run in a virtual machine1, so we don’t need to worry about how the host OS is configured.
- The test framework supports easily starting services and waiting for them to reach a desired state.
- Tests can run anywhere with a bit of RAM and disk space.
At minimum, we need the test to do the following:
- Create a NixOS VM with some configuration.
- Start the VM.
- Run the commands to test the NixOS configuration.
The result is pretty simple (annotated and minimised):
{ pkgs }:
let
machineName = "machine";
in
pkgs.testers.runNixOSTest {
name = "ssh-server-audit";
nodes.${machineName} = {
# Import only the NixOS configuration relevant for this test
imports = [ ../ssh-server.nix ];
# Optional/irrelevant config omitted
};
testScript = ''
${machineName}.start()
# Wait for SSH daemon service to start
${machineName}.wait_for_unit("sshd.service")
# Should pass SSH server audit
${machineName}.succeed("${pkgs.ssh-audit}/bin/ssh-audit 127.0.0.1")
'';
}
This test works great, so we can reuse the general format:
{ pkgs }:
let
machineName = "machine";
in
pkgs.testers.runNixOSTest {
name = "some-name";
nodes.${machineName} = {
# NixOS configuration
};
testScript = ''
${machineName}.start()
${machineName}.succeed("some command")
'';
}
Desktop notification tests need several additional features, and some of these are non-trivial to implement or find examples of. The rest of the article explains how this was achieved. See the final result for the full code.
A functioning graphical desktop
This is available in the existing
nixpkgs GNOME tests.
I already use GNOME, so reusing this was an obvious choice. The relevant parts
of the test are in nodes.${machineName}:
{
services = {
desktopManager.gnome.enable = true;
xserver.enable = true;
};
}
Auto-login to the desktop
We need to be logged in to the desktop so that we actually see the
notifications. The relevant parts of the test, also based on the GNOME tests
above, are in nodes.${machineName}:
let
userName = "alice";
in
{
services.displayManager.autoLogin = {
enable = true;
user = userName;
};
users.users.${userName} = {
isNormalUser = true;
uid = 1000; # Used later
};
}
Wait for the desktop to be fully ready for interaction
This functionality is also found in the GNOME tests, but it’s a big chunk of code:
{ pkgs }:
let
machineName = "machine";
userName = "alice";
in
pkgs.testers.runNixOSTest {
nodes.${machineName} = {
# Allow Eval API access; see
# https://github.com/NixOS/nixpkgs/blob/07700a890da3eaf57033bc408937e8955ca1c393/nixos/tests/gnome.nix#L25-L38
systemd.user.services."org.gnome.Shell@".serviceConfig.ExecStart = [
""
"${pkgs.gnome-shell}/bin/gnome-shell --unsafe-mode"
];
};
testScript =
{ nodes, ... }:
let
user = nodes.${machineName}.users.users.${userName};
inherit (user) name;
bus = "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${toString user.uid}/bus";
in
''
start_all()
# Wait for GNOME desktop. Correct output should be (true, 'false')
${machineName}.wait_until_succeeds(
"su -c '${bus} gdbus call --session --dest=org.gnome.Shell --object-path=/org/gnome/Shell --method=org.gnome.Shell.Eval Main.layoutManager._startingUp' ${name} | grep --fixed-strings --line-regexp --quiet \"(true, 'false')\""
)
'';
}
Open a GUI where the notification shows permanently
Notifications often disappear after showing for a few seconds, and we want to guarantee that we don’t miss it! This can be done by sending the default key to open the notifications menu, [Super](https://en.wikibooks.org/wiki/QEMU/Monitor#sendkey_keys)-v. We also want to clear any existing notifications. Thus:
{ pkgs }:
let
machineName = "machine";
in
pkgs.testers.runNixOSTest {
testScript = ''
# Open notifications menu
${machineName}.send_key("meta_l-v")
# Go to "Clear" button
${machineName}.send_key("down")
# Clear notifications
${machineName}.send_key("spc")
'';
}
Wait for the service to fail rather than succeed
We want to see the error notification, after all. It was easy to extend the framework to do this:
{ pkgs }:
let
machineName = "machine";
userName = "alice";
in
pkgs.testers.runNixOSTest {
testScript =
{ nodes, ... }:
let
user = nodes.${machineName}.users.users.${userName};
inherit (user) name;
in
''
def wait_for_unit_state(node, unit: str, required_state: str, user: str | None = None, timeout: int = 900) -> None:
def check_state(_last_try: bool) -> bool:
return node.get_unit_property(unit, "ActiveState", user) == required_state
with node.nested(
f"waiting for unit {unit}"
+ (f" with user {user}" if user is not None else "")
+ f" to reach state {required_state}"
):
retry(check_state, timeout)
${machineName}.systemctl("start android-backup.service", user="${name}")
wait_for_unit_state(${machineName}, "android-backup", "failed", user="${name}")
'';
}
OCR the screen repeatedly until the expected text shows up
OCR is supported out of the box with a single line of configuration, so this is actually super easy:
{ pkgs }:
let
machineName = "machine";
in
pkgs.testers.runNixOSTest {
enableOCR = true;
testScript = ''
# Wait for the relevant notification
# (the notification text is trimmed to fit the GUI, so we can only check for the first few words)
${machineName}.wait_for_text(r"Could not pull OSMAnd files")
'';
}
Run reliably with minimal resources in CI
This was harder than expected, because GitLab CI runners have limited resources,
and a full NixOS GNOME desktop environment with default settings includes a lot
of things. But we don’t need all of those things, so let’s get rid of what we
can in nodes.${machineName}:
{
environment = {
defaultPackages = [ ];
gnome.excludePackages = [
pkgs.adwaita-icon-theme
pkgs.baobab
pkgs.decibels
pkgs.epiphany
pkgs.gnome-backgrounds
pkgs.gnome-bluetooth
pkgs.gnome-calculator
pkgs.gnome-calendar
pkgs.gnome-characters
pkgs.gnome-clocks
pkgs.gnome-color-manager
pkgs.gnome-connections
pkgs.gnome-console
pkgs.gnome-contacts
pkgs.gnome-control-center
pkgs.gnome-font-viewer
pkgs.gnome-logs
pkgs.gnome-maps
pkgs.gnome-menus
pkgs.gnome-music
pkgs.gnome-system-monitor
pkgs.gnome-text-editor
pkgs.gnome-tour # Avoid pop-up window at start
pkgs.gnome-user-docs
pkgs.gnome-weather
pkgs.gtk3.out
pkgs.loupe
pkgs.nautilus
pkgs.papers
pkgs.showtime
pkgs.simple-scan
pkgs.snapshot
pkgs.xdg-user-dirs
pkgs.xdg-user-dirs-gtk
pkgs.yelp
];
};
hardware.bluetooth.enable = false;
networking = {
dhcpcd.enable = false;
firewall.enable = false;
networkmanager.enable = false;
};
programs = {
fuse.enable = false;
nano.enable = false;
geary.enable = false;
};
services = {
avahi.enable = false;
colord.enable = false;
dleyna.enable = false;
fstrim.enable = false;
geoclue2.enable = false;
gnome = {
core-apps.enable = false;
evolution-data-server.enable = mkForce false;
gcr-ssh-agent.enable = false;
gnome-browser-connector.enable = false;
gnome-initial-setup.enable = false;
gnome-keyring.enable = mkForce false;
gnome-online-accounts.enable = false;
gnome-remote-desktop.enable = false;
gnome-user-share.enable = false;
localsearch.enable = false;
rygel.enable = false;
sushi.enable = false;
tinysparql.enable = false;
};
gvfs.enable = mkForce false;
libinput.enable = false;
lvm.enable = false;
orca.enable = false;
pipewire.enable = false;
pipewire.wireplumber.enable = false;
power-profiles-daemon.enable = false;
speechd.enable = false;
udisks2.enable = mkForce false;
upower.enable = mkForce false;
};
systemd.user = {
tmpfiles.enable = false;
};
xdg.portal.enable = mkForce false;
}
This was done with a few heuristics:
- Check for big dependencies with
nix-tree "$(nix build --print-out-paths .#.checks.x86_64-linux.TEST_NAME.nodes.machine.system.build.toplevel)", and remove any which don’t seem to be necessary. - Look at how
environment.gnome.excludePackagesandservices.xserver.excludePackagesare defined, to exclude everything in there. -
Disable programs and services in GNOME which are not necessary for the tests to run. To figure out which services are enabled in a test I ended up with the following horrible hack:
builtins.attrNames ( nixosConfigurations.ci-full-unstable.pkgs.lib.filterAttrs (name: svc: name != "frp" && svc.enable.isDefined or false && svc.enable.value) (checks.x86_64-linux.androidBackupService.extendNixOS { module = { options, ... }: { passthru = { inherit options; }; }; }).nodes.machine.passthru.options.services )
Using
ls --si --size "$(nix build --print-out-paths .#.checks.x86_64-linux.androidBackupService.nodes.machine.system.build.images.iso)/iso/nixos-test-x86_64-linux.iso"
as a proxy for the test size2, the size before any
optimisations was 2.0 GB, and after it was 1.2 GB (40% smaller).
Heavily inspired by Jacek Galowicz’s excellent Unveiling the Power of the NixOS Integration Test Driver, and made possible by all the contributors to the NixOS test framework ♥️.
-
Many tests can run in containers, but that’s not supported for the full desktop environment we’ll be using in this article. It’s also easy to run tests in multiple containers/VMs, for example to test networking. ↩
-
Please let me know if you are aware of a better way to estimate how much space the VM will actually take when running the test. ↩
No webmentions were found.