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`.
This commit is contained in:
LGUG2Z committed 2026-11-05 10:30:05 -08:00
1 parent e663e346ba
commit f0a0d71b83
6 files changed
+256 -2

No files matched your search

+24
View File
@@ -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).
+19 -1
View File
@@ -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/<scenario>/result.json` names the
+1 -1
View File
@@ -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
+206
View File
@@ -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()
+4
View File
@@ -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,
+2
View File
@@ -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 \