From f0a0d71b83c11eb7cca45d135fdf08f019e9aa3e Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Sat, 31 Oct 2026 14:33:35 -0700 Subject: [PATCH] test(e2e): a Windows scenario `bar` for komorebi-bar New extras scenario e2e/platforms/qemu_windows/extras/bar.py, registered in run.py as `bar` (runs after the tiling windows, like the other Windows extras). It starts komorebi-bar in the guest with a configuration that has storage, time, applications, komorebi workspaces and layout widgets, the Windows-only Keyboard and Systray widgets and a `mouse` block (a message and a command), and checks: `komorebi-bar --schema` equals the checked-in schema.bar.json byte for byte; the process starts and its window titled komorebi-bar appears, visible and sized; it runs 15 s (scaled); `komorebic state` answers and komorebi does not tile the bar; commands that notify the bar work; the bar survives `komorebic stop` and the daemon starting again as the same process (it reconnects: it does NOT exit when the daemon stops, so the task text "exits cleanly when komorebi stops" is checked as `komorebic stop --bar` ending daemon and bar); screenshots are kept as artefacts, no pixel assertions. No known failures. Plumbing: run.py uploads komorebi-bar.exe when the binaries directory has it (other scenarios do not need it, so older build-ref directories still work), the nix app copies it and sets KOMOREBI_E2E_BAR_SCHEMA to the checked-in schema (the e2e directory alone does not contain it), build-ref.sh builds it. README and docs/dev/bar-e2e.md document it. Verified: py_compile and ruff clean; the runner and library unit tests: 157 passed; flake.nix parses. NOT run: the scenario itself (the Windows guest is reserved for gates), so every check is untested against a real bar and may need adjusting (window title, timings, the Systray widget inside the VM). Needs gate: win-e2e `bar`. --- docs/dev/bar-e2e.md | 24 +++ e2e/platforms/qemu_windows/README.md | 20 ++- e2e/platforms/qemu_windows/build-ref.sh | 2 +- e2e/platforms/qemu_windows/extras/bar.py | 206 +++++++++++++++++++++++ e2e/platforms/qemu_windows/run.py | 4 + flake.nix | 2 + 6 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 docs/dev/bar-e2e.md create mode 100644 e2e/platforms/qemu_windows/extras/bar.py diff --git a/docs/dev/bar-e2e.md b/docs/dev/bar-e2e.md new file mode 100644 index 00000000..19a0e4af --- /dev/null +++ b/docs/dev/bar-e2e.md @@ -0,0 +1,24 @@ +# Windows e2e for komorebi-bar + +The `bar` scenario (`e2e/platforms/qemu_windows/extras/bar.py`, registered as `EXTRAS["bar"]` in +`run.py`) is the runtime gate for changes to `komorebi-bar` and `komorebi-bar-core`: the unit tests +and the schema oracle cannot run the bar. Run it on the golden Windows guest: + +```sh +E2E_GOLDEN=golden KOMOREBI_E2E_SCALE=1.5 nix run .#e2e-windows -- OUT bar +# another commit: build-ref.sh REF bins; KOMOREBI_E2E_BINS=bins ... (bins must have komorebi-bar.exe) +``` + +It checks: the `--schema` output equals `schema.bar.json`; the configuration (storage, time, +applications, komorebi workspaces and layout, Keyboard, Systray, a `mouse` block with a message and +a command) is accepted and the bar starts; its window appears; it keeps running 15 s; the daemon +keeps answering and does not tile the bar; the bar survives the daemon stopping and starting again +(it reconnects, it does not exit when the daemon stops); `komorebic stop --bar` ends both. + +Artefacts in `OUT/bar/`: `bar.png`, `bar-after-restart.png` (screenshots, no pixel assertions), +`bar-schema.json`, `bar.out`, `bar.json`, `result.json`. Not covered: clicks, text of the widgets, +the PowerShell session of a `Command` mouse message (the bar starts it; nothing sends the click), +macOS (see `docs/dev/macos-handcheck.md`). + +Not run when it was written (the guest was reserved): lint only (`py_compile`, `ruff`, the runner's +157 unit tests pass). diff --git a/e2e/platforms/qemu_windows/README.md b/e2e/platforms/qemu_windows/README.md index 9f305c2b..52f546dc 100644 --- a/e2e/platforms/qemu_windows/README.md +++ b/e2e/platforms/qemu_windows/README.md @@ -70,7 +70,25 @@ What only Windows checks, run after the tiling windows with `[windows]` labels, from `extras/`: `windows_padding` (the border's 7 px), `windows_cloaking`, `windows_config_file`, `windows_tracking`, `windows_second_display` and `windows_display_change` (the mode of the QXL -display changes while windows are on it). +display changes while windows are on it). `bar` starts `komorebi-bar` next to the +daemon (see below). + +### The `bar` scenario + +`E2E_GOLDEN=golden nix run .#e2e-windows -- OUT bar` (the nix app copies `komorebi-bar.exe` +next to the other binaries and points `KOMOREBI_E2E_BAR_SCHEMA` at the checked-in +`schema.bar.json`; `build-ref.sh` builds the bar too, and an older `KOMOREBI_E2E_BINS` directory +without it makes the scenario fail with that message while the others still run). After the +tiling windows it checks: `komorebi-bar --schema` is `schema.bar.json` byte for byte (the output +is kept as `bar-schema.json`); a bar with a configuration that has storage, time, applications, +komorebi workspaces and layout, Keyboard and Systray widgets and a `mouse` block starts, its +window `komorebi-bar` appears, visible and with a size; it runs for 15 s (scaled); `komorebic +state` answers and komorebi does not tile it; `toggle-pause` twice (events to the bar); the bar +survives `komorebic stop` (it reconnects, it does not exit) and the daemon's restart, as the same +process; `komorebic stop --bar` ends the daemon and the bar. Artefacts: `bar.png`, +`bar-after-restart.png`, `bar.out` (what the bar printed), `bar.json`. No pixel assertions. No +known failures. + Every scenario gets its own guest on a fresh overlay of the golden image; each needs about 4 vCPU and 6 GB. `OUT//result.json` names the diff --git a/e2e/platforms/qemu_windows/build-ref.sh b/e2e/platforms/qemu_windows/build-ref.sh index e13ed5c6..24ad6bec 100755 --- a/e2e/platforms/qemu_windows/build-ref.sh +++ b/e2e/platforms/qemu_windows/build-ref.sh @@ -30,7 +30,7 @@ git -C "$root" worktree add --detach "$tree" "$commit" cp "$root/flake.nix" "$root/flake.lock" "$tree/" mkdir -p "$out" -for package in komorebi komorebic; do +for package in komorebi komorebic komorebi-bar; do path=$(nix build "$tree#$package" --no-link --print-out-paths) cp "$path/bin/$package.exe" "$out/" done diff --git a/e2e/platforms/qemu_windows/extras/bar.py b/e2e/platforms/qemu_windows/extras/bar.py new file mode 100644 index 00000000..d4b78d70 --- /dev/null +++ b/e2e/platforms/qemu_windows/extras/bar.py @@ -0,0 +1,206 @@ +"""komorebi-bar next to the daemon: the process, its window, its schema and what +it does to the daemon. + +Windows only (the bar is built per platform), run after the tiling windows like the +other extras. It needs `komorebi-bar.exe` next to the other binaries (`BAR_BINARY`); +without it the scenario says so and fails, the others do not need it. + +What it exercises is what a bar change can break at runtime and the unit tests cannot +see: the configuration is read (storage, time, applications, komorebi workspaces and +layout widgets, a `mouse` block, and the Windows-only Systray and Keyboard widgets), +the window opens, the bar keeps running while the daemon answers, it survives the daemon +going away and coming back (it reconnects: it does not exit when the daemon stops), and +`komorebic stop --bar` ends it. There are no pixel assertions; the screenshot is kept as +an artefact (`bar.png`, `bar-after-restart.png`). +""" + +import hashlib +import json +import os +import time +from pathlib import Path + +from komorebi_vm.core.checks import Checks +from komorebi_vm.core.platform import Client + +from ..windows_platform import GUEST_BIN, WindowsPlatform + +BAR_BINARY = f"{GUEST_BIN}\\komorebi-bar.exe" +BAR_CONFIG = "C:\\e2e\\bar.json" +BAR_OUTPUT = "C:\\e2e\\bar.out" +# the title eframe gives the window (`run_native("komorebi-bar", ...)`) +BAR_TITLE = "komorebi-bar" +# how long the bar must keep running, in seconds +ALIVE_SECONDS = 15 + +CONFIG = { + "height": 40, + "left_widgets": [ + {"Komorebi": { + "workspaces": {"enable": True, "hide_empty_workspaces": False}, + "layout": {"enable": True}, + }}, + {"Applications": { + "enable": True, + "items": [{"name": "Notepad", "command": "notepad.exe"}], + }}, + ], + "right_widgets": [ + {"Storage": {"enable": True}}, + {"Time": {"enable": True, "format": "TwentyFourHour"}}, + {"Keyboard": {"enable": True}}, + {"Systray": {"enable": True}}, + ], + # one option of each kind: a message to the daemon and a command, which starts the + # bar's PowerShell session + "mouse": { + "on_extra2_click": {"message": {"type": "Retile"}}, + "on_middle_click": {"Command": "echo komorebi-bar-e2e"}, + }, +} + + +def _expected_schema() -> bytes: + """The checked-in Windows schema.bar.json: from `KOMOREBI_E2E_BAR_SCHEMA` (the nix app + sets it), otherwise the one at the root of this checkout.""" + given = os.environ.get("KOMOREBI_E2E_BAR_SCHEMA") + path = Path(given) if given else Path(__file__).resolve().parents[4] / "schema.bar.json" + return path.read_bytes() + + +def _sha(data: bytes) -> str: + return hashlib.sha256(data).hexdigest()[:16] + + +def _bar_pids(platform: WindowsPlatform) -> list[str]: + listing = platform.agent_exec( + "cmd", "/c", 'tasklist /fi "imagename eq komorebi-bar.exe" /fo csv /nh' + )["stdout"] + return [ + line.split(",")[1].strip('"') + for line in listing.splitlines() + if line.lower().startswith('"komorebi-bar.exe"') + ] + + +def _bar_window(platform: WindowsPlatform) -> dict | None: + return platform.os_window_by_title(BAR_TITLE) + + +def _containers(platform: WindowsPlatform) -> int: + return len(platform.state().containers()) + + +def _alive_for(platform: WindowsPlatform, seconds: float) -> bool: + """The bar process runs now and at every second of `seconds`.""" + end = time.monotonic() + seconds + while time.monotonic() < end: + if not _bar_pids(platform): + return False + time.sleep(1) + return bool(_bar_pids(platform)) + + +def run(platform: WindowsPlatform, checks: Checks, clients: list[Client]) -> None: + """Starts the bar on the tiled windows of `tiling` and walks through its life. The + daemon runs at the end and the bar does not.""" + present = platform.agent_exec("cmd", "/c", f"if exist {BAR_BINARY} (echo yes) else (echo no)") + if "yes" not in present["stdout"]: + checks.bad(f"komorebi-bar.exe is in the guest ({BAR_BINARY}): the binaries lack it") + return + + # the schema the bar prints is the checked-in one, byte for byte + expected = _expected_schema() + printed = platform.agent_exec(BAR_BINARY, "--schema") + got = printed["stdout"].replace("\r\n", "\n").encode() + (platform._out / "bar-schema.json").write_bytes(got) + checks.check( + "`komorebi-bar --schema` prints schema.bar.json " + f"(printed {_sha(got)}, checked in {_sha(expected)})", + printed["status"] == 0 and got == expected, + ) + + before = _containers(platform) + local = platform._out / "bar.json" + local.write_text(json.dumps(CONFIG, indent=2)) + platform._agent.put_file(local, BAR_CONFIG) + platform.agent_spawn( + "cmd", "/c", f"{BAR_BINARY} --config {BAR_CONFIG} > {BAR_OUTPUT} 2>&1" + ) + + checks.check( + "the bar process starts", platform.wait_for(lambda: bool(_bar_pids(platform)), timeout=30) + ) + appeared = platform.wait_for(lambda: _bar_window(platform) is not None, timeout=60) + checks.check(f"the bar's window titled {BAR_TITLE!r} appears", appeared) + window = _bar_window(platform) + if window is not None: + frame = window["frame"] + checks.check( + "the bar's window is visible and has a size", + bool(window["visible"]) + and frame["right"] - frame["left"] > 0 + and frame["bottom"] - frame["top"] > 0, + ) + time.sleep(3) + try: + platform.screenshot("bar") + except Exception as e: + print(f" no screenshot: {e}") + + checks.check( + f"the bar keeps running for {ALIVE_SECONDS} s with this configuration (every widget)", + _alive_for(platform, ALIVE_SECONDS * platform._scale), + ) + checks.check( + "`komorebic state` answers while the bar runs", + platform.wait_for(lambda: platform.komorebic_status("state")[0] == 0), + ) + checks.check( + "komorebi does not tile the bar (it ignores komorebi-bar.exe)", + _containers(platform) == before, + ) + # a command that changes the state reaches the bar through its subscription; the bar + # has no way to be asked, so it is that nothing breaks + platform.komorebic("toggle-pause") + platform.komorebic("toggle-pause") + checks.check( + "the daemon answers commands that notify the bar, and the bar runs on", + platform.komorebic_status("state")[0] == 0 and bool(_bar_pids(platform)), + ) + + # the daemon stops and comes back: the bar reconnects, it does not exit + pids = _bar_pids(platform) + platform.komorebic("stop") + platform.wait_for(lambda: platform.komorebic_status("state")[0] != 0, timeout=20) + checks.check( + "the bar is still running after the daemon stopped (it waits to reconnect)", + _alive_for(platform, 5 * platform._scale), + ) + platform.start_komorebi() + checks.check( + "the bar runs on after the daemon is back, the same process", + _alive_for(platform, 5 * platform._scale) and _bar_pids(platform) == pids, + ) + try: + platform.screenshot("bar-after-restart") + except Exception as e: + print(f" no screenshot: {e}") + + # what stops the bar is `komorebic stop --bar` + platform.komorebic("stop", "--bar") + checks.check( + "`komorebic stop --bar` stops the daemon", + platform.wait_for(lambda: platform.komorebic_status("state")[0] != 0, timeout=20), + ) + checks.check( + "`komorebic stop --bar` ends the bar process", + platform.wait_for(lambda: not _bar_pids(platform), timeout=20), + ) + try: + text = platform.agent_get_file(BAR_OUTPUT).decode(errors="replace") + (platform._out / "bar.out").write_text(text) + except Exception as e: + print(f" no bar output: {e}") + # left as the other scenarios leave it: the daemon runs + platform.start_komorebi() diff --git a/e2e/platforms/qemu_windows/run.py b/e2e/platforms/qemu_windows/run.py index c5b60f18..965a48d0 100644 --- a/e2e/platforms/qemu_windows/run.py +++ b/e2e/platforms/qemu_windows/run.py @@ -213,6 +213,8 @@ def _commit() -> str: BINARIES = ["komorebi.exe", "komorebic.exe", "komorebi-testwin.exe"] +# uploaded when the binaries have them: only the `bar` scenario needs the bar +OPTIONAL_BINARIES = ["komorebi-bar.exe"] WITH_CLIENTS = { "focus", "moving", "stacks", "workspaces", "resize", "monocle", "closing", "commands", "hotplug", "unknown_monitor", "lifecycle", @@ -239,6 +241,7 @@ EXTRAS = { "windows_config_file": "config_file", "windows_tracking": "tracking", "windows_second_display": "second_display", "windows_display_change": "display_change", + "bar": "bar", } # a small configuration for the sweep's `replace-configuration` @@ -366,6 +369,7 @@ def run_one( with Guest(golden, out / "guest", state=state) as guest: lap("guest") guest.upload(bin_dir, BINARIES) + guest.upload(bin_dir, [b for b in OPTIONAL_BINARIES if (bin_dir / b).exists()]) lap("upload") platform = WindowsPlatform( guest.qmp, guest.agent, out, diff --git a/flake.nix b/flake.nix index e3a4727b..c2ba9fee 100644 --- a/flake.nix +++ b/flake.nix @@ -457,8 +457,10 @@ cp ${e2eBuild.komorebi}/bin/komorebi.exe "$bins" cp ${e2eBuild.komorebic}/bin/komorebic.exe "$bins" cp ${e2eBuild.komorebi-testwin}/bin/komorebi-testwin.exe "$bins" + cp ${e2eBuild.komorebi-bar}/bin/komorebi-bar.exe "$bins" fi export OVMF_FD=${ovmfWithTpm} + export KOMOREBI_E2E_BAR_SCHEMA=${./schema.bar.json} export KOMOREBI_E2E_COMMIT=''${KOMOREBI_E2E_COMMIT:-${self.rev or "dirty"}} export PYTHONPATH=${./e2e/lib}:${./e2e} exec python -m platforms.qemu_windows.run \