Files
pristontale-eu-linux/tests/test_ptlinux_gamescope_runtime.py

831 lines
29 KiB
Python

import contextlib
import hashlib
import io
import os
from pathlib import Path
import socket
import subprocess
import tempfile
import unittest
from unittest import mock
# noinspection PyProtectedMember
from ptlinux.gamescope_runtime import (
_HostFocusPublisher,
_bundled_gamescope_is_usable,
_is_cinnamon_session,
_is_kde_session,
_process_exit_status,
_read_gamescope_version,
_select_host_backend,
_select_wayland_gamescope,
_supervised_command,
_tune_process_window,
_wait_for_optimized_gamescope,
main,
resize_increments,
run_gamescope,
)
from ptlinux.gamebox_runtime import GAMESCOPE_HOST_FOCUS_STATE_ENV
from ptlinux.window_diagnostics import (
WINDOW_DIAGNOSTICS_ENV,
WINDOW_DIAGNOSTICS_SESSION_ENV,
WindowDiagnosticRecorder,
parse_window_diagnostic,
)
class GamescopeRuntimeTests(unittest.TestCase):
def test_uses_five_percent_resize_steps_with_sensible_bounds(self) -> None:
self.assertEqual(resize_increments(1280, 960), (64, 48))
self.assertEqual(resize_increments(100, 100), (8, 8))
self.assertEqual(resize_increments(7680, 4320), (96, 96))
def test_rejects_invalid_canvas_dimensions(self) -> None:
with self.assertRaisesRegex(ValueError, "positive"):
resize_increments(0, 960)
def test_inserts_selected_backend_before_existing_gamescope_options(self) -> None:
self.assertEqual(
_supervised_command(
["/usr/bin/gamescope", "-w", "1280", "--", "game"],
Path("/tmp/status"),
"wayland",
),
[
"/usr/bin/gamescope",
"--backend",
"wayland",
"-w",
"1280",
"--",
mock.ANY,
"-P",
"-m",
"ptlinux.gamescope_child_runtime",
"--status-file",
"/tmp/status",
"--",
"game",
],
)
def test_can_force_composition_for_an_unsafe_wayland_build(self) -> None:
command = _supervised_command(
["gamescope", "--", "game"],
Path("/tmp/status"),
"wayland",
force_composition=True,
)
self.assertEqual(command[:4], [
"gamescope",
"--backend",
"wayland",
"--force-composition",
])
def test_reads_the_gamescope_release_tuple(self) -> None:
completed = mock.Mock(
returncode=0,
stdout="[gamescope] gamescope version 3.16.20+ds-1 (gcc 15.2.0)\n"
)
with mock.patch(
"ptlinux.gamescope_runtime.subprocess.run",
return_value=completed,
) as run:
self.assertEqual(
_read_gamescope_version("/usr/games/gamescope"),
(3, 16, 20),
)
run.assert_called_once_with(
["/usr/games/gamescope", "--version"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
timeout=1.0,
check=False,
)
def test_selects_upstream_or_launcher_local_wayland_backing_fix(self) -> None:
with mock.patch(
"ptlinux.gamescope_runtime._read_gamescope_version",
side_effect=[(3, 16, 28), (3, 16, 20)],
), mock.patch(
"ptlinux.gamescope_runtime._bundled_gamescope_is_usable",
return_value=True,
), mock.patch(
"ptlinux.gamescope_runtime._bundled_gamescope_path",
return_value=Path("/opt/ptlinux/gamescope"),
):
self.assertEqual(
_select_wayland_gamescope("/usr/games/gamescope"),
("/usr/games/gamescope", False, False),
)
self.assertEqual(
_select_wayland_gamescope("/usr/games/gamescope"),
("/opt/ptlinux/gamescope", False, True),
)
def test_unsafe_wayland_build_uses_composed_fallback(self) -> None:
with (
mock.patch(
"ptlinux.gamescope_runtime._read_gamescope_version",
return_value=(3, 16, 20),
),
mock.patch(
"ptlinux.gamescope_runtime._bundled_gamescope_is_usable",
return_value=False,
),
):
self.assertEqual(
_select_wayland_gamescope("gamescope"),
("gamescope", True, False),
)
def test_kubuntu_keeps_every_native_wayland_build_fully_composed(self) -> None:
environment = {
"XDG_CURRENT_DESKTOP": "KDE",
"XDG_SESSION_DESKTOP": "KDE",
"DESKTOP_SESSION": "plasmawayland",
}
with (
mock.patch(
"ptlinux.gamescope_runtime._read_gamescope_version",
return_value=(3, 16, 28),
),
mock.patch(
"ptlinux.gamescope_runtime._bundled_gamescope_is_usable",
return_value=True,
) as bundled_gamescope,
):
self.assertEqual(
_select_wayland_gamescope(
"/usr/games/gamescope",
environment=environment,
),
("/usr/games/gamescope", True, False),
)
self.assertTrue(_is_kde_session(environment))
bundled_gamescope.assert_not_called()
def test_recognizes_cinnamon_session_names(self) -> None:
self.assertTrue(
_is_cinnamon_session(
{
"XDG_CURRENT_DESKTOP": "X-Cinnamon:GNOME",
"DESKTOP_SESSION": "cinnamon2d",
}
)
)
self.assertFalse(_is_cinnamon_session({"XDG_CURRENT_DESKTOP": "GNOME"}))
def test_launcher_local_gamescope_matches_its_pinned_digest(self) -> None:
with mock.patch(
"ptlinux.gamescope_runtime._bundled_gamescope_host_supported",
return_value=True,
):
self.assertTrue(_bundled_gamescope_is_usable())
def test_repairs_a_non_executable_verified_launcher_local_gamescope(
self,
) -> None:
with tempfile.TemporaryDirectory() as temporary:
executable = Path(temporary) / "gamescope"
payload = b"verified launcher-local gamescope"
executable.write_bytes(payload)
executable.chmod(0o644)
with (
mock.patch(
"ptlinux.gamescope_runtime._bundled_gamescope_host_supported",
return_value=True,
),
mock.patch(
"ptlinux.gamescope_runtime._bundled_gamescope_path",
return_value=executable,
),
mock.patch(
"ptlinux.gamescope_runtime.BUNDLED_GAMESCOPE_SHA256",
hashlib.sha256(payload).hexdigest(),
),
):
self.assertTrue(_bundled_gamescope_is_usable())
self.assertEqual(executable.stat().st_mode & 0o777, 0o755)
def test_does_not_make_an_unverified_launcher_local_gamescope_executable(
self,
) -> None:
with tempfile.TemporaryDirectory() as temporary:
executable = Path(temporary) / "gamescope"
executable.write_bytes(b"unexpected contents")
executable.chmod(0o644)
with (
mock.patch(
"ptlinux.gamescope_runtime._bundled_gamescope_host_supported",
return_value=True,
),
mock.patch(
"ptlinux.gamescope_runtime._bundled_gamescope_path",
return_value=executable,
),
mock.patch(
"ptlinux.gamescope_runtime.BUNDLED_GAMESCOPE_SHA256",
"0" * 64,
),
):
self.assertFalse(_bundled_gamescope_is_usable())
self.assertEqual(executable.stat().st_mode & 0o777, 0o644)
def test_selects_wayland_only_for_an_accessible_socket(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
socket_path = Path(temporary) / "wayland-test"
with socket.socket(socket.AF_UNIX) as wayland_socket:
wayland_socket.bind(str(socket_path))
self.assertEqual(
_select_host_backend(
{
"XDG_RUNTIME_DIR": temporary,
"WAYLAND_DISPLAY": socket_path.name,
}
),
"wayland",
)
self.assertEqual(
_select_host_backend(
{
"XDG_RUNTIME_DIR": temporary,
"WAYLAND_DISPLAY": socket_path.name,
"DISPLAY": ":1",
"XDG_CURRENT_DESKTOP": "X-Cinnamon",
}
),
"sdl",
)
self.assertEqual(
_select_host_backend(
{
"XDG_RUNTIME_DIR": temporary,
"WAYLAND_DISPLAY": socket_path.name,
"XDG_CURRENT_DESKTOP": "X-Cinnamon",
}
),
"wayland",
)
self.assertEqual(
_select_host_backend(
{
"XDG_RUNTIME_DIR": temporary,
"WAYLAND_DISPLAY": socket_path.name,
"DISPLAY": ":1",
"XDG_CURRENT_DESKTOP": "KDE",
}
),
"sdl",
)
self.assertEqual(
_select_host_backend(
{
"XDG_RUNTIME_DIR": temporary,
"WAYLAND_DISPLAY": socket_path.name,
"XDG_CURRENT_DESKTOP": "KDE",
}
),
"wayland",
)
self.assertEqual(
_select_host_backend(
{
"XDG_RUNTIME_DIR": temporary,
"WAYLAND_DISPLAY": "missing-wayland-socket",
}
),
"sdl",
)
def test_maps_signals_to_a_shell_exit_status(self) -> None:
self.assertEqual(_process_exit_status(-6), 134)
self.assertEqual(_process_exit_status(7), 7)
def test_tunes_resize_steps_and_keeps_the_host_window_composited(self) -> None:
mapper = mock.Mock()
mapper.find_window_by_pid.return_value = 41
mapper.enable_window_scaling.return_value = True
mapper.window_allows_scaling.return_value = True
mapper.set_resize_increments.return_value = True
mapper.prefer_composited_presentation.return_value = True
process = mock.Mock()
process.pid = 1234
process.poll.return_value = None
publisher = mock.Mock()
self.assertEqual(
_tune_process_window(
mapper,
process,
1280,
960,
host_focus_publisher=publisher,
),
(64, 48),
)
self.assertEqual(publisher.update.call_args_list, [
mock.call(mapper, 41),
mock.call(mapper, 41),
])
mapper.enable_window_scaling.assert_called_once_with(41, confirm=False)
mapper.window_allows_scaling.assert_called_once_with(41)
mapper.set_resize_increments.assert_called_once_with(
41,
(1280, 960),
(64, 48),
)
mapper.prefer_composited_presentation.assert_called_once_with(41)
def test_tuning_retains_hints_while_muffin_refreshes_its_frame(self) -> None:
mapper = mock.Mock()
mapper.find_window_by_pid.return_value = 41
mapper.enable_window_scaling.return_value = True
mapper.window_allows_scaling.side_effect = [False, True]
mapper.set_resize_increments.return_value = True
mapper.prefer_composited_presentation.return_value = True
process = mock.Mock(pid=1234)
process.poll.return_value = None
with (
mock.patch(
"ptlinux.gamescope_runtime.time.monotonic",
side_effect=[0.0, 0.0, 0.1],
),
mock.patch("ptlinux.gamescope_runtime.time.sleep") as sleep,
):
self.assertEqual(
_tune_process_window(mapper, process, 1280, 960),
(64, 48),
)
mapper.find_window_by_pid.assert_called_once_with(1234)
self.assertEqual(
mapper.enable_window_scaling.call_args_list,
[
mock.call(41, confirm=False),
mock.call(41, confirm=False),
],
)
sleep.assert_called_once_with(0.05)
def test_host_focus_publisher_tracks_active_and_minimized_state(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
with (
mock.patch.dict(os.environ, {"XDG_RUNTIME_DIR": temporary}),
mock.patch(
"ptlinux.gamescope_runtime.time.monotonic",
side_effect=[10.0, 10.1, 10.2],
),
):
publisher = _HostFocusPublisher.create()
try:
mapper = mock.Mock()
mapper.window_state.return_value = 1
mapper.window_is_active.return_value = True
publisher.update(mapper, 41)
self.assertEqual(
publisher.path.read_text(encoding="ascii").split()[0],
"1",
)
mapper.window_state.return_value = 3
publisher.update(mapper, 41)
self.assertEqual(
publisher.path.read_text(encoding="ascii").split()[0],
"0",
)
finally:
state_path = publisher.path
publisher.close()
self.assertFalse(state_path.exists())
def test_host_focus_publisher_preserves_an_inactive_minimized_remap(
self,
) -> None:
with tempfile.TemporaryDirectory() as temporary:
with (
mock.patch.dict(os.environ, {"XDG_RUNTIME_DIR": temporary}),
mock.patch(
"ptlinux.gamescope_runtime.time.monotonic",
side_effect=[10.0, 10.1, 10.2, 10.3, 10.4],
),
):
publisher = _HostFocusPublisher.create()
try:
mapper = mock.Mock()
mapper.window_state.side_effect = [1, 3, 1, 1]
mapper.window_is_active.side_effect = [True, False, True]
publisher.update(mapper, 41)
publisher.update(mapper, 41)
publisher.update(mapper, 41)
mapper.minimize_window.assert_called_once_with(41)
self.assertEqual(
publisher.path.read_text(encoding="ascii").split()[0],
"0",
)
publisher.update(mapper, 41)
self.assertEqual(
publisher.path.read_text(encoding="ascii").split()[0],
"1",
)
mapper.minimize_window.assert_called_once_with(41)
finally:
publisher.close()
def test_host_focus_diagnostics_capture_the_minimize_remap_cycle(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
environment = {
"XDG_RUNTIME_DIR": temporary,
WINDOW_DIAGNOSTICS_ENV: "1",
WINDOW_DIAGNOSTICS_SESSION_ENV: "b" * 32,
}
output = io.StringIO()
with (
mock.patch.dict(os.environ, environment, clear=True),
mock.patch(
"ptlinux.gamescope_runtime.time.monotonic",
side_effect=[10.0, 10.1, 10.2, 10.4],
),
contextlib.redirect_stderr(output),
):
diagnostics = WindowDiagnosticRecorder(
"gamescope_host",
environment=environment,
monotonic=lambda: 20.0,
)
publisher = _HostFocusPublisher.create(diagnostics=diagnostics)
summary: dict[str, object] = {}
try:
mapper = mock.Mock()
mapper.window_state.side_effect = [1, 3, 1]
mapper.window_is_active.side_effect = [True, False]
mapper.minimize_window.return_value = True
mapper.window_diagnostic_snapshot.return_value = {
"wm_state": "normal",
"allows_resize": True,
"allows_maximize_horizontal": False,
"allows_maximize_vertical": False,
}
mapper.window_net_wm_state_flags.side_effect = [
{
"hidden": False,
"above": False,
"demands_attention": False,
"maximized_horizontal": False,
"maximized_vertical": False,
},
{
"hidden": True,
"above": False,
"demands_attention": False,
"maximized_horizontal": False,
"maximized_vertical": False,
},
{
"hidden": False,
"above": False,
"demands_attention": False,
"maximized_horizontal": False,
"maximized_vertical": False,
},
]
publisher.update(mapper, 41)
publisher.update(mapper, 41)
publisher.update(mapper, 41)
summary = publisher.summary_details()
finally:
publisher.close()
diagnostics.close(**summary)
payloads = [
parse_window_diagnostic(line)
for line in output.getvalue().splitlines()
]
diagnostics_by_name = {
payload["diagnostic"]: payload
for payload in payloads
if payload is not None
}
self.assertIn("host_minimize_latched", diagnostics_by_name)
self.assertIn("host_hidden_state_cleared", diagnostics_by_name)
remap = diagnostics_by_name["host_inactive_remap"]
details = remap["details"]
assert isinstance(details, dict)
self.assertTrue(details["minimize_request_accepted"])
self.assertFalse(details["allows_maximize_horizontal"])
self.assertEqual(summary["inactive_remaps"], 1)
self.assertEqual(summary["hidden_state_clears"], 1)
self.assertEqual(summary["reiconify_accepted"], 1)
def test_optimized_wait_refreshes_host_focus_before_returning(self) -> None:
process = mock.Mock(pid=1234)
process.poll.return_value = 0
mapper = mock.Mock()
mapper.find_window_by_pid.return_value = 41
publisher = mock.Mock()
self.assertEqual(
_wait_for_optimized_gamescope(
process,
None,
host_focus_publisher=publisher,
host_mapper=mapper,
),
1,
)
publisher.update.assert_called_once_with(mapper, 41)
def test_optimized_wait_returns_the_nested_child_status(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
status_file = Path(temporary) / "status"
status_file.write_text("0\n", encoding="ascii")
process = mock.Mock()
process.poll.return_value = None
self.assertEqual(_wait_for_optimized_gamescope(process, status_file), 0)
process.terminate.assert_called_once_with()
process.kill.assert_not_called()
process.wait.assert_called_once_with(timeout=2.0)
@mock.patch("ptlinux.gamescope_runtime.subprocess.Popen")
@mock.patch(
"ptlinux.gamescope_runtime._tune_process_window",
return_value=(64, 48),
)
@mock.patch("ptlinux.gamescope_runtime._open_host_x11")
@mock.patch(
"ptlinux.gamescope_runtime._select_host_backend",
return_value="sdl",
)
def test_x11_path_tunes_the_window_and_preserves_child_status(
self,
_select_host_backend: mock.Mock,
open_host_x11: mock.Mock,
tune_process_window: mock.Mock,
popen: mock.Mock,
) -> None:
mapper = open_host_x11.return_value
process = popen.return_value
process.poll.return_value = 7
stderr = io.StringIO()
with (
mock.patch.dict(os.environ, {"DISPLAY": ":0"}, clear=True),
contextlib.redirect_stderr(stderr),
mock.patch(
"ptlinux.gamescope_runtime._read_child_status",
return_value=7,
),
):
status = run_gamescope(["gamescope", "--", "game"], 1280, 960)
self.assertEqual(status, 7)
self.assertEqual(
popen.call_args.args[0],
[
"gamescope",
"--backend",
"sdl",
"--force-composition",
"--",
mock.ANY,
"-P",
"-m",
"ptlinux.gamescope_child_runtime",
"--status-file",
mock.ANY,
"--",
"game",
],
)
self.assertEqual(popen.call_args.kwargs["env"]["SDL_VIDEODRIVER"], "x11")
self.assertEqual(
popen.call_args.kwargs["env"][
"SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR"
],
"0",
)
self.assertIn(
GAMESCOPE_HOST_FOCUS_STATE_ENV,
popen.call_args.kwargs["env"],
)
tune_process_window.assert_called_once_with(
mapper,
process,
1280,
960,
host_focus_publisher=mock.ANY,
)
mapper.close.assert_called_once_with()
self.assertIn("64x48", stderr.getvalue())
@mock.patch("ptlinux.gamescope_runtime.subprocess.Popen")
@mock.patch("ptlinux.gamescope_runtime._open_host_x11", return_value=None)
@mock.patch(
"ptlinux.gamescope_runtime._select_host_backend",
return_value="sdl",
)
def test_x11_path_disables_nested_focus_repair_without_host_monitor(
self,
_select_host_backend: mock.Mock,
_open_host_x11: mock.Mock,
popen: mock.Mock,
) -> None:
process = popen.return_value
process.poll.return_value = 0
with (
mock.patch.dict(os.environ, {"DISPLAY": ":0"}, clear=True),
contextlib.redirect_stderr(io.StringIO()),
mock.patch(
"ptlinux.gamescope_runtime._read_child_status",
return_value=0,
),
):
self.assertEqual(
run_gamescope(["gamescope", "--", "game"], 1280, 960),
0,
)
self.assertEqual(
popen.call_args.kwargs["env"][GAMESCOPE_HOST_FOCUS_STATE_ENV],
"unavailable",
)
@mock.patch("ptlinux.gamescope_runtime.subprocess.Popen")
@mock.patch("ptlinux.gamescope_runtime._open_host_x11")
@mock.patch(
"ptlinux.gamescope_runtime._select_wayland_gamescope",
return_value=("/opt/ptlinux/gamescope", False, True),
)
@mock.patch(
"ptlinux.gamescope_runtime._select_host_backend",
return_value="wayland",
)
def test_wayland_path_avoids_sdl_and_preserves_child_status(
self,
_select_host_backend: mock.Mock,
select_wayland_gamescope: mock.Mock,
open_host_x11: mock.Mock,
popen: mock.Mock,
) -> None:
process = popen.return_value
process.poll.return_value = 0
stderr = io.StringIO()
with (
mock.patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}, clear=True),
contextlib.redirect_stderr(stderr),
mock.patch(
"ptlinux.gamescope_runtime._read_child_status",
return_value=0,
),
):
status = run_gamescope(["gamescope", "--", "game"], 1280, 960)
self.assertEqual(status, 0)
self.assertEqual(
popen.call_args.args[0],
[
"/opt/ptlinux/gamescope",
"--backend",
"wayland",
"--",
mock.ANY,
"-P",
"-m",
"ptlinux.gamescope_child_runtime",
"--status-file",
mock.ANY,
"--",
"game",
],
)
self.assertNotIn("SDL_VIDEODRIVER", popen.call_args.kwargs["env"])
self.assertNotIn(
"SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR",
popen.call_args.kwargs["env"],
)
self.assertEqual(
popen.call_args.kwargs["env"][
"PTLINUX_GAMESCOPE_KEEP_WINDOWS_VISIBLE"
],
"1",
)
open_host_x11.assert_not_called()
select_wayland_gamescope.assert_called_once_with(
"gamescope",
environment=mock.ANY,
)
self.assertIn("native Wayland", stderr.getvalue())
self.assertIn("lazy-output", stderr.getvalue())
self.assertIn("login-window visibility", stderr.getvalue())
@mock.patch("ptlinux.gamescope_runtime.subprocess.Popen")
@mock.patch(
"ptlinux.gamescope_runtime._tune_process_window",
return_value=(64, 48),
)
@mock.patch("ptlinux.gamescope_runtime._open_host_x11")
@mock.patch(
"ptlinux.gamescope_runtime._select_host_backend",
return_value="sdl",
)
def test_kubuntu_wayland_uses_xwayland_presentation_and_reports_policy(
self,
_select_host_backend: mock.Mock,
open_host_x11: mock.Mock,
tune_process_window: mock.Mock,
popen: mock.Mock,
) -> None:
process = popen.return_value
process.poll.return_value = 0
environment = {
"WAYLAND_DISPLAY": "wayland-0",
"DISPLAY": ":1",
"XDG_CURRENT_DESKTOP": "KDE",
"DESKTOP_SESSION": "plasmawayland",
WINDOW_DIAGNOSTICS_ENV: "1",
WINDOW_DIAGNOSTICS_SESSION_ENV: "c" * 32,
}
stderr = io.StringIO()
with (
mock.patch.dict(os.environ, environment, clear=True),
mock.patch(
"ptlinux.gamescope_runtime._read_child_status",
return_value=0,
),
contextlib.redirect_stderr(stderr),
):
status = run_gamescope(["gamescope", "--", "game"], 1280, 960)
self.assertEqual(status, 0)
self.assertEqual(
popen.call_args.args[0][:4],
[
"gamescope",
"--backend",
"sdl",
"--force-composition",
],
)
self.assertNotIn(
"PTLINUX_GAMESCOPE_KEEP_WINDOWS_VISIBLE",
popen.call_args.kwargs["env"],
)
open_host_x11.assert_called_once_with()
tune_process_window.assert_called_once()
self.assertIn("KDE Wayland compatibility mode active", stderr.getvalue())
payloads = [
parse_window_diagnostic(line)
for line in stderr.getvalue().splitlines()
]
runtime_configured = next(
payload
for payload in payloads
if payload is not None and payload["diagnostic"] == "runtime_configured"
)
details = runtime_configured["details"]
assert isinstance(details, dict)
self.assertTrue(details["kde_session"])
self.assertTrue(details["kde_wayland_compatibility_mode"])
self.assertTrue(details["kde_wayland_xwayland_fallback"])
self.assertEqual(
details["backend_reason"],
"kde_wayland_xwayland_compatibility",
)
self.assertEqual(details["presentation_mode"], "sdl_composed")
def test_main_reports_an_empty_command(self) -> None:
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
status = main(["--width", "1280", "--height", "960"])
self.assertEqual(status, 1)
self.assertIn("cannot be empty", stderr.getvalue())
if __name__ == "__main__":
unittest.main()