test/run.py 30.7 KiB 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)