342 lines
9.3 KiB
Python
342 lines
9.3 KiB
Python
#!/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()
|