# V2 harness for the floating Doctor terminal (System › Doctor / # nomarchy-menu doctor → ghostty --class=com.nomarchy.doctor). # # Boots headless softGL Hyprland (same stack as theme-shot), launches the # real menu path, then asserts via hyprctl that the window is floating # and sized (rules in modules/home/hyprland.nix). Screenshots: desktop # (before) + doctor (after). # # nix build --impure -f tools/doctor-float.nix --no-link --print-out-paths # # Maintainer harness — not a checks.* gate (full desktop VM). Ghostty # under softGL is best-effort (MEMORY); if the client never appears the # scripted float asserts fail loudly rather than claiming success. let flake = builtins.getFlake ("git+file://" + toString ../.); inherit (flake.inputs) nixpkgs home-manager; pkgs = import nixpkgs { system = "x86_64-linux"; overlays = [ flake.overlays.default ]; config.allowUnfree = true; }; slug = let s = builtins.getEnv "THEME"; in if s == "" then "boreal" else s; in pkgs.testers.runNixOSTest { name = "doctor-float-${slug}"; node.pkgsReadOnly = false; node.specialArgs = { username = "nomarchy"; }; nodes.machine = { pkgs, lib, ... }: { imports = [ flake.nixosModules.nomarchy home-manager.nixosModules.home-manager ]; virtualisation.memorySize = 4096; virtualisation.cores = 4; hardware.graphics.enable = true; environment.variables.LIBGL_ALWAYS_SOFTWARE = "1"; boot.initrd.availableKernelModules = [ "virtio_gpu" ]; virtualisation.qemu.options = [ "-vga none" "-device virtio-gpu-pci,xres=1920,yres=1080" ]; users.users.nomarchy = { isNormalUser = true; password = "test"; extraGroups = [ "wheel" "video" ]; }; nomarchy.system.stateFile = flake + "/themes/${slug}.json"; systemd.tmpfiles.rules = [ "d /home/nomarchy/.nomarchy 0755 nomarchy users -" "C /home/nomarchy/.nomarchy/theme-state.json 0644 nomarchy users - ${flake + "/themes/${slug}.json"}" ]; services.greetd.settings.initial_session = { command = "start-hyprland"; user = "nomarchy"; }; home-manager.useGlobalPkgs = true; home-manager.users.nomarchy = { imports = [ flake.homeModules.nomarchy ]; nomarchy.stateFile = flake + "/themes/${slug}.json"; nomarchy.idle.enable = false; }; }; testScript = '' import json machine.wait_for_unit("multi-user.target") machine.wait_for_file("/run/user/1000/hypr", 180) hy_env = "XDG_RUNTIME_DIR=/run/user/1000 HYPRLAND_INSTANCE_SIGNATURE=$(ls /run/user/1000/hypr | head -1)" hy = f"su - nomarchy -c '{hy_env} hyprctl" # Wallpaper + bar settle. machine.sleep(25) # Confirm rules are loaded (config path, not just eval). conf = machine.succeed( "su - nomarchy -c 'grep -n doctor /home/nomarchy/.config/hypr/hyprland.conf " "|| grep -n doctor ~/.config/hypr/hyprland.conf || true'" ) assert "com.nomarchy.doctor" in conf or r"com\.nomarchy\.doctor" in conf, ( f"doctor windowrules missing from live hyprland.conf:\n{conf}" ) # Before shot — tiled desktop only. machine.execute(f"{hy} dispatch movecursor 1912 1075' >/dev/null 2>&1") machine.sleep(1) machine.screenshot("desktop-before") # Real product path: Waybar / System › Doctor both call this. machine.execute(f"{hy} dispatch exec nomarchy-menu doctor' >/dev/null 2>&1") # Wait for the classed Ghostty client (softGL may be slow). def clients(): raw = machine.succeed(f"{hy} clients -j'").strip() try: return json.loads(raw) if raw else [] except Exception as e: raise AssertionError(f"hyprctl clients -j not JSON: {e}\n{raw!r}") from e doctor = None for _ in range(40): machine.sleep(1) for c in clients(): cls = c.get("class") or c.get("initialClass") or "" if cls == "com.nomarchy.doctor": doctor = c break if doctor is not None: break dump = machine.succeed(f"{hy} clients -j'") assert doctor is not None, ( "no client with class com.nomarchy.doctor after nomarchy-menu doctor " f"(Ghostty under softGL may have failed — clients dump):\n{dump}" ) assert doctor.get("floating") is True, ( f"doctor window is not floating: {json.dumps(doctor, indent=2)}" ) # Product contract: float + center. Ghostty often keeps its default # 800×600 even when a size rule is present (client geometry wins); # the size rule remains as a best-effort hint. Assert center from # the actual size so a smaller Ghostty window still counts. at = doctor.get("at") or [0, 0] size = doctor.get("size") or [0, 0] w, h = int(size[0]), int(size[1]) x, y = int(at[0]), int(at[1]) assert w >= 400 and h >= 300, f"doctor window too small: {doctor}" mx, my = x + w / 2, y + h / 2 assert abs(mx - 960) < 250, f"doctor not horizontally centered (mid={mx}): {doctor}" assert abs(my - 540) < 250, f"doctor not vertically centered (mid={my}): {doctor}" machine.screenshot("doctor-float") # Leave evidence in the guest for copy-out debugging. machine.succeed( "cat > /tmp/doctor-float-assert.txt <<'EOF'\n" + json.dumps( { "class": doctor.get("class") or doctor.get("initialClass"), "floating": doctor.get("floating"), "at": doctor.get("at"), "size": doctor.get("size"), "title": doctor.get("title"), }, indent=2, ) + "\nEOF" ) ''; }