From 073adf743dc73b2271c1dc3051a5cb5a7ed5dd6e Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Sat, 11 Jul 2026 10:16:34 +0100 Subject: [PATCH] =?UTF-8?q?test(doctor):=20V2=20float=20harness=20for=20Sy?= =?UTF-8?q?stem=20=E2=80=BA=20Doctor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headless softGL Hyprland harness (tools/doctor-float.nix): launches nomarchy-menu doctor, asserts class/floating/center via hyprctl, dumps before/after screenshots. Ran boreal + summer-night — both PASS (floating centered sheet; Ghostty keeps ~800×600). MEMORY recipe. Verified: V2 — THEME=boreal and THEME=summer-night doctor-float green; screenshots viewed (desktop-before vs doctor-float under both themes). --- agent/MEMORY.md | 9 ++- tools/doctor-float.nix | 144 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 tools/doctor-float.nix diff --git a/agent/MEMORY.md b/agent/MEMORY.md index 6b05b80..876debd 100644 --- a/agent/MEMORY.md +++ b/agent/MEMORY.md @@ -7,10 +7,17 @@ here the moment a debugging session teaches you something a future iteration would otherwise rediscover. ## Testing & VM recipes +- **Doctor float V2:** `THEME= nix build --impure -f tools/doctor-float.nix` + — softGL Hyprland, `nomarchy-menu doctor`, asserts + `class=com.nomarchy.doctor` + `floating` + centered midpoints + + screenshots. Ghostty *does* start here under softGL (classed one-shot); + size often stays Ghostty’s default 800×600 (client geometry wins over + the percent `size` windowrule). - **theme-shot softGL cannot start Ghostty** — `btop.png` is best-effort (usually identical to desktop). Guest asserts on `~/.config/btop/themes/nomarchy.theme` prove baking; the TUI look is - hardware/GL tier (HARDWARE-QUEUE). + hardware/GL tier (HARDWARE-QUEUE). Doctor float is the counter-example + that a *classed* one-shot Ghostty can still map under softGL. - **Waybar `custom/doctor` tripwire:** status helper must invoke `nomarchy-doctor` by **absolute store path** (waybar's env can miss system PATH → `command -v … || exit 0` self-hides forever); empty diff --git a/tools/doctor-float.nix b/tools/doctor-float.nix new file mode 100644 index 0000000..b85e965 --- /dev/null +++ b/tools/doctor-float.nix @@ -0,0 +1,144 @@ +# 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" + ) + ''; +}