protocols/
test/
.gitignore
21 B
clients.py
36.0 KiB
run.py
30.7 KiB
unit.c
35.5 KiB
.clang-format
571 B
.clangd
365 B
.gitignore
82 B
.gitsigners
91 B
LICENSE
32.3 KiB
LICENSE.dwl
34.9 KiB
LICENSE.dwm
2.0 KiB
LICENSE.spectrwm
1.3 KiB
LICENSE.sway
1.0 KiB
LICENSE.tinywl
7.0 KiB
Makefile
7.8 KiB
PKGBUILD
952 B
README
5.6 KiB
config.def.h
15.7 KiB
config.mk
269 B
swm.1.adoc
3.3 KiB
swm.c
247.1 KiB
swm.desktop
81 B
swm.h
3.5 KiB
swmctl.1.adoc
2.5 KiB
swmctl.c
19.6 KiB
util.c
2.2 KiB
util.h
399 B
test/run.py
raw
| 1 | #!/usr/bin/env python3 |
| 2 | |
| 3 | """Run swm's unit and headless integration tests.""" |
| 4 | |
| 5 | from __future__ import annotations |
| 6 | |
| 7 | import glob |
| 8 | import os |
| 9 | import re |
| 10 | import shlex |
| 11 | import shutil |
| 12 | import signal |
| 13 | import subprocess |
| 14 | import sys |
| 15 | import time |
| 16 | from pathlib import Path |
| 17 | |
| 18 | |
| 19 | SCRIPT = Path(__file__).resolve() |
| 20 | TESTDIR = SCRIPT.parent |
| 21 | ROOT = TESTDIR.parent |
| 22 | BUILD = Path(os.environ.get("SWM_TEST_BUILD", TESTDIR / ".build")).resolve() |
| 23 | SOURCE = BUILD / "source" |
| 24 | BIN = BUILD / "bin" |
| 25 | PROFILES = BUILD / "profiles" |
| 26 | LOGS = BUILD / "logs" |
| 27 | CHILDREN: list[subprocess.Popen] = [] |
| 28 | CURRENT_COMMAND: list[str] = [] |
| 29 | CURRENT_LOG: Path | None = None |
| 30 | |
| 31 | |
| 32 | class Failure(RuntimeError): |
| 33 | """Report a test failure without an internal traceback.""" |
| 34 | |
| 35 | |
| 36 | def command_text(command: list[str]) -> str: |
| 37 | """Format a command for diagnostics.""" |
| 38 | |
| 39 | return shlex.join(map(str, command)) |
| 40 | |
| 41 | |
| 42 | def run(*args: object, env: dict[str, str] | None = None) -> str: |
| 43 | """Run a command and return its standard output.""" |
| 44 | |
| 45 | global CURRENT_COMMAND |
| 46 | command = [str(arg) for arg in args] |
| 47 | CURRENT_COMMAND = command |
| 48 | result = subprocess.run(command, text=True, capture_output=True, env=env) |
| 49 | if result.returncode: |
| 50 | output = result.stdout + result.stderr |
| 51 | raise Failure( |
| 52 | f"command: {command_text(command)}\nstatus: {result.returncode}\n{output}" |
| 53 | ) |
| 54 | return result.stdout |
| 55 | |
| 56 | |
| 57 | def spawn(log: Path, *args: object, env: dict[str, str] | None = None) -> subprocess.Popen: |
| 58 | """Start and track a background command.""" |
| 59 | |
| 60 | global CURRENT_COMMAND, CURRENT_LOG |
| 61 | command = [str(arg) for arg in args] |
| 62 | CURRENT_COMMAND = command |
| 63 | CURRENT_LOG = log |
| 64 | log.parent.mkdir(parents=True, exist_ok=True) |
| 65 | stream = log.open("w") |
| 66 | process = subprocess.Popen(command, stdout=stream, stderr=subprocess.STDOUT, env=env) |
| 67 | stream.close() |
| 68 | CHILDREN.append(process) |
| 69 | return process |
| 70 | |
| 71 | |
| 72 | def terminate(process: subprocess.Popen) -> None: |
| 73 | """Terminate one tracked process.""" |
| 74 | |
| 75 | if process.poll() is None: |
| 76 | process.terminate() |
| 77 | try: |
| 78 | process.wait(timeout=1) |
| 79 | except subprocess.TimeoutExpired: |
| 80 | process.kill() |
| 81 | process.wait() |
| 82 | if process in CHILDREN: |
| 83 | CHILDREN.remove(process) |
| 84 | |
| 85 | |
| 86 | def cleanup_processes() -> None: |
| 87 | """Terminate all tracked processes in reverse order.""" |
| 88 | |
| 89 | for process in reversed(CHILDREN[:]): |
| 90 | terminate(process) |
| 91 | |
| 92 | |
| 93 | def tail(path: Path, lines: int = 40) -> str: |
| 94 | """Return the tail of a diagnostic file.""" |
| 95 | |
| 96 | if not path.exists(): |
| 97 | return "" |
| 98 | return "\n".join(path.read_text(errors="replace").splitlines()[-lines:]) |
| 99 | |
| 100 | |
| 101 | def eventually(description: str, predicate, timeout: float = 2.0): |
| 102 | """Wait until a predicate returns a truthy value.""" |
| 103 | |
| 104 | deadline = time.monotonic() + timeout |
| 105 | last: Exception | None = None |
| 106 | while time.monotonic() < deadline: |
| 107 | try: |
| 108 | value = predicate() |
| 109 | if value: |
| 110 | return value |
| 111 | except (OSError, ValueError, IndexError) as error: |
| 112 | last = error |
| 113 | time.sleep(0.02) |
| 114 | detail = f": {last}" if last else "" |
| 115 | raise Failure(f"timeout waiting for {description}{detail}") |
| 116 | |
| 117 | |
| 118 | def consistently(description: str, predicate, duration: float = 0.5): |
| 119 | """Fail if a predicate stops holding within a duration.""" |
| 120 | |
| 121 | deadline = time.monotonic() + duration |
| 122 | while time.monotonic() < deadline: |
| 123 | if not predicate(): |
| 124 | raise Failure(f"{description} did not hold") |
| 125 | time.sleep(0.02) |
| 126 | |
| 127 | |
| 128 | def test(name: str, body) -> None: |
| 129 | """Run and report one named test.""" |
| 130 | |
| 131 | global CURRENT_COMMAND, CURRENT_LOG |
| 132 | CURRENT_COMMAND = [] |
| 133 | CURRENT_LOG = None |
| 134 | progress = os.environ.get("SWM_TEST_PROGRESS") |
| 135 | print(f"{name} ... ", end="", flush=True) |
| 136 | if progress: |
| 137 | with open(progress, "a") as stream: |
| 138 | stream.write(f"{name} ... ") |
| 139 | try: |
| 140 | body() |
| 141 | except Exception: |
| 142 | print("FAIL", flush=True) |
| 143 | if progress: |
| 144 | with open(progress, "a") as stream: |
| 145 | stream.write("FAIL\n") |
| 146 | raise |
| 147 | print("ok", flush=True) |
| 148 | if progress: |
| 149 | with open(progress, "a") as stream: |
| 150 | stream.write("ok\n") |
| 151 | |
| 152 | |
| 153 | def generate_python_protocols(pkgconfig_executable: str) -> None: |
| 154 | """Generate Python client protocol bindings.""" |
| 155 | |
| 156 | wlproto = Path( |
| 157 | run(pkgconfig_executable, "--variable=pkgdatadir", "wayland-protocols").strip() |
| 158 | ) |
| 159 | xmls = [ |
| 160 | Path("/usr/share/wayland/wayland.xml"), |
| 161 | wlproto / "stable/xdg-shell/xdg-shell.xml", |
| 162 | wlproto / "staging/ext-workspace/ext-workspace-v1.xml", |
| 163 | wlproto / "staging/ext-session-lock/ext-session-lock-v1.xml", |
| 164 | wlproto / "unstable/idle-inhibit/idle-inhibit-unstable-v1.xml", |
| 165 | wlproto |
| 166 | / "unstable/keyboard-shortcuts-inhibit/keyboard-shortcuts-inhibit-unstable-v1.xml", |
| 167 | wlproto / "unstable/pointer-constraints/pointer-constraints-unstable-v1.xml", |
| 168 | wlproto / "unstable/relative-pointer/relative-pointer-unstable-v1.xml", |
| 169 | wlproto / "unstable/text-input/text-input-unstable-v3.xml", |
| 170 | ROOT / "protocols/input-method-unstable-v2.xml", |
| 171 | ROOT / "protocols/virtual-keyboard-unstable-v1.xml", |
| 172 | ROOT / "protocols/wlr-foreign-toplevel-management-unstable-v1.xml", |
| 173 | ROOT / "protocols/wlr-layer-shell-unstable-v1.xml", |
| 174 | ROOT / "protocols/wlr-output-power-management-unstable-v1.xml", |
| 175 | ROOT / "protocols/wlr-virtual-pointer-unstable-v1.xml", |
| 176 | ] |
| 177 | package = BUILD / "python" / "protocols" |
| 178 | shutil.rmtree(package, ignore_errors=True) |
| 179 | package.mkdir(parents=True) |
| 180 | (package / "__init__.py").touch() |
| 181 | run("pywayland-scanner", "-o", package, "-i", *xmls) |
| 182 | |
| 183 | # pywayland 0.4 generates a circular import for ext-workspace-v1. The |
| 184 | # group events refer to objects that already exist, so they do not need an |
| 185 | # interface constructor. Dropping that annotation breaks the cycle while |
| 186 | # preserving the wire signature. |
| 187 | workspace_group = package / "ext_workspace_v1/ext_workspace_group_handle_v1.py" |
| 188 | generated = workspace_group.read_text() |
| 189 | generated = generated.replace( |
| 190 | "from .ext_workspace_handle_v1 import ExtWorkspaceHandleV1\n", "" |
| 191 | ).replace( |
| 192 | "Argument(ArgumentType.Object, interface=ExtWorkspaceHandleV1)", |
| 193 | "Argument(ArgumentType.Object)", |
| 194 | ) |
| 195 | workspace_group.write_text(generated) |
| 196 | |
| 197 | |
| 198 | def prepare_suite(unit: bool, integration: bool) -> None: |
| 199 | """Prepare runtime files for the selected tests.""" |
| 200 | |
| 201 | binaries = [ |
| 202 | BIN / name |
| 203 | for name, selected in (("unit", unit), ("swm", integration), ("swmctl", integration)) |
| 204 | if selected |
| 205 | ] |
| 206 | missing = [binary for binary in binaries if not binary.exists()] |
| 207 | if missing: |
| 208 | raise Failure(f"test binary not built: {missing[0]}; run tests through make") |
| 209 | for directory in (PROFILES, LOGS): |
| 210 | shutil.rmtree(directory, ignore_errors=True) |
| 211 | directory.mkdir(parents=True) |
| 212 | if unit: |
| 213 | stubs = BUILD / "stubs" |
| 214 | shutil.rmtree(stubs, ignore_errors=True) |
| 215 | stubs.mkdir() |
| 216 | stub_commands = ( |
| 217 | "dbus-update-activation-environment", |
| 218 | "foot", |
| 219 | "waybar", |
| 220 | "swaybg", |
| 221 | "sh", |
| 222 | "wl-paste", |
| 223 | ) |
| 224 | for name in stub_commands: |
| 225 | (stubs / name).symlink_to("/bin/true") |
| 226 | notification = stubs / "notify-send" |
| 227 | notification.write_text( |
| 228 | "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$SWM_TEST_DIR/notification\"\n" |
| 229 | ) |
| 230 | notification.chmod(0o755) |
| 231 | if integration: |
| 232 | for executable in ("pywayland-scanner", "timeout"): |
| 233 | if not shutil.which(executable): |
| 234 | raise Failure(f"required tool not found: {executable}") |
| 235 | generate_python_protocols(os.environ.get("PKG_CONFIG", "pkg-config")) |
| 236 | source_config = (SOURCE / "config.h").read_text() |
| 237 | if re.search(r"#define\s+(?:MODKEY|MOD)\s+WLR_MODIFIER_ALT", source_config): |
| 238 | modkey = 8 |
| 239 | elif re.search(r"#define\s+(?:MODKEY|MOD)\s+WLR_MODIFIER_LOGO", source_config): |
| 240 | modkey = 64 |
| 241 | else: |
| 242 | raise Failure("unsupported modifier in config.def.h") |
| 243 | (BUILD / "modkey").write_text(str(modkey)) |
| 244 | |
| 245 | |
| 246 | def run_unit() -> None: |
| 247 | """Run every compositor unit test in an isolated process.""" |
| 248 | |
| 249 | runtime = BUILD / "unit-runtime" |
| 250 | shutil.rmtree(runtime, ignore_errors=True) |
| 251 | runtime.mkdir() |
| 252 | env = os.environ.copy() |
| 253 | env["LLVM_PROFILE_FILE"] = str(PROFILES / "unit-%m-%p.profraw") |
| 254 | env["SWM_TEST_STUB_PATH"] = str(BUILD / "stubs") |
| 255 | env["SWM_TEST_DIR"] = str(runtime) |
| 256 | for name in run(BIN / "unit", "--list", env=env).splitlines(): |
| 257 | test(name, lambda name=name: run(BIN / "unit", name, env=env)) |
| 258 | shutil.rmtree(runtime) |
| 259 | |
| 260 | |
| 261 | def current_title() -> str: |
| 262 | """Return the compositor's published title.""" |
| 263 | |
| 264 | path = Path(os.environ["XDG_RUNTIME_DIR"]) / "swm-title" |
| 265 | return path.read_text().rstrip("\n") if path.exists() else "" |
| 266 | |
| 267 | |
| 268 | def wait_title(title: str) -> None: |
| 269 | """Wait for the compositor to publish a title.""" |
| 270 | |
| 271 | eventually(f"title {title!r}", lambda: current_title() == title) |
| 272 | |
| 273 | |
| 274 | def client_command(role: str, *args: object) -> list[str]: |
| 275 | """Return a Python protocol-client command.""" |
| 276 | |
| 277 | return [sys.executable, str(TESTDIR / "clients.py"), role, *map(str, args)] |
| 278 | |
| 279 | |
| 280 | def client_log(name: str) -> Path: |
| 281 | """Return a fixture client's log path.""" |
| 282 | |
| 283 | return Path(os.environ["SWM_TEST_LOG_DIR"]) / f"{name}.log" |
| 284 | |
| 285 | |
| 286 | def start_client(name: str, color: str, fullscreen: bool = False) -> subprocess.Popen: |
| 287 | """Start an XDG client and wait for its geometry report.""" |
| 288 | |
| 289 | report = Path(os.environ["SWM_TEST_DIR"]) / f"{name}.geometry" |
| 290 | env = os.environ.copy() |
| 291 | env["SWM_CLIENT_REPORT"] = str(report) |
| 292 | args: list[object] = [color, name] |
| 293 | if fullscreen: |
| 294 | args.append("fullscreen") |
| 295 | process = spawn(client_log(name), *client_command("xdg", *args), env=env) |
| 296 | wait_title(name) |
| 297 | eventually(f"report {report}", lambda: report.exists() and report.stat().st_size) |
| 298 | return process |
| 299 | |
| 300 | |
| 301 | def geometry(name: str) -> list[int]: |
| 302 | """Return a client's reported geometry.""" |
| 303 | |
| 304 | path = Path(os.environ["SWM_TEST_DIR"]) / f"{name}.geometry" |
| 305 | return list(map(int, path.read_text().split())) |
| 306 | |
| 307 | |
| 308 | def published_geometry(title: str) -> list[int]: |
| 309 | """Return a client's compositor-published outer geometry.""" |
| 310 | |
| 311 | path = Path(os.environ["XDG_RUNTIME_DIR"]) / "swm-windows" |
| 312 | match = re.search( |
| 313 | rf"^(\d+),(\d+) (\d+)x(\d+) {re.escape(title)}$", path.read_text(), re.MULTILINE |
| 314 | ) |
| 315 | if not match: |
| 316 | raise Failure(f"missing published geometry for {title}") |
| 317 | return list(map(int, match.groups())) |
| 318 | |
| 319 | |
| 320 | def protocol(role: str, *args: object) -> str: |
| 321 | """Run one native-protocol action implemented in Python.""" |
| 322 | |
| 323 | return run(*client_command(role, *args), env=os.environ.copy()) |
| 324 | |
| 325 | |
| 326 | def virtual_keyboard(key: object, modifiers: int = 0) -> None: |
| 327 | """Send one configured key combination.""" |
| 328 | |
| 329 | configured = int((BUILD / "modkey").read_text()) | modifiers |
| 330 | protocol("keyboard", key, configured) |
| 331 | |
| 332 | |
| 333 | def held_pointer_click(name: str, x: int, button: str | None = None) -> None: |
| 334 | """Click while the compositor modifier is held by another client.""" |
| 335 | |
| 336 | ready = Path(os.environ["SWM_TEST_DIR"]) / f"{name}.ready" |
| 337 | release = Path(os.environ["SWM_TEST_DIR"]) / f"{name}.release" |
| 338 | env = os.environ.copy() |
| 339 | env["SWM_KEYBOARD_READY"] = str(ready) |
| 340 | env["SWM_KEYBOARD_RELEASE"] = str(release) |
| 341 | configured = int((BUILD / "modkey").read_text()) |
| 342 | keyboard = spawn( |
| 343 | client_log(name), |
| 344 | *client_command("keyboard", "none", configured), |
| 345 | env=env, |
| 346 | ) |
| 347 | eventually("held keyboard readiness", lambda: ready.exists()) |
| 348 | args: list[object] = [x] |
| 349 | if button: |
| 350 | args.append(button) |
| 351 | protocol("pointer", *args) |
| 352 | release.write_text("release\n") |
| 353 | eventually("held keyboard exit", lambda: keyboard.poll() is not None) |
| 354 | if keyboard in CHILDREN: |
| 355 | CHILDREN.remove(keyboard) |
| 356 | |
| 357 | |
| 358 | def integration_session() -> None: |
| 359 | """Exercise compositor behavior in a one-output session.""" |
| 360 | |
| 361 | one = start_client("one", "ff5588dd") |
| 362 | two = start_client("two", "ff55aa88") |
| 363 | |
| 364 | def publication() -> None: |
| 365 | windows = (Path(os.environ["XDG_RUNTIME_DIR"]) / "swm-windows").read_text() |
| 366 | if not re.search(r"^\d+,\d+ \d+x\d+ one$", windows, re.MULTILINE): |
| 367 | raise Failure("missing published rectangle for one") |
| 368 | if not re.search(r"^\d+,\d+ \d+x\d+ two$", windows, re.MULTILINE): |
| 369 | raise Failure("missing published rectangle for two") |
| 370 | before = current_title() |
| 371 | virtual_keyboard(36) |
| 372 | wait_title("two" if before == "one" else "one") |
| 373 | |
| 374 | test("window publication and focus", publication) |
| 375 | |
| 376 | def client_resize() -> None: |
| 377 | report = Path(os.environ["SWM_TEST_DIR"]) / "client-resize.geometry" |
| 378 | trigger = Path(os.environ["SWM_TEST_DIR"]) / "client-resize.trigger" |
| 379 | committed = Path(os.environ["SWM_TEST_DIR"]) / "client-resize.committed" |
| 380 | env = os.environ.copy() |
| 381 | env["SWM_CLIENT_REPORT"] = str(report) |
| 382 | env["SWM_RESIZE_TRIGGER"] = str(trigger) |
| 383 | env["SWM_RESIZE_COMMITTED"] = str(committed) |
| 384 | targets = ([200, 120], [520, 320]) |
| 385 | client = spawn( |
| 386 | client_log("client-resize"), |
| 387 | *client_command("xdg-resize", *targets[0], *targets[1]), |
| 388 | env=env, |
| 389 | ) |
| 390 | wait_title("client-resize") |
| 391 | eventually("client resize report", lambda: report.exists() and report.stat().st_size) |
| 392 | |
| 393 | def border_size(): |
| 394 | configured = geometry("client-resize") |
| 395 | outer = published_geometry("client-resize") |
| 396 | delta = [outer[2] - configured[0], outer[3] - configured[1]] |
| 397 | return delta if delta[0] == delta[1] and delta[0] >= 0 else None |
| 398 | |
| 399 | border = eventually("settled floating geometry", border_size) |
| 400 | for index, target in enumerate(targets): |
| 401 | trigger.write_text(f"{index}\n") |
| 402 | eventually( |
| 403 | f"client resize commit {index}", |
| 404 | lambda index=index: committed.exists() |
| 405 | and committed.read_text().strip() == str(index), |
| 406 | ) |
| 407 | expected = [target[0] + border[0], target[1] + border[1]] |
| 408 | eventually( |
| 409 | f"client-selected floating size {index}", |
| 410 | lambda expected=expected: published_geometry("client-resize")[2:] == expected, |
| 411 | ) |
| 412 | consistently( |
| 413 | f"client-selected floating size {index}", |
| 414 | lambda expected=expected: published_geometry("client-resize")[2:] == expected, |
| 415 | ) |
| 416 | terminate(client) |
| 417 | |
| 418 | test("client-selected floating resize", client_resize) |
| 419 | |
| 420 | def window_control() -> None: |
| 421 | target = current_title() |
| 422 | virtual_keyboard(3, 1) |
| 423 | eventually("focus after moving switch target", lambda: current_title() != target) |
| 424 | listed = run(BIN / "swmctl", "window", "list").splitlines() |
| 425 | matches = [line for line in listed if line.split("\t", 1)[0] == target] |
| 426 | if len(matches) != 1 or len(matches[0].split("\t")) != 3: |
| 427 | raise Failure(f"unexpected toplevel list: {listed!r}") |
| 428 | identifier = matches[0].rsplit("\t", 1)[1] |
| 429 | duplicate = start_client(target, "5588ffdd") |
| 430 | listed = run(BIN / "swmctl", "window", "list").splitlines() |
| 431 | matches = [line for line in listed if line.split("\t", 1)[0] == target] |
| 432 | if len(matches) != 2: |
| 433 | raise Failure(f"duplicate title missing from toplevel list: {listed!r}") |
| 434 | run(BIN / "swmctl", "window", "activate", identifier) |
| 435 | wait_title(target) |
| 436 | selected = run(BIN / "swmctl", "workspace", "get", 2, "selected") |
| 437 | if selected != "true\n": |
| 438 | raise Failure("identifier activation selected the duplicate window") |
| 439 | terminate(duplicate) |
| 440 | virtual_keyboard(2, 1) |
| 441 | virtual_keyboard(2) |
| 442 | |
| 443 | test("window control", window_control) |
| 444 | |
| 445 | def layouts() -> None: |
| 446 | three = start_client("three-layout", "ffcc8855") |
| 447 | names = ("one", "two", "three-layout") |
| 448 | normal = {name: geometry(name)[:2] for name in names} |
| 449 | virtual_keyboard(33) |
| 450 | eventually( |
| 451 | "max-stack geometry", |
| 452 | lambda: len({tuple(geometry(name)[:2]) for name in names}) == 1, |
| 453 | ) |
| 454 | focused = current_title() |
| 455 | others = tuple(name for name in names if name != focused) |
| 456 | maximized = geometry(focused)[:2] |
| 457 | held_pointer_click("max-stack-resize", 100, "right") |
| 458 | eventually( |
| 459 | "normal layout restored", |
| 460 | lambda: all(geometry(name)[:2] != maximized for name in others), |
| 461 | ) |
| 462 | eventually( |
| 463 | "max-stack resize persisted", |
| 464 | lambda: geometry(focused)[:2] != normal[focused], |
| 465 | ) |
| 466 | for _ in names: |
| 467 | if current_title() == focused: |
| 468 | break |
| 469 | virtual_keyboard(36) |
| 470 | wait_title(focused) |
| 471 | virtual_keyboard(20) |
| 472 | fullscreen_target = focused |
| 473 | virtual_keyboard(33, 1) |
| 474 | eventually("fullscreen enable", lambda: geometry(fullscreen_target)[2] == 1) |
| 475 | virtual_keyboard(33, 1) |
| 476 | eventually("fullscreen disable", lambda: geometry(fullscreen_target)[2] == 0) |
| 477 | terminate(three) |
| 478 | eventually("third layout window removed", lambda: current_title() != "three-layout") |
| 479 | |
| 480 | test("layouts and fullscreen", layouts) |
| 481 | |
| 482 | def workspaces() -> None: |
| 483 | switcher = start_client("workspace-switcher", "8855ffdd") |
| 484 | virtual_keyboard(3, 1) |
| 485 | protocol("pointer", 100) |
| 486 | pointer_target = current_title() |
| 487 | virtual_keyboard(36) |
| 488 | active_target = current_title() |
| 489 | if active_target == pointer_target: |
| 490 | raise Failure("could not select the second workspace tile") |
| 491 | virtual_keyboard(3) |
| 492 | wait_title("workspace-switcher") |
| 493 | for _ in range(2): |
| 494 | virtual_keyboard(15) |
| 495 | wait_title(active_target) |
| 496 | virtual_keyboard(15) |
| 497 | wait_title("workspace-switcher") |
| 498 | virtual_keyboard(15) |
| 499 | wait_title(active_target) |
| 500 | terminate(switcher) |
| 501 | protocol("pointer", 100) |
| 502 | |
| 503 | current = current_title() |
| 504 | virtual_keyboard(20) |
| 505 | virtual_keyboard(20) |
| 506 | virtual_keyboard(3, 1) |
| 507 | eventually("focus after moving client", lambda: current_title() not in {"", current}) |
| 508 | virtual_keyboard(3) |
| 509 | wait_title(current) |
| 510 | virtual_keyboard(2, 1) |
| 511 | wait_title("") |
| 512 | virtual_keyboard(2) |
| 513 | wait_title(current) |
| 514 | virtual_keyboard(103) |
| 515 | wait_title("") |
| 516 | virtual_keyboard(108) |
| 517 | wait_title(current) |
| 518 | |
| 519 | test("workspaces", workspaces) |
| 520 | |
| 521 | def workspace_metadata() -> None: |
| 522 | def field(name: str) -> str: |
| 523 | return run(BIN / "swmctl", "workspace", "get", 3, name).removesuffix("\n") |
| 524 | |
| 525 | subscription_log = client_log("workspace-subscribe") |
| 526 | subscriber = spawn( |
| 527 | subscription_log, |
| 528 | BIN / "swmctl", |
| 529 | "workspace", |
| 530 | "subscribe", |
| 531 | "--format=waybar", |
| 532 | env=os.environ.copy(), |
| 533 | ) |
| 534 | eventually("initial workspace metadata", lambda: subscription_log.stat().st_size) |
| 535 | run(BIN / "swmctl", "workspace", "set", 3, "title", "code") |
| 536 | run(BIN / "swmctl", "workspace", "set", 3, "color", "#81a1c180") |
| 537 | state = { |
| 538 | "workspace": 3, |
| 539 | "title": field("title"), |
| 540 | "color": field("color"), |
| 541 | "selected": field("selected") == "true", |
| 542 | } |
| 543 | if state != { |
| 544 | "workspace": 3, |
| 545 | "title": "code", |
| 546 | "color": "#81a1c180", |
| 547 | "selected": False, |
| 548 | }: |
| 549 | raise Failure(f"unexpected workspace metadata: {state!r}") |
| 550 | listed = run(BIN / "swmctl", "workspace", "list").splitlines() |
| 551 | if "3" not in listed: |
| 552 | raise Failure(f"workspace missing from list: {listed!r}") |
| 553 | run(BIN / "swmctl", "workspace", "set", 3, "color", "#00000000") |
| 554 | if field("color"): |
| 555 | raise Failure("zero RGBA color was not cleared") |
| 556 | run(BIN / "swmctl", "workspace", "set", 3, "color", "#000000") |
| 557 | if field("color") != "#000000": |
| 558 | raise Failure("opaque black color was not retained") |
| 559 | run(BIN / "swmctl", "workspace", "clear", 3, "color") |
| 560 | if field("title") != "code" or field("color"): |
| 561 | raise Failure("workspace color was not cleared") |
| 562 | run(BIN / "swmctl", "workspace", "set", 3, "color", "#81a1c1") |
| 563 | protocol("workspace", 3) |
| 564 | eventually( |
| 565 | "subscribed workspace metadata", |
| 566 | lambda: "#81a1c1" in subscription_log.read_text() |
| 567 | and "code" in subscription_log.read_text(), |
| 568 | ) |
| 569 | run(BIN / "swmctl", "workspace", "clear", 3, "title") |
| 570 | run(BIN / "swmctl", "workspace", "clear", 3, "color") |
| 571 | if field("title") or field("color") or field("selected") != "true": |
| 572 | raise Failure("workspace metadata was not cleared") |
| 573 | protocol("workspace", 1) |
| 574 | terminate(subscriber) |
| 575 | |
| 576 | test("workspace metadata", workspace_metadata) |
| 577 | |
| 578 | def layout_configuration() -> None: |
| 579 | before_one = geometry("one") |
| 580 | before_two = geometry("two") |
| 581 | virtual_keyboard(36, 1) |
| 582 | eventually( |
| 583 | "client swap", |
| 584 | lambda: geometry("one")[0] == before_two[0] and geometry("two")[0] == before_one[0], |
| 585 | ) |
| 586 | for key in (28, 50, 19, 57): |
| 587 | virtual_keyboard(key) |
| 588 | eventually("master-top layout", lambda: geometry("one")[0] == geometry("two")[0]) |
| 589 | before = geometry("one") |
| 590 | virtual_keyboard(35) |
| 591 | eventually("master size change", lambda: geometry("one") != before) |
| 592 | |
| 593 | test("layout configuration", layout_configuration) |
| 594 | |
| 595 | def protocols() -> None: |
| 596 | target = current_title() |
| 597 | protocol("foreign", target, "fullscreen") |
| 598 | eventually("foreign fullscreen", lambda: geometry(target)[2] == 1) |
| 599 | protocol("foreign", target, "unfullscreen") |
| 600 | eventually("foreign unfullscreen", lambda: geometry(target)[2] == 0) |
| 601 | protocol("pointer", 100) |
| 602 | protocol("pointer", 900) |
| 603 | held_pointer_click("held-left", 100) |
| 604 | held_pointer_click("held-right", 100, "right") |
| 605 | for key, modifiers in ((51, 0), (52, 0), (51, 1), (52, 1), (57, 1), (43, 1)): |
| 606 | virtual_keyboard(key, modifiers) |
| 607 | protocol("output-power") |
| 608 | protocol("output-management") |
| 609 | |
| 610 | test("control protocols", protocols) |
| 611 | |
| 612 | def isolation() -> None: |
| 613 | protocol("workspace", 3) |
| 614 | wait_title("") |
| 615 | three = start_client("three", "ff8855aa") |
| 616 | full = start_client("full", "ffaa5588", True) |
| 617 | eventually("fullscreen state", lambda: geometry("full")[2] == 1) |
| 618 | protocol("foreign", "full", "close") |
| 619 | wait_title("three") |
| 620 | protocol("workspace", 1) |
| 621 | eventually("workspace protocol activation", lambda: current_title() in {"one", "two"}) |
| 622 | terminate(three) |
| 623 | terminate(full) |
| 624 | |
| 625 | test("fullscreen isolation", isolation) |
| 626 | |
| 627 | def max_stack_floating() -> None: |
| 628 | protocol("workspace", 3) |
| 629 | wait_title("") |
| 630 | first = start_client("max-float-one", "55aaffcc") |
| 631 | second = start_client("max-float-two", "ffaa55cc") |
| 632 | third = start_client("max-float-three", "aa55ffcc") |
| 633 | names = ("max-float-one", "max-float-two", "max-float-three") |
| 634 | virtual_keyboard(33) |
| 635 | eventually( |
| 636 | "max-stack geometry before floating toggle", |
| 637 | lambda: len({tuple(geometry(name)[:2]) for name in names}) == 1, |
| 638 | ) |
| 639 | virtual_keyboard(20) |
| 640 | eventually( |
| 641 | "normal layout restored by floating toggle", |
| 642 | lambda: len({tuple(geometry(name)[:2]) for name in names}) > 1, |
| 643 | ) |
| 644 | protocol("workspace", 1) |
| 645 | eventually("workspace left after floating toggle", lambda: current_title() in {"one", "two"}) |
| 646 | protocol("workspace", 3) |
| 647 | eventually("workspace returned after floating toggle", lambda: current_title() in names) |
| 648 | time.sleep(0.1) |
| 649 | if current_title() not in names: |
| 650 | raise Failure("workspace did not remain selected after floating toggle") |
| 651 | terminate(first) |
| 652 | terminate(second) |
| 653 | terminate(third) |
| 654 | protocol("workspace", 1) |
| 655 | eventually("workspace restored after floating toggle", lambda: current_title() in {"one", "two"}) |
| 656 | |
| 657 | test("max-stack floating transition", max_stack_floating) |
| 658 | |
| 659 | def lifecycle() -> None: |
| 660 | terminate(one) |
| 661 | terminate(two) |
| 662 | protocol("xdg-lifecycle", 2) |
| 663 | protocol("transient", 2) |
| 664 | protocol("layer", 2) |
| 665 | protocol("session-lock", 2) |
| 666 | protocol("text-input") |
| 667 | protocol("x11") |
| 668 | |
| 669 | test("protocol lifecycle", lifecycle) |
| 670 | terminate(one) |
| 671 | terminate(two) |
| 672 | |
| 673 | |
| 674 | def integration_multioutput() -> None: |
| 675 | """Exercise workspace movement and output removal.""" |
| 676 | |
| 677 | client = start_client("multi", "ff778899") |
| 678 | |
| 679 | def multioutput() -> None: |
| 680 | virtual_keyboard(105, 1) |
| 681 | wait_title("") |
| 682 | virtual_keyboard(106, 1) |
| 683 | wait_title("multi") |
| 684 | virtual_keyboard(105, 4) |
| 685 | wait_title("") |
| 686 | virtual_keyboard(105, 1) |
| 687 | wait_title("multi") |
| 688 | protocol("output-management", "disable-second") |
| 689 | wait_title("multi") |
| 690 | |
| 691 | test("multiple outputs", multioutput) |
| 692 | terminate(client) |
| 693 | |
| 694 | |
| 695 | def fixture_main(kind: str) -> None: |
| 696 | """Run one fixture inside swm's process tree.""" |
| 697 | |
| 698 | result = Path(os.environ["SWM_TEST_RESULT"]) |
| 699 | status = "ok" |
| 700 | try: |
| 701 | if kind == "session": |
| 702 | integration_session() |
| 703 | elif kind == "multioutput": |
| 704 | integration_multioutput() |
| 705 | else: |
| 706 | raise Failure(f"unknown fixture: {kind}") |
| 707 | except Exception as error: |
| 708 | status = str(error) |
| 709 | finally: |
| 710 | cleanup_processes() |
| 711 | result.write_text(status + "\n") |
| 712 | os.kill(os.getppid(), signal.SIGTERM) |
| 713 | if status != "ok": |
| 714 | print(status, file=sys.stderr) |
| 715 | raise SystemExit(1) |
| 716 | |
| 717 | |
| 718 | def run_fixture(kind: str, outputs: int) -> None: |
| 719 | """Run one isolated headless compositor fixture.""" |
| 720 | |
| 721 | runtime = BUILD / f"runtime-{kind}" |
| 722 | state = BUILD / f"state-{kind}" |
| 723 | fixture = BUILD / f"fixture-{kind}" |
| 724 | logs = LOGS / kind |
| 725 | for path in (runtime, state, fixture, logs): |
| 726 | shutil.rmtree(path, ignore_errors=True) |
| 727 | path.mkdir(parents=True, exist_ok=True) |
| 728 | runtime.chmod(0o700) |
| 729 | result = BUILD / f"{kind}.result" |
| 730 | progress = BUILD / f"{kind}.progress" |
| 731 | result.write_text("") |
| 732 | progress.write_text("") |
| 733 | env = os.environ.copy() |
| 734 | env.update( |
| 735 | { |
| 736 | "XDG_RUNTIME_DIR": str(runtime), |
| 737 | "XDG_STATE_HOME": str(state), |
| 738 | "SWM_NO_AUTOSTART": "1", |
| 739 | "WLR_BACKENDS": "headless", |
| 740 | "WLR_RENDERER": "pixman", |
| 741 | "WLR_LIBINPUT_NO_DEVICES": "1", |
| 742 | "WLR_HEADLESS_OUTPUTS": str(outputs), |
| 743 | "SWM_TEST_RESULT": str(result), |
| 744 | "SWM_TEST_PROGRESS": str(progress), |
| 745 | "SWM_TEST_DIR": str(fixture), |
| 746 | "SWM_TEST_LOG_DIR": str(logs), |
| 747 | "PYTHONPATH": str(BUILD / "python"), |
| 748 | "LLVM_PROFILE_FILE": str(PROFILES / "integration-%m-%p.profraw"), |
| 749 | } |
| 750 | ) |
| 751 | log = LOGS / f"swm-{kind}.log" |
| 752 | startup = f"{shlex.quote(sys.executable)} {shlex.quote(str(SCRIPT))} --fixture {kind}" |
| 753 | command = ["timeout", "--foreground", "30", str(BIN / "swm"), "-s", startup] |
| 754 | compositor = spawn(log, *command, env=env) |
| 755 | progress_offset = 0 |
| 756 | while compositor.poll() is None: |
| 757 | with progress.open() as stream: |
| 758 | stream.seek(progress_offset) |
| 759 | update = stream.read() |
| 760 | progress_offset = stream.tell() |
| 761 | if update: |
| 762 | print(update, end="", flush=True) |
| 763 | time.sleep(0.02) |
| 764 | status = compositor.wait() |
| 765 | CHILDREN.remove(compositor) |
| 766 | with progress.open() as stream: |
| 767 | stream.seek(progress_offset) |
| 768 | update = stream.read() |
| 769 | if update: |
| 770 | print(update, end="", flush=True) |
| 771 | outcome = result.read_text().strip() |
| 772 | if status or outcome != "ok": |
| 773 | raise Failure( |
| 774 | f"fixture {kind} failed\nresult: {outcome!r}\nstatus: {status}\n" |
| 775 | f"compositor log tail ({log}):\n{tail(log, 60)}" |
| 776 | ) |
| 777 | contents = log.read_text(errors="replace") |
| 778 | if re.search(r"pool exhausted|AddressSanitizer|runtime error:|protocol error", contents): |
| 779 | raise Failure(f"fixture {kind} reported a runtime failure\n{tail(log, 60)}") |
| 780 | for path in (runtime, state, fixture): |
| 781 | shutil.rmtree(path) |
| 782 | |
| 783 | |
| 784 | def run_integration() -> None: |
| 785 | """Run every integration fixture.""" |
| 786 | |
| 787 | run_fixture("session", 1) |
| 788 | run_fixture("multioutput", 2) |
| 789 | |
| 790 | |
| 791 | def coverage_report() -> None: |
| 792 | """Merge profiles and report production coverage.""" |
| 793 | |
| 794 | for executable in ("llvm-profdata", "llvm-cov"): |
| 795 | if not shutil.which(executable): |
| 796 | raise Failure(f"required coverage tool not found: {executable}") |
| 797 | raw = glob.glob(str(PROFILES / "*.profraw")) |
| 798 | if not raw: |
| 799 | raise Failure("no LLVM coverage profiles were produced") |
| 800 | profdata = BUILD / "coverage.profdata" |
| 801 | run("llvm-profdata", "merge", "-sparse", *raw, "-o", profdata) |
| 802 | report = run( |
| 803 | "llvm-cov", |
| 804 | "report", |
| 805 | BIN / "swm", |
| 806 | "-instr-profile", |
| 807 | profdata, |
| 808 | "-object", |
| 809 | BIN / "unit", |
| 810 | SOURCE / "swm.c", |
| 811 | ROOT / "util.c", |
| 812 | ) |
| 813 | files: list[str] = [] |
| 814 | total: list[str] | None = None |
| 815 | for line in report.splitlines(): |
| 816 | fields = line.split() |
| 817 | if len(fields) < 10: |
| 818 | continue |
| 819 | if fields[0] == "TOTAL": |
| 820 | total = fields |
| 821 | elif fields[0].endswith(".c"): |
| 822 | files.append(f"{Path(fields[0]).name} {fields[9]}") |
| 823 | if total is None: |
| 824 | raise Failure("could not parse llvm-cov TOTAL line") |
| 825 | percent = float(total[9].rstrip("%")) |
| 826 | print(f"coverage: {', '.join(files)}, aggregate {percent:.2f}%") |
| 827 | |
| 828 | |
| 829 | def clean() -> None: |
| 830 | """Remove all generated test artifacts.""" |
| 831 | |
| 832 | shutil.rmtree(BUILD, ignore_errors=True) |
| 833 | print(f"clean: removed {BUILD}") |
| 834 | |
| 835 | |
| 836 | def main() -> None: |
| 837 | """Dispatch public runner modes and private fixture modes.""" |
| 838 | |
| 839 | if len(sys.argv) == 3 and sys.argv[1] == "--fixture": |
| 840 | fixture_main(sys.argv[2]) |
| 841 | return |
| 842 | modes = {"unit", "integration", "coverage", "clean"} |
| 843 | if len(sys.argv) > 2 or (len(sys.argv) == 2 and sys.argv[1] not in modes): |
| 844 | raise Failure("usage: ./test/run.py [unit|integration|coverage|clean]") |
| 845 | mode = sys.argv[1] if len(sys.argv) == 2 else "all" |
| 846 | if mode == "clean": |
| 847 | clean() |
| 848 | return |
| 849 | run_units = mode in {"all", "unit", "coverage"} |
| 850 | run_integrations = mode in {"all", "integration", "coverage"} |
| 851 | prepare_suite(run_units, run_integrations) |
| 852 | if run_units: |
| 853 | run_unit() |
| 854 | if run_integrations: |
| 855 | run_integration() |
| 856 | if mode == "coverage": |
| 857 | coverage_report() |
| 858 | |
| 859 | |
| 860 | if __name__ == "__main__": |
| 861 | try: |
| 862 | main() |
| 863 | except Exception as error: |
| 864 | print(error, file=sys.stderr) |
| 865 | if CURRENT_COMMAND: |
| 866 | print(f"last command: {command_text(CURRENT_COMMAND)}", file=sys.stderr) |
| 867 | if CURRENT_LOG: |
| 868 | print(f"log tail ({CURRENT_LOG}):\n{tail(CURRENT_LOG)}", file=sys.stderr) |
| 869 | cleanup_processes() |
| 870 | raise SystemExit(1) |