add fuzz tests: launch real programs inside kick to find crashes

14 fuzz tests (pytest -m fuzz) that launch foot, alacritty,
gnome-calculator, evince, xmessage, and combinations thereof inside
kick, then exercise basic interactions (cycle, overlay, close, rapid
cycling, tab-groups stacking, unresponsive close).

Excluded from the default pytest run via the fuzz marker, like flaky.
This commit is contained in:
Andrew Hamilton 2026-08-27 10:19:52 +10:00
parent fc89b1ad27
commit 4367ed29b5
2 changed files with 283 additions and 1 deletions

View file

@ -2,4 +2,5 @@
testpaths = tests
markers =
flaky: known-flaky test, excluded from the default run
addopts = -m "not flaky"
fuzz: fuzz test launching real programs, excluded from the default run
addopts = -m "not flaky and not fuzz"

281
tests/test_fuzz.py Normal file
View file

@ -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()