#!/usr/bin/env python3 """Fuzz tests for kick: launch real programs and verify the compositor survives basic interactions without crashing. Each test launches kick with one or more real programs (terminals, GUI apps, X11 clients), does a few cycles/overlay checks/closes, and asserts the compositor is still alive. Marked @pytest.mark.fuzz -- excluded from the default pytest run. Run with: pytest -m fuzz """ import os import re import shutil import signal import time import pytest from test_smoke import ( ALT, DOWN, UP, LEFT, RIGHT, DELETE, KEY_PRESSED, KEY_RELEASED, BIN, LOG, WL, Session, _nested_session, ) # Skip the entire module if no host Wayland session. if not os.environ.get("WAYLAND_DISPLAY"): pytestmark = pytest.mark.skip("needs a live host Wayland session") # Programs to test, looked up once at import time. FOOT = shutil.which("foot") ALACRITTY = shutil.which("alacritty") GNOME_CALC = shutil.which("gnome-calculator") EVINCE = shutil.which("evince") MPV = shutil.which("mpv") XMSG = shutil.which("xmessage") WAYBAR = shutil.which("waybar") def _alive(s): """Assert the compositor is still running (or exited cleanly).""" rc = s.proc.poll() if rc is not None: assert rc == 0, f"kick crashed with code {rc}" def _crash_free(s): """Assert no crash signals or assertions in the log.""" log = s.read_log() for pat in ("Segmentation fault", "Aborted", "Backtrace", "BACKTRACE"): assert pat not in log, f"crash indicator '{pat}' in log" # Check for wlroots assertion failures (the pattern is "Assertion `...' failed") assert "assertion" not in log.lower() or "assertion failed" not in log.lower(), \ "assertion failure in log" def _run_fuzz(s, extra_steps=None): """Common fuzz sequence: wait for map, cycle, overlay, close active. extra_steps: optional callable(session) run between overlay and close. """ try: # Wait for at least one app to map (single-tab sessions don't emit # 'active app=' so wait_any_active would hang). s.wait_for(r"\[diag\] map app=\d", timeout=10.0) _alive(s) # A few cycles (no-op for single tab, but exercises the key path). for _ in range(3): s.mods(ALT) s.tap(DOWN) time.sleep(0.3) s.mods(0) _alive(s) # Hold Alt to check overlay renders. s.mods(ALT) time.sleep(0.5) s.mods(0) _alive(s) if extra_steps: extra_steps(s) # Close the active tab. s.chord(ALT, DELETE) time.sleep(1.0) _alive(s) _crash_free(s) finally: s.close() # ── single-program tests ──────────────────────────────────────────── @pytest.mark.fuzz def test_fuzz_foot(): if not FOOT: pytest.skip("foot not installed") s = _nested_session(FOOT) _run_fuzz(s) @pytest.mark.fuzz def test_fuzz_alacritty(): if not ALACRITTY: pytest.skip("alacritty not installed") s = _nested_session(ALACRITTY) _run_fuzz(s) @pytest.mark.fuzz def test_fuzz_gnome_calculator(): if not GNOME_CALC: pytest.skip("gnome-calculator not installed") s = _nested_session(GNOME_CALC) _run_fuzz(s) @pytest.mark.fuzz def test_fuzz_evince(): if not EVINCE: pytest.skip("evince not installed") s = _nested_session(EVINCE) _run_fuzz(s) @pytest.mark.fuzz def test_fuzz_mpv(): pytest.skip("mpv exits without a playable file; poor fuzz target") @pytest.mark.fuzz def test_fuzz_xmessage(): if not XMSG: pytest.skip("xmessage not installed") s = _nested_session(f"{XMSG} hello") _run_fuzz(s) # ── layer-shell panel tests ────────────────────────────────────────── def _kill_session_group(s): """SIGTERM the panel kick spawns for --panel (the sh -c wrapper's group), forcing its layer surfaces down through the normal destroy path.""" pid = 0 for line in s.read_log().splitlines(): m = re.search(r"\[panel\] pid=(\d+)", line) if m: pid = int(m.group(1)) assert pid, "no [panel] pid= line in log" os.killpg(pid, signal.SIGTERM) time.sleep(1.0) @pytest.mark.fuzz def test_fuzz_waybar_panel(): """A --panel waybar should keep kick alive, including when the panel dies and tears its layer surfaces down.""" if not WAYBAR: pytest.skip("waybar not installed") s = _nested_session(f"{WL} '#ff0000'", extra_args=("--panel", WAYBAR)) try: s.wait_for(r"\[diag\] map app=\d", timeout=10.0) s.wait_for(r"\[panel\] pid=\d+", timeout=10.0) _alive(s) # A few cycles (exercises matching actions with a panel attached). for _ in range(3): s.mods(ALT) s.tap(DOWN) time.sleep(0.3) s.mods(0) _alive(s) # Hold Alt to check overlay renders. s.mods(ALT) time.sleep(0.5) s.mods(0) _alive(s) # Kill the panel, then re-cycle: layer-surface teardown must not # crash the compositor. _kill_session_group(s) s.mods(ALT) s.tap(DOWN) time.sleep(0.5) s.mods(0) _alive(s) # Close the tab, then check the whole session was crash-free. s.chord(ALT, DELETE) time.sleep(1.0) _alive(s) _crash_free(s) finally: s.close() # ── multi-tab tests ───────────────────────────────────────────────── @pytest.mark.fuzz def test_fuzz_two_terminals(): if not FOOT or not ALACRITTY: pytest.skip("need foot and alacritty") s = _nested_session(FOOT, ALACRITTY) _run_fuzz(s) @pytest.mark.fuzz def test_fuzz_terminal_plus_gui(): if not FOOT or not GNOME_CALC: pytest.skip("need foot and gnome-calculator") s = _nested_session(FOOT, GNOME_CALC) _run_fuzz(s) @pytest.mark.fuzz def test_fuzz_wayland_plus_x11(): if not FOOT or not XMSG: pytest.skip("need foot and xmessage") s = _nested_session(FOOT, f"{XMSG} hello") _run_fuzz(s) @pytest.mark.fuzz def test_fuzz_three_tabs(): if not FOOT or not GNOME_CALC or not XMSG: pytest.skip("need foot, gnome-calculator, xmessage") s = _nested_session(FOOT, GNOME_CALC, f"{XMSG} hello") _run_fuzz(s) # ── tab-groups (stacking) tests ───────────────────────────────────── @pytest.mark.fuzz def test_fuzz_tab_groups_stacking(): if not FOOT or not GNOME_CALC: pytest.skip("need foot and gnome-calculator") s = _nested_session(FOOT, GNOME_CALC, extra_args=("--tab-groups", "on")) _run_fuzz(s) @pytest.mark.fuzz def test_fuzz_tab_groups_cycle_members(): """Cycle between stacked members with Alt+Left/Right.""" if not FOOT or not GNOME_CALC: pytest.skip("need foot and gnome-calculator") s = _nested_session(FOOT, GNOME_CALC, extra_args=("--tab-groups", "on")) try: s.wait_for(r"\[diag\] map app=\d", timeout=10.0) _alive(s) # Cycle between stacked members. s.mods(ALT) for _ in range(3): s.tap(LEFT) time.sleep(0.3) s.mods(0) _alive(s) # Cycle back. s.mods(ALT) for _ in range(3): s.tap(RIGHT) time.sleep(0.3) s.mods(0) _alive(s) _crash_free(s) finally: s.close() # ── edge-case tests ───────────────────────────────────────────────── @pytest.mark.fuzz def test_fuzz_short_lived_command(): """A command that exits immediately should not crash kick.""" s = _nested_session("true") try: # Give it a moment, then check kick survived. time.sleep(2.0) _alive(s) _crash_free(s) finally: s.close() @pytest.mark.fuzz def test_fuzz_unresponsive_close(): """Closing an unresponsive program should not crash kick.""" if not FOOT: pytest.skip("need foot") s = _nested_session(FOOT, "sleep 999") try: s.wait_for(r"\[diag\] map app=\d", timeout=10.0) _alive(s) # Close the active tab (whichever is focused). s.chord(ALT, DELETE) time.sleep(2.0) _alive(s) # Close the remaining tab. s.chord(ALT, DELETE) time.sleep(2.0) _alive(s) _crash_free(s) finally: s.close() @pytest.mark.fuzz def test_fuzz_rapid_cycling(): """Rapid Alt+Down cycling should not crash kick.""" if not FOOT or not GNOME_CALC: pytest.skip("need foot and gnome-calculator") s = _nested_session(FOOT, GNOME_CALC) try: s.wait_for(r"\[diag\] map app=\d", timeout=10.0) _alive(s) # Rapid cycling: 20 cycles in quick succession. s.mods(ALT) for _ in range(20): s.tap(DOWN) time.sleep(1.0) s.mods(0) _alive(s) # Rapid cycling back. s.mods(ALT) for _ in range(20): s.tap(UP) time.sleep(1.0) s.mods(0) _alive(s) _crash_free(s) finally: s.close()