diff --git a/pytest.ini b/pytest.ini index ea9c926..afaaa14 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,4 +2,5 @@ testpaths = tests markers = flaky: known-flaky test, excluded from the default run -addopts = -m "not flaky" \ No newline at end of file + fuzz: fuzz test launching real programs, excluded from the default run +addopts = -m "not flaky and not fuzz" \ No newline at end of file diff --git a/tests/test_fuzz.py b/tests/test_fuzz.py new file mode 100644 index 0000000..f20c4de --- /dev/null +++ b/tests/test_fuzz.py @@ -0,0 +1,281 @@ +#!/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 shutil +import signal +import time + +import pytest + +from test_smoke import ( + ALT, DOWN, UP, LEFT, RIGHT, DELETE, KEY_PRESSED, KEY_RELEASED, + BIN, LOG, 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") + + +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"): + 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) + + +# ── 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()