[Backport release-26.05] nixosTests/cosmic: move core logic into a dedicated script (#535620)

This commit is contained in:
Gaétan Lepage 2026-06-26 21:09:51 +00:00 committed by GitHub
commit bc26245eca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 528 additions and 151 deletions

View file

@ -407,25 +407,25 @@ in
corerad = runTest ./corerad.nix;
corteza = runTest ./corteza.nix;
cosmic = runTest {
imports = [ ./cosmic.nix ];
imports = [ ./cosmic ];
_module.args.testName = "cosmic";
_module.args.enableAutologin = false;
_module.args.enableXWayland = true;
};
cosmic-autologin = runTest {
imports = [ ./cosmic.nix ];
imports = [ ./cosmic ];
_module.args.testName = "cosmic-autologin";
_module.args.enableAutologin = true;
_module.args.enableXWayland = true;
};
cosmic-autologin-noxwayland = runTest {
imports = [ ./cosmic.nix ];
imports = [ ./cosmic ];
_module.args.testName = "cosmic-autologin-noxwayland";
_module.args.enableAutologin = true;
_module.args.enableXWayland = false;
};
cosmic-noxwayland = runTest {
imports = [ ./cosmic.nix ];
imports = [ ./cosmic ];
_module.args.testName = "cosmic-noxwayland";
_module.args.enableAutologin = false;
_module.args.enableXWayland = false;

View file

@ -1,147 +0,0 @@
{
config,
lib,
testName,
enableAutologin,
enableXWayland,
...
}:
{
name = testName;
meta.maintainers = lib.teams.cosmic.members;
nodes.machine = {
imports = [ ./common/user-account.nix ];
services = {
# For `cosmic-store` to be added to `environment.systemPackages`
# and for it to work correctly because Flatpak is a runtime
# dependency of `cosmic-store`.
flatpak.enable = true;
displayManager.cosmic-greeter.enable = true;
desktopManager.cosmic = {
enable = true;
xwayland.enable = enableXWayland;
};
};
services.displayManager.autoLogin = lib.mkIf enableAutologin {
enable = true;
user = "alice";
};
environment.systemPackages = with config.node.pkgs; [
# These two packages are used to check if a window was opened
# under the COSMIC session or not. Kinda important.
# TODO: Move the check from the test module to
# `nixos/lib/test-driver/src/test_driver/machine.py` so more
# Wayland-only testing can be done using the existing testing
# infrastructure.
jq
lswt
];
# So far, all COSMIC tests launch a few GUI applications. In doing
# so, the default allocated memory to the guest of 1024M quickly
# poses a very high risk of an OOM-shutdown which is worse than an
# OOM-kill. Because now, the test failed, but not for a genuine
# reason, but an OOM-shutdown. That's an inconclusive failure
# which might possibly mask an actual failure. Not enabling
# systemd-oomd because we need said applications running for a
# few seconds. So instead, bump the allocated memory to the guest
# from 1024M to 4x; 4096M.
virtualisation.memorySize = 4096;
};
testScript =
{ nodes, ... }:
let
cfg = nodes.machine;
user = cfg.users.users.alice;
DISPLAY = lib.strings.optionalString enableXWayland (
if enableAutologin then "DISPLAY=:0" else "DISPLAY=:1"
);
emptyPDF = config.node.pkgs.stdenvNoCC.mkDerivation {
name = "empty-pdf";
dontUnpack = true;
nativeBuildInputs = [ config.node.pkgs.imagemagick ];
buildPhase = ''
magick xc:none -page Letter empty.pdf
'';
installPhase = ''
mkdir $out
mv empty.pdf $out/empty.pdf
'';
};
in
''
#testName: ${testName}
''
+ (
if enableAutologin then
''
with subtest("cosmic-greeter initialisation"):
machine.wait_for_unit("graphical.target", timeout=120)
''
else
''
from time import sleep
machine.wait_for_unit("graphical.target", timeout=120)
machine.wait_until_succeeds("pgrep --uid ${toString cfg.users.users.cosmic-greeter.name} --full cosmic-greeter", timeout=30)
# Sleep for 10 seconds for ensuring that `greetd` loads the
# password prompt for the login screen properly.
sleep(10)
with subtest("cosmic-session login"):
machine.send_chars("${user.password}\n", delay=0.2)
''
)
+ ''
# _One_ of the final processes to start as part of the
# `cosmic-session` target is the Workspaces applet. So, wait
# for it to start. The process existing means that COSMIC
# now handles any opened windows from now on.
machine.wait_until_succeeds("pgrep --uid ${toString user.uid} --full 'cosmic-panel-button com.system76.CosmicWorkspaces'", timeout=30)
# The best way to test for Wayland and XWayland is to launch
# the GUI applications and see the results yourself.
with subtest("Launch applications"):
# key: binary_name
# value: "app-id" as reported by `lswt`
gui_apps_to_launch = {}
# We want to ensure that the first-party applications
# start/launch properly.
gui_apps_to_launch['cosmic-edit'] = 'com.system76.CosmicEdit'
gui_apps_to_launch['cosmic-files'] = 'com.system76.CosmicFiles'
gui_apps_to_launch['cosmic-player'] = 'com.system76.CosmicPlayer'
gui_apps_to_launch['cosmic-reader'] = 'com.system76.CosmicReader'
gui_apps_to_launch['cosmic-settings'] = 'com.system76.CosmicSettings'
gui_apps_to_launch['cosmic-store'] = 'com.system76.CosmicStore'
gui_apps_to_launch['cosmic-term'] = 'com.system76.CosmicTerm'
for gui_app, app_id in gui_apps_to_launch.items():
# Don't fail the test if binary is absent
if machine.execute(f"su - ${user.name} -c 'command -v {gui_app}'", timeout=5)[0] == 0:
match gui_app:
case 'cosmic-reader':
opt_arg = '${emptyPDF}/empty.pdf'
case _:
opt_arg = ""
machine.succeed(f"su - ${user.name} -c 'WAYLAND_DISPLAY=wayland-1 XDG_RUNTIME_DIR=/run/user/${toString user.uid} ${DISPLAY} {gui_app} {opt_arg} >&2 &'", timeout=5)
# Nix builds the following non-commented expression to the following:
# `su - alice -c 'WAYLAND_DISPLAY=wayland-1 XDG_RUNTIME_DIR=/run/user/1000 lswt --json | jq ".toplevels" | grep "^ \\"app-id\\": \\"{app_id}\\"$"' `
machine.wait_until_succeeds(f''''su - ${user.name} -c 'WAYLAND_DISPLAY=wayland-1 XDG_RUNTIME_DIR=/run/user/${toString user.uid} lswt --json | jq ".toplevels" | grep "^ \\"app-id\\": \\"{app_id}\\"$"' '''', timeout=60)
machine.succeed(f"pkill {gui_app}", timeout=5)
machine.succeed("echo 'test completed succeessfully' > /${testName}", timeout=5)
machine.copy_from_machine('/${testName}')
machine.shutdown()
'';
}

View file

@ -0,0 +1,168 @@
{
config,
lib,
testName,
enableAutologin,
enableXWayland,
...
}:
let
user = config.nodes.machine.users.users.alice;
logFilePath = "/home/${user.name}/${testName}";
# Use `writeShellScriptBin` instead of `writeShellScript` so that the
# process name in the journald log appears as 'cosmicTest[$pid]'
cosmicTest = config.node.pkgs.writeShellScriptBin "cosmicTest" ''
exec ${lib.getExe config.node.pkgs.python3Minimal} ${./test-script.py} \
--log-file-path ${logFilePath} \
--cosmic-reader-pdf ${config.node.pkgs.empty-pdf} \
--polkit-agent-helper-path ${config.node.pkgs.polkit.out}/lib/polkit-1/polkit-agent-helper-1 \
--root-user-password ${user.password}
'';
cosmicTestDesktop = config.node.pkgs.makeDesktopItem {
name = "cosmicTest";
desktopName = "COSMIC NixOS VM test (${testName})";
exec = "cosmicTest";
};
cosmicTestAutostartItem = config.node.pkgs.makeAutostartItem {
name = "cosmicTest";
package = cosmicTestDesktop;
};
in
{
name = testName;
meta.maintainers = lib.teams.cosmic.members;
nodes.machine = {
imports = [ ../common/user-account.nix ];
services = {
# For `cosmic-store` to be added to `environment.systemPackages`
# and for it to work correctly because Flatpak is a runtime
# dependency of `cosmic-store`.
flatpak.enable = true;
displayManager.cosmic-greeter.enable = true;
desktopManager.cosmic = {
enable = true;
xwayland.enable = enableXWayland;
};
};
services.displayManager.autoLogin = lib.mkIf enableAutologin {
enable = true;
user = user.name;
};
users.users = {
alice.extraGroups = [
"uinput" # for ydotoold
];
root.password = user.password;
root.hashedPasswordFile = lib.mkForce null;
};
hardware.uinput.enable = true;
environment.systemPackages = with config.node.pkgs; [
ydotool
cosmicTest
cosmicTestAutostartItem
# These two packages are used to check if a window was opened
# under the COSMIC session or not. Kinda important.
# TODO: Move the check from the test module to
# `nixos/lib/test-driver/src/test_driver/machine.py` so more
# Wayland-only testing can be done using the existing testing
# infrastructure.
jq
lswt
];
# So far, all COSMIC tests launch a few GUI applications. In doing
# so, the default allocated memory to the guest of 1024M quickly
# poses a very high risk of an OOM-shutdown which is worse than an
# OOM-kill. Because now, the test failed, but not for a genuine
# reason, but an OOM-shutdown. That's an inconclusive failure
# which might possibly mask an actual failure. Not enabling
# systemd-oomd because we need said applications running for a
# few seconds. So instead, bump the allocated memory to the guest
# from 1024M to 4x; 4096M.
virtualisation.memorySize = 4096;
};
testScript =
{ nodes, ... }:
''
#testName: ${testName}
import sys
''
+ (
if enableAutologin then
''
with subtest("cosmic-greeter initialisation"):
machine.wait_for_unit("graphical.target", timeout=120)
''
else
''
from time import sleep
machine.wait_for_unit("graphical.target", timeout=120)
machine.wait_until_succeeds("pgrep --uid ${config.nodes.machine.users.users.cosmic-greeter.name} --full cosmic-greeter", timeout=30)
# Sleep for 10 seconds for ensuring that `greetd` loads the
# password prompt for the login screen properly.
sleep(10)
with subtest("cosmic-session login"):
machine.send_chars("${user.password}\n", delay=0.2)
''
)
+ ''
with subtest("xdg autostart support in cosmic"):
# When checking the status of our `cosmicTest` package with:
# `machine.wait_for_unit("app-cosmicTest@autostart.service", user="${user.name}")`
# We are immediately greeted with the error:
# ```
# subtest: xdg autostart support in cosmic
# machine: waiting for unit app-cosmicTest@autostart.service with user alice
# machine # [ 26.497516] cosmic-comp[1352]: [EGL] 0x3008 (BAD_DISPLAY) eglCreateSync: _eglCreateSync
# machine # [ 26.511706] su[1416]: Successful su for alice by root
# machine # [ 26.528190] su[1416]: pam_unix(su:session): session opened for user alice(uid=1000) by (uid=0)
# machine # Failed to connect to user scope bus via local transport: No such file or directory
# machine # [ 26.599563] su[1416]: pam_unix(su:session): session closed for user alice
# !!! Test "xdg autostart support in cosmic" failed with error: "retrieving systemctl property "ActiveState" for unit "app-cosmicTest@autostart.service" under user "alice" failed with exit code 1"
# ```
# Meaning, our session is extremely new and the D-Bus user
# session socket does not yet exist. Instead, lets poll for
# the log file that the test is guaranteed to write to, as
# soon as it starts.
machine.wait_for_file("${logFilePath}.log", timeout=120)
exit_code = 0
try:
machine.wait_for_file("${logFilePath}.done", timeout=700)
except Exception:
exit_code = 1
# The log file is created in the very beginning of the test
# script's execution. If we are here, it means that the
# `wait_for_unit`'s "guard" on the test script's autostart unit
# plus the 630 second combined timeout of other two
# `wait_for_file`s, make it extremely likely for the log file to
# be present.
machine.copy_from_machine("${logFilePath}.log")
machine.shutdown()
with open(f"{machine.out_dir}/${testName}.log") as test_log_file:
contents = test_log_file.read()
print(contents)
if any("Z [ERROR] [L:" in line for line in contents.splitlines()):
exit_code = 1
sys.exit(exit_code)
'';
}

View file

@ -0,0 +1,315 @@
#!/usr/bin/env python3
import argparse
import logging
import os
import pathlib
import subprocess
import time
def parse_cli_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument(
"--log-file-path",
required=True,
type=str,
help="The path to the log file (without the '.log' suffix/extension)",
)
parser.add_argument(
"--cosmic-reader-pdf",
required=True,
type=str,
help="The PDF that the `cosmic-reader` should open for testing",
)
parser.add_argument(
"--polkit-agent-helper-path",
required=True,
type=str,
help="The path to the polkit agent helper (`${pkgs.polkit.out}/lib/polkit-1/polkit-agent-helper-1`)",
)
parser.add_argument(
"--root-user-password", required=True, type=str, help="The root user's password"
)
args = parser.parse_args()
return args
def start_ydotool_daemon() -> tuple[str, subprocess.Popen]:
"""
The ydotool requires a daemon to be running.
"""
xdg_runtime_dir = os.getenv("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
ydotool_daemon_socket_path = f"{xdg_runtime_dir}/.ydotool_socket"
ydotool_daemon_process = subprocess.Popen(
[
"ydotoold",
"--socket-path",
ydotool_daemon_socket_path,
"--mouse-off",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return ydotool_daemon_socket_path, ydotool_daemon_process
def wait_for_cosmic_de_readiness() -> None:
"""
Wait for the COSMIC DE to be ready, before running the tests. This
is done by waiting on the supposedly last component of the COSMIC
DE to be "ready." That component is the notification watcher, of
the `cosmic-applet` derivation.
"""
logging.info("=" * 80)
logging.info("Waiting for COSMIC DE to complete initialization")
notification_watcher_wait_deadline = time.monotonic() + 360
notification_watcher_exists = False
while time.monotonic() < notification_watcher_wait_deadline:
busctl_process = subprocess.run(
["busctl", "--user", "status", "com.system76.CosmicStatusNotifierWatcher"],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if busctl_process.returncode == 0:
notification_watcher_exists = True
break
else:
time.sleep(1)
logging_msg = "The COSMIC DE is "
if notification_watcher_exists:
logging.info(f"{logging_msg} ready")
else:
logging.error(f"{logging_msg} not ready")
return
def perform_polkit_authentication_test(
cli_args: argparse.Namespace,
ydotool_daemon_socket_path: str,
ydotool_daemon_process: subprocess.Popen,
) -> None:
"""
1. Run `pkexec` as a background process that produces a specific
output to stdout upon successful completion.
2. Wait unil it has been confimred that `cosmic-osd` has created
a pop-up requesting the root user's password.
3. Use ydotool to type the root user's password in the pop-up
prompt.
4. Ensure that the the `pkexec` background process' stdout matches
the output that we expect.
Any breakage in this flow is considered a failure of the polkit
authenticaion test.
"""
logging.info("=" * 80)
logging.info("Performing polkit authentication test")
polkit_test_passed = False
polkit_test_command = [
"pkexec",
"--disable-internal-agent",
"bash",
"-c",
"echo -n 'polkit test was successful'",
]
logging.info(f"Running: {polkit_test_command}")
polkit_test_process = subprocess.Popen(
polkit_test_command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
polkit_popup_deadline = time.monotonic() + 60
pop_up_msg = "the pop-up for polkit password authentication"
encountered_polkit_authentication_popup = False
while time.monotonic() < polkit_popup_deadline:
polkit_popup_check_process = subprocess.run(
[
"pgrep",
"-afx",
f"{cli_args.polkit_agent_helper_path} --socket-activated",
],
check=False,
)
if polkit_popup_check_process.returncode == 0:
encountered_polkit_authentication_popup = True
logging.info(f"Noticed {pop_up_msg}")
if ydotool_daemon_process.poll() is None:
# The polkit-agent-helper process exists, but that
# doesn't necessarily mean that the pop-up is
# **rendered** and ready to accept the password. So we
# sleep for a few seconds.
time.sleep(20)
ydotool_process = subprocess.run(
[
"ydotool",
"type",
"--key-delay=500",
f"{cli_args.root_user_password}\n",
],
env={
**os.environ.copy(),
"YDOTOOL_SOCKET": ydotool_daemon_socket_path,
},
check=False,
)
ydotool_msg = (
"the root user's password in the pop-up for polkit authentication"
)
if ydotool_process.returncode == 0:
logging.info(f"ydotool typed {ydotool_msg}")
else:
logging.error(f"ydotool did not type {ydotool_msg}")
else:
logging.error(
"The ydotool daemon exited for some reason before it could be used"
)
break
time.sleep(1)
if not encountered_polkit_authentication_popup:
logging.error(f"Did not notice {pop_up_msg}")
polkit_test_process_stdout = ""
polkit_test_process_stderr = ""
try:
polkit_test_process_stdout, polkit_test_process_stderr = (
polkit_test_process.communicate(timeout=45)
)
except subprocess.TimeoutExpired:
polkit_test_process.kill()
polkit_test_process_stdout, polkit_test_process_stderr = (
polkit_test_process.communicate()
)
logging.info(f"polkit stdout: '{polkit_test_process_stdout}'")
logging.info(f"polkit stderr: '{polkit_test_process_stderr}'")
if polkit_test_process_stdout:
logging.info(f"pkexec command stdout: {polkit_test_process_stdout}")
polkit_test_passed = "polkit test was successful" in polkit_test_process_stdout
else:
logging.warning("Could not capture stdout from the polkit test command")
if polkit_test_passed:
logging.info("The polkit authentication test passed")
else:
logging.error("The polkit authentication test failed")
return
def perform_gui_application_test(cli_args: argparse.Namespace) -> None:
"""
1. Start one GUI application as a background process.
2. Wait unil it has been confimred that the GUI application is
running.
3. Kill the background process of the GUI application.
Any breakage in this flow is considered a failure of the test for
the GUI application.
"""
logging.info("=" * 80)
logging.info("Performing test to launch GUI applications")
gui_apps_to_test = {
"com.system76.CosmicEdit": [
"cosmic-edit",
],
"com.system76.CosmicFiles": [
"cosmic-files",
],
"com.system76.CosmicPlayer": [
"cosmic-player",
],
"com.system76.CosmicReader": [
"cosmic-reader",
cli_args.cosmic_reader_pdf,
],
"com.system76.CosmicSettings": [
"cosmic-settings",
],
"com.system76.CosmicStore": [
"cosmic-store",
],
"com.system76.CosmicTerm": [
"cosmic-term",
],
}
for gui_app_id, gui_app_command in gui_apps_to_test.items():
logging.info(f"Running: {gui_app_command}")
gui_app_bg_process = subprocess.Popen(
gui_app_command,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
gui_app_bg_process_deadline = time.monotonic() + 30
gui_app_is_running = False
while time.monotonic() < gui_app_bg_process_deadline and not gui_app_is_running:
lswt_process = subprocess.run(
[
"lswt",
"--custom",
"a",
],
check=False,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
)
lswt_process_stdout = lswt_process.stdout.strip()
if lswt_process_stdout:
if gui_app_id in lswt_process_stdout.splitlines():
gui_app_is_running = True
time.sleep(1)
pkill_process = subprocess.run(
["pkill", gui_app_command[0]],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
log_message = (
f"The GUI application test for '{gui_app_command[0]}' ({gui_app_id})"
)
if gui_app_is_running:
logging.info(f"{log_message} passed")
else:
logging.error(f"{log_message} failed")
return
def main() -> None:
cli_args = parse_cli_args()
logging.basicConfig(
level=logging.INFO,
format=f"%(asctime)sZ [%(levelname)s] [L:%(lineno)d] %(message)s",
datefmt="%H:%M:%S",
handlers=[
logging.StreamHandler(),
logging.FileHandler(f"{cli_args.log_file_path}.log", mode="w"),
],
)
logging.Formatter.converter = time.gmtime
logging.info(f"Logging to '{cli_args.log_file_path}.log'")
ydotool_daemon_socket_path, ydotool_daemon_process = start_ydotool_daemon()
# Wait for the DE to be ready
wait_for_cosmic_de_readiness()
# tests go here
perform_polkit_authentication_test(
cli_args, ydotool_daemon_socket_path, ydotool_daemon_process
)
perform_gui_application_test(cli_args)
pathlib.Path(f"{cli_args.log_file_path}.done").touch()
return
if __name__ == "__main__":
main()

View file

@ -0,0 +1,41 @@
{
stdenvNoCC,
imagemagick,
lib,
}:
stdenvNoCC.mkDerivation {
name = "empty-pdf";
__structuredAttrs = true;
strictDeps = true;
dontUnpack = true;
nativeBuildInputs = [ imagemagick ];
buildPhase = ''
runHook preBuild
magick xc:none -page Letter empty.pdf
runHook postBuild
'';
installPhase = ''
runHook preInstall
mv empty.pdf $out
runHook postInstall
'';
meta = {
description = "Empty PDF file intended for testing";
maintainers = with lib.maintainers; [
pandapip1
thefossguy
];
platforms = imagemagick.meta.platforms;
};
}