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
@@ -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
|
||||||
@@ -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]
|
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
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
Reference in new issue
Block a user