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:
Samuel Onoja authored and GitHub committed 2026-10-11 11:24:02 +01:00
1 parent b356f63b93
commit 216ef38972
6 files changed
+338 -11

No files matched your search

+84 -3
View File
@@ -1,4 +1,4 @@
name: Workspace tests and capture checks name: Workspace and native stability checks
on: on:
pull_request: pull_request:
paths: paths:
@@ -56,14 +56,34 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install -y build-essential pkg-config dbus-daemon libwayland-dev \ sudo apt-get install -y build-essential pkg-config dbus-daemon libwayland-dev \
libxkbcommon-dev xkb-data libinput-dev libudev-dev libseat-dev libgbm-dev \ libxkbcommon-dev xkb-data libinput-dev libudev-dev libseat-dev libgbm-dev \
libegl-dev libegl-mesa0 libgl1-mesa-dri mesa-vulkan-drivers libdrm-dev libfontconfig1-dev libfreetype-dev libexpat1-dev xauth \ python3-gi python3-pil libegl-dev libegl-mesa0 libgl1-mesa-dri mesa-vulkan-drivers libdrm-dev libfontconfig1-dev libfreetype-dev libexpat1-dev xauth \
clang libclang-dev libpipewire-0.3-dev libspa-0.2-dev libglib2.0-dev libgstreamer1.0-dev libpam-modules clang libclang-dev libpipewire-0.3-dev libspa-0.2-dev libglib2.0-dev libgstreamer1.0-dev libpam-modules
- name: Create private test runtime directory - name: Create private test runtime directory
run: install -d -m 0700 "$XDG_RUNTIME_DIR" run: install -d -m 0700 "$XDG_RUNTIME_DIR"
- name: Install Rust - name: Install Rust
run: rustup toolchain install 1.95.0 --profile minimal run: rustup toolchain install 1.95.0 --profile minimal --component rustfmt --component clippy
- name: Check formatting and all workspace lints
run: |
cargo +1.95.0 fmt --all --check
cargo +1.95.0 clippy --locked --workspace --all-targets -- -D warnings
- name: Build desktop binaries
run: cargo +1.95.0 build --locked --workspace
- name: Test every workspace package - name: Test every workspace package
run: cargo +1.95.0 test --locked --workspace --no-fail-fast -- --nocapture run: cargo +1.95.0 test --locked --workspace --no-fail-fast -- --nocapture
- name: Validate every Settings control against the compositor
env:
FERESE_TEST_BINARY: ${{ github.workspace }}/target/debug/ferese
run: cargo +1.95.0 test --locked -p ferese-settings all_gui_controls_and_presets_pass_compositor_validation -- --ignored --nocapture
- name: Fetch pinned upstream portal contracts
uses: actions/checkout@v6
with:
repository: flatpak/xdg-desktop-portal
ref: 23a76c392170dbbd26230f85ef56c3a57e52b857 # 1.20.3
path: build/portal-contracts
- name: Test installer, session recovery and private portal contracts
env:
FERESE_TEST_PORTAL_INTERFACES: ${{ github.workspace }}/build/portal-contracts/data
run: python3 -m unittest discover -s scripts/tests -v
- name: Test capture privacy, resize dependencies and cursor composition - name: Test capture privacy, resize dependencies and cursor composition
env: env:
LIBGL_ALWAYS_SOFTWARE: "1" LIBGL_ALWAYS_SOFTWARE: "1"
@@ -72,3 +92,64 @@ jobs:
cargo +1.95.0 test --locked -p ferese state::capture_privacy::tests -- --ignored --nocapture cargo +1.95.0 test --locked -p ferese state::capture_privacy::tests -- --ignored --nocapture
cargo +1.95.0 test --locked -p ferese backends::direct::capture::tests::capture_recomposes_cursor_without_touching_the_display_target -- --ignored --nocapture cargo +1.95.0 test --locked -p ferese backends::direct::capture::tests::capture_recomposes_cursor_without_touching_the_display_target -- --ignored --nocapture
cargo +1.95.0 test --locked -p ferese winit::tests::capture_failure_restores_display_target_and_subsequent_frames_render -- --ignored --nocapture cargo +1.95.0 test --locked -p ferese winit::tests::capture_failure_restores_display_target_and_subsequent_frames_render -- --ignored --nocapture
- name: Share desktop binaries with native checks
uses: actions/upload-artifact@v7
with:
name: native-check-binaries
path: |
target/debug/ferese
target/debug/ferese-shell
target/debug/feresectl
if-no-files-found: error
retention-days: 1
native:
name: Native panel and XWayland checks
needs: workspace
runs-on: ubuntu-24.04
timeout-minutes: 20
env:
CARGO_BUILD_JOBS: "2"
CARGO_PROFILE_DEV_DEBUG: "0"
LIBGL_ALWAYS_SOFTWARE: "1"
steps:
- uses: actions/checkout@v6
- name: Install native test dependencies
run: |
sudo apt-get update
sudo apt-get install -y weston xwayland x11-utils xauth dbus-daemon \
build-essential pkg-config clang libclang-dev libxcb1-dev libxcb-cursor-dev libxkbcommon-dev \
libwayland-dev libgbm1 libseat1 libinput10 libpipewire-0.3-0t64 \
libegl1 libegl-mesa0 libgl1-mesa-dri fonts-dejavu-core
- name: Create the shared X11 socket directory
run: sudo install -d -m 1777 -o root -g root /tmp/.X11-unix
- uses: actions/download-artifact@v8
with:
name: native-check-binaries
path: target/debug
- name: Restore executable permissions
run: chmod +x target/debug/ferese target/debug/ferese-shell target/debug/feresectl
- name: Install pinned Rust
run: rustup toolchain install 1.95.0 --profile minimal
- name: Build pinned XWayland Satellite with readiness support
uses: actions/checkout@v6
with:
repository: Supreeeme/xwayland-satellite
ref: b83eab900644e4c7c77982ce3d44cb490f0c5e1d # v0.8.3
path: build/xwayland-satellite
- name: Build XWayland Satellite
run: cargo +1.95.0 build --manifest-path build/xwayland-satellite/Cargo.toml --locked --features systemd
- name: Run isolated native checks on a headless Wayland host
env:
FERESE_TEST_BINARY: target/debug/ferese
FERESE_TEST_SHELL: target/debug/ferese-shell
FERESE_TEST_CTL: target/debug/feresectl
FERESE_TEST_SATELLITE: build/xwayland-satellite/target/debug/xwayland-satellite
run: python3 scripts/ci/run-native-checks.py --log-dir build/native-check-logs
- name: Save native test logs
if: always()
uses: actions/upload-artifact@v7
with:
name: native-check-logs
path: build/native-check-logs
if-no-files-found: warn
retention-days: 7
+66
View File
@@ -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()
+3 -1
View File
@@ -14,6 +14,7 @@ from fixtures.theme_owner import ThemeOwner
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
BINARY = REPO / os.environ.get("FERESE_TEST_PORTAL_BINARY", "target/debug/xdg-desktop-portal-ferese") 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" NAME = "org.freedesktop.impl.portal.desktop.ferese"
PATH = "/org/freedesktop/portal/desktop" 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), env = dict(os.environ, XDG_CONFIG_HOME=directory, XDG_RUNTIME_DIR=str(runtime),
WAYLAND_DISPLAY="wayland-unavailable") WAYLAND_DISPLAY="wayland-unavailable")
env.pop("WAYLAND_SOCKET", None) 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) process = subprocess.Popen([str(BINARY)], env=env, stdout=log, stderr=log, start_new_session=True)
try: try:
deadline = time.monotonic() + 15 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"): for name in ("ScreenCast", "Settings", "Screenshot", "Wallpaper", "Background", "Usb", "Lockdown", "GlobalShortcuts", "Inhibit", "InputCapture"):
interface = node.find(f"interface[@name='org.freedesktop.impl.portal.{name}']") interface = node.find(f"interface[@name='org.freedesktop.impl.portal.{name}']")
assert interface is not None, 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: for member in expected:
if member.tag not in ("method", "signal", "property"): if member.tag not in ("method", "signal", "property"):
continue continue
+33 -7
View File
@@ -10,6 +10,7 @@ from pathlib import Path
from session_socket import ipc_environment, ipc_socket from session_socket import ipc_environment, ipc_socket
import re import re
import signal import signal
import shutil
import subprocess import subprocess
import tempfile import tempfile
import time import time
@@ -194,11 +195,11 @@ class ShellMotionTest(unittest.TestCase):
"notification materials did not follow their fade") "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) 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")) modal = wait_for(lambda: layer_surface("ferese-system-modal"))
wait_for(lambda: frames(modal) >= 4) wait_for(lambda: frames(modal) >= 4)
time.sleep(.3) 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) wait_for(lambda: destroyed(modal), timeout=5)
self.assertGreater(frames(modal), 10, "modal close did not follow frames") self.assertGreater(frames(modal), 10, "modal close did not follow frames")
self.assertIsNone(compositor.poll(), log_path.read_text()[-8000:]) self.assertIsNone(compositor.poll(), log_path.read_text()[-8000:])
@@ -225,10 +226,10 @@ class ShellMotionTest(unittest.TestCase):
for _ in range(40): for _ in range(40):
subprocess.run([str(ctl), "toggle-keybinding-guide"], 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) time.sleep(.03)
subprocess.run([str(ctl), "toggle-keybinding-guide"], 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) time.sleep(.06)
# A final toggle may leave the owned modal closing. Let # A final toggle may leave the owned modal closing. Let
# that motion finish before deciding whether a live modal # 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") self.assertLessEqual(len(remaining), 1, "cancelled modals retained native surfaces")
if remaining: if remaining:
subprocess.run([str(ctl), "toggle-keybinding-guide"], 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])) wait_for(lambda: destroyed(remaining[0]))
print("Modal: rapid toggles released all native surfaces", flush=True) print("Modal: rapid toggles released all native surfaces", flush=True)
if bar_layout == "islands": if bar_layout == "islands":
for layout_name, expected_count in [("continuous", 1), ("islands", None)]: 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)) 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)) wait_for(lambda: bar_region_count(bar) == (expected_count or 2))
self.assertIsNone(compositor.poll(), log_path.read_text()[-8000:]) self.assertIsNone(compositor.poll(), log_path.read_text()[-8000:])
@@ -261,12 +262,33 @@ class ShellMotionTest(unittest.TestCase):
source = config_file.read_text() source = config_file.read_text()
config_file.write_text(re.sub( config_file.write_text(re.sub(
r'island-padding \d+', f'island-padding {padding}', source)) 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) expected_width = original_input[0][2] - 2 * (12 - padding)
wait_for(lambda: len(bar_input_rectangles(bar)) == 2 wait_for(lambda: len(bar_input_rectangles(bar)) == 2
and bar_input_rectangles(bar)[0][2] == expected_width) and bar_input_rectangles(bar)[0][2] == expected_width)
print("Bar: live padding reductions updated the input regions", flush=True) 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: finally:
for child in reversed(children): for child in reversed(children):
if child.poll() is None: if child.poll() is None:
@@ -278,6 +300,10 @@ class ShellMotionTest(unittest.TestCase):
child.wait(timeout=5) child.wait(timeout=5)
if child.stdout is not None: if child.stdout is not None:
child.stdout.close() 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__": if __name__ == "__main__":
+151
View File
@@ -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()
@@ -81,6 +81,7 @@ mod tests {
]) ])
.env(CHILD, "1") .env(CHILD, "1")
.env("XDG_RUNTIME_DIR", root.path()) .env("XDG_RUNTIME_DIR", root.path())
.env_remove("FERESE_SOCKET")
.output() .output()
.unwrap(); .unwrap();
assert!( assert!(