From 267d1f4ff708fe2f3e37dd75021f0f0f185fa284 Mon Sep 17 00:00:00 2001 From: Pratham Patel Date: Thu, 28 May 2026 18:31:09 +0530 Subject: [PATCH 1/4] nixosTests/cosmic: move test into a dedicated directory --- nixos/tests/all-tests.nix | 8 ++++---- nixos/tests/{cosmic.nix => cosmic/default.nix} | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) rename nixos/tests/{cosmic.nix => cosmic/default.nix} (99%) diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 5f480a1dd532e..260f570cbd0ff 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -404,25 +404,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; diff --git a/nixos/tests/cosmic.nix b/nixos/tests/cosmic/default.nix similarity index 99% rename from nixos/tests/cosmic.nix rename to nixos/tests/cosmic/default.nix index f7fde6ca0e0ff..91f4ebe501ffa 100644 --- a/nixos/tests/cosmic.nix +++ b/nixos/tests/cosmic/default.nix @@ -13,7 +13,7 @@ meta.maintainers = lib.teams.cosmic.members; nodes.machine = { - imports = [ ./common/user-account.nix ]; + imports = [ ../common/user-account.nix ]; services = { # For `cosmic-store` to be added to `environment.systemPackages` From f3ace50233970a7503fa671283dfe74d0ffe27f7 Mon Sep 17 00:00:00 2001 From: Pratham Patel Date: Tue, 9 Jun 2026 17:13:45 +0530 Subject: [PATCH 2/4] empty-pdf: init --- pkgs/by-name/em/empty-pdf/package.nix | 36 +++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 pkgs/by-name/em/empty-pdf/package.nix diff --git a/pkgs/by-name/em/empty-pdf/package.nix b/pkgs/by-name/em/empty-pdf/package.nix new file mode 100644 index 0000000000000..8ef09968da9cc --- /dev/null +++ b/pkgs/by-name/em/empty-pdf/package.nix @@ -0,0 +1,36 @@ +{ + stdenvNoCC, + imagemagick, + lib, +}: + +stdenvNoCC.mkDerivation { + name = "empty-pdf"; + __structuredAttrs = 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 = "An empty PDF file intended to be used for testing."; + maintainers = with lib.maintainers; [ thefossguy ]; + platforms = imagemagick.meta.platforms; + }; +} From 91bd21e948973fde71dc33431d431f02149e2dc1 Mon Sep 17 00:00:00 2001 From: Pratham Patel Date: Thu, 28 May 2026 18:31:09 +0530 Subject: [PATCH 3/4] nixosTests/cosmic: move core testing logic into a dedicated script --- nixos/tests/cosmic/default.nix | 132 ++++++++++---------- nixos/tests/cosmic/test-script.py | 166 ++++++++++++++++++++++++++ pkgs/by-name/em/empty-pdf/package.nix | 2 + 3 files changed, 239 insertions(+), 61 deletions(-) create mode 100644 nixos/tests/cosmic/test-script.py diff --git a/nixos/tests/cosmic/default.nix b/nixos/tests/cosmic/default.nix index 91f4ebe501ffa..671906f55b8c4 100644 --- a/nixos/tests/cosmic/default.nix +++ b/nixos/tests/cosmic/default.nix @@ -7,6 +7,30 @@ ... }: +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} \ + --ydotool-drv-store-path ${config.node.pkgs.ydotool} + ''; + 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; @@ -30,10 +54,13 @@ services.displayManager.autoLogin = lib.mkIf enableAutologin { enable = true; - user = "alice"; + user = user.name; }; environment.systemPackages = with config.node.pkgs; [ + 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 @@ -58,27 +85,9 @@ 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} + import sys '' + ( if enableAutologin then @@ -91,7 +100,7 @@ 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) + 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) @@ -101,47 +110,48 @@ '' ) + '' - # _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}') + 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) ''; } diff --git a/nixos/tests/cosmic/test-script.py b/nixos/tests/cosmic/test-script.py new file mode 100644 index 0000000000000..be87c10888596 --- /dev/null +++ b/nixos/tests/cosmic/test-script.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +import argparse +import logging +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", + ) + args = parser.parse_args() + return args + + +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_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'") + + # Wait for the DE to be ready + wait_for_cosmic_de_readiness() + + # tests go here + perform_gui_application_test(cli_args) + + pathlib.Path(f"{cli_args.log_file_path}.done").touch() + return + + +if __name__ == "__main__": + main() diff --git a/pkgs/by-name/em/empty-pdf/package.nix b/pkgs/by-name/em/empty-pdf/package.nix index 8ef09968da9cc..d194778997f5e 100644 --- a/pkgs/by-name/em/empty-pdf/package.nix +++ b/pkgs/by-name/em/empty-pdf/package.nix @@ -6,7 +6,9 @@ stdenvNoCC.mkDerivation { name = "empty-pdf"; + __structuredAttrs = true; + strictDeps = true; dontUnpack = true; From 1e52b744d12b1b699ca8aaa2a7eba1a50a416a5d Mon Sep 17 00:00:00 2001 From: Pratham Patel Date: Thu, 28 May 2026 18:31:10 +0530 Subject: [PATCH 4/4] nixosTests/cosmic: add test for polkit authentication --- nixos/tests/cosmic/default.nix | 11 +++ nixos/tests/cosmic/test-script.py | 153 ++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) diff --git a/nixos/tests/cosmic/default.nix b/nixos/tests/cosmic/default.nix index 671906f55b8c4..496c8da1f814f 100644 --- a/nixos/tests/cosmic/default.nix +++ b/nixos/tests/cosmic/default.nix @@ -57,6 +57,17 @@ in 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; [ cosmicTest cosmicTestAutostartItem diff --git a/nixos/tests/cosmic/test-script.py b/nixos/tests/cosmic/test-script.py index be87c10888596..691afa31580ce 100644 --- a/nixos/tests/cosmic/test-script.py +++ b/nixos/tests/cosmic/test-script.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import argparse import logging +import os import pathlib import subprocess import time @@ -20,10 +21,44 @@ def parse_cli_args() -> argparse.Namespace: 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" + ) + parser.add_argument( + "--ydotool-drv-store-path", + required=True, + type=str, + help="The path to the ydotool derivation's store path (`${pkgs.ydotool}`)", + ) args = parser.parse_args() return args +def start_ydotool_daemon(cli_args: argparse.Namespace) -> 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( + [ + f"{cli_args.ydotool_drv_store_path}/bin/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 @@ -56,6 +91,119 @@ def wait_for_cosmic_de_readiness() -> None: 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( + [ + "env", + f"YDOTOOL_SOCKET={ydotool_daemon_socket_path}", + f"{cli_args.ydotool_drv_store_path}/bin/ydotool", + "type", + "--key-delay=500", + f"{cli_args.root_user_password}\n", + ], + 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. @@ -152,10 +300,15 @@ def main() -> None: 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(cli_args) + # 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()