diff --git a/e2e/platforms/qemu_windows/extras/__init__.py b/e2e/platforms/qemu_windows/extras/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/e2e/platforms/qemu_windows/extras/cloaking.py b/e2e/platforms/qemu_windows/extras/cloaking.py new file mode 100644 index 00000000..760bfe58 --- /dev/null +++ b/e2e/platforms/qemu_windows/extras/cloaking.py @@ -0,0 +1,44 @@ +"""How kfw puts windows away, and what `komorebic stop` does to them.""" + +from komorebi_vm.core.checks import Checks +from komorebi_vm.core.platform import Client + +from ..windows_platform import WindowsPlatform + + +def _flags(platform: WindowsPlatform, client: Client) -> dict: + window = platform.os_window_by_title(client.title) or {} + return {"cloaked": window.get("cloaked"), "minimized": window.get("minimized")} + + +def run(platform: WindowsPlatform, checks: Checks, clients: list[Client]) -> None: + """Sends the last of `clients` to workspace 1 and minimizes the first, + stops the daemon and starts it again. The daemon has `clients` tiled; it + runs at the end.""" + away, minimized = clients[-1], clients[0] + platform.activate(away) + platform.wait_for(lambda: True, 1) + platform.komorebic("send-to-workspace", "1") + checks.check( + "a window on another workspace is cloaked, not minimized", + platform.wait_for(lambda: platform.hidden(away)) + and _flags(platform, away)["minimized"] is False, + ) + platform.minimize(minimized) + checks.check( + "a minimized window is minimized, not cloaked", + platform.wait_for(lambda: _flags(platform, minimized)["minimized"] is True) + and _flags(platform, minimized)["cloaked"] is False, + ) + + platform.komorebic("stop") + platform.wait_for(lambda: platform.komorebic_status("state")[0] != 0, 20) + checks.check( + "after `komorebic stop` the window that was put away is not cloaked", + platform.wait_for(lambda: _flags(platform, away)["cloaked"] is False), + ) + checks.check( + "... and the minimized one is still minimized", + _flags(platform, minimized)["minimized"] is True, + ) + platform.start_komorebi() diff --git a/e2e/platforms/qemu_windows/extras/config_file.py b/e2e/platforms/qemu_windows/extras/config_file.py new file mode 100644 index 00000000..446841b2 --- /dev/null +++ b/e2e/platforms/qemu_windows/extras/config_file.py @@ -0,0 +1,36 @@ +"""kfw watches the configuration file it was started with.""" + +import json + +from komorebi_vm.core.checks import Checks +from komorebi_vm.core.platform import Client + +from ..windows_platform import CONFIG_PATH, WindowsPlatform + + +def _config(name: str) -> dict: + return {"monitors": [{"workspaces": [{"name": name, "layout": "BSP"}]}]} + + +def _name(platform: WindowsPlatform) -> str | None: + return platform.state().monitors[0].workspaces[0].name + + +def run(platform: WindowsPlatform, checks: Checks, clients: list[Client]) -> None: + """Starts the daemon from a file and changes the file on disk. The daemon + runs at the end, on the new file.""" + platform.restart_with(_config("before")) + checks.check("the daemon starts with the name in the file", _name(platform) == "before") + + changed = platform._out / "komorebi-changed.json" + changed.write_text(json.dumps(_config("after"))) + platform._agent.put_file(changed, CONFIG_PATH) + checks.check( + "a changed file is read by the daemon on its own", + platform.wait_for(lambda: _name(platform) == "after", timeout=30), + ) + platform.komorebic("replace-configuration", CONFIG_PATH) + checks.check( + "`replace-configuration` reads it again and the name stays", + platform.wait_for(lambda: _name(platform) == "after", timeout=10), + ) diff --git a/e2e/platforms/qemu_windows/extras/padding.py b/e2e/platforms/qemu_windows/extras/padding.py new file mode 100644 index 00000000..1a1bd602 --- /dev/null +++ b/e2e/platforms/qemu_windows/extras/padding.py @@ -0,0 +1,46 @@ +"""What kfw adds to the padding its state names: the room of the border.""" + +from komorebi_vm.core.checks import Checks +from komorebi_vm.core.platform import Client + +from ..windows_platform import WindowsPlatform + +# kfw pads a window by the width of its border plus the border's offset when +# borders are on, which they are by default (border_manager: 8 and -1) +BORDER_ROOM = 8 - 1 + + +def run(platform: WindowsPlatform, checks: Checks, clients: list[Client]) -> None: + """Measures how far the tiled windows of the first workspace are from the + edges of the work area and from each other, against the paddings the + state gives. The daemon has `clients` tiled, with its default padding.""" + workspace = platform.state().workspace() + padding = workspace.workspace_padding + container = workspace.container_padding + if padding is None or container is None: + checks.skip("the state gives the paddings", "it does not") + return + area = platform.work_area() + frames = [w.frame for c in clients if (w := platform.os_window(c)) is not None] + inset = padding + container + BORDER_ROOM + edges = ( + min(f.x for f in frames) - area.x, + min(f.y for f in frames) - area.y, + area.x + area.w - max(f.x + f.w for f in frames), + area.y + area.h - max(f.y + f.h for f in frames), + ) + print(f" paddings {padding}/{container}, edges {edges}") + checks.check( + f"the windows are {inset} px from every edge: both paddings and " + f"{BORDER_ROOM} px for the border", + all(edge == inset for edge in edges), + ) + first = min(frames, key=lambda f: f.x) + neighbours = [f for f in frames if f.x > first.x and f.y < first.y + first.h + and f.y + f.h > first.y] + if neighbours: + gap = min(f.x for f in neighbours) - (first.x + first.w) + checks.check( + f"two windows side by side are {2 * (container + BORDER_ROOM)} px apart", + gap == 2 * (container + BORDER_ROOM), + ) diff --git a/e2e/platforms/qemu_windows/extras/second_display.py b/e2e/platforms/qemu_windows/extras/second_display.py new file mode 100644 index 00000000..207e2a61 --- /dev/null +++ b/e2e/platforms/qemu_windows/extras/second_display.py @@ -0,0 +1,50 @@ +"""A second display, before any is unplugged: its monitor and a window sent to it.""" + +from komorebi_vm.core.checks import Checks +from komorebi_vm.core.platform import Client + +from ..windows_platform import WindowsPlatform + + +def run(platform: WindowsPlatform, checks: Checks, clients: list[Client]) -> None: + """Plugs in a second display, checks the monitor the daemon has for it, + sends the last of `clients` there and checks where it is and what has + the focus. The display stays plugged in.""" + platform.add_display() + checks.check( + "the daemon has a monitor for the second display", + platform.wait_for(lambda: len(platform.state().monitors) == 2, timeout=30), + ) + displays = platform.displays() + sizes = [(m.size.w, m.size.h) for m in platform.state().monitors] + checks.check( + "each monitor has the size of its display", + sizes == [(d.w, d.h) for d in displays], + ) + + moved = clients[-1] + platform.activate(moved) + platform.komorebic("move-to-monitor", "1") + checks.check( + "`move-to-monitor 1` takes the window to the second monitor", + platform.wait_for(lambda: platform.state().workspace_of(moved.title, monitor=1) == 0), + ) + checks.check( + "... where the operating system has it inside the second display", + platform.wait_for( + lambda: (w := platform.os_window(moved)) is not None + and w.frame.inside(displays[1], 1) + ), + ) + checks.check( + "... and the others stay on the first monitor", + all(platform.state().workspace_of(c.title, monitor=0) is not None + for c in clients[:-1]), + ) + checks.check( + "the daemon's focus is the operating system's: the moved window", + platform.wait_for( + lambda: platform.state().focused_title(monitor=1) == moved.title + and (w := platform.os_window(moved)) is not None and w.focused + ), + ) diff --git a/e2e/platforms/qemu_windows/extras/tracking.py b/e2e/platforms/qemu_windows/extras/tracking.py new file mode 100644 index 00000000..80db92ed --- /dev/null +++ b/e2e/platforms/qemu_windows/extras/tracking.py @@ -0,0 +1,34 @@ +"""kfw's focus follows mouse is the setting of Windows itself.""" + +from komorebi_vm.core.checks import Checks +from komorebi_vm.core.platform import Client + +from ..windows_platform import WindowsPlatform + +# SPI_GETACTIVEWINDOWTRACKING +GET = ( + "Add-Type -Name S -Namespace K -MemberDefinition " + "'[DllImport(\"user32.dll\")] public static extern bool " + "SystemParametersInfo(uint a, uint b, ref int c, uint d);'; " + "$v = 0; [void][K.S]::SystemParametersInfo(0x1000, 0, [ref]$v, 0); $v" +) + + +def _tracking(platform: WindowsPlatform) -> str: + return platform._agent.exec("powershell", "-NoProfile", "-Command", GET)["stdout"].strip() + + +def run(platform: WindowsPlatform, checks: Checks, clients: list[Client]) -> None: + """Turns focus follows mouse on and off and reads the system's active + window tracking each time. It is left off.""" + checks.check("window tracking is off to begin with", _tracking(platform) == "0") + platform.komorebic("focus-follows-mouse", "enable") + checks.check( + "`focus-follows-mouse enable` turns on the system's window tracking", + platform.wait_for(lambda: _tracking(platform) == "1"), + ) + platform.komorebic("focus-follows-mouse", "disable") + checks.check( + "`focus-follows-mouse disable` turns it off", + platform.wait_for(lambda: _tracking(platform) == "0"), + ) diff --git a/e2e/platforms/qemu_windows/run.py b/e2e/platforms/qemu_windows/run.py index 39608650..5fbd9b16 100644 --- a/e2e/platforms/qemu_windows/run.py +++ b/e2e/platforms/qemu_windows/run.py @@ -37,6 +37,7 @@ import zipfile from pathlib import Path from komorebi_vm.core.checks import Checks +from komorebi_vm.core.suite import platform_only, run_suite, shared from .agent import AgentError, AgentLost from .guest import Guest @@ -124,6 +125,13 @@ WITH_CLIENTS = { "scrolling", "locking", } +# what only Windows can check, `extras/.py`, after the tiling windows +EXTRAS = { + "windows_padding": "padding", "windows_cloaking": "cloaking", + "windows_config_file": "config_file", "windows_tracking": "tracking", + "windows_second_display": "second_display", +} + # a small configuration for the sweep's `replace-configuration` SWEEP_CONFIG = '{"monitors":[{"workspaces":[{"name":"again","layout":"BSP"}]}]}' SWEEP_CONFIG_PATH = "C:\\e2e\\sweep.json" @@ -229,9 +237,11 @@ def run_one( ) -> RecordedChecks: out.mkdir(parents=True, exist_ok=True) module = "commands" if name == "unknown_monitor" else name - scenario = importlib.import_module( - f"komorebi_vm.core.scenarios.{module}" - ) + scenario = None + if name not in EXTRAS: + scenario = importlib.import_module( + f"komorebi_vm.core.scenarios.{module}" + ) tiling = importlib.import_module("komorebi_vm.core.scenarios.tiling") capture = os.environ.get("KOMOREBI_E2E_CAPTURE") @@ -256,6 +266,17 @@ def run_one( if recorder: _start_recorder(platform, Path(recorder), guest, out) try: + if name in EXTRAS: + extra = importlib.import_module( + f"platforms.qemu_windows.extras.{EXTRAS[name]}" + ) + clients = run_suite( + checks, shared(tiling.run, platform, checks) + )[0] + run_suite(checks, platform_only( + "windows", extra.run, platform, checks, clients + )) + return checks if name in WITH_CLIENTS: clients = tiling.run(platform, checks) if capture and name == "stacks":