Release 1.1.3 with game window and Gamescope lifecycle fixes
This commit is contained in:
214
tests/test_dashboard_operation_controller.py
Normal file
214
tests/test_dashboard_operation_controller.py
Normal file
@@ -0,0 +1,214 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from ptlinux.ui.controllers.operation import start_operation_process
|
||||
from ptlinux.ui.viewmodels.downloader import ProgressCoalescer
|
||||
|
||||
|
||||
class _InlineThread:
|
||||
def __init__(self, *, target, name: str, daemon: bool) -> None:
|
||||
self._target = target
|
||||
self.name = name
|
||||
self.daemon = daemon
|
||||
|
||||
def start(self) -> None:
|
||||
self._target()
|
||||
|
||||
|
||||
class _Process:
|
||||
def __init__(self, lines: list[str], returncode: int = 0, pid: int = 4321) -> None:
|
||||
self.stdout = iter(lines)
|
||||
self.returncode = returncode
|
||||
self.pid = pid
|
||||
|
||||
def wait(self) -> int:
|
||||
return self.returncode
|
||||
|
||||
|
||||
def _drain(events: queue.Queue[tuple[str, object]]) -> list[tuple[str, object]]:
|
||||
values: list[tuple[str, object]] = []
|
||||
while not events.empty():
|
||||
values.append(events.get_nowait())
|
||||
return values
|
||||
|
||||
|
||||
class DashboardOperationControllerTests(unittest.TestCase):
|
||||
def test_routes_status_progress_output_and_completion(self) -> None:
|
||||
events: queue.Queue[tuple[str, object]] = queue.Queue()
|
||||
progress = ProgressCoalescer()
|
||||
registry = MagicMock()
|
||||
process = _Process(
|
||||
[
|
||||
'STATUS {"step": 1, "total": 3, "message": "Starting"}\n',
|
||||
'PROGRESS {"phase": "downloading", "bytes_completed": 8}\n',
|
||||
"plain output\n",
|
||||
"STATUS invalid\n",
|
||||
],
|
||||
returncode=7,
|
||||
)
|
||||
seen_process_args: list[tuple[list[str], dict[str, object]]] = []
|
||||
|
||||
def process_factory(command: list[str], **kwargs: object) -> _Process:
|
||||
seen_process_args.append((command, kwargs))
|
||||
return process
|
||||
|
||||
start_operation_process(
|
||||
["launcher", "update"],
|
||||
events=events,
|
||||
progress_updates=progress,
|
||||
game_launch_supervisors=registry,
|
||||
child_environment=lambda: {"ROLE": "operation"},
|
||||
interrupt_supervisor=MagicMock(),
|
||||
process_factory=process_factory,
|
||||
thread_factory=_InlineThread,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
_drain(events),
|
||||
[
|
||||
(
|
||||
"operation_status",
|
||||
{"step": 1, "total": 3, "message": "Starting"},
|
||||
),
|
||||
("output", "plain output\n"),
|
||||
("output", "STATUS invalid\n"),
|
||||
("done", 7),
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
progress.take(),
|
||||
{"phase": "downloading", "bytes_completed": 8},
|
||||
)
|
||||
self.assertEqual(seen_process_args[0][0], ["launcher", "update"])
|
||||
self.assertEqual(seen_process_args[0][1]["env"], {"ROLE": "operation"})
|
||||
self.assertEqual(seen_process_args[0][1]["stdout"], subprocess.PIPE)
|
||||
self.assertEqual(seen_process_args[0][1]["stderr"], subprocess.STDOUT)
|
||||
|
||||
def test_routes_game_readiness_and_cancellation_race(self) -> None:
|
||||
events: queue.Queue[tuple[str, object]] = queue.Queue()
|
||||
registry = MagicMock()
|
||||
registry.is_cancel_requested.return_value = False
|
||||
registry.attach.return_value = True
|
||||
process = _Process(
|
||||
[
|
||||
"Game window created; preparing it\n",
|
||||
"Game window ready: PristonTale EU\n",
|
||||
],
|
||||
)
|
||||
interrupt = MagicMock(return_value=True)
|
||||
|
||||
start_operation_process(
|
||||
["launcher", "play"],
|
||||
events=events,
|
||||
progress_updates=ProgressCoalescer(),
|
||||
game_launch_supervisors=registry,
|
||||
child_environment=dict,
|
||||
interrupt_supervisor=interrupt,
|
||||
game_client_id=3,
|
||||
process_factory=lambda *_args, **_kwargs: process,
|
||||
thread_factory=_InlineThread,
|
||||
)
|
||||
|
||||
kinds = [kind for kind, _value in _drain(events)]
|
||||
self.assertEqual(
|
||||
kinds,
|
||||
[
|
||||
"game_supervisor_started",
|
||||
"output",
|
||||
"output",
|
||||
"game_operation_status",
|
||||
"game_started",
|
||||
"output",
|
||||
"game_window_ready",
|
||||
"game_done",
|
||||
],
|
||||
)
|
||||
registry.attach.assert_called_once_with(3, process)
|
||||
registry.detach.assert_called_once_with(3, process)
|
||||
interrupt.assert_called_once_with(process)
|
||||
|
||||
def test_unmonitored_process_releases_controls_without_a_window_ready_event(self) -> None:
|
||||
for line in (
|
||||
"Game window monitoring unavailable; process started: PristonTale EU\n",
|
||||
"Game window ready: unmanaged PristonTale EU\n",
|
||||
):
|
||||
with self.subTest(line=line):
|
||||
events: queue.Queue[tuple[str, object]] = queue.Queue()
|
||||
registry = MagicMock()
|
||||
registry.is_cancel_requested.return_value = False
|
||||
registry.attach.return_value = False
|
||||
process = _Process([line], returncode=1)
|
||||
start_operation_process(
|
||||
["launcher", "play"], events=events,
|
||||
progress_updates=ProgressCoalescer(), game_launch_supervisors=registry,
|
||||
child_environment=dict, interrupt_supervisor=MagicMock(), game_client_id=3,
|
||||
process_factory=lambda *_args, **_kwargs: process, thread_factory=_InlineThread,
|
||||
)
|
||||
result = _drain(events)
|
||||
self.assertIn(("game_window_unmonitored", 3), result)
|
||||
self.assertNotIn(("game_window_ready", 3), result)
|
||||
self.assertEqual(result[-1], ("game_done", (3, 1)))
|
||||
|
||||
def test_cancellation_before_process_start_skips_the_child(self) -> None:
|
||||
events: queue.Queue[tuple[str, object]] = queue.Queue()
|
||||
registry = MagicMock()
|
||||
registry.is_cancel_requested.return_value = True
|
||||
process_factory = MagicMock()
|
||||
|
||||
start_operation_process(
|
||||
["launcher", "play"],
|
||||
events=events,
|
||||
progress_updates=ProgressCoalescer(),
|
||||
game_launch_supervisors=registry,
|
||||
child_environment=dict,
|
||||
interrupt_supervisor=MagicMock(),
|
||||
game_client_id=4,
|
||||
process_factory=process_factory,
|
||||
thread_factory=_InlineThread,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
_drain(events),
|
||||
[
|
||||
(
|
||||
"output",
|
||||
"[Client 4] Launch cancelled before the game supervisor started.\n",
|
||||
),
|
||||
("game_done", (4, 130)),
|
||||
],
|
||||
)
|
||||
process_factory.assert_not_called()
|
||||
|
||||
def test_process_start_failure_uses_the_matching_completion_event(self) -> None:
|
||||
for client_id, done_event in ((None, ("done", 1)), (5, ("game_done", (5, 1)))):
|
||||
with self.subTest(client_id=client_id):
|
||||
events: queue.Queue[tuple[str, object]] = queue.Queue()
|
||||
registry = MagicMock()
|
||||
registry.is_cancel_requested.return_value = False
|
||||
|
||||
def fail_to_start(*_args: object, **_kwargs: object) -> _Process:
|
||||
raise OSError("missing executable")
|
||||
|
||||
start_operation_process(
|
||||
["missing"],
|
||||
events=events,
|
||||
progress_updates=ProgressCoalescer(),
|
||||
game_launch_supervisors=registry,
|
||||
child_environment=dict,
|
||||
interrupt_supervisor=MagicMock(),
|
||||
game_client_id=client_id,
|
||||
process_factory=fail_to_start,
|
||||
thread_factory=_InlineThread,
|
||||
)
|
||||
|
||||
emitted = _drain(events)
|
||||
self.assertIn(done_event, emitted)
|
||||
self.assertIn("Could not start operation", str(emitted[0][1]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
594
tests/test_game_launch_compatibility.py
Normal file
594
tests/test_game_launch_compatibility.py
Normal file
@@ -0,0 +1,594 @@
|
||||
from dataclasses import asdict, replace
|
||||
import ctypes
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from ptlinux.application.commands.play import (
|
||||
ensure_portable_umu_for_play,
|
||||
play_locked,
|
||||
runtime_issue_summary,
|
||||
)
|
||||
from ptlinux.cli import _load_or_default_settings
|
||||
from ptlinux.depot import DepotError, DepotLaunchCatalog, DepotStartOption
|
||||
from ptlinux.game.arguments import split_windows_arguments
|
||||
from ptlinux.game.depot.catalogs import select_depot_start_option
|
||||
from ptlinux.game.depot.manifest import parse_start_options
|
||||
from ptlinux.game.runtime import (
|
||||
RuntimeErrorPT,
|
||||
RuntimeSelection,
|
||||
game_runtime_ready,
|
||||
prepare_game_runtime,
|
||||
run_game,
|
||||
run_runtime_prefix_command,
|
||||
runtime_settings_for_realm,
|
||||
select_runtime,
|
||||
)
|
||||
from ptlinux.game.runtime_view import (
|
||||
KNOWN_SOURCE_SHA256,
|
||||
OverlayError,
|
||||
_scene_action,
|
||||
prepare_runtime_view,
|
||||
select_scene_action,
|
||||
)
|
||||
from ptlinux.paths import AppPaths
|
||||
from ptlinux.presentation import game_window
|
||||
from ptlinux.settings import (
|
||||
Settings,
|
||||
ensure_settings,
|
||||
load_settings,
|
||||
reset_settings,
|
||||
save_settings,
|
||||
update_settings,
|
||||
)
|
||||
from ptlinux.telemetry.runtime import _selected_prefix, _wine_command
|
||||
|
||||
|
||||
class WindowsArgumentTests(unittest.TestCase):
|
||||
def test_preserves_windows_paths_quotes_empty_arguments_and_shell_literals(self) -> None:
|
||||
arguments = (
|
||||
"PTEU_REBORN", r"C:\game\data", "Babel's Realm", "", "a b",
|
||||
'a"b', "trailing\\", r"$(touch /tmp/should-not-exist)", "a;b", "%TEMP%",
|
||||
)
|
||||
self.assertEqual(split_windows_arguments(subprocess.list2cmdline(arguments)), arguments)
|
||||
|
||||
def test_windows_quoting_examples(self) -> None:
|
||||
for command, expected in (
|
||||
('"a b c" d e', ("a b c", "d", "e")),
|
||||
(r'a\\\b d"e f"g h', (r"a\\\b", "de fg", "h")),
|
||||
(r'a\\\"b c d', ('a\\"b', "c", "d")),
|
||||
(r'a\\\\"b c" d e', (r"a\\b c", "d", "e")),
|
||||
('a"b"" c d', ('ab" c d',)),
|
||||
("Babel's Realm", ("Babel's", "Realm")),
|
||||
(" \t ", ()),
|
||||
):
|
||||
with self.subTest(command=command):
|
||||
self.assertEqual(split_windows_arguments(command), expected)
|
||||
|
||||
def test_manifest_allows_no_arguments_and_keeps_publisher_default(self) -> None:
|
||||
options, default = parse_start_options(
|
||||
[
|
||||
{"Name": "Babel", "Path": "Game.exe", "Arguments": ""},
|
||||
{"Name": "New Realm", "Path": "New.exe"},
|
||||
],
|
||||
"New Realm",
|
||||
)
|
||||
self.assertEqual(options[0].arguments, "")
|
||||
self.assertEqual(options[1].arguments, "")
|
||||
catalog = DepotLaunchCatalog(10, options, default)
|
||||
self.assertEqual(select_depot_start_option(catalog, "Removed Realm"), options[1])
|
||||
|
||||
|
||||
class RuntimeDefaultMigrationTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name)
|
||||
self.paths = AppPaths(*(self.root / name for name in ("data", "config", "state", "cache")))
|
||||
self.paths.config.mkdir(parents=True)
|
||||
|
||||
def write_settings(self, value: dict[str, object]) -> None:
|
||||
self.paths.settings_file.write_text(json.dumps(value), encoding="utf-8")
|
||||
|
||||
def test_new_and_reset_installations_use_ge_proton(self) -> None:
|
||||
self.assertEqual(Settings().runtime, "ge-proton")
|
||||
self.assertEqual(load_settings(self.paths).runtime, "ge-proton")
|
||||
self.assertFalse(self.paths.settings_file.exists())
|
||||
self.assertEqual(ensure_settings(self.paths).runtime, "ge-proton")
|
||||
save_settings(self.paths, Settings(runtime="proton7"))
|
||||
self.assertEqual(reset_settings(self.paths).runtime, "ge-proton")
|
||||
self.assertEqual(ensure_settings(self.paths).runtime, "ge-proton")
|
||||
|
||||
def test_all_old_profiles_migrate_without_changing_other_preferences(self) -> None:
|
||||
for runtime in ("auto", "proton7", "proton7-container", "umu", "ge-proton"):
|
||||
with self.subTest(runtime=runtime):
|
||||
old_settings = Settings(
|
||||
runtime=runtime,
|
||||
proton_path="/Steam Library/Proton 7.0/proton",
|
||||
renderer="wined3d",
|
||||
proton_sync="esync",
|
||||
telemetry_enabled=False,
|
||||
game_crash_dump_upload_enabled=False,
|
||||
game_source="/Games/Publisher",
|
||||
ui_scale="150",
|
||||
borderless=True,
|
||||
allow_window_resizing=True,
|
||||
suppress_unresponsive_warnings=False,
|
||||
download_speed_limit_mbps=4.0,
|
||||
low_bandwidth_mode=True,
|
||||
update_channel="prerelease",
|
||||
)
|
||||
self.write_settings({
|
||||
**asdict(old_settings), "schema": 1,
|
||||
"title_frame_default_migrated": True,
|
||||
"future_setting": {"keep": True},
|
||||
})
|
||||
before = self.paths.settings_file.read_bytes()
|
||||
expected = replace(old_settings, runtime="ge-proton")
|
||||
self.assertEqual(load_settings(self.paths), expected)
|
||||
self.assertEqual(self.paths.settings_file.read_bytes(), before)
|
||||
self.assertEqual(ensure_settings(self.paths), expected)
|
||||
payload = json.loads(self.paths.settings_file.read_text())
|
||||
self.assertEqual(payload["runtime"], "ge-proton")
|
||||
self.assertIs(payload["ge_proton_default_migrated"], True)
|
||||
self.assertEqual(payload["future_setting"], {"keep": True})
|
||||
self.assertEqual(payload["schema"], 1)
|
||||
self.assertEqual(self.paths.settings_file.stat().st_mode & 0o777, 0o600)
|
||||
|
||||
def test_migration_is_persisted_once_and_later_manual_choices_survive(self) -> None:
|
||||
self.write_settings({"runtime": "proton7", "ge_proton_default_migrated": False})
|
||||
self.assertEqual(ensure_settings(self.paths).runtime, "ge-proton")
|
||||
before = self.paths.settings_file.stat()
|
||||
self.assertEqual(ensure_settings(self.paths).runtime, "ge-proton")
|
||||
after = self.paths.settings_file.stat()
|
||||
self.assertEqual((after.st_ino, after.st_mtime_ns), (before.st_ino, before.st_mtime_ns))
|
||||
for runtime in ("auto", "proton7", "proton7-container", "umu"):
|
||||
with self.subTest(runtime=runtime):
|
||||
update_settings(self.paths, runtime=runtime)
|
||||
update_settings(self.paths, proton_sync="esync")
|
||||
self.assertEqual(ensure_settings(self.paths).runtime, runtime)
|
||||
self.assertEqual(load_settings(self.paths).runtime, runtime)
|
||||
|
||||
def test_unrelated_update_migrates_the_runtime(self) -> None:
|
||||
self.write_settings({"runtime": "umu"})
|
||||
settings = update_settings(self.paths, low_bandwidth_mode=True)
|
||||
self.assertEqual(settings.runtime, "ge-proton")
|
||||
self.assertTrue(settings.low_bandwidth_mode)
|
||||
self.assertEqual(ensure_settings(self.paths), settings)
|
||||
|
||||
def test_explicit_runtime_choice_during_first_save_is_preserved(self) -> None:
|
||||
for full_save in (False, True):
|
||||
with self.subTest(full_save=full_save):
|
||||
self.write_settings({"runtime": "auto"})
|
||||
if full_save:
|
||||
save_settings(self.paths, Settings(runtime="umu"))
|
||||
else:
|
||||
update_settings(self.paths, runtime="umu")
|
||||
self.assertEqual(ensure_settings(self.paths).runtime, "umu")
|
||||
|
||||
def test_migration_keeps_the_ptr_override_and_ignores_its_path_for_babel(self) -> None:
|
||||
proton = self.root / "Steam/steamapps/common/Proton 7.0/proton"
|
||||
proton.parent.mkdir(parents=True)
|
||||
proton.touch()
|
||||
for realm in ("PTR Babel", "Test Babel"):
|
||||
with self.subTest(realm=realm):
|
||||
self.write_settings({"runtime": "proton7", "realm": realm, "proton_path": str(proton)})
|
||||
settings = ensure_settings(self.paths)
|
||||
self.assertEqual(settings.runtime, "ge-proton")
|
||||
self.assertEqual(settings.realm, realm)
|
||||
self.assertEqual(runtime_settings_for_realm(settings).runtime, "proton7")
|
||||
with patch("ptlinux.game.runtime.find_umu_launcher", return_value="/usr/bin/umu-run"):
|
||||
ptr = select_runtime(settings, {"HOME": str(self.root)})
|
||||
babel = select_runtime(replace(settings, realm="Babel"), {})
|
||||
self.assertEqual(ptr.kind, "proton7")
|
||||
self.assertEqual(ptr.proton_path, str(proton))
|
||||
self.assertEqual(babel.kind, "ge-proton")
|
||||
self.assertIsNone(babel.proton_path)
|
||||
|
||||
def test_invalid_runtime_and_marker_are_rejected_without_rewriting(self) -> None:
|
||||
for value in (
|
||||
{"runtime": "unsupported"}, {"runtime": None}, {"runtime": True},
|
||||
{"ge_proton_default_migrated": "true"}, {"ge_proton_default_migrated": 1},
|
||||
):
|
||||
with self.subTest(value=value):
|
||||
self.write_settings(value)
|
||||
before = self.paths.settings_file.read_bytes()
|
||||
with self.assertRaises(ValueError):
|
||||
ensure_settings(self.paths)
|
||||
self.assertEqual(self.paths.settings_file.read_bytes(), before)
|
||||
|
||||
def test_default_cannot_silently_fall_back_to_legacy_proton(self) -> None:
|
||||
with (
|
||||
patch("ptlinux.game.runtime.find_umu_launcher", return_value=None),
|
||||
patch("ptlinux.game.runtime.find_steam_proton7") as find_legacy,
|
||||
):
|
||||
with self.assertRaisesRegex(RuntimeErrorPT, "umu-run was not found"):
|
||||
select_runtime(Settings(), {})
|
||||
find_legacy.assert_not_called()
|
||||
|
||||
def test_default_bootstrap_error_does_not_fall_back_to_proton7(self) -> None:
|
||||
api = MagicMock()
|
||||
api.provision_umu.side_effect = RuntimeError("network unavailable")
|
||||
with self.assertRaisesRegex(RuntimeError, "portable UMU.*network unavailable"):
|
||||
ensure_portable_umu_for_play(api, self.paths, Settings(), dry_run=False)
|
||||
api.select_runtime.assert_not_called()
|
||||
|
||||
def test_cli_migrates_existing_settings_and_dry_runs_remain_read_only(self) -> None:
|
||||
self.write_settings({"runtime": "proton7"})
|
||||
before = self.paths.settings_file.read_bytes()
|
||||
self.assertEqual(_load_or_default_settings(self.paths, persist=False).runtime, "ge-proton")
|
||||
self.assertEqual(self.paths.settings_file.read_bytes(), before)
|
||||
self.assertEqual(_load_or_default_settings(self.paths, persist=True).runtime, "ge-proton")
|
||||
payload = json.loads(self.paths.settings_file.read_text())
|
||||
self.assertEqual(payload["runtime"], "ge-proton")
|
||||
self.assertIs(payload["ge_proton_default_migrated"], True)
|
||||
|
||||
|
||||
class LaunchCompatibilityTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name)
|
||||
self.paths = AppPaths(*(self.root / name for name in ("data", "config", "state", "cache")))
|
||||
self.paths.runtime_view.mkdir(parents=True)
|
||||
(self.paths.runtime_view / "Game.exe").touch()
|
||||
self.runner = MagicMock(dry_run=False)
|
||||
self.selection = RuntimeSelection("ge-proton", "/usr/bin/umu-run", None, None)
|
||||
self.host_check = patch("ptlinux.game.runtime._require_container_host")
|
||||
self.host_check.start()
|
||||
self.addCleanup(self.host_check.stop)
|
||||
|
||||
def test_recovers_legacy_renderer_option_and_persists_migration(self) -> None:
|
||||
self.paths.config.mkdir(parents=True)
|
||||
self.paths.settings_file.write_text(json.dumps({"proton_path": "PROTON_USE_WINED3D=1"}))
|
||||
settings = ensure_settings(self.paths)
|
||||
self.assertIsNone(settings.proton_path)
|
||||
self.assertEqual(settings.renderer, "wined3d")
|
||||
self.assertEqual(load_settings(self.paths), settings)
|
||||
self.assertIsNone(json.loads(self.paths.settings_file.read_text())["proton_path"])
|
||||
with patch("ptlinux.game.runtime.find_umu_launcher", return_value="/usr/bin/umu-run"):
|
||||
selection = select_runtime(settings, {})
|
||||
run_game(self.paths, settings, selection, self.runner, use_gamebox=False)
|
||||
environment = self.runner.run.call_args.kwargs["env_overrides"]
|
||||
self.assertEqual(environment["PROTON_USE_WINED3D"], "1")
|
||||
self.assertIn("d3d9=b", environment["WINEDLLOVERRIDES"])
|
||||
|
||||
def test_does_not_execute_or_migrate_arbitrary_path_commands(self) -> None:
|
||||
for value in ("PROTON_LOG=1", "env PROTON_USE_WINED3D=1 %command%"):
|
||||
with self.subTest(value=value):
|
||||
settings = Settings(runtime="auto", proton_path=value)
|
||||
with self.assertRaisesRegex(ValueError, "filesystem path"):
|
||||
save_settings(self.paths, settings)
|
||||
with self.assertRaisesRegex(RuntimeErrorPT, "filesystem path"):
|
||||
select_runtime(settings, {})
|
||||
|
||||
def test_existing_explicit_renderer_wins_over_misplaced_legacy_option(self) -> None:
|
||||
self.paths.config.mkdir(parents=True)
|
||||
self.paths.settings_file.write_text(json.dumps({
|
||||
"proton_path": "PROTON_USE_WINED3D=1", "renderer": "dxvk",
|
||||
}))
|
||||
self.assertEqual(ensure_settings(self.paths).renderer, "dxvk")
|
||||
|
||||
def test_proton_script_field_accepts_a_steam_proton_directory_with_spaces(self) -> None:
|
||||
proton = self.root / "Steam/steamapps/common/Proton 7.0/proton"
|
||||
proton.parent.mkdir(parents=True)
|
||||
proton.touch()
|
||||
selection = select_runtime(Settings(runtime="proton7", proton_path=str(proton.parent)), {})
|
||||
self.assertEqual(selection.proton_path, str(proton))
|
||||
|
||||
def test_auto_selects_ge_across_distributions_and_keeps_explicit_umu(self) -> None:
|
||||
with patch("ptlinux.game.runtime.find_umu_launcher", return_value="/usr/bin/umu-run"):
|
||||
for runtime in ("auto", "ge-proton"):
|
||||
for distribution in ("ubuntu", "cachyos", "arch", "fedora"):
|
||||
with self.subTest(runtime=runtime, distribution=distribution):
|
||||
self.assertEqual(select_runtime(Settings(runtime=runtime), {}, distribution_id=distribution).kind, "ge-proton")
|
||||
self.assertEqual(select_runtime(Settings(runtime="umu"), {}).kind, "umu")
|
||||
|
||||
def test_future_publisher_scene_is_copied_unchanged_without_old_texture_repair(self) -> None:
|
||||
source = self.paths.source_game
|
||||
for relative, content in (
|
||||
("Game.exe", b"new executable"),
|
||||
("game.dll", b"new dll"),
|
||||
("game.ini", b"[Screen]\nWindowed=1\n"),
|
||||
("game/maps/WhisperingVale.rovug", b"future publisher scene format"),
|
||||
):
|
||||
path = source / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(content)
|
||||
with patch("ptlinux.game.runtime_view.normalize_wrapped_assbin_textures") as repair:
|
||||
result = prepare_runtime_view(self.paths, source)
|
||||
self.assertEqual(result.scene_action, "upstream-unmodified-copy")
|
||||
self.assertEqual(result.binary_action, "upstream-pair")
|
||||
self.assertEqual(result.source_scene_sha256, result.runtime_scene_sha256)
|
||||
self.assertEqual((source / "game/maps/WhisperingVale.rovug").read_bytes(), b"future publisher scene format")
|
||||
repair.assert_not_called()
|
||||
|
||||
def test_unknown_scene_texture_count_does_not_trigger_legacy_patch(self) -> None:
|
||||
self.assertEqual(_scene_action(MagicMock(file_sha256="new", embedded_textures=())), "upstream-unmodified-copy")
|
||||
self.assertEqual(select_scene_action("new", [16, 16], [2, 2]), "upstream-unmodified-copy")
|
||||
self.assertEqual(select_scene_action(KNOWN_SOURCE_SHA256, [16, 16], [2, 2]), "normalize-known-profile")
|
||||
with self.assertRaises(OverlayError):
|
||||
select_scene_action(KNOWN_SOURCE_SHA256, [16, 8], [2, 2])
|
||||
|
||||
def test_ge_setup_game_and_helpers_use_one_isolated_wow64_prefix(self) -> None:
|
||||
self.runner.dry_run = True
|
||||
prepare_game_runtime(self.paths, self.selection, self.runner)
|
||||
run_game(self.paths, Settings(), self.selection, self.runner, use_gamebox=False)
|
||||
run_runtime_prefix_command(self.paths, self.selection, self.runner, ["reg.exe", "query"])
|
||||
self.assertEqual(self.runner.run.call_count, 3)
|
||||
for call in self.runner.run.call_args_list:
|
||||
environment = call.kwargs["env_overrides"]
|
||||
self.assertEqual(environment["PROTONPATH"], "GE-Proton")
|
||||
self.assertEqual(environment["PROTON_USE_WOW64"], "1")
|
||||
self.assertEqual(environment["WINEPREFIX"], str(self.paths.ge_proton_prefix))
|
||||
self.assertNotEqual(self.paths.ge_proton_prefix, self.paths.umu_prefix)
|
||||
self.assertNotEqual(self.paths.ge_proton_prefix, self.paths.proton_prefix)
|
||||
|
||||
def test_failed_directx_postcondition_does_not_mark_runtime_ready(self) -> None:
|
||||
with self.assertRaisesRegex(RuntimeErrorPT, "DirectX setup completed without"):
|
||||
prepare_game_runtime(self.paths, self.selection, self.runner)
|
||||
self.assertFalse(self.paths.game_runtime_marker.exists())
|
||||
self.assertFalse(game_runtime_ready(self.paths, self.selection))
|
||||
|
||||
def test_shared_clients_cannot_reuse_a_prefix_for_another_runtime(self) -> None:
|
||||
self.paths.state.mkdir(parents=True)
|
||||
for prefix in (self.paths.umu_prefix, self.paths.ge_proton_prefix):
|
||||
directory = prefix / "drive_c/windows/syswow64"
|
||||
directory.mkdir(parents=True)
|
||||
for name in ("d3dx9_43", "d3dcompiler_43"):
|
||||
(directory / f"{name}.dll").touch()
|
||||
(prefix / "user.reg").write_text('"*d3dx9_43"="native"\n"*d3dcompiler_43"="native"\n')
|
||||
self.paths.game_runtime_marker.write_text(json.dumps({
|
||||
"configuration_version": 1, "runtime": asdict(self.selection),
|
||||
}))
|
||||
self.assertTrue(game_runtime_ready(self.paths, self.selection))
|
||||
self.assertFalse(game_runtime_ready(self.paths, RuntimeSelection("umu", "/usr/bin/umu-run", None, None)))
|
||||
|
||||
def test_shared_ge_clients_keep_same_environment_except_for_verb(self) -> None:
|
||||
for concurrent in (False, True):
|
||||
run_game(
|
||||
self.paths, Settings(), self.selection, self.runner,
|
||||
use_gamebox=False, concurrent_prefix_launch=concurrent,
|
||||
)
|
||||
first, second = [dict(call.kwargs["env_overrides"]) for call in self.runner.run.call_args_list]
|
||||
self.assertEqual(first.pop("PROTON_VERB"), "waitforexitandrun")
|
||||
self.assertEqual(second.pop("PROTON_VERB"), "runinprefix")
|
||||
self.assertEqual(first, second)
|
||||
|
||||
def test_renderer_is_environment_and_native_helpers_preserve_unrelated_overrides(self) -> None:
|
||||
for kind in ("umu", "ge-proton", "proton7", "proton7-container"):
|
||||
for renderer, value, override in (("dxvk", "0", "d3d9=n,b"), ("wined3d", "1", "d3d9=b")):
|
||||
with self.subTest(kind=kind, renderer=renderer):
|
||||
selection = RuntimeSelection(kind, "/usr/bin/umu-run", "/Steam/proton", "/Steam")
|
||||
run_game(
|
||||
self.paths, Settings(renderer=renderer), selection, self.runner,
|
||||
use_gamebox=False,
|
||||
extra_environment={"WINEDLLOVERRIDES": "*d3dx9_43,foo=b;d3dcompiler_43=;d3d9=b"},
|
||||
)
|
||||
environment = self.runner.run.call_args.kwargs["env_overrides"]
|
||||
self.assertEqual(environment["PROTON_USE_WINED3D"], value)
|
||||
self.assertEqual(environment["WINEDLLOVERRIDES"],
|
||||
f"foo=b;d3dx9_43=n,b;d3dcompiler_43=n,b;{override}")
|
||||
self.assertNotIn("PROTON_USE_WINED3D", " ".join(self.runner.run.call_args.args[0]))
|
||||
|
||||
def test_windows_arguments_are_passed_in_order_without_shell_expansion(self) -> None:
|
||||
folder = self.paths.runtime_view / "New Client"
|
||||
folder.mkdir()
|
||||
(folder / "CLIENT.exe").touch()
|
||||
arguments = ("NEW_TOKEN", r"C:\data\assets", "", "a b", "$(echo literal)")
|
||||
option = DepotStartOption("New Realm", "New Client/client.exe", subprocess.list2cmdline(arguments))
|
||||
run_game(self.paths, Settings(), self.selection, self.runner, start_option=option, use_gamebox=False)
|
||||
call = self.runner.run.call_args
|
||||
self.assertEqual(call.args[0], ["/usr/bin/umu-run", str(folder / "CLIENT.exe"), *arguments])
|
||||
self.assertEqual(call.kwargs["cwd"], self.paths.runtime_view)
|
||||
|
||||
def test_missing_advertised_executable_fails_before_runtime_start(self) -> None:
|
||||
with self.assertRaisesRegex(RuntimeErrorPT, "missing New.exe"):
|
||||
run_game(self.paths, Settings(), self.selection, self.runner,
|
||||
start_option=DepotStartOption("New", "New.exe", ""), use_gamebox=False)
|
||||
self.runner.run.assert_not_called()
|
||||
|
||||
def test_build_mismatch_blocks_play_before_game_view_or_runtime_changes(self) -> None:
|
||||
self.paths.state.mkdir(parents=True)
|
||||
self.paths.native_depot_state.write_text(json.dumps({"build": 100, "dry_run": False}))
|
||||
api = MagicMock()
|
||||
api.current_telemetry.return_value = None
|
||||
api.game_import_requires_verification.return_value = False
|
||||
api._load_or_default_settings.return_value = Settings()
|
||||
api.is_running_inside_steam_runtime.return_value = False
|
||||
for build in (99, 101, None):
|
||||
api.read_cached_depot_launch_catalog.return_value = (
|
||||
DepotLaunchCatalog(build, (DepotStartOption("Babel", "Game.exe", "NEW"),), "Babel")
|
||||
if build is not None else None
|
||||
)
|
||||
with self.subTest(build=build), self.assertRaisesRegex(DepotError, "update.*before playing"):
|
||||
play_locked(api, self.paths, None, False, False, False, False)
|
||||
api.prepare_runtime_view.assert_not_called()
|
||||
api.prepare_game_runtime.assert_not_called()
|
||||
api.run_game.assert_not_called()
|
||||
|
||||
def test_proton_telemetry_does_not_report_host_wine_as_bundled_wine(self) -> None:
|
||||
with patch("ptlinux.telemetry.runtime.shutil.which", return_value="/usr/bin/wine") as which:
|
||||
for kind in ("umu", "ge-proton"):
|
||||
self.assertIsNone(_wine_command({"kind": kind, "executable": "/usr/bin/umu-run"}, {}))
|
||||
which.assert_not_called()
|
||||
(self.paths.ge_proton_prefix / "drive_c/windows").mkdir(parents=True)
|
||||
self.assertEqual(_selected_prefix(self.paths, {"runtime": asdict(self.selection)}), self.paths.ge_proton_prefix)
|
||||
|
||||
def test_gstreamer_negotiation_errors_are_visible_without_claiming_crash_cause(self) -> None:
|
||||
report = runtime_issue_summary(
|
||||
"GStreamer-Video-CRITICAL: gst_video_info_from_caps: assertion 'gst_caps_is_fixed (caps)' failed\n"
|
||||
"GStreamer-Audio-CRITICAL: gst_audio_info_from_caps: assertion 'gst_caps_is_fixed (caps)' failed\n"
|
||||
)
|
||||
self.assertEqual(report["gstreamer_caps_error_count"], 2)
|
||||
self.assertFalse(report["gstreamer_stream_error"])
|
||||
|
||||
|
||||
class GameKeyboardFocusTests(unittest.TestCase):
|
||||
def test_startup_hands_keyboard_focus_to_the_desktop_game_window(self) -> None:
|
||||
# Model a WM that rejects raw X11 focus changes to an inactive client.
|
||||
mapper = MagicMock(spec=game_window._X11)
|
||||
mapper.window_state.return_value = 1
|
||||
active, keyboard = [7], [7]
|
||||
pending: list[int] = []
|
||||
|
||||
def activate(window: int) -> bool:
|
||||
pending.append(window)
|
||||
return True
|
||||
|
||||
def settle(_seconds: float) -> None:
|
||||
if pending:
|
||||
active[0] = pending.pop()
|
||||
|
||||
def focus(window: int) -> None:
|
||||
if active[0] == window:
|
||||
keyboard[0] = window
|
||||
|
||||
mapper.activate_window.side_effect = activate
|
||||
mapper.window_is_active.side_effect = lambda window: active[0] == window
|
||||
mapper.window_is_focused.side_effect = lambda window: keyboard[0] == window
|
||||
mapper.restore_input_focus.side_effect = focus
|
||||
with patch.object(game_window.time, "sleep", side_effect=settle):
|
||||
game_window._focus_game_window(mapper, 12)
|
||||
self.assertEqual((active[0], keyboard[0]), (12, 12))
|
||||
|
||||
def test_activation_preserves_minimize_before_and_during_handoff(self) -> None:
|
||||
for states, requests in (([3], 0), ([1, 3], 1)):
|
||||
with self.subTest(states=states):
|
||||
mapper = MagicMock(spec=game_window._X11)
|
||||
mapper.window_state.side_effect = states
|
||||
mapper.window_is_active.return_value = False
|
||||
with patch.object(game_window.time, "sleep"):
|
||||
game_window._focus_game_window(mapper, 12)
|
||||
self.assertEqual(mapper.activate_window.call_count, requests)
|
||||
mapper.focus_window.assert_not_called()
|
||||
|
||||
def test_nested_gamescope_focus_does_not_request_desktop_activation(self) -> None:
|
||||
mapper = MagicMock(spec=game_window._X11)
|
||||
mapper.window_state.return_value = 1
|
||||
mapper.window_is_focused.return_value = False
|
||||
with patch.object(game_window.time, "sleep"):
|
||||
game_window._focus_game_window(mapper, 12, activate=False)
|
||||
mapper.activate_window.assert_not_called()
|
||||
mapper.restore_input_focus.assert_called_once_with(12)
|
||||
mapper.focus_window.assert_not_called()
|
||||
|
||||
def test_activation_uses_only_the_games_own_user_time(self) -> None:
|
||||
for properties, expected in (
|
||||
({}, 0),
|
||||
({(12, b"_NET_WM_USER_TIME"): (12345,)}, 12345),
|
||||
({(12, b"_NET_WM_USER_TIME_WINDOW"): (13,),
|
||||
(13, b"_NET_WM_USER_TIME"): (45678,)}, 45678),
|
||||
({(12, b"_NET_WM_USER_TIME_WINDOW"): (13,),
|
||||
(12, b"_NET_WM_USER_TIME"): (56789,)}, 56789),
|
||||
):
|
||||
with self.subTest(properties=properties):
|
||||
mapper: game_window._X11 = object.__new__(game_window._X11)
|
||||
with (
|
||||
patch.object(mapper, "_intern_atom", return_value=77),
|
||||
patch.object(
|
||||
mapper, "_long_property",
|
||||
side_effect=lambda window, name, _count: properties.get((window, name)),
|
||||
),
|
||||
patch.object(mapper, "_send_root_client_message", return_value=True) as send,
|
||||
):
|
||||
self.assertTrue(mapper.activate_window(12))
|
||||
send.assert_called_once_with(12, 77, (1, expected, 0, 0, 0))
|
||||
|
||||
def test_focus_diagnostics_distinguish_a_request_from_actual_activation(self) -> None:
|
||||
mapper = MagicMock(spec=game_window._X11)
|
||||
mapper.window_state.return_value = 1
|
||||
mapper.activate_window.return_value = True
|
||||
mapper.window_is_active.return_value = False
|
||||
mapper.window_diagnostic_snapshot.return_value = {"active": False, "focused": False}
|
||||
diagnostics = MagicMock()
|
||||
with (
|
||||
patch.object(game_window.time, "sleep"),
|
||||
patch.object(game_window, "WINDOW_STATE_CONFIRM_TIMEOUT_SECONDS", 0.0),
|
||||
):
|
||||
game_window._focus_game_window(mapper, 12, diagnostics=diagnostics)
|
||||
diagnostics.record.assert_called_once_with(
|
||||
"focus_handoff", window_id=12, activation_requested=True,
|
||||
activation_submitted=True, outcome="not_activated", active=False, focused=False,
|
||||
)
|
||||
|
||||
def test_focus_repair_respects_background_and_minimized_windows(self) -> None:
|
||||
for state, active in ((1, False), (3, True)):
|
||||
with self.subTest(state=state, active=active):
|
||||
mapper = MagicMock(spec=game_window._X11)
|
||||
mapper.window_state.return_value = state
|
||||
mapper.window_is_active.return_value = active
|
||||
mapper.window_is_focused.return_value = False
|
||||
self.assertFalse(game_window._repair_game_window_focus(mapper, 12))
|
||||
mapper.restore_input_focus.assert_not_called()
|
||||
mapper.activate_window.assert_not_called()
|
||||
mapper.focus_window.assert_not_called()
|
||||
|
||||
def test_borderless_launch_keeps_focus_repair_without_live_resizing(self) -> None:
|
||||
mapper = MagicMock(spec=game_window._X11)
|
||||
mapper.root_window_ids.return_value = set()
|
||||
mapper.find_first_window.return_value = (12, "PristonTale EU V23.86.96")
|
||||
process = MagicMock()
|
||||
process.poll.return_value = None
|
||||
with (
|
||||
patch.object(game_window, "_X11", return_value=mapper),
|
||||
patch.object(game_window.subprocess, "Popen", return_value=process),
|
||||
patch.object(game_window, "WINDOW_READY_DELAY_SECONDS", 0),
|
||||
patch.object(game_window, "_prepare_game_window"),
|
||||
patch.object(game_window, "_focus_game_window"),
|
||||
patch.object(game_window, "_guard_game_window_startup", return_value=True),
|
||||
patch.object(game_window, "_maintain_game_window_scaling") as resize,
|
||||
patch.object(game_window, "_maintain_game_window_focus", return_value=17) as maintain,
|
||||
):
|
||||
self.assertEqual(game_window.run_game_window(["unused"], borderless=True), 17)
|
||||
maintain.assert_called_once_with(
|
||||
mapper, process, 12, window_title=game_window.WINDOW_TITLE,
|
||||
existing_window_ids=set(), expected_pid=mapper.window_pid.return_value,
|
||||
)
|
||||
resize.assert_not_called()
|
||||
mapper.close.assert_called_once_with()
|
||||
|
||||
def test_child_input_focus_is_preserved_and_foreign_focus_is_rejected(self) -> None:
|
||||
for focused, parents, expected in (
|
||||
(12, {}, True),
|
||||
(14, {14: 13, 13: 12}, True),
|
||||
(24, {24: 23, 23: 1000, 1000: 0}, False),
|
||||
(24, {}, False), # The focused window vanished during the read.
|
||||
(24, {24: 25, 25: 24}, False),
|
||||
(0, {}, False),
|
||||
(1, {}, False),
|
||||
):
|
||||
with self.subTest(focused=focused, parents=parents):
|
||||
mapper: game_window._X11 = object.__new__(game_window._X11)
|
||||
mapper.display = ctypes.c_void_p(1)
|
||||
mapper.library = MagicMock()
|
||||
allocations: list[object] = []
|
||||
|
||||
def read_focus(_display, pointer, _revert):
|
||||
pointer._obj.value = focused
|
||||
return 1
|
||||
|
||||
def query_tree(_display, window, root, parent, children, count):
|
||||
if window not in parents:
|
||||
return 0
|
||||
root._obj.value = 1000
|
||||
parent._obj.value = parents[window]
|
||||
allocation = (ctypes.c_ulong * 1)(99)
|
||||
allocations.append(allocation)
|
||||
ctypes.cast(children, ctypes.POINTER(ctypes.POINTER(ctypes.c_ulong)))[0] = allocation
|
||||
count._obj.value = 1
|
||||
return 1
|
||||
|
||||
mapper.library.XGetInputFocus.side_effect = read_focus
|
||||
mapper.library.XQueryTree.side_effect = query_tree
|
||||
with (
|
||||
patch.object(mapper, "window_state", return_value=1),
|
||||
patch.object(mapper, "window_is_active", return_value=True),
|
||||
patch.object(mapper, "restore_input_focus") as restore,
|
||||
):
|
||||
self.assertEqual(game_window._repair_game_window_focus(mapper, 12), expected)
|
||||
self.assertEqual(restore.call_count, 0 if expected else 1)
|
||||
self.assertEqual(mapper.library.XFree.call_count, len(allocations))
|
||||
164
tests/test_gamescope_session_outcomes.py
Normal file
164
tests/test_gamescope_session_outcomes.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
import io
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from ptlinux.application.commands.play import (
|
||||
game_exit_metadata,
|
||||
game_failure_reason,
|
||||
game_readiness_signal,
|
||||
play_locked,
|
||||
print_game_outcome,
|
||||
runtime_issue_summary,
|
||||
)
|
||||
from ptlinux.command import CommandOutcome
|
||||
from ptlinux.depot import DepotStartOption
|
||||
from ptlinux.paths import AppPaths
|
||||
from ptlinux.runtime import RuntimeSelection
|
||||
from ptlinux.settings import Settings
|
||||
|
||||
|
||||
def missing_status_message(status: int) -> str:
|
||||
return (
|
||||
f"Gamescope exited without a recorded game status (compositor status {status}); "
|
||||
"the game's exit result is unknown.\n"
|
||||
)
|
||||
|
||||
|
||||
class GamescopeSessionOutcomeTests(unittest.TestCase):
|
||||
def test_missing_status_is_not_attributed_to_the_game_or_umu(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
paths = AppPaths(*(root / name for name in ("data", "config", "state", "cache")))
|
||||
paths.runtime_view.mkdir(parents=True)
|
||||
api = MagicMock()
|
||||
api.game_import_requires_verification.return_value = False
|
||||
api._load_or_default_settings.return_value = Settings(runtime="ge-proton")
|
||||
api.is_running_inside_steam_runtime.return_value = False
|
||||
api.read_cached_depot_launch_catalog.return_value = None
|
||||
api.read_current_depot_file_catalog.return_value = None
|
||||
api._current_game_log.return_value = None
|
||||
api._current_game_crash.return_value = None
|
||||
api.select_runtime.return_value = RuntimeSelection("ge-proton", "umu-run", None, None)
|
||||
api.select_depot_start_option.return_value = DepotStartOption("Babel", "Game.exe", "PTEU_REBORN")
|
||||
api._runtime_issue_summary.side_effect = runtime_issue_summary
|
||||
api._game_exit_metadata.side_effect = game_exit_metadata
|
||||
for ready in (True, False):
|
||||
for compositor_status, wrapper_status in ((0, 1), (-11, 139), (247, 247), (0, 0)):
|
||||
with self.subTest(ready=ready, compositor=compositor_status, wrapper=wrapper_status):
|
||||
api.current_telemetry.return_value.reset_mock()
|
||||
api._game_readiness_signal.return_value = "window_ready" if ready else None
|
||||
api.run_game.return_value = CommandOutcome(
|
||||
[], wrapper_status, "", missing_status_message(compositor_status), 120.0, False, False,
|
||||
)
|
||||
self.assertEqual(play_locked(api, paths, None, False, False, False, True, reuse_prepared=True), 1)
|
||||
report = api._emit.call_args.args[0]
|
||||
self.assertFalse(report["ok"])
|
||||
self.assertEqual(report["launch_succeeded"], ready)
|
||||
event_name = "game_exited" if ready else "game_launch_failed"
|
||||
event = next(call for call in api.current_telemetry.return_value.send_event.call_args_list if call.args[0] == event_name)
|
||||
metadata = event.kwargs["metadata"]
|
||||
self.assertEqual(metadata["failure_reason"], "gamescope_game_status_missing")
|
||||
self.assertFalse(metadata["game_exit_status_available"])
|
||||
self.assertEqual(metadata["exit_status_source"], "gamescope_supervisor")
|
||||
self.assertEqual(metadata["gamescope_exit_code"], compositor_status)
|
||||
self.assertEqual(metadata["exit_code"], wrapper_status)
|
||||
self.assertNotIn("signal_inferred", metadata)
|
||||
self.assertNotIn("signal", metadata)
|
||||
self.assertIn("before the game reported its exit status", event.kwargs["message"])
|
||||
self.assertNotIn("Game client exited with status", event.kwargs["message"])
|
||||
|
||||
def test_truncated_output_retains_observed_shutdown_status(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
paths = AppPaths(*(root / name for name in ("data", "config", "state", "cache")))
|
||||
paths.runtime_view.mkdir(parents=True)
|
||||
api = MagicMock()
|
||||
api.game_import_requires_verification.return_value = False
|
||||
api._load_or_default_settings.return_value = Settings(runtime="ge-proton")
|
||||
api.is_running_inside_steam_runtime.return_value = False
|
||||
api.read_cached_depot_launch_catalog.return_value = None
|
||||
api.read_current_depot_file_catalog.return_value = None
|
||||
api._current_game_log.return_value = None
|
||||
api._current_game_crash.return_value = None
|
||||
api.select_runtime.return_value = RuntimeSelection("ge-proton", "umu-run", None, None)
|
||||
api.select_depot_start_option.return_value = DepotStartOption("Babel", "Game.exe", "PTEU_REBORN")
|
||||
api._runtime_issue_summary.side_effect = runtime_issue_summary
|
||||
api._game_readiness_signal.side_effect = game_readiness_signal
|
||||
api._gamescope_presentation_failure.return_value = None
|
||||
api.parse_window_diagnostic.return_value = None
|
||||
api._game_exit_metadata.side_effect = game_exit_metadata
|
||||
|
||||
def run(*args, **kwargs):
|
||||
observe = kwargs["output_line_filter"]
|
||||
observe("Game window ready: PristonTale EU\n")
|
||||
observe(missing_status_message(247))
|
||||
return CommandOutcome([], 247, "", "<truncated>final child cleanup output\n", 120.0, False, False)
|
||||
|
||||
api.run_game.side_effect = run
|
||||
self.assertEqual(play_locked(api, paths, None, False, False, False, True, reuse_prepared=True), 1)
|
||||
event = next(call for call in api.current_telemetry.return_value.send_event.call_args_list if call.args[0] == "game_exited")
|
||||
metadata = event.kwargs["metadata"]
|
||||
self.assertEqual(metadata["failure_reason"], "gamescope_game_status_missing")
|
||||
self.assertEqual(metadata["gamescope_exit_code"], 247)
|
||||
self.assertNotIn("signal_inferred", metadata)
|
||||
self.assertTrue(metadata["game_window_ready"])
|
||||
|
||||
def test_unobserved_window_is_not_readiness_evidence(self) -> None:
|
||||
for message in (
|
||||
"Game window ready: unmanaged PristonTale EU\n",
|
||||
"Game window monitoring unavailable; process started: PristonTale EU\n",
|
||||
):
|
||||
with self.subTest(message=message):
|
||||
self.assertIsNone(game_readiness_signal(message))
|
||||
self.assertFalse(runtime_issue_summary(message)["game_window_ready"])
|
||||
self.assertEqual(game_readiness_signal(message + "Game window ready: PristonTale EU\n"), "window_ready")
|
||||
|
||||
def test_human_output_distinguishes_unknown_status_in_old_and_new_logs(self) -> None:
|
||||
messages = (
|
||||
missing_status_message(0),
|
||||
missing_status_message(-11),
|
||||
"Gamescope exited without a recorded game status; the game session was interrupted.\n",
|
||||
)
|
||||
for message in messages:
|
||||
with self.subTest(message=message):
|
||||
outcome = CommandOutcome([], 1, "", message, 120.0, False, False)
|
||||
issues = runtime_issue_summary(message)
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(output), redirect_stderr(output):
|
||||
print_game_outcome(outcome, None, issues)
|
||||
self.assertIn("before the game reported its exit status", output.getvalue())
|
||||
self.assertNotIn("Game client exited", output.getvalue())
|
||||
self.assertNotIn("crashed", output.getvalue())
|
||||
|
||||
def test_missing_status_preserves_stronger_failure_evidence(self) -> None:
|
||||
cases = (
|
||||
({"runtime_out_of_memory": True}, None, False, "direct3d_resource_exhaustion"),
|
||||
(None, {"path": "/tmp/crash_game.dat"}, False, "client_crash_dump"),
|
||||
(None, None, True, "session_timeout"),
|
||||
)
|
||||
for native_log, crash, timed_out, reason in cases:
|
||||
with self.subTest(reason=reason):
|
||||
outcome = CommandOutcome([], 1, "", missing_status_message(0), 120.0, timed_out, False)
|
||||
self.assertEqual(game_failure_reason(outcome, native_log, crash, {}), reason)
|
||||
|
||||
def test_human_output_keeps_crash_and_timeout_evidence(self) -> None:
|
||||
for timed_out, crash, expected in (
|
||||
(False, {"path": "/tmp/crash_game.dat"}, "crashed and wrote a dump"),
|
||||
(True, None, "timed out and was stopped"),
|
||||
):
|
||||
with self.subTest(expected=expected):
|
||||
outcome = CommandOutcome([], 1, "", missing_status_message(0), 120.0, timed_out, False)
|
||||
output = io.StringIO()
|
||||
with redirect_stderr(output):
|
||||
print_game_outcome(outcome, None, runtime_issue_summary(outcome.stderr), crash)
|
||||
self.assertIn(expected, output.getvalue())
|
||||
self.assertNotIn("before the game reported", output.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
110
tests/test_gamescope_shutdown_regressions.py
Normal file
110
tests/test_gamescope_shutdown_regressions.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Exercise supervisor failure paths without launching a game or display server."""
|
||||
|
||||
from contextlib import redirect_stderr
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from ptlinux.presentation import gamescope
|
||||
from scripts.repository_paths import SOURCE_ROOT
|
||||
|
||||
|
||||
class GamescopeShutdownRegressions(unittest.TestCase):
|
||||
def test_missing_status_storage_cannot_be_reported_as_a_clean_game_exit(self) -> None:
|
||||
process = MagicMock()
|
||||
process.poll.return_value = 0
|
||||
output = io.StringIO()
|
||||
with (
|
||||
patch.object(gamescope, "_select_host_backend", return_value="sdl"),
|
||||
patch.object(gamescope, "_open_host_x11", return_value=None),
|
||||
patch.object(gamescope, "_create_child_status_file", side_effect=OSError("no space left")),
|
||||
patch.object(gamescope.subprocess, "Popen", return_value=process) as popen,
|
||||
redirect_stderr(output),
|
||||
):
|
||||
self.assertEqual(gamescope.run_gamescope(["gamescope", "--", "game"], 1280, 960), 1)
|
||||
self.assertNotIn("--keep-alive", popen.call_args.args[0])
|
||||
self.assertIn("supervision is unavailable", output.getvalue())
|
||||
self.assertIn("without a recorded game status (compositor status 0)", output.getvalue())
|
||||
|
||||
def test_interrupt_during_startup_tuning_closes_the_compositor_and_state_files(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
mapper = MagicMock()
|
||||
process = MagicMock()
|
||||
process.poll.return_value = None
|
||||
process.wait.return_value = -15
|
||||
with (
|
||||
patch.dict(os.environ, {"DISPLAY": ":0", "XDG_RUNTIME_DIR": temporary}, clear=True),
|
||||
patch.object(gamescope, "_select_host_backend", return_value="sdl"),
|
||||
patch.object(gamescope, "_open_host_x11", return_value=mapper),
|
||||
patch.object(gamescope, "_tune_process_window", side_effect=KeyboardInterrupt),
|
||||
patch.object(gamescope.subprocess, "Popen", return_value=process),
|
||||
self.assertRaises(KeyboardInterrupt),
|
||||
):
|
||||
gamescope.run_gamescope(["gamescope", "--", "game"], 1280, 960)
|
||||
process.terminate.assert_called_once_with()
|
||||
process.wait.assert_called_once_with(timeout=gamescope.GAMESCOPE_SHUTDOWN_TIMEOUT_SECONDS)
|
||||
mapper.close.assert_called_once_with()
|
||||
self.assertEqual(list(Path(temporary).iterdir()), [])
|
||||
|
||||
def test_child_report_failure_does_not_leave_an_empty_compositor_alive(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
fake_gamescope = root / "gamescope"
|
||||
fake_gamescope.write_text(
|
||||
f"#!{sys.executable}\n" + textwrap.dedent("""\
|
||||
import subprocess, sys, time
|
||||
separator = sys.argv.index('--')
|
||||
subprocess.run(sys.argv[separator + 1:], check=False)
|
||||
# Gamescope's default exits when the primary child finishes;
|
||||
# --keep-alive disables that behavior, even if the child failed.
|
||||
if '--keep-alive' in sys.argv[1:separator]:
|
||||
time.sleep(15)
|
||||
"""), encoding="utf-8",
|
||||
)
|
||||
fake_gamescope.chmod(0o700)
|
||||
runner = root / "supervisor.py"
|
||||
runner.write_text(textwrap.dedent("""\
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
from ptlinux.presentation import gamescope
|
||||
status = Path(sys.argv[2])
|
||||
with (
|
||||
patch.object(gamescope, '_select_host_backend', return_value='wayland'),
|
||||
patch.object(gamescope, '_select_wayland_gamescope', return_value=(sys.argv[1], True, False)),
|
||||
patch.object(gamescope, '_create_child_status_file', return_value=status),
|
||||
):
|
||||
raise SystemExit(gamescope.run_gamescope(
|
||||
[sys.argv[1], '--', sys.executable, '-c', 'raise SystemExit(7)'], 1280, 960,
|
||||
))
|
||||
"""), encoding="utf-8")
|
||||
for status, expected in ((root / "status", 7), (root / "removed-directory" / "status", 1)):
|
||||
with self.subTest(status=status):
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, str(runner), str(fake_gamescope), str(status)],
|
||||
env={**os.environ, "PYTHONPATH": str(SOURCE_ROOT)},
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True,
|
||||
)
|
||||
try:
|
||||
_stdout, stderr = process.communicate(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
process.communicate(timeout=5)
|
||||
raise
|
||||
self.assertEqual(process.returncode, expected, stderr)
|
||||
if expected == 1:
|
||||
self.assertIn("Could not report the nested game status", stderr)
|
||||
self.assertIn("without a recorded game status", stderr)
|
||||
else:
|
||||
self.assertNotIn("without a recorded game status", stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
690
tests/test_launcher_resilience.py
Normal file
690
tests/test_launcher_resilience.py
Normal file
@@ -0,0 +1,690 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import ExitStack, redirect_stderr, redirect_stdout
|
||||
from dataclasses import replace
|
||||
import hashlib
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import resource
|
||||
import runpy
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from scripts.repository_paths import SOURCE_ROOT
|
||||
|
||||
from ptlinux.command import CommandOutcome, CommandRunner
|
||||
from ptlinux.application.commands.play import (
|
||||
game_exit_metadata,
|
||||
game_failure_reason,
|
||||
play_locked,
|
||||
print_game_outcome,
|
||||
recommended_runtime_comparison,
|
||||
runtime_issue_summary,
|
||||
)
|
||||
from ptlinux.depot import DepotClient, DepotError, DepotFile, DepotStartOption
|
||||
from ptlinux.diagnostics.native_logs import parse_native_log_lines
|
||||
from ptlinux.doctor import inspect_host
|
||||
from ptlinux.game.ptr_compat import PtrCompatibilityError, ptr_bcrypt_dll_overrides, stage_ptr_bcrypt_compatibility
|
||||
from ptlinux.game.runtime import RuntimeErrorPT, RuntimeSelection, prepare_game_runtime, run_game, run_runtime_prefix_command
|
||||
from ptlinux.game.runtime_view import prepare_runtime_view
|
||||
from ptlinux.gamebox import build_gamebox_command
|
||||
from ptlinux.gui_bootstrap import recover_gui_python, tkinter_path
|
||||
from ptlinux.locking import managed_game_process_is_active
|
||||
from ptlinux.paths import AppPaths
|
||||
from ptlinux.presentation.gamescope import _read_gamescope_version, _supervised_command, _wait_for_optimized_gamescope
|
||||
from ptlinux.settings import Settings
|
||||
from ptlinux.telemetry.dependencies import _dependency_health
|
||||
from ptlinux.telemetry.runtime import _selected_prefix
|
||||
from ptlinux.telemetry import _resource_usage
|
||||
|
||||
|
||||
class NativeCrashLogTests(unittest.TestCase):
|
||||
def test_current_client_collision_and_minimap_allocation_failures(self) -> None:
|
||||
log = r"""
|
||||
[18:16:32.2832206] [CRIT] - Stage 0x3d1d0020 (not in the map list) has no scene/collision: stage without new stage at X: -10 Z: -110. 0 more lookups in the last minute.
|
||||
[18:16:38.7429594] [CRIT] - DX::EffectFactory::CreateEffectFromFile : 115 : E_OUTOFMEMORY : Ran out of memory
|
||||
[18:16:38.7429983] [CRIT] - Failed to create effect: game\scripts\shaders\UI\Minimap.fx 0x11a73ffc
|
||||
[18:16:38.7517353] [CRIT] - DX::EffectFactory::CreateEffectFromFile : 115 : E_OUTOFMEMORY : Ran out of memory
|
||||
[18:16:38.7517827] [CRIT] - Failed to create effect: game\scripts\shaders\UI\Minimap.fx 0x11a73ffc
|
||||
[18:16:39.2249996] [CRIT] - DX::EffectFactory::CreateEffectFromFile : 115 : E_OUTOFMEMORY : Ran out of memory
|
||||
[18:16:39.2250202] [CRIT] - Failed to create effect: game\scripts\shaders\UI\Minimap.fx 0x11a73ffc
|
||||
[18:16:39.2271872] [CRIT] - DX::EffectFactory::CreateEffectFromFile : 115 : E_OUTOFMEMORY : Ran out of memory
|
||||
[18:16:39.2271993] [CRIT] - Failed to create effect: game\scripts\shaders\UI\Minimap.fx 0x11a73ffc
|
||||
"""
|
||||
analysis = parse_native_log_lines(log.splitlines())
|
||||
summary = analysis.to_summary()
|
||||
self.assertTrue(summary["stage_without_new_stage"])
|
||||
self.assertTrue(summary["runtime_out_of_memory"])
|
||||
self.assertEqual(summary["out_of_memory_count"], 4)
|
||||
self.assertEqual(
|
||||
summary["out_of_memory_effect_paths"],
|
||||
[r"game\scripts\shaders\UI\Minimap.fx"],
|
||||
)
|
||||
self.assertEqual(
|
||||
len([event for event in analysis.events if event.kind == "stage_without_new_stage"]),
|
||||
1,
|
||||
)
|
||||
|
||||
def test_collision_warning_alone_does_not_imply_resource_exhaustion(self) -> None:
|
||||
analysis = parse_native_log_lines([
|
||||
"[21:14:40.2199865] [CRIT] - Stage 0x43635c38 (not in the map list) "
|
||||
"has no scene/collision: stage without new stage at X: -10 Z: -110. "
|
||||
"0 more lookups in the last minute."
|
||||
])
|
||||
self.assertTrue(analysis.stage_without_new_stage)
|
||||
self.assertFalse(analysis.runtime_out_of_memory)
|
||||
self.assertFalse(analysis.import_scene_threw)
|
||||
self.assertFalse(analysis.asset_pack_health_failed)
|
||||
|
||||
|
||||
class ResourceExhaustionGuidanceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.native_log = {
|
||||
"runtime_out_of_memory": True,
|
||||
"out_of_memory_count": 4,
|
||||
"out_of_memory_effect_paths": [r"game\scripts\shaders\UI\Minimap.fx"],
|
||||
"stage_without_new_stage": True,
|
||||
"managed_asset_integrity_failed": False,
|
||||
}
|
||||
self.runtime_issues = runtime_issue_summary(
|
||||
"Game window ready: PristonTale EU V23.86.96\n"
|
||||
"plugin.so: wrong ELF class: ELFCLASS64\n"
|
||||
"libavfilter.so.7: cannot open shared object file: No such file or directory\n"
|
||||
)
|
||||
|
||||
def test_proton7_report_keeps_oom_classification_and_suggests_ge(self) -> None:
|
||||
outcome = CommandOutcome([], 241, "", "", 138.0, False, False)
|
||||
self.assertEqual(
|
||||
game_failure_reason(outcome, self.native_log, None, game_exit_metadata(241, "proton7")),
|
||||
"direct3d_resource_exhaustion",
|
||||
)
|
||||
for kind in ("proton7", "proton7-container", "umu"):
|
||||
with self.subTest(runtime=kind):
|
||||
self.assertEqual(
|
||||
recommended_runtime_comparison(
|
||||
kind, self.runtime_issues, current_game_log=self.native_log, realm="Babel",
|
||||
),
|
||||
"ge-proton",
|
||||
)
|
||||
|
||||
def test_oom_evidence_takes_priority_over_secondary_runtime_warnings(self) -> None:
|
||||
issues = {
|
||||
**self.runtime_issues,
|
||||
"gstreamer_stream_error": True,
|
||||
"dbghelp_symdeferred_assertion_count": 1,
|
||||
"host_futex_fatal_count": 1,
|
||||
}
|
||||
for kind, expected in (("proton7", "ge-proton"), ("proton7-container", "ge-proton"), ("ge-proton", None)):
|
||||
with self.subTest(runtime=kind):
|
||||
self.assertEqual(
|
||||
recommended_runtime_comparison(kind, issues, current_game_log=self.native_log, realm="Babel"),
|
||||
expected,
|
||||
)
|
||||
|
||||
def test_ptr_and_publisher_realm_alias_keep_the_required_runtime(self) -> None:
|
||||
for realm in ("PTR Babel", "ptr babel", "Test Babel", "test babel"):
|
||||
with self.subTest(realm=realm):
|
||||
self.assertIsNone(recommended_runtime_comparison(
|
||||
"proton7", self.runtime_issues, current_game_log=self.native_log, realm=realm,
|
||||
))
|
||||
|
||||
def test_plugin_and_collision_warnings_alone_do_not_suggest_a_runtime_switch(self) -> None:
|
||||
for log in (None, {**self.native_log, "runtime_out_of_memory": False}):
|
||||
with self.subTest(log=log):
|
||||
self.assertIsNone(recommended_runtime_comparison(
|
||||
"proton7", self.runtime_issues, current_game_log=log, realm="Babel",
|
||||
))
|
||||
|
||||
def test_completed_play_reports_the_current_log_recommendation(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
paths = AppPaths(*(root / name for name in ("data", "config", "state", "cache")))
|
||||
paths.runtime_view.mkdir(parents=True)
|
||||
api = MagicMock()
|
||||
api.game_import_requires_verification.return_value = False
|
||||
api._load_or_default_settings.return_value = Settings(runtime="proton7")
|
||||
api.is_running_inside_steam_runtime.return_value = False
|
||||
api.read_cached_depot_launch_catalog.return_value = None
|
||||
api.read_current_depot_file_catalog.return_value = None
|
||||
api._current_game_log.return_value = self.native_log
|
||||
api._current_game_crash.return_value = None
|
||||
api.select_runtime.return_value = RuntimeSelection("proton7", "proton", "proton", None)
|
||||
api.run_game.return_value = CommandOutcome([], 241, "", "", 138.0, False, False)
|
||||
api._runtime_issue_summary.return_value = dict(self.runtime_issues)
|
||||
api._game_readiness_signal.return_value = "window_ready"
|
||||
api._game_exit_metadata.return_value = game_exit_metadata(241, "proton7")
|
||||
for realm, expected in (("Babel", "ge-proton"), ("PTR Babel", None)):
|
||||
with self.subTest(realm=realm):
|
||||
api.current_telemetry.return_value.reset_mock()
|
||||
api.select_depot_start_option.return_value = DepotStartOption(realm, "Game.exe", "PTEU_REBORN")
|
||||
self.assertEqual(play_locked(api, paths, None, False, False, False, True, reuse_prepared=True), 1)
|
||||
report = api._emit.call_args.args[0]
|
||||
self.assertFalse(report["ok"])
|
||||
self.assertTrue(report["degraded"])
|
||||
self.assertEqual(report["runtime_issues"].get("recommended_one_variable_comparison"), expected)
|
||||
exit_event = next(call for call in api.current_telemetry.return_value.send_event.call_args_list if call.args[0] == "game_exited")
|
||||
self.assertEqual(exit_event.kwargs["metadata"]["failure_reason"], "direct3d_resource_exhaustion")
|
||||
self.assertEqual(exit_event.kwargs["metadata"]["runtime_issues"].get("recommended_one_variable_comparison"), expected)
|
||||
api._runtime_issue_summary.return_value = dict(self.runtime_issues)
|
||||
|
||||
def test_runtime_advice_is_visible_in_the_launcher_output(self) -> None:
|
||||
output = io.StringIO()
|
||||
with redirect_stderr(output):
|
||||
print_game_outcome(
|
||||
CommandOutcome([], 241, "", "", 138.0, False, False), None,
|
||||
{**self.runtime_issues, "recommended_one_variable_comparison": "ge-proton"},
|
||||
)
|
||||
self.assertIn("runtime comparison: ge-proton", output.getvalue())
|
||||
|
||||
|
||||
class ResourceUsageScopeTests(unittest.TestCase):
|
||||
def test_peak_memory_is_explicitly_launcher_rss_and_cpu_is_launcher_time(self) -> None:
|
||||
with (
|
||||
patch("ptlinux.telemetry.sys.platform", "linux"),
|
||||
patch("ptlinux.telemetry.resource.getrusage", return_value=SimpleNamespace(
|
||||
ru_maxrss=305398, ru_utime=40.0, ru_stime=0.891,
|
||||
)) as read_usage,
|
||||
):
|
||||
self.assertEqual(_resource_usage(), {
|
||||
"resource_usage_scope": "launcher_process",
|
||||
"peak_memory_mb": 298.24,
|
||||
"cpu_time_seconds": 40.891,
|
||||
})
|
||||
read_usage.assert_called_once_with(resource.RUSAGE_SELF)
|
||||
|
||||
def test_unavailable_process_usage_does_not_emit_a_scope_without_measurements(self) -> None:
|
||||
with patch("ptlinux.telemetry.resource.getrusage", side_effect=OSError("unavailable")):
|
||||
self.assertEqual(_resource_usage(), {})
|
||||
|
||||
|
||||
class GuiRecoveryTests(unittest.TestCase):
|
||||
def test_missing_python_package_is_not_hidden_by_a_present_extension(self) -> None:
|
||||
def imports(name: str) -> object:
|
||||
if name == "tkinter":
|
||||
raise ModuleNotFoundError("No module named 'tkinter'")
|
||||
return MagicMock()
|
||||
with patch("ptlinux.gui_bootstrap.import_module", side_effect=imports):
|
||||
self.assertIsNone(tkinter_path())
|
||||
|
||||
def test_broken_tcl_data_is_reported_as_missing_gui_support(self) -> None:
|
||||
class TclError(Exception):
|
||||
pass
|
||||
tk = MagicMock()
|
||||
tk.Tcl.side_effect = TclError("Can't find a usable init.tcl")
|
||||
with patch("ptlinux.gui_bootstrap.import_module", return_value=tk):
|
||||
self.assertIsNone(tkinter_path())
|
||||
|
||||
def test_fallback_restarts_the_same_release_and_arguments_with_system_python(self) -> None:
|
||||
with (
|
||||
patch("ptlinux.gui_bootstrap.tkinter_path", return_value=None),
|
||||
patch("ptlinux.gui_bootstrap.Path.is_file", return_value=True),
|
||||
patch.object(sys, "executable", "/custom/python3"),
|
||||
patch.dict(os.environ, {"PYTHONHOME": "/custom", "PYTHONPATH": "/old", "DISPLAY": ":0"}, clear=True),
|
||||
patch("ptlinux.gui_bootstrap.subprocess.run", return_value=SimpleNamespace(returncode=0)) as probe,
|
||||
patch("ptlinux.gui_bootstrap.os.execve", side_effect=SystemExit(0)) as restart,
|
||||
self.assertRaises(SystemExit),
|
||||
):
|
||||
recover_gui_python(["gui"])
|
||||
self.assertEqual(probe.call_args.args[0][0], "/usr/bin/python3")
|
||||
self.assertEqual(restart.call_args.args[1], ["/usr/bin/python3", "-B", "-P", "-m", "ptlinux", "gui"])
|
||||
env = restart.call_args.args[2]
|
||||
self.assertNotIn("PYTHONHOME", env)
|
||||
self.assertEqual(env["DISPLAY"], ":0")
|
||||
self.assertTrue((Path(env["PYTHONPATH"]) / "ptlinux/__main__.py").is_file())
|
||||
|
||||
def test_explicit_interpreter_and_retry_guard_do_not_loop(self) -> None:
|
||||
for env in ({"PTLINUX_PYTHON": "/custom/python"}, {"PTLINUX_GUI_PYTHON_RETRIED": "1"}):
|
||||
with (
|
||||
patch.dict(os.environ, env, clear=True),
|
||||
patch("ptlinux.gui_bootstrap.tkinter_path", return_value=None),
|
||||
patch("ptlinux.gui_bootstrap.subprocess.run") as probe,
|
||||
):
|
||||
self.assertFalse(recover_gui_python([]))
|
||||
probe.assert_not_called()
|
||||
|
||||
def test_missing_tk_reports_dependency_and_shows_desktop_repair_before_bootstrap(self) -> None:
|
||||
telemetry = MagicMock()
|
||||
with (
|
||||
patch.object(sys, "argv", ["pristontale-eu", "gui"]),
|
||||
patch("ptlinux.gui_bootstrap.recover_gui_python", return_value=False),
|
||||
patch("ptlinux.gui_bootstrap.show_gui_dependency_error") as show,
|
||||
patch("ptlinux.telemetry.initialize_shared_telemetry", return_value=(telemetry, True)),
|
||||
patch("ptlinux.telemetry.shutdown_shared_telemetry") as shutdown,
|
||||
patch("ptlinux.umu.provision_umu_for_startup") as bootstrap,
|
||||
patch("sys.stderr", new_callable=io.StringIO),
|
||||
self.assertRaises(SystemExit) as stopped,
|
||||
):
|
||||
runpy.run_module("ptlinux.__main__", run_name="__main__")
|
||||
self.assertEqual(stopped.exception.code, 1)
|
||||
self.assertEqual(telemetry.send_event.call_args.args[0], "dependency_check_failed")
|
||||
self.assertEqual(telemetry.send_event.call_args.kwargs["metadata"]["dependencies"], ["python-tk"])
|
||||
telemetry.report_exception.assert_not_called()
|
||||
bootstrap.assert_not_called()
|
||||
show.assert_called_once()
|
||||
shutdown.assert_called_once_with(telemetry)
|
||||
|
||||
|
||||
class RuntimeDependencyTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.stack = ExitStack()
|
||||
self.addCleanup(self.stack.close)
|
||||
for target, value in (
|
||||
("_read_os_release", {"ID": "steamos", "VERSION_ID": "3.8"}),
|
||||
("_python_tk_path", "tkinter"),
|
||||
("find_wine_tools", None),
|
||||
("_umu_path", "/usr/bin/umu-run"),
|
||||
("inspect_container_host", SimpleNamespace(ready=True, bwrap_path="/usr/bin/bwrap", broken_umu_profile_link=False)),
|
||||
):
|
||||
self.stack.enter_context(patch(f"ptlinux.doctor.{target}", return_value=value))
|
||||
self.stack.enter_context(patch("ptlinux.doctor.shutil.which", side_effect=lambda name, **kw: "/usr/bin/openssl" if name == "openssl" else None))
|
||||
self.support = {"vulkan_available": True, "vulkan_64bit": True, "vulkan_32bit": True}
|
||||
|
||||
def test_steamos_umu_does_not_require_host_wine_or_installation_tools(self) -> None:
|
||||
for kind in ("umu", "ge-proton"):
|
||||
with self.subTest(kind=kind):
|
||||
health = _dependency_health({}, None, {**self.support, "kind": kind})
|
||||
self.assertTrue(health["healthy"])
|
||||
self.assertEqual(health["missing_required"], [])
|
||||
self.assertFalse(health["tools"]["wine"])
|
||||
self.assertFalse(health["archive_tools_present"])
|
||||
|
||||
def test_legacy_prefix_still_requires_tools_until_directx_is_prepared(self) -> None:
|
||||
for ready in (False, True):
|
||||
with patch("ptlinux.doctor.native_d3d_ready", return_value=ready):
|
||||
report = inspect_host({}, vulkan_support=self.support,
|
||||
game_runtime={"kind": "proton7"}, runtime_prefix=Path("/test/prefix"))
|
||||
self.assertEqual(report.ok, ready)
|
||||
missing = {tool.name for tool in report.tools if tool.required and tool.path is None}
|
||||
self.assertEqual(missing, set() if ready else {"winetricks", "cabextract"})
|
||||
|
||||
def test_modern_wow64_uses_64bit_driver_while_umu_keeps_multilib_check(self) -> None:
|
||||
for kind, expected in (("ge-proton", True), ("umu", False)):
|
||||
health = _dependency_health({}, None, {**self.support, "kind": kind, "vulkan_32bit": False})
|
||||
self.assertEqual(health["healthy"], expected)
|
||||
|
||||
|
||||
class LaunchIsolationTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name)
|
||||
self.paths = AppPaths(*(self.root / name for name in ("data", "config", "state", "cache")))
|
||||
self.paths.runtime_view.mkdir(parents=True)
|
||||
(self.paths.runtime_view / "Game.exe").touch()
|
||||
self.selection = RuntimeSelection("ge-proton", "/fake/umu-run", None, None)
|
||||
self.runner = MagicMock(dry_run=False)
|
||||
self.host = patch("ptlinux.game.runtime._require_container_host")
|
||||
self.host.start()
|
||||
self.addCleanup(self.host.stop)
|
||||
|
||||
def _bundled_proton_launcher(self, kind: str) -> RuntimeSelection:
|
||||
proton_directory = self.root / "bundled Proton"
|
||||
proton_directory.mkdir(exist_ok=True)
|
||||
(proton_directory / "ptlinux_proton_test_dependency.py").write_text("VALUE = 'bundled'\n")
|
||||
proton = proton_directory / "proton"
|
||||
proton.write_text(
|
||||
"import json, sys\n"
|
||||
"from ptlinux_proton_test_dependency import VALUE\n"
|
||||
"print(json.dumps({'dependency': VALUE, 'safe_path': sys.flags.safe_path, "
|
||||
"'argv': sys.argv[1:]}))\n"
|
||||
)
|
||||
umu = self.root / "umu-run"
|
||||
umu.write_text(
|
||||
f"#!{sys.executable}\n"
|
||||
"import os, sys\n"
|
||||
f"os.execv(sys.executable, [sys.executable, {str(proton)!r}, *sys.argv[1:]])\n"
|
||||
)
|
||||
umu.chmod(0o755)
|
||||
return RuntimeSelection(kind, str(umu), str(proton), str(self.root / "Steam"))
|
||||
|
||||
def test_bundled_proton_imports_survive_launcher_safe_path_during_setup(self) -> None:
|
||||
for kind in ("umu", "ge-proton"):
|
||||
with self.subTest(kind=kind), patch.dict(os.environ, {"PYTHONSAFEPATH": "1"}):
|
||||
selection = self._bundled_proton_launcher(kind)
|
||||
runner = CommandRunner(self.root / "commands.jsonl")
|
||||
with patch("ptlinux.game.runtime.native_d3d_ready", side_effect=[False, True]):
|
||||
outcomes = prepare_game_runtime(self.paths, selection, runner)
|
||||
child = json.loads(outcomes[0].stdout)
|
||||
self.assertEqual(child["dependency"], "bundled")
|
||||
self.assertFalse(child["safe_path"])
|
||||
self.assertEqual(child["argv"], ["winetricks", "-q", "d3dx9_43", "d3dcompiler_43"])
|
||||
self.assertEqual(os.environ["PYTHONSAFEPATH"], "1")
|
||||
|
||||
def test_bundled_proton_imports_survive_safe_path_for_games_and_helpers(self) -> None:
|
||||
for kind in ("umu", "ge-proton", "proton7-container"):
|
||||
with self.subTest(kind=kind), patch.dict(os.environ, {"PYTHONSAFEPATH": "1"}):
|
||||
selection = self._bundled_proton_launcher(kind)
|
||||
runner = CommandRunner(self.root / "commands.jsonl")
|
||||
helper = run_runtime_prefix_command(self.paths, selection, runner,
|
||||
["reg.exe", "query"], timeout_seconds=5)
|
||||
with redirect_stdout(io.StringIO()):
|
||||
game = run_game(self.paths, Settings(), selection, runner,
|
||||
use_gamebox=False, timeout_seconds=5)
|
||||
for outcome in (helper, game):
|
||||
child = json.loads(outcome.stdout)
|
||||
self.assertEqual(child["dependency"], "bundled")
|
||||
self.assertFalse(child["safe_path"])
|
||||
self.assertEqual(os.environ["PYTHONSAFEPATH"], "1")
|
||||
|
||||
def test_launcher_wrappers_ignore_python_modules_in_the_game_directory(self) -> None:
|
||||
shadow = self.paths.runtime_view / "ptlinux"
|
||||
shadow.mkdir()
|
||||
(shadow / "__init__.py").write_text("raise RuntimeError('game directory shadowed launcher')\n")
|
||||
command = build_gamebox_command(["umu-run", "Game.exe"],
|
||||
Settings(allow_window_resizing=True), game_size=(1280, 960), gamescope_path="gamescope")
|
||||
separator = command.index("--")
|
||||
command[separator + 1:] = _supervised_command(
|
||||
command[separator + 1:], self.root / "status", "wayland")
|
||||
environment = {
|
||||
**os.environ,
|
||||
"PYTHONSAFEPATH": "",
|
||||
"PYTHONPATH": str(SOURCE_ROOT),
|
||||
}
|
||||
for module in ("ptlinux.gamescope_runtime", "ptlinux.gamescope_child_runtime", "ptlinux.gamebox_runtime"):
|
||||
with self.subTest(module=module):
|
||||
index = command.index(module)
|
||||
wrapper = command[command.index(sys.executable, index - 3):index + 1]
|
||||
result = subprocess.run([*wrapper, "--help"], cwd=self.paths.runtime_view,
|
||||
env=environment, capture_output=True, text=True, timeout=5)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("usage:", result.stdout)
|
||||
|
||||
def test_gamescope_can_finish_its_shutdown_without_sigkill(self) -> None:
|
||||
cleanup = self.root / "display-closed"
|
||||
status = self.root / "status"
|
||||
status.write_text("0\n")
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, "-c",
|
||||
"import signal\nfrom pathlib import Path\n"
|
||||
"def stop(*args):\n"
|
||||
f" Path({str(cleanup)!r}).touch()\n"
|
||||
" raise SystemExit(0)\n"
|
||||
"signal.signal(signal.SIGTERM, stop)\n"
|
||||
"print('ready', flush=True)\n"
|
||||
"signal.pause()\n"],
|
||||
stdout=subprocess.PIPE, text=True,
|
||||
)
|
||||
try:
|
||||
assert process.stdout is not None
|
||||
self.assertEqual(process.stdout.readline().strip(), "ready")
|
||||
self.assertEqual(_wait_for_optimized_gamescope(process, status), 0)
|
||||
self.assertEqual(process.returncode, 0)
|
||||
self.assertTrue(cleanup.exists())
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
if process.stdout is not None:
|
||||
process.stdout.close()
|
||||
|
||||
def test_version_detection_recognizes_the_bundled_gamescope_filename(self) -> None:
|
||||
banner = (
|
||||
"[gamescope-3.16.20-ubuntu26.04-amd64] [\x1b[0;34mInfo\x1b[0m] "
|
||||
"\x1b[0;37mconsole:\x1b[0m gamescope-3.16.20-ubuntu26.04-amd64 "
|
||||
"version 3.16.20+ds-1 (gcc 15.2.0)\n"
|
||||
)
|
||||
with patch("ptlinux.presentation.gamescope.subprocess.run",
|
||||
return_value=SimpleNamespace(returncode=0, stdout=banner)):
|
||||
self.assertEqual(_read_gamescope_version("gamescope-custom"), (3, 16, 20))
|
||||
|
||||
def test_gamescope_cleanup_timeout_preserves_the_game_failure(self) -> None:
|
||||
status = self.root / "status"
|
||||
status.write_text("-11\n")
|
||||
process = MagicMock()
|
||||
process.poll.return_value = None
|
||||
process.wait.side_effect = [subprocess.TimeoutExpired("gamescope", 2), -9]
|
||||
output = io.StringIO()
|
||||
with patch("sys.stderr", output):
|
||||
self.assertEqual(_wait_for_optimized_gamescope(process, status), 139)
|
||||
process.terminate.assert_called_once_with()
|
||||
process.kill.assert_called_once_with()
|
||||
self.assertTrue(runtime_issue_summary(output.getvalue())["gamescope_forced_cleanup"])
|
||||
|
||||
def test_gamescope_exit_without_game_status_is_not_reported_as_clean(self) -> None:
|
||||
status = self.root / "status"
|
||||
for contents in (None, "", "invalid", "999"):
|
||||
for compositor_status, expected in ((0, 1), (-11, 139), (247, 247)):
|
||||
with self.subTest(contents=contents, status=compositor_status):
|
||||
status.unlink(missing_ok=True)
|
||||
if contents is not None:
|
||||
status.write_text(contents)
|
||||
process = MagicMock()
|
||||
process.poll.return_value = compositor_status
|
||||
output = io.StringIO()
|
||||
with patch("sys.stderr", output):
|
||||
self.assertEqual(_wait_for_optimized_gamescope(process, status), expected)
|
||||
issues = runtime_issue_summary(output.getvalue())
|
||||
self.assertTrue(issues["gamescope_child_status_missing"])
|
||||
self.assertEqual(issues["gamescope_exit_code"], compositor_status)
|
||||
process.terminate.assert_not_called()
|
||||
|
||||
def test_optional_runtime_warnings_are_distinct_from_presentation_failures(self) -> None:
|
||||
output = (
|
||||
"sd_bus_call(g_dbus, message, UINT64_MAX, NULL, &reply)failed with ret -30\n" * 2
|
||||
+ "No CAP_SYS_NICE, falling back to regular-priority compute and threads.\n"
|
||||
+ "Proton: Error: unable to use parent for game drive, path \n"
|
||||
+ "Game window ready: PristonTale EU V23.86.96\n"
|
||||
)
|
||||
report = runtime_issue_summary(output)
|
||||
self.assertEqual(report["gamescope_memory_protection_warning_count"], 2)
|
||||
self.assertTrue(report["gamescope_priority_fallback"])
|
||||
self.assertTrue(report["proton_game_drive_warning"])
|
||||
self.assertTrue(report["game_window_ready"])
|
||||
self.assertFalse(report["gamescope_child_status_missing"])
|
||||
self.assertFalse(report["gamescope_forced_cleanup"])
|
||||
self.assertEqual(report["gamescope_vulkan_output_failed_count"], 0)
|
||||
|
||||
def test_actual_child_receives_literal_arguments_prefix_and_renderer(self) -> None:
|
||||
fake_umu = self.root / "fake umu-run"
|
||||
fake_umu.write_text(
|
||||
f"#!{sys.executable}\n"
|
||||
"import json, os, sys\n"
|
||||
"print(json.dumps({'argv': sys.argv[1:], 'cwd': os.getcwd(), "
|
||||
"'prefix': os.environ['WINEPREFIX'], 'renderer': os.environ.get('PROTON_USE_WINED3D'), "
|
||||
"'verb': os.environ['PROTON_VERB']}))\n"
|
||||
)
|
||||
fake_umu.chmod(0o755)
|
||||
selection = replace(self.selection, executable=str(fake_umu))
|
||||
arguments = ("NEW_TOKEN", "", r"C:\game data\assets", "$(touch should-not-exist)")
|
||||
option = DepotStartOption("Babel", "Game.exe", subprocess.list2cmdline(arguments))
|
||||
runner = CommandRunner(self.root / "commands.jsonl")
|
||||
with redirect_stdout(io.StringIO()):
|
||||
outcome = run_game(self.paths, Settings(renderer="wined3d"), selection, runner,
|
||||
start_option=option, use_gamebox=False, timeout_seconds=5)
|
||||
child = json.loads(outcome.stdout)
|
||||
self.assertEqual(child["argv"], [str(self.paths.runtime_view / "Game.exe"), *arguments])
|
||||
self.assertEqual(child["cwd"], str(self.paths.runtime_view))
|
||||
self.assertEqual(child["prefix"], str(self.paths.ge_proton_prefix))
|
||||
self.assertEqual(child["renderer"], "1")
|
||||
self.assertEqual(child["verb"], "waitforexitandrun")
|
||||
self.assertFalse((self.paths.runtime_view / "should-not-exist").exists())
|
||||
|
||||
def test_changed_renderer_or_sync_cannot_join_an_active_prefix(self) -> None:
|
||||
run_game(self.paths, Settings(), self.selection, self.runner, use_gamebox=False)
|
||||
marker = (self.paths.state / "game-launch.json").read_bytes()
|
||||
for settings in (Settings(renderer="wined3d"), Settings(proton_sync="esync")):
|
||||
self.runner.run.reset_mock()
|
||||
shared = MagicMock()
|
||||
with self.assertRaisesRegex(RuntimeErrorPT, "close every client"):
|
||||
run_game(self.paths, settings, self.selection, self.runner,
|
||||
use_gamebox=False, concurrent_prefix_launch=True, before_start=shared)
|
||||
self.runner.run.assert_not_called()
|
||||
shared.assert_not_called()
|
||||
self.assertEqual((self.paths.state / "game-launch.json").read_bytes(), marker)
|
||||
|
||||
def test_preparation_finishes_before_shared_lease_and_spawn(self) -> None:
|
||||
order = []
|
||||
def share() -> None:
|
||||
self.assertTrue((self.paths.state / "game-launch.json").is_file())
|
||||
order.append("shared")
|
||||
self.runner.run.side_effect = lambda *a, **kw: order.append("spawn")
|
||||
run_game(self.paths, Settings(), self.selection, self.runner, use_gamebox=False, before_start=share)
|
||||
self.assertEqual(order, ["shared", "spawn"])
|
||||
|
||||
def test_helpers_join_umu_prefix_without_waiting_for_game_exit(self) -> None:
|
||||
for kind in ("umu", "ge-proton", "proton7-container"):
|
||||
selection = RuntimeSelection(kind, "/fake/umu-run", "/Steam/proton", "/Steam")
|
||||
run_runtime_prefix_command(self.paths, selection, self.runner, ["reg.exe", "query"])
|
||||
self.assertEqual(self.runner.run.call_args.kwargs["env_overrides"]["PROTON_VERB"], "runinprefix")
|
||||
|
||||
def test_missing_selected_prefix_does_not_report_another_prefixs_components(self) -> None:
|
||||
(self.paths.proton_prefix / "drive_c/windows").mkdir(parents=True)
|
||||
self.assertIsNone(_selected_prefix(self.paths, {"runtime": {"kind": "ge-proton"}}))
|
||||
|
||||
def test_windows_paths_and_long_game_names_are_detected_but_services_are_not(self) -> None:
|
||||
process = self.root / "proc/1000"
|
||||
process.mkdir(parents=True)
|
||||
(process / "environ").write_bytes(f"WINEPREFIX={self.paths.proton_prefix}\0".encode())
|
||||
(process / "comm").write_text("FutureGameClien\n")
|
||||
for command, expected in (
|
||||
(r"C:\Program Files (x86)\Pristontale EU\FutureGameClient.exe", True),
|
||||
(str(self.paths.runtime_view / "FutureGameClient.exe"), True),
|
||||
(r"C:\windows\system32\services.exe", False),
|
||||
(str(self.paths.runtime_view) + "-other/Game.exe", False),
|
||||
):
|
||||
(process / "cmdline").write_bytes(command.encode() + b"\0")
|
||||
self.assertEqual(managed_game_process_is_active(self.paths.runtime_view,
|
||||
(self.paths.proton_prefix,), process_root=self.root / "proc"), expected)
|
||||
|
||||
def test_ptr_dlls_are_removed_before_preparing_a_new_stable_session(self) -> None:
|
||||
source = self.paths.source_game
|
||||
for name in ("Game.exe", "game.dll", "game.ini", "game/maps/WhisperingVale.rovug"):
|
||||
path = source / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"future publisher data")
|
||||
prepare_runtime_view(self.paths, source)
|
||||
for name in ("bcrypt.dll", "bcrypt_orig.dll"):
|
||||
(self.paths.runtime_view / name).write_bytes(b"PTR specific DLL")
|
||||
prepare_runtime_view(self.paths, source)
|
||||
self.assertFalse((self.paths.runtime_view / "bcrypt.dll").exists())
|
||||
self.assertFalse((self.paths.runtime_view / "bcrypt_orig.dll").exists())
|
||||
self.assertEqual((source / "game.dll").read_bytes(), b"future publisher data")
|
||||
|
||||
def test_shared_ptr_preparation_checks_bytes_without_replacing_them(self) -> None:
|
||||
proton = self.root / "proton/proton"
|
||||
original = proton.parent / "dist/lib/wine/i386-windows/bcrypt.dll"
|
||||
original.parent.mkdir(parents=True)
|
||||
proton.touch()
|
||||
pe = bytearray(128)
|
||||
pe[:2] = b"MZ"
|
||||
pe[60:64] = (64).to_bytes(4, "little")
|
||||
pe[64:68] = b"PE\0\0"
|
||||
pe[68:70] = (0x14c).to_bytes(2, "little")
|
||||
pe[88:90] = (0x10b).to_bytes(2, "little")
|
||||
original.write_bytes(pe)
|
||||
proxy, target = stage_ptr_bcrypt_compatibility(self.paths.runtime_view, proton)
|
||||
before = (proxy.stat().st_mtime_ns, target.stat().st_mtime_ns)
|
||||
stage_ptr_bcrypt_compatibility(self.paths.runtime_view, proton, read_only=True)
|
||||
self.assertEqual(before, (proxy.stat().st_mtime_ns, target.stat().st_mtime_ns))
|
||||
target.write_bytes(b"wrong version")
|
||||
with self.assertRaises(PtrCompatibilityError):
|
||||
stage_ptr_bcrypt_compatibility(self.paths.runtime_view, proton, read_only=True)
|
||||
self.assertEqual(target.read_bytes(), b"wrong version")
|
||||
self.assertEqual(ptr_bcrypt_dll_overrides("*bcrypt,foo=b"), "foo=b;bcrypt=n")
|
||||
|
||||
|
||||
class Response:
|
||||
def __init__(self, body: bytes) -> None:
|
||||
self.body = io.BytesIO(body)
|
||||
self.headers = {"Content-Length": str(len(body))}
|
||||
self.status = 200
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
return self.body.read(size)
|
||||
def close(self) -> None:
|
||||
self.body.close()
|
||||
|
||||
|
||||
class DepotRecoveryTests(unittest.TestCase):
|
||||
def test_manifest_read_timeout_retries_and_closes_failed_response(self) -> None:
|
||||
failed = MagicMock(headers={})
|
||||
failed.read.side_effect = TimeoutError("The read operation timed out")
|
||||
opener = MagicMock(side_effect=[failed, Response(b'{"Build": 42}')])
|
||||
client = DepotClient(opener=opener, retry_delays=(0,), sleeper=lambda _: None)
|
||||
self.assertEqual(client._get_json("https://cdn.example/manifest.json"), {"Build": 42})
|
||||
failed.close.assert_called_once()
|
||||
self.assertEqual(opener.call_count, 2)
|
||||
|
||||
def test_permanent_manifest_error_falls_back_without_repeated_requests(self) -> None:
|
||||
opener = MagicMock(side_effect=HTTPError("https://cdn.example/manifest.json", 404, "missing", {}, None))
|
||||
client = DepotClient(opener=opener, retry_delays=(0, 0), sleeper=lambda _: None)
|
||||
with self.assertRaises(DepotError):
|
||||
client._get_json("https://cdn.example/manifest.json")
|
||||
self.assertEqual(opener.call_count, 1)
|
||||
|
||||
def test_timeout_cooldown_avoids_failed_mirror_on_following_files_then_expires(self) -> None:
|
||||
body = b"verified data"
|
||||
file = DepotFile("file.bin", 42, len(body), hashlib.sha1(body).hexdigest())
|
||||
urls = []
|
||||
def open_response(request: Request, *, timeout: float) -> Response:
|
||||
urls.append(request.full_url)
|
||||
if len(urls) == 1:
|
||||
raise TimeoutError("The read operation timed out")
|
||||
return Response(body)
|
||||
client = DepotClient(opener=open_response, retry_delays=(0,), sleeper=lambda _: None)
|
||||
client._candidate_servers = ("primary.example", "secondary.example")
|
||||
with tempfile.TemporaryDirectory() as temporary, patch("ptlinux.depot.time.monotonic", return_value=100) as clock:
|
||||
for name in ("first", "second"):
|
||||
client.download_file("primary.example", file, Path(temporary) / name)
|
||||
clock.return_value = 161
|
||||
client.download_file("primary.example", file, Path(temporary) / "third")
|
||||
self.assertEqual([url.split("/")[2] for url in urls],
|
||||
["primary.example", "secondary.example", "secondary.example", "primary.example"])
|
||||
|
||||
def test_real_http_read_timeout_retains_small_chunk_and_resumes_verified_bytes(self) -> None:
|
||||
body = b"a small initial block followed by the remaining verified data"
|
||||
cut = 12
|
||||
requests = []
|
||||
recovered = threading.Event()
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args: object) -> None:
|
||||
pass
|
||||
def do_GET(self) -> None:
|
||||
requests.append(self.headers.get("Range"))
|
||||
if len(requests) == 1:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body[:cut])
|
||||
self.wfile.flush()
|
||||
recovered.wait(5)
|
||||
else:
|
||||
self.send_response(206)
|
||||
self.send_header("Content-Length", str(len(body) - cut))
|
||||
self.send_header("Content-Range", f"bytes {cut}-{len(body)-1}/{len(body)}")
|
||||
self.end_headers()
|
||||
self.wfile.write(body[cut:])
|
||||
recovered.set()
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
def local_open(request: Request, *, timeout: float):
|
||||
return urlopen(Request(f"http://127.0.0.1:{server.server_port}/data",
|
||||
headers=dict(request.header_items())), timeout=timeout)
|
||||
client = DepotClient(opener=local_open, timeout_seconds=0.2, retry_delays=(0,), sleeper=lambda _: None)
|
||||
file = DepotFile("wind_a.TGA", 42, len(body), hashlib.sha1(body).hexdigest())
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
destination = Path(temporary) / "wind_a.TGA"
|
||||
destination.write_bytes(b"previous verified version")
|
||||
client.download_file("cdn.example", file, destination)
|
||||
self.assertEqual(destination.read_bytes(), body)
|
||||
self.assertEqual(requests, [None, f"bytes={cut}-"])
|
||||
finally:
|
||||
recovered.set()
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
1425
tests/test_ptlinux_cli.py
Normal file
1425
tests/test_ptlinux_cli.py
Normal file
File diff suppressed because it is too large
Load Diff
2554
tests/test_ptlinux_gamebox_runtime.py
Normal file
2554
tests/test_ptlinux_gamebox_runtime.py
Normal file
File diff suppressed because it is too large
Load Diff
830
tests/test_ptlinux_gamescope_runtime.py
Normal file
830
tests/test_ptlinux_gamescope_runtime.py
Normal file
@@ -0,0 +1,830 @@
|
||||
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()
|
||||
866
tests/test_ptlinux_packaging.py
Normal file
866
tests/test_ptlinux_packaging.py
Normal file
@@ -0,0 +1,866 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from ptlinux import __version__
|
||||
from ptlinux.desktop_entry import escape_icon_path, quote_exec_argument
|
||||
from ptlinux.launcher_wrapper import quote_shell_word
|
||||
from scripts.repository_paths import PROJECT_ROOT, SOURCE_ROOT
|
||||
from tools.sign_release_artifact import sign_release_artifact
|
||||
|
||||
|
||||
class AioPackagingTests(unittest.TestCase):
|
||||
def test_bootstrap_release_remains_reproducible_with_a_fixed_epoch(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
outputs = (root / "first", root / "second")
|
||||
for output in outputs:
|
||||
environment = os.environ.copy()
|
||||
environment.pop("PTLINUX_VERSION", None)
|
||||
environment.update(
|
||||
{
|
||||
"PTLINUX_OUTPUT_DIRECTORY": str(output),
|
||||
"PTLINUX_PYTHON": sys.executable,
|
||||
"SOURCE_DATE_EPOCH": "0",
|
||||
}
|
||||
)
|
||||
built = subprocess.run(
|
||||
["bash", str(PROJECT_ROOT / "tools/build-aio-release.sh")],
|
||||
cwd=PROJECT_ROOT,
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(built.returncode, 0, built.stderr)
|
||||
|
||||
first = {
|
||||
path.name: path.read_bytes()
|
||||
for path in outputs[0].iterdir()
|
||||
if path.is_file()
|
||||
}
|
||||
second = {
|
||||
path.name: path.read_bytes()
|
||||
for path in outputs[1].iterdir()
|
||||
if path.is_file()
|
||||
}
|
||||
self.assertEqual(first, second)
|
||||
|
||||
def test_packaged_installer_uses_the_compatible_top_level_source_layout(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
output = root / "dist"
|
||||
build_environment = os.environ.copy()
|
||||
build_environment.pop("PTLINUX_VERSION", None)
|
||||
build_environment.update(
|
||||
{
|
||||
"PTLINUX_OUTPUT_DIRECTORY": str(output),
|
||||
"PTLINUX_PYTHON": sys.executable,
|
||||
"SOURCE_DATE_EPOCH": "0",
|
||||
}
|
||||
)
|
||||
built = subprocess.run(
|
||||
["bash", str(PROJECT_ROOT / "tools/build-aio-release.sh")],
|
||||
cwd=PROJECT_ROOT,
|
||||
env=build_environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(built.returncode, 0, built.stderr)
|
||||
|
||||
release_name = (
|
||||
f"pristontale-eu-linux-installer-v{__version__.replace('.', '-')}"
|
||||
)
|
||||
archive = output / f"{release_name}.tar.gz"
|
||||
extracted = root / "extracted"
|
||||
with tarfile.open(archive, "r:gz") as bundle:
|
||||
bundle.extractall(extracted, filter="data")
|
||||
packaged_root = extracted / release_name
|
||||
self.assertTrue((packaged_root / "ptlinux").is_dir())
|
||||
self.assertFalse((packaged_root / "src").exists())
|
||||
|
||||
home = root / "home"
|
||||
data_home = root / "desktop-data"
|
||||
install_directory = root / "managed-install"
|
||||
install_environment = os.environ.copy()
|
||||
install_environment.pop("PTLINUX_INSTALL_DIR", None)
|
||||
install_environment.update(
|
||||
{
|
||||
"HOME": str(home),
|
||||
"XDG_DATA_HOME": str(data_home),
|
||||
"PATH": "/usr/bin:/bin",
|
||||
"PTLINUX_PYTHON": sys.executable,
|
||||
}
|
||||
)
|
||||
installed = subprocess.run(
|
||||
[
|
||||
"bash",
|
||||
str(packaged_root / "install.sh"),
|
||||
"--install-dir",
|
||||
str(install_directory),
|
||||
"--skip-deps",
|
||||
"--skip-doctor",
|
||||
"--skip-runtime-bootstrap",
|
||||
],
|
||||
cwd=packaged_root,
|
||||
env=install_environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(installed.returncode, 0, installed.stderr)
|
||||
current = install_directory / "app/current"
|
||||
self.assertTrue((current / "ptlinux/cli.py").is_file())
|
||||
self.assertTrue((current / "ptdiag/logs.py").is_file())
|
||||
|
||||
def test_installer_dry_run_reports_destinations_without_writing(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
home = root / "home"
|
||||
data_home = root / "desktop-data"
|
||||
install_directory = root / "managed-install"
|
||||
environment = os.environ.copy()
|
||||
environment.pop("PTLINUX_INSTALL_DIR", None)
|
||||
environment.update(
|
||||
{
|
||||
"HOME": str(home),
|
||||
"XDG_DATA_HOME": str(data_home),
|
||||
"PATH": "/usr/bin:/bin",
|
||||
"PTLINUX_PYTHON": sys.executable,
|
||||
}
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"bash",
|
||||
str(PROJECT_ROOT / "install.sh"),
|
||||
"--install-dir",
|
||||
str(install_directory),
|
||||
"--dry-run",
|
||||
"--skip-deps",
|
||||
"--skip-doctor",
|
||||
"--skip-runtime-bootstrap",
|
||||
],
|
||||
cwd=PROJECT_ROOT,
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn(f"Install data: {install_directory}", result.stdout)
|
||||
self.assertIn("Skipping host dependency installation.", result.stdout)
|
||||
self.assertFalse(install_directory.exists())
|
||||
self.assertFalse(home.exists())
|
||||
self.assertFalse(data_home.exists())
|
||||
|
||||
def test_installer_writes_path_independent_desktop_entry(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
home = root / "home with spaces"
|
||||
data_home = root / "data with spaces"
|
||||
home.mkdir()
|
||||
fake_bin = root / "bin"
|
||||
fake_bin.mkdir()
|
||||
fake_umu = fake_bin / "umu-run"
|
||||
fake_umu.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||
fake_umu.chmod(0o755)
|
||||
environment = os.environ.copy()
|
||||
environment.pop("PTLINUX_INSTALL_DIR", None)
|
||||
environment.update(
|
||||
{
|
||||
"HOME": str(home),
|
||||
"XDG_DATA_HOME": str(data_home),
|
||||
"PATH": f"{fake_bin}:/usr/bin:/bin",
|
||||
"PTLINUX_PYTHON": sys.executable,
|
||||
}
|
||||
)
|
||||
result = subprocess.run(
|
||||
[
|
||||
"bash",
|
||||
str(PROJECT_ROOT / "install.sh"),
|
||||
"--skip-deps",
|
||||
"--skip-doctor",
|
||||
],
|
||||
cwd=PROJECT_ROOT,
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn(str(fake_umu.resolve()), result.stdout)
|
||||
self.assertIn(
|
||||
f"Note: {home / '.local/bin'} is not in PATH for this shell.",
|
||||
result.stdout,
|
||||
)
|
||||
output_lines = result.stdout.splitlines()
|
||||
start_line = output_lines[output_lines.index("Start PristonTale EU with:") + 1]
|
||||
self.assertEqual(
|
||||
shlex.split(start_line),
|
||||
[str(home / ".local/bin/pristontale-eu"), "gui"],
|
||||
)
|
||||
self.assertIn("export PATH=", result.stdout)
|
||||
default_install = home / "Games/PristontaleEU"
|
||||
self.assertTrue((default_install / "app/current/ptlinux").is_dir())
|
||||
self.assertTrue((default_install / "app/current/ptlinux/umu.py").is_file())
|
||||
self.assertTrue(
|
||||
(default_install / "app/current/ptlinux/ui/dashboard.py").is_file()
|
||||
)
|
||||
self.assertTrue(
|
||||
(
|
||||
default_install
|
||||
/ "app/current/ptlinux/ui/viewmodels/downloader.py"
|
||||
).is_file()
|
||||
)
|
||||
self.assertTrue(
|
||||
(default_install / "app/current/ptlinux/native/pt-identity-probe.exe").is_file()
|
||||
)
|
||||
self.assertTrue(
|
||||
(default_install / "app/current/ptlinux/native/ptr-bcrypt-proxy.dll").is_file()
|
||||
)
|
||||
gamescope = (
|
||||
default_install
|
||||
/ "app/current/ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64"
|
||||
)
|
||||
self.assertTrue(gamescope.is_file())
|
||||
self.assertEqual(gamescope.stat().st_mode & 0o777, 0o755)
|
||||
self.assertFalse(
|
||||
(default_install / "app/current/ptlinux/desktop_entry.py").exists()
|
||||
)
|
||||
self.assertFalse(
|
||||
(default_install / "app/current/ptlinux/launcher_wrapper.py").exists()
|
||||
)
|
||||
self.assertFalse(
|
||||
(default_install / "app/current/ptlinux/world_fix.py").exists()
|
||||
)
|
||||
self.assertTrue((default_install / "app/current").is_symlink())
|
||||
self.assertEqual(
|
||||
(default_install / "app/current").resolve().stat().st_mode & 0o777,
|
||||
0o755,
|
||||
)
|
||||
launcher = home / ".local/bin/pristontale-eu"
|
||||
desktop = data_home / "applications/eu.pristontale.PTLinux.desktop"
|
||||
icon = data_home / "pixmaps/eu.pristontale.PTLinux.png"
|
||||
self.assertTrue(launcher.is_file())
|
||||
self.assertEqual(
|
||||
icon.read_bytes(),
|
||||
(PROJECT_ROOT / "packaging/eu.pristontale.PTLinux.png").read_bytes(),
|
||||
)
|
||||
entry = desktop.read_text(encoding="utf-8")
|
||||
exec_line = next(line for line in entry.splitlines() if line.startswith("Exec="))
|
||||
icon_line = next(line for line in entry.splitlines() if line.startswith("Icon="))
|
||||
self.assertEqual(
|
||||
exec_line,
|
||||
f"Exec={quote_exec_argument(str(launcher))} gui",
|
||||
)
|
||||
self.assertEqual(icon_line, f"Icon={escape_icon_path(str(icon))}")
|
||||
self.assertIn("StartupWMClass=PristonTaleEU", entry)
|
||||
self.assertNotIn("TryExec=", entry)
|
||||
self.assertIn(
|
||||
f"install_directory={quote_shell_word(str(default_install))}",
|
||||
launcher.read_text(encoding="utf-8"),
|
||||
)
|
||||
self.assertIn(
|
||||
'exec "$python_bin" -B -P -m ptlinux',
|
||||
launcher.read_text(encoding="utf-8"),
|
||||
)
|
||||
|
||||
arguments = shlex.split(exec_line.removeprefix("Exec="))
|
||||
self.assertEqual(arguments, [str(launcher), "gui"])
|
||||
arguments[1] = "--help"
|
||||
launched = subprocess.run(
|
||||
arguments,
|
||||
cwd=root,
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(launched.returncode, 0, launched.stderr)
|
||||
self.assertIn("PristonTale EU all-in-one", launched.stdout)
|
||||
|
||||
stale_directory = root / "unpacked-old-installer"
|
||||
stale_package = stale_directory / "ptlinux"
|
||||
stale_package.mkdir(parents=True)
|
||||
(stale_package / "__init__.py").write_text("", encoding="utf-8")
|
||||
(stale_package / "__main__.py").write_text(
|
||||
'print("STALE LAUNCHER WAS IMPORTED")\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
isolated = subprocess.run(
|
||||
[launcher, "--version"],
|
||||
cwd=stale_directory,
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(isolated.returncode, 0, isolated.stderr)
|
||||
self.assertEqual(isolated.stdout.strip(), f"pristontale-eu {__version__}")
|
||||
self.assertNotIn("STALE LAUNCHER", isolated.stdout)
|
||||
|
||||
def test_installer_persists_custom_install_directory(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
home = root / "home"
|
||||
data_home = root / "desktop data"
|
||||
install_directory = root / "External $(touch INJECTED)'s Game"
|
||||
home.mkdir()
|
||||
environment = os.environ.copy()
|
||||
environment.pop("PTLINUX_INSTALL_DIR", None)
|
||||
environment.update(
|
||||
{
|
||||
"HOME": str(home),
|
||||
"XDG_DATA_HOME": str(data_home),
|
||||
"PATH": "/usr/bin:/bin",
|
||||
"PTLINUX_PYTHON": sys.executable,
|
||||
}
|
||||
)
|
||||
result = subprocess.run(
|
||||
[
|
||||
"bash",
|
||||
str(PROJECT_ROOT / "install.sh"),
|
||||
"--install-dir",
|
||||
str(install_directory),
|
||||
"--skip-deps",
|
||||
"--skip-doctor",
|
||||
"--skip-runtime-bootstrap",
|
||||
],
|
||||
cwd=root,
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertTrue((install_directory / "app/current/ptlinux").is_dir())
|
||||
self.assertTrue((install_directory / "app/current").is_symlink())
|
||||
launcher = home / ".local/bin/pristontale-eu"
|
||||
wrapper = launcher.read_text(encoding="utf-8")
|
||||
self.assertIn(
|
||||
f"install_directory={quote_shell_word(str(install_directory))}",
|
||||
wrapper,
|
||||
)
|
||||
|
||||
runtime_environment = environment.copy()
|
||||
runtime_environment.pop("PTLINUX_INSTALL_DIR", None)
|
||||
runtime_environment["XDG_DATA_HOME"] = str(root / "different data home")
|
||||
launched = subprocess.run(
|
||||
[str(launcher), "--help"],
|
||||
cwd=root,
|
||||
env=runtime_environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(launched.returncode, 0, launched.stderr)
|
||||
self.assertIn("PristonTale EU all-in-one", launched.stdout)
|
||||
self.assertFalse((root / "INJECTED").exists())
|
||||
|
||||
def test_installer_preserves_the_previous_default_on_upgrade(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
home = root / "home"
|
||||
data_home = root / "data"
|
||||
legacy_install = data_home / "pristontale-eu"
|
||||
legacy_game = (
|
||||
legacy_install
|
||||
/ "launcher-prefix/drive_c/Program Files (x86)/Pristontale EU/Game.exe"
|
||||
)
|
||||
legacy_game.parent.mkdir(parents=True)
|
||||
legacy_game.write_bytes(b"existing managed game")
|
||||
legacy_package = legacy_install / "app/ptlinux"
|
||||
legacy_package.mkdir(parents=True)
|
||||
(legacy_package / "__init__.py").write_text(
|
||||
'__version__ = "0.4.7"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
settings_file = home / ".config/pristontale-eu/settings.json"
|
||||
settings_file.parent.mkdir(parents=True)
|
||||
settings_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"borderless": False,
|
||||
"runtime": "proton7",
|
||||
"proton_sync": "esync",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
environment = os.environ.copy()
|
||||
environment.pop("PTLINUX_INSTALL_DIR", None)
|
||||
environment.update(
|
||||
{
|
||||
"HOME": str(home),
|
||||
"XDG_DATA_HOME": str(data_home),
|
||||
"PATH": "/usr/bin:/bin",
|
||||
"PTLINUX_PYTHON": sys.executable,
|
||||
}
|
||||
)
|
||||
result = subprocess.run(
|
||||
[
|
||||
"bash",
|
||||
str(PROJECT_ROOT / "install.sh"),
|
||||
"--skip-deps",
|
||||
"--skip-doctor",
|
||||
"--skip-runtime-bootstrap",
|
||||
],
|
||||
cwd=PROJECT_ROOT,
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("preserving its previous data location", result.stdout)
|
||||
launcher = home / ".local/bin/pristontale-eu"
|
||||
self.assertIn(
|
||||
f"install_directory={quote_shell_word(str(legacy_install))}",
|
||||
launcher.read_text(encoding="utf-8"),
|
||||
)
|
||||
self.assertFalse((home / "Games/PristontaleEU/app").exists())
|
||||
self.assertTrue((legacy_install / "app/current").is_symlink())
|
||||
settings_result = subprocess.run(
|
||||
[launcher, "--json", "settings", "--init"],
|
||||
cwd=root,
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(settings_result.returncode, 0, settings_result.stderr)
|
||||
migrated = json.loads(settings_result.stdout)["settings"]
|
||||
self.assertFalse(migrated["borderless"])
|
||||
self.assertEqual(migrated["runtime"], "ge-proton")
|
||||
self.assertEqual(migrated["proton_sync"], "esync")
|
||||
persisted = json.loads(settings_file.read_text(encoding="utf-8"))
|
||||
self.assertEqual(persisted["runtime"], "ge-proton")
|
||||
self.assertIs(persisted["ge_proton_default_migrated"], True)
|
||||
|
||||
def test_production_release_requires_a_signing_key(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
output = Path(temporary_directory) / "output"
|
||||
environment = os.environ.copy()
|
||||
environment.pop("PTLINUX_SIGNING_KEY", None)
|
||||
environment["PTLINUX_RELEASE_MODE"] = "production"
|
||||
environment["PTLINUX_OUTPUT_DIRECTORY"] = str(output)
|
||||
result = subprocess.run(
|
||||
["bash", str(PROJECT_ROOT / "tools/build-aio-release.sh")],
|
||||
cwd=PROJECT_ROOT,
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("PTLINUX_SIGNING_KEY is required", result.stderr)
|
||||
self.assertFalse(output.exists())
|
||||
|
||||
def test_production_release_rejects_a_key_path_inside_the_checkout(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
output = Path(temporary_directory) / "output"
|
||||
environment = os.environ.copy()
|
||||
environment["PTLINUX_RELEASE_MODE"] = "production"
|
||||
environment["PTLINUX_OUTPUT_DIRECTORY"] = str(output)
|
||||
environment["PTLINUX_PYTHON"] = sys.executable
|
||||
environment["PTLINUX_SIGNING_KEY"] = str(
|
||||
PROJECT_ROOT / "release-keys/fixture-private.pem"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["bash", str(PROJECT_ROOT / "tools/build-aio-release.sh")],
|
||||
cwd=PROJECT_ROOT,
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("outside the repository", result.stderr)
|
||||
self.assertFalse(output.exists())
|
||||
|
||||
def test_release_artifact_signature_is_generated_and_verified(self) -> None:
|
||||
openssl = shutil.which("openssl")
|
||||
if openssl is None:
|
||||
self.skipTest("OpenSSL is unavailable")
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
private_key = root / "private.pem"
|
||||
public_key = root / "public.pem"
|
||||
artifact = root / "installer.tar.gz"
|
||||
artifact.write_bytes(b"release bytes")
|
||||
commands = (
|
||||
[
|
||||
openssl,
|
||||
"genpkey",
|
||||
"-quiet",
|
||||
"-algorithm",
|
||||
"RSA",
|
||||
"-pkeyopt",
|
||||
"rsa_keygen_bits:2048",
|
||||
"-out",
|
||||
str(private_key),
|
||||
],
|
||||
[
|
||||
openssl,
|
||||
"pkey",
|
||||
"-in",
|
||||
str(private_key),
|
||||
"-pubout",
|
||||
"-out",
|
||||
str(public_key),
|
||||
],
|
||||
)
|
||||
for command in commands:
|
||||
generated = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(generated.returncode, 0, generated.stderr)
|
||||
private_key.chmod(0o600)
|
||||
|
||||
signature = sign_release_artifact(
|
||||
artifact,
|
||||
signing_key=private_key,
|
||||
public_key=public_key,
|
||||
)
|
||||
|
||||
self.assertTrue(signature.is_file())
|
||||
self.assertEqual(signature.stat().st_mode & 0o777, 0o644)
|
||||
artifact.write_bytes(b"tampered release bytes")
|
||||
verification = subprocess.run(
|
||||
[
|
||||
openssl,
|
||||
"dgst",
|
||||
"-sha256",
|
||||
"-verify",
|
||||
str(public_key),
|
||||
"-signature",
|
||||
str(signature),
|
||||
str(artifact),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
self.assertNotEqual(verification.returncode, 0)
|
||||
|
||||
def test_release_builder_packages_current_updater_sources(self) -> None:
|
||||
self.assertIn(
|
||||
f'version = "{__version__}"',
|
||||
(PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8"),
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
output = Path(temporary_directory)
|
||||
environment = os.environ.copy()
|
||||
environment.pop("PTLINUX_VERSION", None)
|
||||
environment["PTLINUX_OUTPUT_DIRECTORY"] = str(output)
|
||||
environment["SOURCE_DATE_EPOCH"] = "0"
|
||||
result = subprocess.run(
|
||||
["bash", str(PROJECT_ROOT / "tools/build-aio-release.sh")],
|
||||
cwd=PROJECT_ROOT,
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
release_name = (
|
||||
f"pristontale-eu-linux-installer-v{__version__.replace('.', '-')}"
|
||||
)
|
||||
archive = output / f"{release_name}.tar.gz"
|
||||
checksum = archive.with_suffix(archive.suffix + ".sha256")
|
||||
self.assertTrue(archive.is_file())
|
||||
self.assertTrue(checksum.is_file())
|
||||
self.assertFalse(Path(f"{archive}.sig").exists())
|
||||
self.assertTrue((output / "launcher-update-public.pem").is_file())
|
||||
zip_archive = output / f"{release_name}.zip"
|
||||
self.assertTrue(zip_archive.is_file())
|
||||
self.assertFalse(Path(f"{zip_archive}.sig").exists())
|
||||
update_archive = output / f"pristontale-eu-linux-update-v{__version__}.tar.gz"
|
||||
self.assertTrue(update_archive.is_file())
|
||||
update_checksum = update_archive.with_suffix(update_archive.suffix + ".sha256")
|
||||
self.assertTrue(update_checksum.is_file())
|
||||
manifest = output / "stable.json"
|
||||
self.assertTrue(manifest.is_file())
|
||||
for artifact in (
|
||||
archive,
|
||||
checksum,
|
||||
update_archive,
|
||||
update_checksum,
|
||||
manifest,
|
||||
):
|
||||
self.assertEqual(artifact.stat().st_mode & 0o777, 0o644)
|
||||
expected_sha256 = checksum.read_text(encoding="utf-8").split()[0]
|
||||
self.assertEqual(
|
||||
hashlib.sha256(archive.read_bytes()).hexdigest(),
|
||||
expected_sha256,
|
||||
)
|
||||
|
||||
release_root = release_name
|
||||
with tarfile.open(archive, "r:gz") as release:
|
||||
members = release.getmembers()
|
||||
names = {member.name for member in members}
|
||||
self.assertTrue(
|
||||
all(member.mode & 0o022 == 0 for member in members),
|
||||
"release entries must not be group- or world-writable",
|
||||
)
|
||||
self.assertEqual(
|
||||
next(member.mode for member in members if member.name == release_root),
|
||||
0o755,
|
||||
)
|
||||
self.assertEqual(
|
||||
{name for name in names if name.endswith(".md")},
|
||||
{f"{release_root}/README.md"},
|
||||
)
|
||||
self.assertIn(f"{release_root}/ptlinux/desktop_entry.py", names)
|
||||
self.assertIn(f"{release_root}/ptlinux/install_status.py", names)
|
||||
self.assertIn(f"{release_root}/ptlinux/ui/dashboard.py", names)
|
||||
self.assertIn(
|
||||
f"{release_root}/ptlinux/ui/components/progress_panel.py",
|
||||
names,
|
||||
)
|
||||
self.assertIn(f"{release_root}/ptlinux/launcher_wrapper.py", names)
|
||||
self.assertIn(f"{release_root}/ptlinux/self_update.py", names)
|
||||
self.assertIn(f"{release_root}/ptlinux/container_host.py", names)
|
||||
self.assertIn(f"{release_root}/release/config.json", names)
|
||||
self.assertIn(f"{release_root}/scripts/__init__.py", names)
|
||||
self.assertIn(f"{release_root}/scripts/repository_paths.py", names)
|
||||
self.assertIn(f"{release_root}/scripts/release_lib.py", names)
|
||||
self.assertFalse(any(name.startswith(f"{release_root}/devtools/") for name in names))
|
||||
self.assertIn(
|
||||
f"{release_root}/ptlinux/launcher-update-public.pem",
|
||||
names,
|
||||
)
|
||||
self.assertIn(
|
||||
f"{release_root}/ptlinux/native/pt-identity-probe.exe",
|
||||
names,
|
||||
)
|
||||
self.assertIn(
|
||||
f"{release_root}/ptlinux/native/ptr-bcrypt-proxy.dll",
|
||||
names,
|
||||
)
|
||||
gamescope_name = (
|
||||
f"{release_root}/ptlinux/native/"
|
||||
"gamescope-3.16.20-ubuntu26.04-amd64"
|
||||
)
|
||||
self.assertIn(gamescope_name, names)
|
||||
self.assertEqual(
|
||||
next(member.mode for member in members if member.name == gamescope_name),
|
||||
0o755,
|
||||
)
|
||||
for name in (
|
||||
"gamescope-compat.README",
|
||||
"gamescope-compat.copyright",
|
||||
"gamescope-lazy-output-images.patch",
|
||||
"gamescope-wayland-live-resize.patch",
|
||||
"gamescope-wayland-letterbox-backing.patch",
|
||||
"gamescope-wayland-libdecor-input-scope.patch",
|
||||
"gamescope-keep-nested-windows-visible.patch",
|
||||
):
|
||||
self.assertIn(f"{release_root}/ptlinux/native/{name}", names)
|
||||
self.assertNotIn(
|
||||
f"{release_root}/ptlinux/native/pt-world-fix.exe",
|
||||
names,
|
||||
)
|
||||
self.assertNotIn(f"{release_root}/ptlinux/world_fix.py", names)
|
||||
archived_readme = release.extractfile(f"{release_root}/README.md")
|
||||
self.assertIsNotNone(archived_readme)
|
||||
assert archived_readme is not None
|
||||
readme = archived_readme.read().decode("utf-8")
|
||||
self.assertIn(release_name, readme)
|
||||
self.assertIn("./install.sh", readme)
|
||||
self.assertIn("pristontale-eu update --repair", readme)
|
||||
self.assertIn("pristontale-eu --help", readme)
|
||||
for command in (
|
||||
"gui",
|
||||
"doctor",
|
||||
"container-host",
|
||||
"fetch",
|
||||
"setup",
|
||||
"update",
|
||||
"sync",
|
||||
"wine-update",
|
||||
"import-game",
|
||||
"cache-compatibility",
|
||||
"prepare",
|
||||
"play",
|
||||
"settings",
|
||||
"launcher-update",
|
||||
):
|
||||
self.assertIn(f"pristontale-eu {command}", readme)
|
||||
archived_depot = release.extractfile(f"{release_root}/ptlinux/depot.py")
|
||||
self.assertIsNotNone(archived_depot)
|
||||
assert archived_depot is not None
|
||||
self.assertEqual(
|
||||
archived_depot.read(),
|
||||
(SOURCE_ROOT / "ptlinux/depot.py").read_bytes(),
|
||||
)
|
||||
archived_login_trace = release.extractfile(
|
||||
f"{release_root}/ptlinux/login_trace.py"
|
||||
)
|
||||
self.assertIsNotNone(archived_login_trace)
|
||||
assert archived_login_trace is not None
|
||||
login_trace_source = archived_login_trace.read().decode("utf-8")
|
||||
self.assertIn("LOGIN_TRACE_ENABLED = False", login_trace_source)
|
||||
self.assertNotIn("+relay", login_trace_source)
|
||||
self.assertNotIn("RelayInclude", login_trace_source)
|
||||
|
||||
with tarfile.open(update_archive, "r:gz") as update:
|
||||
update_root = f"pristontale-eu-linux-update-v{__version__}"
|
||||
update_members = update.getmembers()
|
||||
update_names = {member.name for member in update_members}
|
||||
self.assertIn(f"{update_root}/release.json", update_names)
|
||||
self.assertIn(f"{update_root}/ptlinux/self_update.py", update_names)
|
||||
self.assertIn(f"{update_root}/ptlinux/container_host.py", update_names)
|
||||
self.assertIn(f"{update_root}/ptlinux/import_state.py", update_names)
|
||||
self.assertIn(f"{update_root}/ptlinux/ui/dashboard.py", update_names)
|
||||
self.assertIn(
|
||||
f"{update_root}/ptlinux/ui/dialogs/settings_dialog.py",
|
||||
update_names,
|
||||
)
|
||||
self.assertNotIn(
|
||||
f"{update_root}/ptlinux/desktop_entry.py",
|
||||
update_names,
|
||||
)
|
||||
self.assertNotIn(
|
||||
f"{update_root}/ptlinux/launcher_wrapper.py",
|
||||
update_names,
|
||||
)
|
||||
self.assertIn(
|
||||
f"{update_root}/ptlinux/launcher-update-public.pem",
|
||||
update_names,
|
||||
)
|
||||
self.assertIn(
|
||||
f"{update_root}/ptlinux/native/pt-identity-probe.exe",
|
||||
update_names,
|
||||
)
|
||||
self.assertIn(
|
||||
f"{update_root}/ptlinux/native/ptr-bcrypt-proxy.dll",
|
||||
update_names,
|
||||
)
|
||||
gamescope_name = (
|
||||
f"{update_root}/ptlinux/native/"
|
||||
"gamescope-3.16.20-ubuntu26.04-amd64"
|
||||
)
|
||||
self.assertIn(gamescope_name, update_names)
|
||||
self.assertEqual(
|
||||
next(
|
||||
member.mode
|
||||
for member in update_members
|
||||
if member.name == gamescope_name
|
||||
),
|
||||
0o755,
|
||||
)
|
||||
for name in (
|
||||
"gamescope-compat.README",
|
||||
"gamescope-compat.copyright",
|
||||
"gamescope-lazy-output-images.patch",
|
||||
"gamescope-wayland-live-resize.patch",
|
||||
"gamescope-wayland-letterbox-backing.patch",
|
||||
"gamescope-wayland-libdecor-input-scope.patch",
|
||||
"gamescope-keep-nested-windows-visible.patch",
|
||||
):
|
||||
self.assertIn(
|
||||
f"{update_root}/ptlinux/native/{name}",
|
||||
update_names,
|
||||
)
|
||||
self.assertNotIn(
|
||||
f"{update_root}/ptlinux/native/pt-world-fix.exe",
|
||||
update_names,
|
||||
)
|
||||
self.assertNotIn(f"{update_root}/ptlinux/world_fix.py", update_names)
|
||||
|
||||
def test_release_builder_rejects_unsafe_version_text(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
output = Path(temporary_directory) / "output"
|
||||
environment = os.environ.copy()
|
||||
environment["PTLINUX_VERSION"] = "../unsafe"
|
||||
environment["PTLINUX_OUTPUT_DIRECTORY"] = str(output)
|
||||
result = subprocess.run(
|
||||
["bash", str(PROJECT_ROOT / "tools/build-aio-release.sh")],
|
||||
cwd=PROJECT_ROOT,
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("expected X.Y.Z", result.stderr)
|
||||
self.assertFalse(output.exists())
|
||||
|
||||
def test_compact_release_can_import_overlay_in_clean_interpreter(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
release_root = Path(temporary_directory)
|
||||
shutil.copytree(SOURCE_ROOT / "ptlinux", release_root / "ptlinux")
|
||||
(release_root / "ptdiag").mkdir()
|
||||
shutil.copy2(
|
||||
SOURCE_ROOT / "ptdiag" / "__init__.py",
|
||||
release_root / "ptdiag" / "__init__.py",
|
||||
)
|
||||
shutil.copy2(
|
||||
SOURCE_ROOT / "ptdiag" / "assbin.py",
|
||||
release_root / "ptdiag" / "assbin.py",
|
||||
)
|
||||
shutil.copy2(
|
||||
SOURCE_ROOT / "ptdiag" / "logs.py",
|
||||
release_root / "ptdiag" / "logs.py",
|
||||
)
|
||||
|
||||
environment = os.environ.copy()
|
||||
environment["PYTHONPATH"] = str(release_root)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", "import ptlinux.overlay"],
|
||||
cwd=release_root,
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
134
tests/test_ptlinux_window_diagnostics.py
Normal file
134
tests/test_ptlinux_window_diagnostics.py
Normal file
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from ptlinux.window_diagnostics import (
|
||||
WINDOW_DIAGNOSTIC_EVENT_LIMIT,
|
||||
WINDOW_DIAGNOSTIC_PREFIX,
|
||||
WINDOW_DIAGNOSTICS_ENV,
|
||||
WINDOW_DIAGNOSTICS_SESSION_ENV,
|
||||
WindowDiagnosticLog,
|
||||
WindowDiagnosticRecorder,
|
||||
parse_window_diagnostic,
|
||||
)
|
||||
|
||||
|
||||
class WindowDiagnosticTests(unittest.TestCase):
|
||||
def test_local_log_keeps_bounded_samples_and_summaries_from_this_session(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
path = Path(temporary) / "logs" / "windows-session.jsonl"
|
||||
log = WindowDiagnosticLog(path, "a" * 32)
|
||||
for component in ("game_window", "gamescope_host"):
|
||||
payload = {
|
||||
"component": component,
|
||||
"session_id": "a" * 32,
|
||||
"diagnostic": "focus_state_changed",
|
||||
}
|
||||
log.record({**payload, "session_id": "b" * 32})
|
||||
for sequence in range(WINDOW_DIAGNOSTIC_EVENT_LIMIT + 10):
|
||||
log.record({**payload, "sequence": sequence + 1})
|
||||
for _ in range(2):
|
||||
log.record({**payload, "diagnostic": "session_summary"})
|
||||
payloads = [json.loads(line) for line in path.read_text().splitlines()]
|
||||
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual({item["session_id"] for item in payloads}, {"a" * 32})
|
||||
for component in ("game_window", "gamescope_host"):
|
||||
records = [item for item in payloads if item["component"] == component]
|
||||
self.assertEqual(len(records), WINDOW_DIAGNOSTIC_EVENT_LIMIT + 1)
|
||||
self.assertEqual(records[-1]["diagnostic"], "session_summary")
|
||||
|
||||
def test_local_log_io_failure_warns_once_without_interrupting_play(self) -> None:
|
||||
log = WindowDiagnosticLog(Path("/unused/windows-session.jsonl"), "a" * 32)
|
||||
payload = {"component": "game_window", "session_id": "a" * 32, "diagnostic": "focus"}
|
||||
output = io.StringIO()
|
||||
with (
|
||||
patch("ptlinux.window_diagnostics.Path.mkdir", side_effect=OSError("disk full")) as mkdir,
|
||||
contextlib.redirect_stderr(output),
|
||||
):
|
||||
log.record(payload)
|
||||
log.record(payload)
|
||||
mkdir.assert_called_once()
|
||||
self.assertEqual(output.getvalue().count("could not be saved locally"), 1)
|
||||
|
||||
def test_recorder_bounds_samples_and_always_emits_a_summary(self) -> None:
|
||||
environment = {
|
||||
WINDOW_DIAGNOSTICS_ENV: "1",
|
||||
WINDOW_DIAGNOSTICS_SESSION_ENV: "a" * 32,
|
||||
}
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stderr(output):
|
||||
recorder = WindowDiagnosticRecorder(
|
||||
"gamescope_host",
|
||||
environment=environment,
|
||||
monotonic=lambda: 10.0,
|
||||
)
|
||||
for index in range(WINDOW_DIAGNOSTIC_EVENT_LIMIT + 8):
|
||||
recorder.record("host_inactive_remap", attempt=index)
|
||||
recorder.close(minimized_latched=True)
|
||||
|
||||
payloads = [
|
||||
parse_window_diagnostic(line)
|
||||
for line in output.getvalue().splitlines()
|
||||
]
|
||||
self.assertNotIn(None, payloads)
|
||||
self.assertEqual(len(payloads), WINDOW_DIAGNOSTIC_EVENT_LIMIT + 1)
|
||||
summary = payloads[-1]
|
||||
assert summary is not None
|
||||
self.assertEqual(summary["diagnostic"], "session_summary")
|
||||
self.assertEqual(summary["session_id"], "a" * 32)
|
||||
details = summary["details"]
|
||||
assert isinstance(details, dict)
|
||||
self.assertEqual(
|
||||
details["counts"],
|
||||
{"host_inactive_remap": WINDOW_DIAGNOSTIC_EVENT_LIMIT + 8},
|
||||
)
|
||||
self.assertEqual(details["sampled_events"], WINDOW_DIAGNOSTIC_EVENT_LIMIT)
|
||||
self.assertEqual(details["suppressed_events"], 8)
|
||||
|
||||
def test_transition_records_only_state_changes(self) -> None:
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stderr(output):
|
||||
recorder = WindowDiagnosticRecorder(
|
||||
"game_window",
|
||||
environment={WINDOW_DIAGNOSTICS_ENV: "true"},
|
||||
monotonic=lambda: 1.0,
|
||||
)
|
||||
recorder.transition("focus", (False, False), "focus_state_changed")
|
||||
recorder.transition("focus", (False, False), "focus_state_changed")
|
||||
recorder.transition("focus", (True, True), "focus_state_changed")
|
||||
recorder.close()
|
||||
|
||||
payloads = [
|
||||
json.loads(line.removeprefix(WINDOW_DIAGNOSTIC_PREFIX))
|
||||
for line in output.getvalue().splitlines()
|
||||
]
|
||||
self.assertEqual(
|
||||
[payload["diagnostic"] for payload in payloads],
|
||||
["focus_state_changed", "focus_state_changed", "session_summary"],
|
||||
)
|
||||
self.assertEqual(payloads[-1]["details"]["counts"], {"focus_state_changed": 2})
|
||||
|
||||
def test_parser_rejects_untrusted_or_malformed_records(self) -> None:
|
||||
self.assertIsNone(parse_window_diagnostic("ordinary game output\n"))
|
||||
self.assertIsNone(
|
||||
parse_window_diagnostic(
|
||||
WINDOW_DIAGNOSTIC_PREFIX
|
||||
+ '{"schema":1,"component":"other","diagnostic":"state",'
|
||||
+ '"sequence":1,"elapsed_ms":0,"details":{}}\n'
|
||||
)
|
||||
)
|
||||
self.assertIsNone(
|
||||
parse_window_diagnostic(
|
||||
WINDOW_DIAGNOSTIC_PREFIX + "{not-json}\n"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
514
tests/test_window_focus_regressions.py
Normal file
514
tests/test_window_focus_regressions.py
Normal file
@@ -0,0 +1,514 @@
|
||||
"""Focus ownership and supervisor failure-path regressions (no live desktop)."""
|
||||
|
||||
from contextlib import ExitStack
|
||||
import ctypes
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from ptlinux.presentation import game_window as gw
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self) -> None:
|
||||
self.now = 0.0
|
||||
|
||||
def sleep(self, seconds: float) -> None:
|
||||
self.now += seconds
|
||||
|
||||
|
||||
class WindowFocusRegressions(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.context = self.enterContext(ExitStack())
|
||||
self.clock = Clock()
|
||||
self.context.enter_context(patch.object(gw.time, "sleep", self.clock.sleep))
|
||||
self.context.enter_context(patch.object(gw.time, "monotonic", lambda: self.clock.now))
|
||||
self.mapper = MagicMock(spec=gw._X11)
|
||||
self.mapper.window_state.return_value = 1
|
||||
self.mapper.window_is_active.return_value = True
|
||||
self.mapper.window_is_focused.return_value = False
|
||||
self.mapper.activate_window.return_value = True
|
||||
|
||||
def assert_no_input_change(self) -> None:
|
||||
self.mapper.focus_window.assert_not_called()
|
||||
self.mapper.restore_input_focus.assert_not_called()
|
||||
|
||||
def test_denied_desktop_activation_is_not_overridden_with_raw_focus(self) -> None:
|
||||
self.mapper.window_is_active.return_value = False
|
||||
gw._focus_game_window(self.mapper, 12)
|
||||
self.assert_no_input_change()
|
||||
self.mapper.activate_window.assert_called_once_with(12)
|
||||
self.assertLessEqual(self.clock.now, 2.0)
|
||||
|
||||
def test_slow_desktop_activation_is_confirmed_before_input_repair(self) -> None:
|
||||
self.mapper.window_is_active.side_effect = lambda _window: self.clock.now >= 0.6
|
||||
self.mapper.window_is_focused.side_effect = [False, True]
|
||||
gw._focus_game_window(self.mapper, 12)
|
||||
self.assertGreaterEqual(self.clock.now, 0.6)
|
||||
self.assertLessEqual(self.clock.now, 2.0)
|
||||
self.mapper.restore_input_focus.assert_called_once_with(12)
|
||||
self.mapper.focus_window.assert_not_called()
|
||||
|
||||
def test_already_focused_child_is_not_replaced_by_startup_handoff(self) -> None:
|
||||
self.mapper.window_is_focused.return_value = True
|
||||
gw._focus_game_window(self.mapper, 12)
|
||||
self.assert_no_input_change()
|
||||
self.mapper.activate_window.assert_not_called()
|
||||
|
||||
def test_alt_tab_during_focus_inspection_is_not_undone(self) -> None:
|
||||
self.mapper.window_is_active.side_effect = [True, False]
|
||||
gw._repair_game_window_focus(self.mapper, 12)
|
||||
self.assert_no_input_change()
|
||||
|
||||
def test_unmapped_or_unknown_window_is_never_focused(self) -> None:
|
||||
for state in (None, 0, 2, 3):
|
||||
for repair in (gw._repair_game_window_focus, gw._repair_gamescope_game_window_focus):
|
||||
with self.subTest(state=state, repair=repair.__name__):
|
||||
self.mapper.reset_mock()
|
||||
self.mapper.window_state.return_value = state
|
||||
with patch.object(gw, "_gamescope_host_focus_is_allowed", return_value=True):
|
||||
self.assertFalse(repair(self.mapper, 12))
|
||||
self.assert_no_input_change()
|
||||
|
||||
def test_nested_handoff_rechecks_the_host_after_the_dashboard_delay(self) -> None:
|
||||
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": False}):
|
||||
gw._focus_game_window(self.mapper, 12, activate=False)
|
||||
self.assert_no_input_change()
|
||||
self.mapper.map_window.assert_not_called()
|
||||
|
||||
def test_nested_startup_maps_the_ge_client_before_repairing_input(self) -> None:
|
||||
self.mapper.window_state.return_value = None
|
||||
self.mapper.window_size.return_value = (1920, 1080)
|
||||
mapped_at = None
|
||||
|
||||
def map_window(_window, *, borderless):
|
||||
nonlocal mapped_at
|
||||
self.assertFalse(borderless)
|
||||
mapped_at = self.clock.now
|
||||
|
||||
self.mapper.map_window.side_effect = map_window
|
||||
self.mapper.window_state.side_effect = lambda _window: (
|
||||
1 if mapped_at is not None and self.clock.now - mapped_at >= 0.1 else None
|
||||
)
|
||||
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
|
||||
gw._prepare_gamescope_startup_window(self.mapper, 12, set())
|
||||
gw._repair_gamescope_game_window_focus(self.mapper, 12)
|
||||
self.mapper.map_window.assert_called_once_with(12, borderless=False)
|
||||
self.mapper.restore_input_focus.assert_called_once_with(12)
|
||||
self.mapper.activate_window.assert_not_called()
|
||||
self.mapper.force_windowed_state.assert_not_called()
|
||||
self.mapper.refresh_render_surface.assert_not_called()
|
||||
|
||||
def test_nested_startup_does_not_remap_existing_or_destroyed_windows(self) -> None:
|
||||
for state, size in ((1, (1920, 1080)), (3, (1920, 1080)), (0, (1920, 1080)), (None, None)):
|
||||
with self.subTest(state=state, size=size):
|
||||
self.mapper.reset_mock()
|
||||
self.mapper.window_state.return_value = state
|
||||
self.mapper.window_size.return_value = size
|
||||
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
|
||||
gw._prepare_gamescope_startup_window(self.mapper, 12, set())
|
||||
self.mapper.map_window.assert_not_called()
|
||||
self.mapper.activate_window.assert_not_called()
|
||||
|
||||
def test_nested_startup_does_not_map_after_host_focus_changes(self) -> None:
|
||||
self.mapper.window_state.return_value = None
|
||||
self.mapper.window_size.return_value = (1920, 1080)
|
||||
with patch.object(gw, "_gamescope_host_focus_status", side_effect=[
|
||||
{"allowed": True}, {"allowed": False}, {"allowed": False},
|
||||
]):
|
||||
gw._prepare_gamescope_startup_window(self.mapper, 12, set())
|
||||
self.mapper.map_window.assert_not_called()
|
||||
self.assert_no_input_change()
|
||||
|
||||
def test_nested_startup_mapping_wait_is_bounded_and_does_not_force_focus(self) -> None:
|
||||
self.mapper.window_state.return_value = None
|
||||
self.mapper.window_size.return_value = (1920, 1080)
|
||||
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
|
||||
gw._prepare_gamescope_startup_window(self.mapper, 12, set())
|
||||
self.mapper.map_window.assert_called_once_with(12, borderless=False)
|
||||
self.assertLessEqual(self.clock.now, 1.0)
|
||||
self.assert_no_input_change()
|
||||
|
||||
def test_nested_repair_rechecks_host_focus_before_setting_input(self) -> None:
|
||||
with patch.object(gw, "_gamescope_host_focus_status", side_effect=[
|
||||
{"allowed": True}, {"allowed": False},
|
||||
]):
|
||||
gw._repair_gamescope_game_window_focus(self.mapper, 12)
|
||||
self.assert_no_input_change()
|
||||
|
||||
def test_nested_mapping_is_deferred_until_host_focus_returns(self) -> None:
|
||||
self.mapper.window_state.return_value = None
|
||||
self.mapper.window_size.return_value = (1920, 1080)
|
||||
self.mapper.find_first_window.return_value = None
|
||||
mapped_at = []
|
||||
|
||||
def map_window(_window, *, borderless):
|
||||
mapped_at.append(self.clock.now)
|
||||
self.mapper.window_state.return_value = 1
|
||||
|
||||
self.mapper.map_window.side_effect = map_window
|
||||
process = MagicMock()
|
||||
process.poll.side_effect = lambda: 0 if self.clock.now >= 2 else None
|
||||
process.wait.return_value = 0
|
||||
with patch.object(gw, "_gamescope_host_focus_status", side_effect=lambda **_kw: {
|
||||
"allowed": self.clock.now >= 1.0,
|
||||
}):
|
||||
gw._focus_game_window(self.mapper, 12, activate=False)
|
||||
gw._maintain_gamescope_game_window_focus(self.mapper, process, 12)
|
||||
self.assertEqual(len(mapped_at), 1)
|
||||
self.assertGreaterEqual(mapped_at[0], 1.0)
|
||||
self.mapper.activate_window.assert_not_called()
|
||||
self.mapper.restore_input_focus.assert_called_with(12)
|
||||
|
||||
def test_nested_replacement_login_window_gets_its_own_startup_mapping(self) -> None:
|
||||
mapped = set()
|
||||
self.mapper.window_state.side_effect = lambda window: (
|
||||
1 if window in mapped or (window == 12 and self.clock.now < 0.5) else None
|
||||
)
|
||||
self.mapper.window_size.side_effect = lambda window: (
|
||||
(1920, 1080) if window == 21 else None
|
||||
)
|
||||
self.mapper.find_first_window.return_value = (21, "PristonTale EU Login")
|
||||
self.mapper.map_window.side_effect = lambda window, **_kw: mapped.add(window)
|
||||
process = MagicMock()
|
||||
process.poll.side_effect = lambda: 0 if self.clock.now >= 2 else None
|
||||
process.wait.return_value = 0
|
||||
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
|
||||
gw._maintain_gamescope_game_window_focus(self.mapper, process, 12)
|
||||
self.mapper.map_window.assert_called_once_with(21, borderless=False)
|
||||
self.mapper.restore_input_focus.assert_called_with(21)
|
||||
|
||||
def test_nested_monitor_does_not_keep_remapping_an_unacknowledged_client(self) -> None:
|
||||
self.mapper.window_state.return_value = None
|
||||
self.mapper.window_size.return_value = (1920, 1080)
|
||||
self.mapper.find_first_window.return_value = None
|
||||
process = MagicMock()
|
||||
process.poll.side_effect = lambda: 0 if self.clock.now >= 3 else None
|
||||
process.wait.return_value = 0
|
||||
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
|
||||
gw._maintain_gamescope_game_window_focus(self.mapper, process, 12)
|
||||
self.mapper.map_window.assert_called_once_with(12, borderless=False)
|
||||
self.assert_no_input_change()
|
||||
|
||||
def test_nested_mapping_stays_deferred_while_the_host_is_minimized(self) -> None:
|
||||
self.mapper.window_state.return_value = None
|
||||
self.mapper.window_size.return_value = (1920, 1080)
|
||||
self.mapper.find_first_window.return_value = None
|
||||
process = MagicMock()
|
||||
process.poll.side_effect = lambda: 0 if self.clock.now >= 2 else None
|
||||
process.wait.return_value = 0
|
||||
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": False}):
|
||||
gw._maintain_gamescope_game_window_focus(self.mapper, process, 12)
|
||||
self.mapper.map_window.assert_not_called()
|
||||
self.assert_no_input_change()
|
||||
|
||||
def test_nested_monitor_does_not_resurrect_a_previously_managed_client(self) -> None:
|
||||
self.mapper.window_state.side_effect = lambda _window: 1 if self.clock.now < 0.5 else None
|
||||
self.mapper.window_size.return_value = (1920, 1080)
|
||||
self.mapper.find_first_window.return_value = None
|
||||
self.mapper.window_is_focused.return_value = True
|
||||
process = MagicMock()
|
||||
process.poll.side_effect = lambda: 0 if self.clock.now >= 2 else None
|
||||
process.wait.return_value = 0
|
||||
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
|
||||
gw._maintain_gamescope_game_window_focus(self.mapper, process, 12)
|
||||
self.mapper.map_window.assert_not_called()
|
||||
self.assert_no_input_change()
|
||||
|
||||
def test_nested_mapping_error_does_not_end_the_game_supervisor(self) -> None:
|
||||
self.mapper.window_state.return_value = None
|
||||
self.mapper.window_size.return_value = (1920, 1080)
|
||||
self.mapper.find_first_window.return_value = None
|
||||
self.mapper.map_window.side_effect = OSError("window disappeared")
|
||||
process = MagicMock()
|
||||
process.poll.side_effect = lambda: 0 if self.clock.now >= 2 else None
|
||||
process.wait.return_value = 0
|
||||
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
|
||||
self.assertEqual(gw._maintain_gamescope_game_window_focus(self.mapper, process, 12), 0)
|
||||
self.mapper.map_window.assert_called_once_with(12, borderless=False)
|
||||
self.assert_no_input_change()
|
||||
|
||||
def test_nested_startup_rechecks_minimize_before_mapping(self) -> None:
|
||||
self.mapper.window_state.side_effect = [None, 3]
|
||||
self.mapper.window_size.return_value = (1920, 1080)
|
||||
prepared_windows = set()
|
||||
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
|
||||
gw._prepare_gamescope_startup_window(self.mapper, 12, prepared_windows)
|
||||
# Losing WM_STATE after minimization must not trigger startup mapping.
|
||||
self.mapper.window_state.side_effect = None
|
||||
self.mapper.window_state.return_value = None
|
||||
gw._prepare_gamescope_startup_window(self.mapper, 12, prepared_windows)
|
||||
self.mapper.map_window.assert_not_called()
|
||||
self.assert_no_input_change()
|
||||
|
||||
def test_activation_errors_do_not_fall_back_to_forced_focus(self) -> None:
|
||||
self.mapper.window_is_active.return_value = False
|
||||
self.mapper.activate_window.side_effect = RuntimeError("WM disconnected")
|
||||
gw._focus_game_window(self.mapper, 12)
|
||||
self.assert_no_input_change()
|
||||
|
||||
def test_initial_preparation_respects_minimize_after_discovery(self) -> None:
|
||||
self.mapper.root_window_ids.return_value = set()
|
||||
self.mapper.find_first_window.return_value = (12, "PristonTale EU V23.86.96")
|
||||
self.mapper.window_state.return_value = 3
|
||||
process = MagicMock()
|
||||
process.poll.return_value = None
|
||||
with (
|
||||
patch.object(gw, "_X11", return_value=self.mapper),
|
||||
patch.object(gw.subprocess, "Popen", return_value=process),
|
||||
patch.object(gw, "WINDOW_READY_DELAY_SECONDS", 0),
|
||||
patch.object(gw, "_maintain_game_window_focus", return_value=17),
|
||||
):
|
||||
self.assertEqual(gw.run_game_window(["unused"], borderless=True), 17)
|
||||
self.mapper.map_window.assert_not_called()
|
||||
self.mapper.force_windowed_state.assert_not_called()
|
||||
self.mapper.refresh_render_surface.assert_not_called()
|
||||
self.mapper.activate_window.assert_not_called()
|
||||
self.assert_no_input_change()
|
||||
|
||||
def test_preparation_never_raises_a_client_before_desktop_activation(self) -> None:
|
||||
gw._prepare_game_window(self.mapper, 12,
|
||||
borderless=False, suppress_wm_ping=False, raise_window=False)
|
||||
self.mapper.map_window.assert_called_once_with(12, borderless=False, raise_window=False)
|
||||
self.assert_no_input_change()
|
||||
|
||||
def test_layout_recovery_does_not_remap_or_raise_the_x11_client(self) -> None:
|
||||
mapper: gw._X11 = object.__new__(gw._X11)
|
||||
mapper.library = MagicMock()
|
||||
mapper.display = ctypes.c_void_p(1)
|
||||
with (
|
||||
patch.object(mapper, "window_size", return_value=(1280, 720)),
|
||||
patch.object(mapper, "_remove_decorations"),
|
||||
):
|
||||
self.assertEqual(mapper.map_window(12, borderless=True, raise_window=False), (1280, 720))
|
||||
mapper.library.XMapRaised.assert_not_called()
|
||||
mapper.library.XMapWindow.assert_not_called()
|
||||
mapper.library.XSetInputFocus.assert_not_called()
|
||||
|
||||
def test_bad_bridge_timestamps_do_not_crash_the_supervisor(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
state = Path(directory) / "focus"
|
||||
environment = {gw.GAMESCOPE_HOST_FOCUS_STATE_ENV: str(state)}
|
||||
for timestamp in ("nan", "inf", "-inf", "1e999", "1e308", "-1e308"):
|
||||
with self.subTest(timestamp=timestamp):
|
||||
state.write_text(f"1 {timestamp}\n", encoding="ascii")
|
||||
self.assertFalse(gw._gamescope_host_focus_is_allowed(environment=environment, now=10))
|
||||
|
||||
def test_startup_recovery_does_not_activate_a_background_window(self) -> None:
|
||||
self.mapper.window_is_active.return_value = False
|
||||
self.mapper.window_is_fullscreen_or_maximized.return_value = True
|
||||
process = MagicMock()
|
||||
process.poll.side_effect = [None, 0]
|
||||
with patch.object(gw, "_prepare_game_window") as prepare:
|
||||
gw._guard_game_window_startup(self.mapper, process, 12, borderless=False)
|
||||
prepare.assert_not_called()
|
||||
self.mapper.activate_window.assert_not_called()
|
||||
self.assert_no_input_change()
|
||||
|
||||
def test_startup_recovery_does_not_undo_alt_tab_during_surface_refresh(self) -> None:
|
||||
self.mapper.window_is_fullscreen_or_maximized.return_value = True
|
||||
process = MagicMock()
|
||||
process.poll.side_effect = [None, 0]
|
||||
|
||||
def switch_away(*_args, **_kwargs) -> None:
|
||||
self.mapper.window_is_active.return_value = False
|
||||
|
||||
with patch.object(gw, "_prepare_game_window", side_effect=switch_away) as prepare:
|
||||
gw._guard_game_window_startup(self.mapper, process, 12, borderless=False)
|
||||
self.assertIs(prepare.call_args.kwargs["raise_window"], False)
|
||||
self.mapper.activate_window.assert_not_called()
|
||||
self.assert_no_input_change()
|
||||
|
||||
def test_scaling_failures_keep_the_focus_supervisor_running(self) -> None:
|
||||
for failure in ("subscribe", "configure", "poll", "repair"):
|
||||
with self.subTest(failure=failure):
|
||||
mapper = MagicMock(spec=gw._X11)
|
||||
mapper.enable_window_scaling.return_value = failure != "configure"
|
||||
if failure == "subscribe":
|
||||
mapper.select_scaling_hint_events.side_effect = RuntimeError("subscription failed")
|
||||
elif failure == "poll":
|
||||
mapper.wait_for_scaling_hint_change.side_effect = RuntimeError("poll failed")
|
||||
elif failure == "repair":
|
||||
mapper.enable_window_scaling.side_effect = [True] + [RuntimeError("hints failed")] * 3
|
||||
process = MagicMock()
|
||||
process.poll.return_value = None
|
||||
with patch.object(gw, "_maintain_game_window_focus", return_value=17) as maintain:
|
||||
self.assertEqual(gw._maintain_game_window_scaling(mapper, process, 12), 17)
|
||||
maintain.assert_called_once_with(
|
||||
mapper, process, 12, window_title=gw.WINDOW_TITLE,
|
||||
existing_window_ids=(), expected_pid=None,
|
||||
)
|
||||
process.wait.assert_not_called()
|
||||
|
||||
def test_delayed_desktop_activation_is_repaired_during_startup_guard(self) -> None:
|
||||
self.mapper.window_is_fullscreen_or_maximized.return_value = False
|
||||
self.mapper.window_is_active.side_effect = lambda _window: self.clock.now >= 1.5
|
||||
process = MagicMock()
|
||||
process.poll.side_effect = lambda: None if self.clock.now < 4 else 0
|
||||
repair_times: list[float] = []
|
||||
|
||||
def repair(_window: int) -> None:
|
||||
repair_times.append(self.clock.now)
|
||||
self.mapper.window_is_focused.return_value = True
|
||||
|
||||
self.mapper.restore_input_focus.side_effect = repair
|
||||
gw._guard_game_window_startup(self.mapper, process, 12, borderless=False)
|
||||
self.assertEqual(len(repair_times), 1)
|
||||
self.assertGreaterEqual(repair_times[0], 1.5)
|
||||
self.assertLess(repair_times[0], 4)
|
||||
self.mapper.activate_window.assert_not_called()
|
||||
self.mapper.map_window.assert_not_called()
|
||||
self.mapper.refresh_render_surface.assert_not_called()
|
||||
|
||||
def test_startup_guard_hands_a_vanished_window_to_the_monitor_immediately(self) -> None:
|
||||
process = MagicMock()
|
||||
process.poll.return_value = None
|
||||
for state in (None, 0):
|
||||
with self.subTest(state=state):
|
||||
self.mapper.window_state.return_value = state
|
||||
self.assertFalse(gw._guard_game_window_startup(
|
||||
self.mapper, process, 12, borderless=False,
|
||||
))
|
||||
self.assertEqual(self.clock.now, 0.0)
|
||||
self.assert_no_input_change()
|
||||
|
||||
def test_direct_monitor_follows_the_same_process_without_resizing_or_activating(self) -> None:
|
||||
self.mapper.window_pid.return_value = 42
|
||||
self.mapper.window_state.side_effect = lambda window: 0 if window == 12 else 1
|
||||
self.mapper.find_first_window.return_value = (22, "PristonTale EU V23.86.96")
|
||||
self.mapper.restore_input_focus.side_effect = lambda _window: setattr(
|
||||
self.mapper.window_is_focused, "return_value", True,
|
||||
)
|
||||
process = MagicMock()
|
||||
process.poll.side_effect = lambda: None if self.clock.now < 3 else 17
|
||||
process.wait.return_value = 17
|
||||
self.assertEqual(gw._maintain_game_window_focus(
|
||||
self.mapper, process, 12, expected_pid=42, existing_window_ids={8, 9},
|
||||
), 17)
|
||||
self.mapper.find_first_window.assert_called_once_with(
|
||||
gw.WINDOW_TITLE, excluded_window_ids={8, 9, 12}, expected_pid=42,
|
||||
)
|
||||
self.mapper.restore_input_focus.assert_called_once_with(22)
|
||||
self.mapper.activate_window.assert_not_called()
|
||||
self.mapper.map_window.assert_not_called()
|
||||
self.mapper.refresh_render_surface.assert_not_called()
|
||||
|
||||
def test_a_replaced_background_window_does_not_take_keyboard_focus(self) -> None:
|
||||
self.mapper.window_pid.return_value = 42
|
||||
self.mapper.window_state.side_effect = lambda window: None if window == 12 else 1
|
||||
self.mapper.find_first_window.return_value = (22, "PristonTale EU V23.86.96")
|
||||
self.mapper.window_is_active.return_value = False
|
||||
process = MagicMock()
|
||||
process.poll.side_effect = lambda: None if self.clock.now < 2 else 0
|
||||
process.wait.return_value = 0
|
||||
gw._maintain_game_window_focus(self.mapper, process, 12, expected_pid=42)
|
||||
self.assert_no_input_change()
|
||||
self.mapper.activate_window.assert_not_called()
|
||||
|
||||
def test_minimized_direct_client_is_not_replaced_or_restored(self) -> None:
|
||||
self.mapper.window_pid.return_value = 42
|
||||
self.mapper.window_state.return_value = 3
|
||||
process = MagicMock()
|
||||
process.poll.side_effect = lambda: None if self.clock.now < 2 else 0
|
||||
process.wait.return_value = 0
|
||||
gw._maintain_game_window_focus(self.mapper, process, 12, expected_pid=42)
|
||||
self.mapper.find_first_window.assert_not_called()
|
||||
self.mapper.map_window.assert_not_called()
|
||||
self.mapper.restore_normal_window_state.assert_not_called()
|
||||
self.assert_no_input_change()
|
||||
|
||||
def test_missing_process_identity_does_not_attach_to_another_account(self) -> None:
|
||||
self.mapper.window_pid.return_value = None
|
||||
self.mapper.window_state.return_value = None
|
||||
process = MagicMock()
|
||||
process.poll.side_effect = lambda: None if self.clock.now < 2 else 0
|
||||
process.wait.return_value = 0
|
||||
gw._maintain_game_window_focus(self.mapper, process, 12)
|
||||
self.mapper.find_first_window.assert_not_called()
|
||||
self.assert_no_input_change()
|
||||
|
||||
def test_reused_window_id_from_another_process_is_not_focused(self) -> None:
|
||||
self.mapper.window_pid.return_value = 99
|
||||
self.mapper.find_first_window.return_value = None
|
||||
process = MagicMock()
|
||||
process.poll.side_effect = lambda: None if self.clock.now < 2 else 0
|
||||
process.wait.return_value = 0
|
||||
gw._maintain_game_window_focus(self.mapper, process, 12, expected_pid=42)
|
||||
self.assert_no_input_change()
|
||||
for call in self.mapper.find_first_window.call_args_list:
|
||||
self.assertEqual(call.kwargs["expected_pid"], 42)
|
||||
self.assertIn(12, call.kwargs["excluded_window_ids"])
|
||||
|
||||
def test_discovery_keeps_the_original_pid_if_the_window_disappears_during_startup(self) -> None:
|
||||
self.mapper.root_window_ids.return_value = {8, 9}
|
||||
self.mapper.find_first_window.return_value = (12, "PristonTale EU V23.86.96")
|
||||
self.mapper.window_pid.return_value = 42
|
||||
process = MagicMock()
|
||||
process.poll.return_value = None
|
||||
|
||||
def disappear(*_args, **_kwargs) -> bool:
|
||||
self.mapper.window_pid.return_value = None
|
||||
return False
|
||||
|
||||
with (
|
||||
patch.object(gw, "_X11", return_value=self.mapper),
|
||||
patch.object(gw.subprocess, "Popen", return_value=process),
|
||||
patch.object(gw, "WINDOW_READY_DELAY_SECONDS", 0),
|
||||
patch.object(gw, "_prepare_game_window"),
|
||||
patch.object(gw, "_focus_game_window"),
|
||||
patch.object(gw, "_guard_game_window_startup", side_effect=disappear),
|
||||
patch.object(gw, "_maintain_game_window_focus", return_value=17) as maintain,
|
||||
):
|
||||
self.assertEqual(gw.run_game_window(["unused"]), 17)
|
||||
maintain.assert_called_once_with(
|
||||
self.mapper, process, 12, window_title=gw.WINDOW_TITLE,
|
||||
existing_window_ids={8, 9}, expected_pid=42,
|
||||
)
|
||||
|
||||
|
||||
class ClientWindowDiscoveryTests(unittest.TestCase):
|
||||
def test_replacement_discovery_rejects_other_processes_with_identical_titles(self) -> None:
|
||||
mapper: gw._X11 = object.__new__(gw._X11)
|
||||
with (
|
||||
patch.object(mapper, "_root_windows", return_value=[50, 60, 70]),
|
||||
patch.object(mapper, "_client_window", side_effect={50: 12, 60: 13, 70: 14}.__getitem__),
|
||||
patch.object(mapper, "window_pid", side_effect={12: 99, 13: None, 14: 42}.__getitem__),
|
||||
patch.object(mapper, "_window_title", return_value="PristonTale EU V23.86.96"),
|
||||
):
|
||||
self.assertEqual(mapper.find_first_window(
|
||||
gw.WINDOW_TITLE, excluded_window_ids=set(), expected_pid=42,
|
||||
), (14, "PristonTale EU V23.86.96"))
|
||||
|
||||
def test_title_is_read_from_the_client_inside_an_untitled_wm_frame(self) -> None:
|
||||
mapper: gw._X11 = object.__new__(gw._X11)
|
||||
with (
|
||||
patch.object(mapper, "_root_windows", return_value=[50]),
|
||||
patch.object(mapper, "_client_window", return_value=12),
|
||||
patch.object(mapper, "_window_title", side_effect=lambda window:
|
||||
"PristonTale EU V23.86.96" if window == 12 else None),
|
||||
):
|
||||
self.assertEqual(mapper.find_first_window("PristonTale EU", excluded_window_ids=set()),
|
||||
(12, "PristonTale EU V23.86.96"))
|
||||
|
||||
def test_an_existing_client_is_excluded_even_if_its_frame_changes(self) -> None:
|
||||
mapper: gw._X11 = object.__new__(gw._X11)
|
||||
frames = {50: 12, 60: 12, 70: 13}
|
||||
with (
|
||||
patch.object(mapper, "_root_windows", side_effect=[[50], [60, 70]]),
|
||||
patch.object(mapper, "_client_window", side_effect=frames.__getitem__),
|
||||
patch.object(mapper, "_window_title", return_value="PristonTale EU V23.86.96"),
|
||||
):
|
||||
existing = mapper.root_window_ids()
|
||||
found = mapper.find_first_window("PristonTale EU", excluded_window_ids=existing)
|
||||
self.assertIn(12, existing)
|
||||
self.assertEqual(found, (13, "PristonTale EU V23.86.96"))
|
||||
|
||||
def test_a_retired_client_is_excluded_even_when_a_frame_supplies_its_title(self) -> None:
|
||||
mapper: gw._X11 = object.__new__(gw._X11)
|
||||
with (
|
||||
patch.object(mapper, "_root_windows", return_value=[50]),
|
||||
patch.object(mapper, "_client_window", return_value=12),
|
||||
patch.object(mapper, "_window_title", return_value="PristonTale EU V23.86.96"),
|
||||
):
|
||||
self.assertIsNone(mapper.find_first_window("PristonTale EU", excluded_window_ids={12}))
|
||||
Reference in New Issue
Block a user