Add pre-merge native stability checks (#21)
* Add pre-merge native stability checks * Install native binding generator dependencies explicitly * Pin portal contract fixtures for reproducible CI
This commit is contained in:
1 parent
b356f63b93
commit
216ef38972
6 files changed
+338
-11
No files matched your search
@@ -0,0 +1,66 @@
|
||||
"""Run native regressions against a private, headless Weston instance."""
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--log-dir", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
logs = args.log_dir.resolve()
|
||||
logs.mkdir(parents=True, exist_ok=True)
|
||||
repo = Path(__file__).resolve().parents[2]
|
||||
|
||||
# No inherited session endpoints or activation settings reach the test host.
|
||||
env = {key: value for key, value in os.environ.items()
|
||||
if not key.startswith("FERESE_") or key.startswith("FERESE_TEST_")}
|
||||
for key in ("DISPLAY", "WAYLAND_DISPLAY", "WAYLAND_SOCKET", "XAUTHORITY",
|
||||
"DBUS_SESSION_BUS_ADDRESS", "DBUS_SESSION_BUS_PID", "EGL_PLATFORM"):
|
||||
env.pop(key, None)
|
||||
env.update(LIBGL_ALWAYS_SOFTWARE="1", FERESE_TEST_LOG_DIR=str(logs))
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="ferese-native-host-") as temporary:
|
||||
root = Path(temporary)
|
||||
runtime = root / "runtime"
|
||||
runtime.mkdir(mode=0o700)
|
||||
env.update(XDG_RUNTIME_DIR=str(runtime), WAYLAND_DISPLAY="ferese-test-host",
|
||||
XDG_CONFIG_HOME=str(root / "config"), XDG_STATE_HOME=str(root / "state"),
|
||||
XDG_CACHE_HOME=str(root / "cache"))
|
||||
with (logs / "weston.log").open("w") as log:
|
||||
host = subprocess.Popen([
|
||||
env.get("FERESE_TEST_WESTON", "weston"), "--backend=headless-backend.so",
|
||||
"--renderer=pixman", "--width=1600", "--height=1000",
|
||||
"--socket=ferese-test-host", "--idle-time=0", "--no-config",
|
||||
], env=env, stdout=log, stderr=log, start_new_session=True)
|
||||
try:
|
||||
deadline = time.monotonic() + 15
|
||||
while not (runtime / env["WAYLAND_DISPLAY"]).is_socket():
|
||||
if host.poll() is not None or time.monotonic() >= deadline:
|
||||
raise RuntimeError((logs / "weston.log").read_text())
|
||||
time.sleep(.05)
|
||||
for layout in ("continuous", "islands"):
|
||||
subprocess.run([
|
||||
sys.executable, "scripts/tests/test_shell_motion_isolated.py", "-v",
|
||||
], cwd=repo, env=dict(env, FERESE_TEST_SHELL_MOTION="1",
|
||||
FERESE_TEST_BAR_LAYOUT=layout), check=True)
|
||||
subprocess.run([
|
||||
sys.executable, "scripts/tests/test_xwayland_isolated.py", "-v",
|
||||
], cwd=repo, env=dict(env, FERESE_TEST_XWAYLAND="1"), check=True)
|
||||
finally:
|
||||
if host.poll() is None:
|
||||
os.killpg(host.pid, signal.SIGTERM)
|
||||
try:
|
||||
host.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(host.pid, signal.SIGKILL)
|
||||
host.wait(timeout=5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -14,6 +14,7 @@ from fixtures.theme_owner import ThemeOwner
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
BINARY = REPO / os.environ.get("FERESE_TEST_PORTAL_BINARY", "target/debug/xdg-desktop-portal-ferese")
|
||||
INTERFACES = Path(os.environ.get("FERESE_TEST_PORTAL_INTERFACES", "/usr/share/dbus-1/interfaces"))
|
||||
NAME = "org.freedesktop.impl.portal.desktop.ferese"
|
||||
PATH = "/org/freedesktop/portal/desktop"
|
||||
|
||||
@@ -59,6 +60,7 @@ def private_bus_checks(standalone=False):
|
||||
env = dict(os.environ, XDG_CONFIG_HOME=directory, XDG_RUNTIME_DIR=str(runtime),
|
||||
WAYLAND_DISPLAY="wayland-unavailable")
|
||||
env.pop("WAYLAND_SOCKET", None)
|
||||
env.pop("FERESE_SOCKET", None)
|
||||
process = subprocess.Popen([str(BINARY)], env=env, stdout=log, stderr=log, start_new_session=True)
|
||||
try:
|
||||
deadline = time.monotonic() + 15
|
||||
@@ -74,7 +76,7 @@ def private_bus_checks(standalone=False):
|
||||
for name in ("ScreenCast", "Settings", "Screenshot", "Wallpaper", "Background", "Usb", "Lockdown", "GlobalShortcuts", "Inhibit", "InputCapture"):
|
||||
interface = node.find(f"interface[@name='org.freedesktop.impl.portal.{name}']")
|
||||
assert interface is not None, name
|
||||
expected = ET.parse(f"/usr/share/dbus-1/interfaces/org.freedesktop.impl.portal.{name}.xml").getroot().find("interface")
|
||||
expected = ET.parse(INTERFACES / f"org.freedesktop.impl.portal.{name}.xml").getroot().find("interface")
|
||||
for member in expected:
|
||||
if member.tag not in ("method", "signal", "property"):
|
||||
continue
|
||||
|
||||
@@ -10,6 +10,7 @@ from pathlib import Path
|
||||
from session_socket import ipc_environment, ipc_socket
|
||||
import re
|
||||
import signal
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
@@ -194,11 +195,11 @@ class ShellMotionTest(unittest.TestCase):
|
||||
"notification materials did not follow their fade")
|
||||
print(f"Toast: {opening_frames} opening callback requests, {idle_frames / 2:g} settled commits/s; destroyed", flush=True)
|
||||
|
||||
subprocess.run([str(ctl), "toggle-keybinding-guide"], env=ipc_environment(env), check=True, capture_output=True)
|
||||
subprocess.run([str(ctl), "toggle-keybinding-guide"], env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
||||
modal = wait_for(lambda: layer_surface("ferese-system-modal"))
|
||||
wait_for(lambda: frames(modal) >= 4)
|
||||
time.sleep(.3)
|
||||
subprocess.run([str(ctl), "toggle-keybinding-guide"], env=ipc_environment(env), check=True, capture_output=True)
|
||||
subprocess.run([str(ctl), "toggle-keybinding-guide"], env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
||||
wait_for(lambda: destroyed(modal), timeout=5)
|
||||
self.assertGreater(frames(modal), 10, "modal close did not follow frames")
|
||||
self.assertIsNone(compositor.poll(), log_path.read_text()[-8000:])
|
||||
@@ -225,10 +226,10 @@ class ShellMotionTest(unittest.TestCase):
|
||||
|
||||
for _ in range(40):
|
||||
subprocess.run([str(ctl), "toggle-keybinding-guide"],
|
||||
env=ipc_environment(env), check=True, capture_output=True)
|
||||
env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
||||
time.sleep(.03)
|
||||
subprocess.run([str(ctl), "toggle-keybinding-guide"],
|
||||
env=ipc_environment(env), check=True, capture_output=True)
|
||||
env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
||||
time.sleep(.06)
|
||||
# A final toggle may leave the owned modal closing. Let
|
||||
# that motion finish before deciding whether a live modal
|
||||
@@ -243,14 +244,14 @@ class ShellMotionTest(unittest.TestCase):
|
||||
self.assertLessEqual(len(remaining), 1, "cancelled modals retained native surfaces")
|
||||
if remaining:
|
||||
subprocess.run([str(ctl), "toggle-keybinding-guide"],
|
||||
env=ipc_environment(env), check=True, capture_output=True)
|
||||
env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
||||
wait_for(lambda: destroyed(remaining[0]))
|
||||
print("Modal: rapid toggles released all native surfaces", flush=True)
|
||||
|
||||
if bar_layout == "islands":
|
||||
for layout_name, expected_count in [("continuous", 1), ("islands", None)]:
|
||||
config_file.write_text('animations { speed 0.5; }\nstatus { keybinding-guide #false; }\n' + panel_config(layout_name))
|
||||
subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True)
|
||||
subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
||||
wait_for(lambda: bar_region_count(bar) == (expected_count or 2))
|
||||
|
||||
self.assertIsNone(compositor.poll(), log_path.read_text()[-8000:])
|
||||
@@ -261,12 +262,33 @@ class ShellMotionTest(unittest.TestCase):
|
||||
source = config_file.read_text()
|
||||
config_file.write_text(re.sub(
|
||||
r'island-padding \d+', f'island-padding {padding}', source))
|
||||
subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True)
|
||||
subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
||||
expected_width = original_input[0][2] - 2 * (12 - padding)
|
||||
wait_for(lambda: len(bar_input_rectangles(bar)) == 2
|
||||
and bar_input_rectangles(bar)[0][2] == expected_width)
|
||||
|
||||
print("Bar: live padding reductions updated the input regions", flush=True)
|
||||
saved = config_file.read_text()
|
||||
config_file.write_text('panel main { surface "invalid-surface"; }\n')
|
||||
rejected = subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env),
|
||||
capture_output=True, text=True, timeout=5)
|
||||
self.assertNotEqual(rejected.returncode, 0, "invalid reload was accepted")
|
||||
self.assertIn("invalid_config", rejected.stderr)
|
||||
self.assertFalse(destroyed(bar), "invalid reload destroyed the current panel")
|
||||
self.assertEqual(dbus("GetServerInformation", check=False).returncode, 0)
|
||||
config_file.write_text(saved)
|
||||
subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env),
|
||||
check=True, capture_output=True, timeout=5)
|
||||
subprocess.run([str(ctl), "toggle-keybinding-guide"], env=ipc_environment(env),
|
||||
check=True, capture_output=True, timeout=5)
|
||||
recovered = wait_for(lambda: (surface := layer_surface("ferese-system-modal"))
|
||||
and not destroyed(surface) and surface)
|
||||
subprocess.run([str(ctl), "toggle-keybinding-guide"], env=ipc_environment(env),
|
||||
check=True, capture_output=True, timeout=5)
|
||||
wait_for(lambda: destroyed(recovered))
|
||||
self.assertIsNone(compositor.poll(), log_path.read_text()[-8000:])
|
||||
self.assertNotIn("panicked at", log_path.read_text())
|
||||
print("Reload: invalid config preserved the panel; repaired config reopened and released a modal", flush=True)
|
||||
finally:
|
||||
for child in reversed(children):
|
||||
if child.poll() is None:
|
||||
@@ -278,6 +300,10 @@ class ShellMotionTest(unittest.TestCase):
|
||||
child.wait(timeout=5)
|
||||
if child.stdout is not None:
|
||||
child.stdout.close()
|
||||
if directory := os.environ.get("FERESE_TEST_LOG_DIR"):
|
||||
logs = Path(directory)
|
||||
logs.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(log_path, logs / f"shell-{bar_layout}.log")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Verify real on-demand/eager XWayland startup and teardown in a nested session.
|
||||
|
||||
FERESE_TEST_XWAYLAND=1 python3 scripts/tests/test_xwayland_isolated.py
|
||||
Requires a Wayland host, Xwayland, xdpyinfo and Satellite built with systemd support.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from session_socket import ipc_environment, ipc_socket
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get("FERESE_TEST_XWAYLAND") == "1", "opt-in nested XWayland test")
|
||||
class XwaylandTest(unittest.TestCase):
|
||||
def test_startup_authorization_and_owned_process_cleanup(self):
|
||||
repo = Path(__file__).resolve().parents[2]
|
||||
binary = repo / os.environ.get("FERESE_TEST_BINARY", "target/release/ferese")
|
||||
ctl = repo / os.environ.get("FERESE_TEST_CTL", "target/release/feresectl")
|
||||
satellite = os.environ.get("FERESE_TEST_SATELLITE") or shutil.which("xwayland-satellite")
|
||||
self.assertIsNotNone(satellite, "Satellite must be installed; build it with --features systemd")
|
||||
satellite = str(Path(satellite).resolve())
|
||||
self.assertTrue(Path(satellite).is_file(), f"Satellite does not exist: {satellite}")
|
||||
for mode in ("on-demand", "eager"):
|
||||
with self.subTest(mode=mode), tempfile.TemporaryDirectory(prefix="ferese-xwayland-") as temporary:
|
||||
root = Path(temporary)
|
||||
runtime = root / "runtime"
|
||||
runtime.mkdir(mode=0o700)
|
||||
config = root / "config/ferese"
|
||||
config.mkdir(parents=True)
|
||||
(config / "config.kdl").write_text(
|
||||
f'xwayland {{ startup "{mode}"; path {json.dumps(satellite)}; }}\n')
|
||||
display = Path(os.environ["WAYLAND_DISPLAY"])
|
||||
if not display.is_absolute():
|
||||
display = Path(os.environ["XDG_RUNTIME_DIR"]) / display
|
||||
env = {key: value for key, value in os.environ.items()
|
||||
if not key.startswith("FERESE_") or key.startswith("FERESE_TEST_")}
|
||||
for key in ("WAYLAND_SOCKET", "DISPLAY", "XAUTHORITY", "DBUS_SESSION_BUS_ADDRESS"):
|
||||
env.pop(key, None)
|
||||
env.update(XDG_RUNTIME_DIR=str(runtime), WAYLAND_DISPLAY=str(display),
|
||||
XDG_CONFIG_HOME=str(root / "config"), XDG_STATE_HOME=str(root / "state"),
|
||||
XDG_CACHE_HOME=str(root / "cache"))
|
||||
# This managed child receives Ferese's reserved display and cookie.
|
||||
# Waiting for a gate lets us prove on-demand stays idle until an X client connects.
|
||||
client = root / "client.py"
|
||||
client.write_text('''import json, os
|
||||
from pathlib import Path
|
||||
import subprocess, time
|
||||
root = Path(__file__).parent
|
||||
def publish(name, value):
|
||||
temporary = root / (name + ".tmp")
|
||||
temporary.write_text(json.dumps(value))
|
||||
temporary.replace(root / name)
|
||||
publish("endpoint.json", {
|
||||
"display": os.environ["DISPLAY"], "authority": os.environ["XAUTHORITY"]})
|
||||
deadline = time.monotonic() + 30
|
||||
while not (root / "connect").exists():
|
||||
if time.monotonic() >= deadline:
|
||||
raise SystemExit("X client was never released")
|
||||
time.sleep(.025)
|
||||
result = subprocess.run(["xdpyinfo"], capture_output=True, text=True, timeout=15)
|
||||
publish("client-result.json", {
|
||||
"code": result.returncode, "stdout": result.stdout, "stderr": result.stderr})
|
||||
''')
|
||||
log_path = root / "compositor.log"
|
||||
with log_path.open("w") as log:
|
||||
compositor = subprocess.Popen([
|
||||
str(binary), "--backend", "nested", "--", sys.executable, str(client),
|
||||
], env=ipc_environment(env), stdout=log, stderr=log, start_new_session=True)
|
||||
|
||||
def wait_for(predicate, timeout=15):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
value = predicate()
|
||||
if value:
|
||||
return value
|
||||
if compositor.poll() is not None:
|
||||
break
|
||||
time.sleep(.025)
|
||||
self.fail(log_path.read_text()[-12000:])
|
||||
|
||||
def command(*args):
|
||||
return subprocess.run([str(ctl), "-j", *args], env=ipc_environment(env),
|
||||
check=True, capture_output=True, text=True, timeout=5)
|
||||
|
||||
def status_is(state):
|
||||
status = json.loads(command("xwayland", "status").stdout)
|
||||
self.assertNotEqual(status["state"], "failed", status["last_error"])
|
||||
return status if status["state"] == state else None
|
||||
|
||||
try:
|
||||
wait_for(lambda: ipc_socket(runtime).is_socket())
|
||||
initial = wait_for(lambda: status_is("idle" if mode == "on-demand" else "running"))
|
||||
self.assertEqual(initial["effective_startup"], mode)
|
||||
wait_for(lambda: (root / "endpoint.json").exists())
|
||||
endpoint = json.loads((root / "endpoint.json").read_text())
|
||||
self.assertEqual(endpoint["display"], initial["display"])
|
||||
self.assertTrue(Path(endpoint["authority"]).is_relative_to(runtime))
|
||||
(root / "connect").touch()
|
||||
running = wait_for(lambda: status_is("running"), timeout=10)
|
||||
self.assertEqual(running["readiness"], "verified")
|
||||
self.assertEqual(running["recent_failures"], 0)
|
||||
wait_for(lambda: (root / "client-result.json").exists())
|
||||
result = json.loads((root / "client-result.json").read_text())
|
||||
self.assertEqual(result["code"], 0, result["stderr"])
|
||||
self.assertIn("X.Org", result["stdout"])
|
||||
# Record the complete owned service group before requesting a clean exit.
|
||||
leader = running["satellite_pid"]
|
||||
members = []
|
||||
for proc in Path("/proc").iterdir():
|
||||
if not proc.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
stat = (proc / "stat").read_text()
|
||||
if int(stat[stat.rfind(")") + 1:].split()[2]) == leader:
|
||||
members.append(proc)
|
||||
except (FileNotFoundError, ProcessLookupError, PermissionError):
|
||||
continue
|
||||
self.assertGreaterEqual(len(members), 2, "Satellite and Xwayland must both be running")
|
||||
command("exit")
|
||||
compositor.wait(timeout=10)
|
||||
self.assertEqual(compositor.returncode, 0, log_path.read_text())
|
||||
self.assertFalse(any(proc.exists() for proc in members), "owned X11 processes survived shutdown")
|
||||
self.assertFalse(Path(endpoint["authority"]).exists(), "Xauthority survived shutdown")
|
||||
slot = endpoint["display"].removeprefix(":")
|
||||
self.assertFalse(Path(f"/tmp/.X11-unix/X{slot}").exists(), "X11 socket survived shutdown")
|
||||
self.assertFalse(Path(f"/tmp/.X{slot}-lock").exists(), "X11 display lock survived shutdown")
|
||||
self.assertNotIn("panicked at", log_path.read_text())
|
||||
print(f"XWayland: {mode} startup, authorized client and complete teardown passed", flush=True)
|
||||
finally:
|
||||
if compositor.poll() is None:
|
||||
os.killpg(compositor.pid, signal.SIGTERM)
|
||||
try:
|
||||
compositor.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(compositor.pid, signal.SIGKILL)
|
||||
compositor.wait(timeout=5)
|
||||
if directory := os.environ.get("FERESE_TEST_LOG_DIR"):
|
||||
logs = Path(directory)
|
||||
logs.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(log_path, logs / f"xwayland-{mode}.log")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user