1426 lines
43 KiB
Python
1426 lines
43 KiB
Python
import contextlib
|
|
import io
|
|
import json
|
|
from pathlib import Path
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from ptlinux.cli import (
|
|
_configure_launcher_runtime,
|
|
_configured_download_options,
|
|
_launcher_update,
|
|
_native_update,
|
|
_doctor,
|
|
_current_game_crash,
|
|
_ensure_portable_umu_for_play,
|
|
_game_exit_metadata,
|
|
_play,
|
|
_play_locked,
|
|
_print_game_outcome,
|
|
_print_interrupted_game_log,
|
|
_runtime_issue_summary,
|
|
_source_game,
|
|
build_parser,
|
|
main,
|
|
)
|
|
from ptlinux.command import CommandOutcome
|
|
from ptlinux.application.commands.play import (
|
|
game_log_telemetry_summary,
|
|
recommended_runtime_comparison,
|
|
)
|
|
from ptlinux.depot import (
|
|
DepotIntegrityIssue,
|
|
DepotPreflightResult,
|
|
DepotSnapshot,
|
|
DepotStartOption,
|
|
DepotSyncResult,
|
|
cache_depot_launch_catalog,
|
|
)
|
|
from ptlinux.launcher import LauncherError
|
|
from ptlinux.doctor import ToolStatus
|
|
from ptlinux.paths import AppPaths
|
|
from ptlinux.runtime import RuntimeErrorPT, RuntimeSelection
|
|
from ptlinux.self_update import LauncherUpdateResult
|
|
from ptlinux.settings import Settings
|
|
from ptlinux.umu import UmuBootstrapError, UmuBootstrapResult
|
|
from ptlinux.window_diagnostics import (
|
|
WINDOW_DIAGNOSTIC_PREFIX,
|
|
WINDOW_DIAGNOSTICS_SESSION_ENV,
|
|
)
|
|
|
|
|
|
class CliInterruptTests(unittest.TestCase):
|
|
def test_doctor_separates_optional_tools_from_launch_blockers(self) -> None:
|
|
report = MagicMock(
|
|
ok=False,
|
|
distribution="linuxmint",
|
|
version="22",
|
|
architecture="x86_64",
|
|
display_backend="x11",
|
|
tools=[
|
|
ToolStatus("python-tk", None, True, "native dashboard"),
|
|
ToolStatus("gamescope", None, False, "optional scaling"),
|
|
],
|
|
blockers=("python-tk is missing (native dashboard).",),
|
|
warnings=[],
|
|
package_hint="Install Python/Tk with APT.",
|
|
)
|
|
stdout = io.StringIO()
|
|
with (
|
|
patch("ptlinux.cli.inspect_host", return_value=report),
|
|
contextlib.redirect_stdout(stdout),
|
|
):
|
|
returncode = _doctor(False)
|
|
|
|
output = stdout.getvalue()
|
|
self.assertEqual(returncode, 1)
|
|
self.assertIn(
|
|
"Optional tools missing (these do not block launch): gamescope",
|
|
output,
|
|
)
|
|
self.assertIn("Host readiness: BLOCKED", output)
|
|
self.assertIn("Blocking diagnostics:", output)
|
|
self.assertIn("python-tk is missing (native dashboard).", output)
|
|
|
|
def test_interprets_umu_status_247_as_wrapped_sigkill(self) -> None:
|
|
self.assertEqual(
|
|
_game_exit_metadata(247, "umu"),
|
|
{
|
|
"exit_code": 137,
|
|
"raw_exit_code": 247,
|
|
"signal": 9,
|
|
"signal_name": "SIGKILL",
|
|
"signal_inferred": True,
|
|
"signal_inference": "umu_python_system_exit",
|
|
},
|
|
)
|
|
|
|
def test_interprets_direct_proton_status_247_as_wrapped_sigkill(self) -> None:
|
|
self.assertEqual(
|
|
_game_exit_metadata(247, "proton7"),
|
|
{
|
|
"exit_code": 137,
|
|
"raw_exit_code": 247,
|
|
"signal": 9,
|
|
"signal_name": "SIGKILL",
|
|
"signal_inferred": True,
|
|
"signal_inference": "proton_python_system_exit",
|
|
},
|
|
)
|
|
|
|
def test_launcher_update_accepts_a_non_installing_full_verification(self) -> None:
|
|
arguments = build_parser().parse_args(["launcher-update", "--verify"])
|
|
|
|
self.assertTrue(arguments.verify)
|
|
|
|
def test_runtime_bootstrap_has_a_dedicated_install_command(self) -> None:
|
|
arguments = build_parser().parse_args(["runtime-bootstrap"])
|
|
|
|
self.assertEqual(arguments.command, "runtime-bootstrap")
|
|
|
|
def test_container_host_has_an_explicit_repair_command(self) -> None:
|
|
arguments = build_parser().parse_args(["container-host", "--repair"])
|
|
|
|
self.assertEqual(arguments.command, "container-host")
|
|
self.assertTrue(arguments.repair)
|
|
|
|
def test_forced_runtime_setup_emits_recovery_request_and_outcome(self) -> None:
|
|
telemetry = MagicMock()
|
|
runner = MagicMock(dry_run=False)
|
|
with (
|
|
patch("ptlinux.cli.current_telemetry", return_value=telemetry),
|
|
patch("ptlinux.cli.configure_launcher_prefix", return_value=[]) as configure,
|
|
):
|
|
result = _configure_launcher_runtime(
|
|
MagicMock(),
|
|
MagicMock(),
|
|
runner,
|
|
force_runtime=True,
|
|
)
|
|
|
|
self.assertEqual(result, [])
|
|
configure.assert_called_once()
|
|
events = [call.args[0] for call in telemetry.send_event.call_args_list]
|
|
self.assertEqual(
|
|
events,
|
|
[
|
|
"runtime_reinstall_requested",
|
|
"runtime_repair_started",
|
|
"runtime_repair_success",
|
|
],
|
|
)
|
|
|
|
def test_explicit_game_repair_emits_requested_started_and_success(self) -> None:
|
|
telemetry = MagicMock()
|
|
paths = MagicMock()
|
|
paths.source_game.exists.return_value = True
|
|
with (
|
|
patch("ptlinux.cli.AppPaths.from_environment", return_value=paths),
|
|
patch(
|
|
"ptlinux.cli.initialize_shared_telemetry",
|
|
return_value=(telemetry, False),
|
|
) as initialize_telemetry,
|
|
patch("ptlinux.cli._dispatch_command", return_value=0),
|
|
):
|
|
returncode = main(
|
|
["sync", "--repair"],
|
|
_manage_telemetry=True,
|
|
)
|
|
|
|
self.assertEqual(returncode, 0)
|
|
self.assertFalse(
|
|
initialize_telemetry.call_args.kwargs["report_launcher_lifecycle"]
|
|
)
|
|
telemetry.mark_ready.assert_not_called()
|
|
events = [call.args[0] for call in telemetry.send_event.call_args_list]
|
|
self.assertEqual(
|
|
events,
|
|
["repair_requested", "repair_started", "repair_success"],
|
|
)
|
|
|
|
def test_forced_runtime_setup_emits_failed_recovery_outcome(self) -> None:
|
|
telemetry = MagicMock()
|
|
runner = MagicMock(dry_run=False)
|
|
with (
|
|
patch("ptlinux.cli.current_telemetry", return_value=telemetry),
|
|
patch(
|
|
"ptlinux.cli.configure_launcher_prefix",
|
|
side_effect=LauncherError("runtime setup failed"),
|
|
),
|
|
self.assertRaisesRegex(LauncherError, "runtime setup failed"),
|
|
):
|
|
_configure_launcher_runtime(
|
|
MagicMock(),
|
|
MagicMock(),
|
|
runner,
|
|
force_runtime=True,
|
|
)
|
|
|
|
events = [call.args[0] for call in telemetry.send_event.call_args_list]
|
|
self.assertEqual(events[-1], "runtime_repair_failed")
|
|
|
|
def test_launcher_update_still_restarts_when_umu_must_retry(self) -> None:
|
|
update = LauncherUpdateResult(
|
|
changed=True,
|
|
current_version="0.8.3",
|
|
installed_version="0.8.4",
|
|
release_directory="/release/0.8.4",
|
|
previous_release="/release/0.8.3",
|
|
archive="/cache/update.tar.gz",
|
|
archive_sha256="a" * 64,
|
|
restart_required=True,
|
|
state_recorded=True,
|
|
warning=None,
|
|
)
|
|
stderr = io.StringIO()
|
|
container_host = MagicMock(
|
|
ready=True,
|
|
broken_umu_profile_link=False,
|
|
repair_error=None,
|
|
recommendation=None,
|
|
)
|
|
container_host.to_dict.return_value = {"ready": True}
|
|
with (
|
|
patch("ptlinux.cli.install_launcher_update", return_value=update),
|
|
patch(
|
|
"ptlinux.cli.provision_umu",
|
|
side_effect=UmuBootstrapError("network unavailable"),
|
|
),
|
|
patch("ptlinux.cli.find_umu_launcher", return_value=None),
|
|
patch("ptlinux.cli.repair_container_host", return_value=container_host),
|
|
patch("ptlinux.cli._print_operation_status"),
|
|
patch("ptlinux.cli._emit") as emit,
|
|
contextlib.redirect_stderr(stderr),
|
|
):
|
|
returncode = _launcher_update(
|
|
MagicMock(),
|
|
False,
|
|
False,
|
|
False,
|
|
None,
|
|
None,
|
|
False,
|
|
)
|
|
|
|
self.assertEqual(returncode, 0)
|
|
self.assertIn("network unavailable", stderr.getvalue())
|
|
payload = emit.call_args.args[0]
|
|
self.assertTrue(payload["ok"])
|
|
self.assertIn("network unavailable", payload["runtime_bootstrap"]["warning"])
|
|
|
|
def test_login_diagnostics_accepts_a_read_only_runtime_override(self) -> None:
|
|
arguments = build_parser().parse_args(
|
|
["login-diagnostics", "--runtime", "proton7"]
|
|
)
|
|
|
|
self.assertEqual(arguments.runtime, "proton7")
|
|
|
|
def test_play_accepts_an_official_realm_name(self) -> None:
|
|
arguments = build_parser().parse_args(["play", "--realm", "Seasonal"])
|
|
|
|
self.assertEqual(arguments.realm, "Seasonal")
|
|
|
|
def test_keyboard_interrupt_returns_130_without_a_traceback(self) -> None:
|
|
stderr = io.StringIO()
|
|
with (
|
|
patch("ptlinux.cli._play", side_effect=KeyboardInterrupt),
|
|
contextlib.redirect_stderr(stderr),
|
|
):
|
|
returncode = main(["play"])
|
|
|
|
self.assertEqual(returncode, 130)
|
|
self.assertIn("compatibility runtime were stopped", stderr.getvalue())
|
|
self.assertNotIn("Traceback", stderr.getvalue())
|
|
|
|
def test_json_interrupt_is_machine_readable(self) -> None:
|
|
stdout = io.StringIO()
|
|
with (
|
|
patch("ptlinux.cli._play", side_effect=KeyboardInterrupt),
|
|
contextlib.redirect_stdout(stdout),
|
|
):
|
|
returncode = main(["--json", "play"])
|
|
|
|
self.assertEqual(returncode, 130)
|
|
self.assertEqual(json.loads(stdout.getvalue())["interrupted"], True)
|
|
|
|
def test_play_preflight_interrupt_is_telemetry_cancellation(self) -> None:
|
|
telemetry = MagicMock()
|
|
stderr = io.StringIO()
|
|
with (
|
|
patch(
|
|
"ptlinux.cli.initialize_shared_telemetry",
|
|
return_value=(telemetry, False),
|
|
),
|
|
patch("ptlinux.cli._play", side_effect=KeyboardInterrupt),
|
|
contextlib.redirect_stderr(stderr),
|
|
):
|
|
returncode = main(["play"], _manage_telemetry=True)
|
|
|
|
self.assertEqual(returncode, 130)
|
|
events = [call.args[0] for call in telemetry.send_event.call_args_list]
|
|
self.assertEqual(events, ["game_launch_cancelled"])
|
|
cancelled = telemetry.send_event.call_args
|
|
self.assertEqual(cancelled.kwargs["severity"], "info")
|
|
self.assertTrue(cancelled.kwargs["metadata"]["interrupted"])
|
|
self.assertEqual(cancelled.kwargs["metadata"]["launcher_exit_code"], 130)
|
|
self.assertEqual(
|
|
cancelled.kwargs["context"],
|
|
{"operation": "launch_game", "stage": "preflight"},
|
|
)
|
|
|
|
def test_genuine_play_failure_keeps_its_runtime_stage(self) -> None:
|
|
telemetry = MagicMock()
|
|
stderr = io.StringIO()
|
|
with (
|
|
patch(
|
|
"ptlinux.cli.initialize_shared_telemetry",
|
|
return_value=(telemetry, False),
|
|
),
|
|
patch(
|
|
"ptlinux.cli._play",
|
|
side_effect=RuntimeErrorPT("Proton runtime is unavailable"),
|
|
),
|
|
contextlib.redirect_stderr(stderr),
|
|
):
|
|
returncode = main(["play"], _manage_telemetry=True)
|
|
|
|
self.assertEqual(returncode, 1)
|
|
failure = next(
|
|
call
|
|
for call in telemetry.send_event.call_args_list
|
|
if call.args[0] == "game_launch_failed"
|
|
)
|
|
self.assertEqual(failure.kwargs["severity"], "error")
|
|
self.assertEqual(
|
|
failure.kwargs["context"],
|
|
{"operation": "launch_game", "stage": "runtime_check"},
|
|
)
|
|
|
|
def test_launcher_update_writable_release_uses_filesystem_stage(self) -> None:
|
|
telemetry = MagicMock()
|
|
stderr = io.StringIO()
|
|
with (
|
|
patch(
|
|
"ptlinux.cli.initialize_shared_telemetry",
|
|
return_value=(telemetry, False),
|
|
),
|
|
patch(
|
|
"ptlinux.cli._launcher_update",
|
|
side_effect=RuntimeError(
|
|
"launcher release path is group/world writable: release/__pycache__"
|
|
),
|
|
),
|
|
contextlib.redirect_stderr(stderr),
|
|
):
|
|
returncode = main(["launcher-update"], _manage_telemetry=True)
|
|
|
|
self.assertEqual(returncode, 1)
|
|
failure = next(
|
|
call
|
|
for call in telemetry.send_event.call_args_list
|
|
if call.args[0] == "update_failed"
|
|
)
|
|
self.assertEqual(
|
|
failure.kwargs["context"],
|
|
{"operation": "launcher_update", "stage": "filesystem"},
|
|
)
|
|
|
|
|
|
class CliGameSourceTests(unittest.TestCase):
|
|
def test_persisted_import_source_does_not_redirect_play(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
paths = AppPaths(
|
|
data=root / "data",
|
|
config=root / "config",
|
|
state=root / "state",
|
|
cache=root / "cache",
|
|
)
|
|
settings = Settings(game_source=str(root / "publisher-game"))
|
|
|
|
self.assertEqual(_source_game(paths, settings, None), paths.source_game)
|
|
|
|
def test_explicit_diagnostic_game_directory_still_takes_precedence(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
paths = AppPaths(
|
|
data=root / "data",
|
|
config=root / "config",
|
|
state=root / "state",
|
|
cache=root / "cache",
|
|
)
|
|
explicit = root / "diagnostic-game"
|
|
|
|
self.assertEqual(
|
|
_source_game(paths, Settings(), explicit),
|
|
explicit.resolve(),
|
|
)
|
|
|
|
|
|
class CliDownloadSettingsTests(unittest.TestCase):
|
|
def test_default_download_settings_need_no_override(self) -> None:
|
|
self.assertIsNone(_configured_download_options(Settings()))
|
|
|
|
def test_saved_download_controls_are_forwarded_to_the_depot(self) -> None:
|
|
options = _configured_download_options(
|
|
Settings(
|
|
download_speed_limit_mbps=10.0,
|
|
low_bandwidth_mode=True,
|
|
)
|
|
)
|
|
|
|
self.assertIsNotNone(options)
|
|
assert options is not None
|
|
self.assertEqual(options.bandwidth_limit_bytes_per_second, 10_000_000)
|
|
self.assertEqual(options.effective_bandwidth_limit, 5_000_000)
|
|
self.assertTrue(options.low_bandwidth)
|
|
|
|
|
|
class CliPlayLockTests(unittest.TestCase):
|
|
def test_play_passes_its_preparation_lease_to_the_game_launch(self) -> None:
|
|
paths = MagicMock()
|
|
lease = MagicMock()
|
|
lease.preparation_allowed = True
|
|
guard = MagicMock()
|
|
guard.__enter__.return_value = lease
|
|
guard.__exit__.return_value = False
|
|
with (
|
|
patch("ptlinux.cli._play_guard", return_value=guard) as play_guard,
|
|
patch("ptlinux.cli._play_locked", return_value=7) as play_locked,
|
|
):
|
|
result = _play(
|
|
paths,
|
|
None,
|
|
False,
|
|
False,
|
|
False,
|
|
False,
|
|
)
|
|
|
|
play_guard.assert_called_once_with(paths, False)
|
|
guard.__enter__.assert_called_once_with()
|
|
guard.__exit__.assert_called_once()
|
|
play_locked.assert_called_once_with(
|
|
paths,
|
|
None,
|
|
False,
|
|
False,
|
|
False,
|
|
False,
|
|
realm=None,
|
|
reuse_prepared=False,
|
|
before_game=lease.allow_shared_clients,
|
|
game_lease_fds=lease.inherited_fds,
|
|
)
|
|
self.assertEqual(result, 7)
|
|
|
|
def test_running_game_interrupt_emits_one_cancellation(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
paths = AppPaths(
|
|
data=root / "data",
|
|
config=root / "config",
|
|
state=root / "state",
|
|
cache=root / "cache",
|
|
)
|
|
paths.runtime_view.mkdir(parents=True)
|
|
selection = RuntimeSelection("umu", "umu-run", None, None)
|
|
telemetry = MagicMock()
|
|
|
|
def interrupt_after_window_ready(
|
|
*_args: object,
|
|
**kwargs: object,
|
|
) -> CommandOutcome:
|
|
output_filter = kwargs["output_line_filter"]
|
|
assert callable(output_filter)
|
|
output_filter("Game window ready: PristonTale EU\n")
|
|
raise KeyboardInterrupt
|
|
|
|
def play_until_interrupted(*_args: object, **_kwargs: object) -> int:
|
|
return _play_locked(
|
|
paths,
|
|
None,
|
|
False,
|
|
False,
|
|
False,
|
|
False,
|
|
reuse_prepared=True,
|
|
)
|
|
|
|
stderr = io.StringIO()
|
|
with (
|
|
patch("ptlinux.cli.AppPaths.from_environment", return_value=paths),
|
|
patch(
|
|
"ptlinux.cli.initialize_shared_telemetry",
|
|
return_value=(telemetry, False),
|
|
),
|
|
patch("ptlinux.cli.current_telemetry", return_value=telemetry),
|
|
patch("ptlinux.cli._play", side_effect=play_until_interrupted),
|
|
patch(
|
|
"ptlinux.cli.game_import_requires_verification",
|
|
return_value=False,
|
|
),
|
|
patch("ptlinux.cli._load_or_default_settings", return_value=Settings()),
|
|
patch("ptlinux.cli.select_runtime", return_value=selection),
|
|
patch("ptlinux.cli._runner", return_value=MagicMock()),
|
|
patch("ptlinux.cli.game_runtime_ready", return_value=True),
|
|
patch("ptlinux.cli.snapshot_native_logs", return_value={}),
|
|
patch("ptlinux.cli.run_game", side_effect=interrupt_after_window_ready),
|
|
patch("ptlinux.cli._current_game_log", return_value=None),
|
|
patch("ptlinux.cli._print_operation_status"),
|
|
contextlib.redirect_stderr(stderr),
|
|
):
|
|
returncode = main(["play"], _manage_telemetry=True)
|
|
|
|
self.assertEqual(returncode, 130)
|
|
events = [call.args[0] for call in telemetry.send_event.call_args_list]
|
|
self.assertEqual(events.count("game_launch_cancelled"), 1)
|
|
self.assertNotIn("game_launch_failed", events)
|
|
self.assertNotIn("game_exited", events)
|
|
cancelled = next(
|
|
call
|
|
for call in telemetry.send_event.call_args_list
|
|
if call.args[0] == "game_launch_cancelled"
|
|
)
|
|
self.assertEqual(cancelled.kwargs["severity"], "info")
|
|
self.assertEqual(cancelled.kwargs["context"]["stage"], "running")
|
|
self.assertTrue(cancelled.kwargs["metadata"]["launch_succeeded"])
|
|
self.assertTrue(cancelled.kwargs["metadata"]["game_window_ready"])
|
|
self.assertEqual(
|
|
cancelled.kwargs["metadata"]["readiness_signal"],
|
|
"window_ready",
|
|
)
|
|
|
|
def test_play_resolves_ptr_babel_from_the_official_catalog(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
paths = AppPaths(
|
|
data=root / "data",
|
|
config=root / "config",
|
|
state=root / "state",
|
|
cache=root / "cache",
|
|
)
|
|
paths.runtime_view.mkdir(parents=True)
|
|
cache_depot_launch_catalog(
|
|
paths.depot_launch_catalog_cache,
|
|
DepotSnapshot(
|
|
region="Europe",
|
|
server="cdn.example/pristontale-eu",
|
|
name="PristonTale EU",
|
|
build=10,
|
|
version="10.0.0",
|
|
files=(),
|
|
deleted_files=(),
|
|
create_if_missing=(),
|
|
start_options=(
|
|
DepotStartOption("Babel", "Game.exe", "PTEU_REBORN"),
|
|
DepotStartOption(
|
|
"Test Babel",
|
|
"GameSe.exe",
|
|
"PTEU_REBORN",
|
|
),
|
|
),
|
|
default_start_option="Babel",
|
|
),
|
|
)
|
|
selection = RuntimeSelection("umu", "umu-run", None, None)
|
|
outcome = CommandOutcome([], 0, "", "", 0.0, False, False)
|
|
|
|
with (
|
|
patch("ptlinux.cli.game_import_requires_verification", return_value=False),
|
|
patch("ptlinux.cli._load_or_default_settings", return_value=Settings()),
|
|
patch("ptlinux.cli.prepare_runtime_view", return_value=None),
|
|
patch(
|
|
"ptlinux.cli._ensure_portable_umu_for_play",
|
|
return_value=None,
|
|
) as ensure_umu,
|
|
patch(
|
|
"ptlinux.cli.select_runtime",
|
|
return_value=selection,
|
|
) as select_runtime_mock,
|
|
patch("ptlinux.cli._runner", return_value=MagicMock()),
|
|
patch("ptlinux.cli.prepare_game_runtime"),
|
|
patch("ptlinux.cli.snapshot_native_logs", return_value={}),
|
|
patch("ptlinux.cli.run_game", return_value=outcome) as run_game,
|
|
patch("ptlinux.cli._current_game_log", return_value=None),
|
|
patch("ptlinux.cli._emit"),
|
|
):
|
|
result = _play_locked(
|
|
paths,
|
|
None,
|
|
False,
|
|
False,
|
|
False,
|
|
False,
|
|
realm="PTR Babel",
|
|
)
|
|
|
|
self.assertEqual(result, 0)
|
|
self.assertEqual(
|
|
run_game.call_args.kwargs["start_option"],
|
|
DepotStartOption("PTR Babel", "GameSe.exe", "PTEU_REBORN"),
|
|
)
|
|
effective_settings = run_game.call_args.args[1]
|
|
self.assertEqual(effective_settings.realm, "PTR Babel")
|
|
self.assertEqual(effective_settings.runtime, "proton7")
|
|
ensure_umu.assert_called_once_with(
|
|
paths,
|
|
effective_settings,
|
|
dry_run=False,
|
|
)
|
|
self.assertEqual(select_runtime_mock.call_args.args[0], effective_settings)
|
|
self.assertFalse(run_game.call_args.kwargs["check"])
|
|
|
|
def test_additional_client_reuses_runtime_without_mutating_it(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
paths = AppPaths(
|
|
data=root / "data",
|
|
config=root / "config",
|
|
state=root / "state",
|
|
cache=root / "cache",
|
|
)
|
|
paths.runtime_view.mkdir(parents=True)
|
|
selection = RuntimeSelection("umu", "umu-run", None, None)
|
|
outcome = CommandOutcome([], 0, "", "", 0.0, False, False)
|
|
launch_order: list[str] = []
|
|
|
|
def before_game() -> None:
|
|
launch_order.append("shared lease")
|
|
|
|
def run_game(*_args: object, **_kwargs: object) -> CommandOutcome:
|
|
_kwargs["before_start"]()
|
|
launch_order.append("game")
|
|
return outcome
|
|
|
|
with (
|
|
patch("ptlinux.cli.game_import_requires_verification", return_value=False),
|
|
patch("ptlinux.cli._load_or_default_settings", return_value=Settings()),
|
|
patch("ptlinux.cli._ensure_portable_umu_for_play", return_value=None),
|
|
patch("ptlinux.cli.select_runtime", return_value=selection),
|
|
patch("ptlinux.cli._runner", return_value=MagicMock()),
|
|
patch("ptlinux.cli.game_runtime_ready", return_value=True),
|
|
patch("ptlinux.cli.prepare_runtime_view") as prepare_view,
|
|
patch("ptlinux.cli.prepare_game_runtime") as prepare_runtime,
|
|
patch("ptlinux.cli.snapshot_native_logs", return_value={}),
|
|
patch("ptlinux.cli.run_game", side_effect=run_game) as run_game_mock,
|
|
patch("ptlinux.cli._current_game_log", return_value=None),
|
|
patch("ptlinux.cli._emit"),
|
|
):
|
|
result = _play_locked(
|
|
paths,
|
|
None,
|
|
False,
|
|
False,
|
|
False,
|
|
False,
|
|
reuse_prepared=True,
|
|
before_game=before_game,
|
|
game_lease_fds=(17,),
|
|
)
|
|
|
|
prepare_view.assert_not_called()
|
|
prepare_runtime.assert_not_called()
|
|
self.assertEqual(launch_order, ["shared lease", "game"])
|
|
self.assertTrue(
|
|
run_game_mock.call_args.kwargs["concurrent_prefix_launch"]
|
|
)
|
|
self.assertEqual(run_game_mock.call_args.kwargs["pass_fds"], (17,))
|
|
self.assertEqual(result, 0)
|
|
|
|
def test_live_ready_window_survives_truncated_nonzero_process_output(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
paths = AppPaths(
|
|
data=root / "data",
|
|
config=root / "config",
|
|
state=root / "state",
|
|
cache=root / "cache",
|
|
)
|
|
paths.runtime_view.mkdir(parents=True)
|
|
selection = RuntimeSelection("umu", "umu-run", None, None)
|
|
outcome = CommandOutcome(
|
|
[],
|
|
247,
|
|
"",
|
|
"GStreamer-WARNING: final retained output after the readiness marker\n",
|
|
221.0,
|
|
False,
|
|
False,
|
|
)
|
|
telemetry = MagicMock()
|
|
events_before_process_exit: list[str] = []
|
|
|
|
def run_game_with_ready_signal(
|
|
*_args: object,
|
|
**kwargs: object,
|
|
) -> CommandOutcome:
|
|
output_filter = kwargs["output_line_filter"]
|
|
assert callable(output_filter)
|
|
extra_environment = kwargs["extra_environment"]
|
|
assert isinstance(extra_environment, dict)
|
|
diagnostic_session = extra_environment[
|
|
WINDOW_DIAGNOSTICS_SESSION_ENV
|
|
]
|
|
diagnostic_line = WINDOW_DIAGNOSTIC_PREFIX + json.dumps(
|
|
{
|
|
"schema": 1,
|
|
"component": "gamescope_host",
|
|
"diagnostic": "host_inactive_remap",
|
|
"sequence": 3,
|
|
"elapsed_ms": 1200,
|
|
"session_id": diagnostic_session,
|
|
"details": {
|
|
"wm_state": "normal",
|
|
"minimize_request_accepted": True,
|
|
},
|
|
}
|
|
) + "\n"
|
|
spoofed_diagnostic_line = diagnostic_line.replace(
|
|
diagnostic_session,
|
|
"0" * 32,
|
|
)
|
|
self.assertEqual(
|
|
output_filter(spoofed_diagnostic_line),
|
|
spoofed_diagnostic_line,
|
|
)
|
|
self.assertIsNone(output_filter(diagnostic_line))
|
|
self.assertEqual(
|
|
output_filter(
|
|
"vulkan: physical device queue doesn't support "
|
|
"presenting on our surface\n"
|
|
),
|
|
"vulkan: physical device queue doesn't support "
|
|
"presenting on our surface\n",
|
|
)
|
|
output_filter(
|
|
"vulkan: physical device queue doesn't support "
|
|
"presenting on our surface\n"
|
|
)
|
|
self.assertEqual(
|
|
output_filter("Game window ready: PristonTale EU\n"),
|
|
"Game window ready: PristonTale EU\n",
|
|
)
|
|
output_filter(
|
|
"Game window resizing is disabled to prevent repeated "
|
|
"Direct3D device resets.\n"
|
|
)
|
|
events_before_process_exit.extend(
|
|
call.args[0] for call in telemetry.send_event.call_args_list
|
|
)
|
|
return outcome
|
|
|
|
with (
|
|
patch("ptlinux.cli.current_telemetry", return_value=telemetry),
|
|
patch("ptlinux.cli.game_import_requires_verification", return_value=False),
|
|
patch("ptlinux.cli._load_or_default_settings", return_value=Settings()),
|
|
patch("ptlinux.cli.select_runtime", return_value=selection),
|
|
patch("ptlinux.cli._runner", return_value=MagicMock()),
|
|
patch("ptlinux.cli.game_runtime_ready", return_value=True),
|
|
patch("ptlinux.cli.snapshot_native_logs", return_value={}),
|
|
patch("ptlinux.cli.run_game", side_effect=run_game_with_ready_signal),
|
|
patch("ptlinux.cli._current_game_log", return_value=None),
|
|
patch("ptlinux.cli._current_game_crash", return_value=None),
|
|
patch("ptlinux.cli._emit"),
|
|
):
|
|
result = _play_locked(
|
|
paths,
|
|
None,
|
|
False,
|
|
False,
|
|
False,
|
|
True,
|
|
reuse_prepared=True,
|
|
)
|
|
|
|
diagnostic_files = list(paths.logs.glob("windows-*.jsonl"))
|
|
self.assertEqual(len(diagnostic_files), 1)
|
|
diagnostics = [json.loads(line) for line in diagnostic_files[0].read_text().splitlines()]
|
|
self.assertEqual(len(diagnostics), 1)
|
|
self.assertEqual(diagnostics[0]["diagnostic"], "host_inactive_remap")
|
|
self.assertNotEqual(diagnostics[0]["session_id"], "0" * 32)
|
|
|
|
self.assertEqual(result, 1)
|
|
events = [call.args[0] for call in telemetry.send_event.call_args_list]
|
|
self.assertIn("game_launch_success", events_before_process_exit)
|
|
self.assertIn("game_window_diagnostic", events_before_process_exit)
|
|
self.assertNotIn("game_exited", events_before_process_exit)
|
|
self.assertIn("game_launch_success", events)
|
|
self.assertIn("game_exited", events)
|
|
self.assertNotIn("game_launch_failed", events)
|
|
self.assertEqual(events.count("game_launch_success"), 1)
|
|
self.assertEqual(events.count("game_window_diagnostic"), 2)
|
|
self.assertLess(events.index("game_launch_success"), events.index("game_exited"))
|
|
launch_call = next(
|
|
call
|
|
for call in telemetry.send_event.call_args_list
|
|
if call.args[0] == "game_launch_success"
|
|
)
|
|
self.assertEqual(launch_call.kwargs["context"]["stage"], "ready")
|
|
self.assertEqual(
|
|
launch_call.kwargs["metadata"]["readiness_signal"],
|
|
"window_ready",
|
|
)
|
|
exit_call = next(
|
|
call
|
|
for call in telemetry.send_event.call_args_list
|
|
if call.args[0] == "game_exited"
|
|
)
|
|
self.assertEqual(exit_call.kwargs["severity"], "warning")
|
|
self.assertEqual(
|
|
exit_call.kwargs["message"],
|
|
"Game session ended with status 247, consistent with SIGKILL wrapped by UMU",
|
|
)
|
|
self.assertEqual(exit_call.kwargs["metadata"]["exit_code"], 137)
|
|
self.assertEqual(exit_call.kwargs["metadata"]["raw_exit_code"], 247)
|
|
self.assertEqual(exit_call.kwargs["metadata"]["signal"], 9)
|
|
self.assertTrue(exit_call.kwargs["metadata"]["game_window_ready"])
|
|
self.assertEqual(
|
|
exit_call.kwargs["metadata"]["readiness_signal"],
|
|
"window_ready",
|
|
)
|
|
self.assertNotIn(
|
|
"Game window resizing",
|
|
exit_call.kwargs["metadata"]["stderr"],
|
|
)
|
|
diagnostic_call = next(
|
|
call
|
|
for call in telemetry.send_event.call_args_list
|
|
if call.args[0] == "game_window_diagnostic"
|
|
and call.kwargs["metadata"]["diagnostic"] == "host_inactive_remap"
|
|
)
|
|
self.assertEqual(diagnostic_call.kwargs["severity"], "warning")
|
|
self.assertEqual(
|
|
diagnostic_call.kwargs["metadata"]["diagnostic"],
|
|
"host_inactive_remap",
|
|
)
|
|
self.assertEqual(
|
|
diagnostic_call.kwargs["metadata"]["session_id"],
|
|
exit_call.kwargs["metadata"]["launch_session_id"],
|
|
)
|
|
presentation_call = next(
|
|
call
|
|
for call in telemetry.send_event.call_args_list
|
|
if call.args[0] == "game_window_diagnostic"
|
|
and call.kwargs["metadata"]["diagnostic"]
|
|
== "gamescope_present_queue_unsupported"
|
|
)
|
|
self.assertEqual(presentation_call.kwargs["severity"], "warning")
|
|
self.assertEqual(
|
|
presentation_call.kwargs["context"]["stage"],
|
|
"presentation",
|
|
)
|
|
|
|
def test_new_crash_dump_makes_a_zero_status_launch_fail(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
paths = AppPaths(
|
|
data=root / "data",
|
|
config=root / "config",
|
|
state=root / "state",
|
|
cache=root / "cache",
|
|
)
|
|
paths.runtime_view.mkdir(parents=True)
|
|
selection = RuntimeSelection("umu", "umu-run", None, None)
|
|
start_option = DepotStartOption("Babel", "Game.exe", "PTEU_REBORN")
|
|
outcome = CommandOutcome([], 0, "", "", 1.0, False, False)
|
|
crash = {
|
|
"path": str(root / "crash" / "crash_game.dat"),
|
|
"modified_at": "2026-09-05T11:07:07+02:00",
|
|
"size": 231_320,
|
|
}
|
|
native_log = {
|
|
"path": str(root / "logs" / "log_current.txt"),
|
|
"size": 4096,
|
|
"runtime_out_of_memory": True,
|
|
"out_of_memory_count": 2,
|
|
"out_of_memory_effect_paths": [
|
|
r"game\scripts\shaders\UI\Minimap.fx",
|
|
],
|
|
}
|
|
crash_attachment = {
|
|
"schema": 1,
|
|
"kind": "windows_minidump",
|
|
"encoding": "gzip+base64url",
|
|
"original_size": 231_320,
|
|
"compressed_size": 24_000,
|
|
"sha256": "a" * 64,
|
|
"data_base64url": "A" * 32_000,
|
|
}
|
|
telemetry = MagicMock()
|
|
telemetry.enabled = True
|
|
|
|
with (
|
|
patch("ptlinux.cli.current_telemetry", return_value=telemetry),
|
|
patch("ptlinux.cli.game_import_requires_verification", return_value=False),
|
|
patch(
|
|
"ptlinux.cli._load_or_default_settings",
|
|
return_value=Settings(game_crash_dump_upload_enabled=True),
|
|
),
|
|
patch("ptlinux.cli.select_depot_start_option", return_value=start_option),
|
|
patch("ptlinux.cli.read_current_depot_file_catalog", return_value=None),
|
|
patch("ptlinux.cli.select_runtime", return_value=selection),
|
|
patch("ptlinux.cli._runner", return_value=MagicMock()),
|
|
patch("ptlinux.cli.game_runtime_ready", return_value=True),
|
|
patch("ptlinux.cli.snapshot_native_logs", return_value={}),
|
|
patch("ptlinux.cli.run_game", return_value=outcome),
|
|
patch("ptlinux.cli._current_game_log", return_value=native_log),
|
|
patch("ptlinux.cli._current_game_crash", return_value=crash),
|
|
patch(
|
|
"ptlinux.application.commands.play.prepare_minidump_attachment",
|
|
return_value=("included", crash_attachment),
|
|
),
|
|
patch("ptlinux.cli._emit") as emit,
|
|
):
|
|
result = _play_locked(
|
|
paths,
|
|
None,
|
|
False,
|
|
False,
|
|
False,
|
|
True,
|
|
reuse_prepared=True,
|
|
)
|
|
|
|
self.assertEqual(result, 1)
|
|
payload = emit.call_args.args[0]
|
|
self.assertFalse(payload["ok"])
|
|
self.assertEqual(payload["current_game_crash"], crash)
|
|
exit_call = next(
|
|
call
|
|
for call in telemetry.send_event.call_args_list
|
|
if call.args[0] == "game_exited"
|
|
)
|
|
self.assertEqual(
|
|
exit_call.kwargs["metadata"]["crash_dump_info"],
|
|
{
|
|
"modified_at": "2026-09-05T11:07:07+02:00",
|
|
"size": 231_320,
|
|
},
|
|
)
|
|
self.assertEqual(
|
|
exit_call.kwargs["metadata"]["failure_reason"],
|
|
"direct3d_resource_exhaustion",
|
|
)
|
|
self.assertEqual(
|
|
exit_call.kwargs["metadata"]["crash_dump_upload_status"],
|
|
"included",
|
|
)
|
|
self.assertEqual(exit_call.kwargs["attachment"], crash_attachment)
|
|
self.assertEqual(
|
|
exit_call.kwargs["message"],
|
|
"Game client logged Direct3D resource exhaustion and wrote a crash dump",
|
|
)
|
|
self.assertEqual(
|
|
exit_call.kwargs["metadata"]["native_game_log"],
|
|
{
|
|
"size": 4096,
|
|
"runtime_out_of_memory": True,
|
|
"out_of_memory_count": 2,
|
|
"out_of_memory_effect_paths": [
|
|
r"game\scripts\shaders\UI\Minimap.fx",
|
|
],
|
|
},
|
|
)
|
|
|
|
def test_zero_status_resource_exhaustion_is_reported_as_degraded(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
paths = AppPaths(
|
|
data=root / "data",
|
|
config=root / "config",
|
|
state=root / "state",
|
|
cache=root / "cache",
|
|
)
|
|
paths.runtime_view.mkdir(parents=True)
|
|
selection = RuntimeSelection("umu", "umu-run", None, None)
|
|
start_option = DepotStartOption("Babel", "Game.exe", "PTEU_REBORN")
|
|
outcome = CommandOutcome([], 0, "", "", 1.0, False, False)
|
|
native_log = {
|
|
"path": str(root / "logs" / "log_current.txt"),
|
|
"size": 4096,
|
|
"runtime_out_of_memory": True,
|
|
"out_of_memory_count": 4,
|
|
"out_of_memory_effect_paths": [
|
|
r"game\scripts\shaders\UI\Minimap.fx",
|
|
],
|
|
}
|
|
telemetry = MagicMock()
|
|
|
|
with (
|
|
patch("ptlinux.cli.current_telemetry", return_value=telemetry),
|
|
patch("ptlinux.cli.game_import_requires_verification", return_value=False),
|
|
patch("ptlinux.cli._load_or_default_settings", return_value=Settings()),
|
|
patch("ptlinux.cli.select_depot_start_option", return_value=start_option),
|
|
patch("ptlinux.cli.read_current_depot_file_catalog", return_value=None),
|
|
patch("ptlinux.cli.select_runtime", return_value=selection),
|
|
patch("ptlinux.cli._runner", return_value=MagicMock()),
|
|
patch("ptlinux.cli.game_runtime_ready", return_value=True),
|
|
patch("ptlinux.cli.snapshot_native_logs", return_value={}),
|
|
patch("ptlinux.cli.run_game", return_value=outcome),
|
|
patch("ptlinux.cli._current_game_log", return_value=native_log),
|
|
patch("ptlinux.cli._current_game_crash", return_value=None),
|
|
patch("ptlinux.cli._emit") as emit,
|
|
):
|
|
result = _play_locked(
|
|
paths,
|
|
None,
|
|
False,
|
|
False,
|
|
False,
|
|
True,
|
|
reuse_prepared=True,
|
|
)
|
|
|
|
self.assertEqual(result, 0)
|
|
payload = emit.call_args.args[0]
|
|
self.assertTrue(payload["ok"])
|
|
self.assertTrue(payload["degraded"])
|
|
self.assertEqual(
|
|
payload["session_issue"],
|
|
"direct3d_resource_exhaustion",
|
|
)
|
|
exit_call = next(
|
|
call
|
|
for call in telemetry.send_event.call_args_list
|
|
if call.args[0] == "game_exited"
|
|
)
|
|
self.assertEqual(exit_call.kwargs["severity"], "warning")
|
|
self.assertEqual(
|
|
exit_call.kwargs["message"],
|
|
"Game client exited after logging Direct3D resource exhaustion",
|
|
)
|
|
self.assertTrue(exit_call.kwargs["metadata"]["clean_exit"])
|
|
self.assertTrue(exit_call.kwargs["metadata"]["degraded"])
|
|
self.assertEqual(
|
|
exit_call.kwargs["metadata"]["failure_reason"],
|
|
"direct3d_resource_exhaustion",
|
|
)
|
|
self.assertEqual(
|
|
exit_call.kwargs["metadata"]["native_game_log"]["out_of_memory_count"],
|
|
4,
|
|
)
|
|
|
|
def test_managed_play_stops_before_prepare_when_preflight_finds_damage(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
paths = AppPaths(
|
|
data=root / "data",
|
|
config=root / "config",
|
|
state=root / "state",
|
|
cache=root / "cache",
|
|
)
|
|
catalog = MagicMock(files=())
|
|
preflight = DepotPreflightResult(
|
|
build=10,
|
|
files_total=1,
|
|
verified_files=0,
|
|
unverified_files=0,
|
|
changed_files_hashed=1,
|
|
issue_count=1,
|
|
issues=(DepotIntegrityIssue("game/data.bin", "hash mismatch"),),
|
|
duration_ms=2,
|
|
)
|
|
|
|
with (
|
|
patch("ptlinux.cli.game_import_requires_verification", return_value=False),
|
|
patch("ptlinux.cli._load_or_default_settings", return_value=Settings()),
|
|
patch("ptlinux.cli.read_current_depot_file_catalog", return_value=catalog),
|
|
patch("ptlinux.cli.preflight_managed_game", return_value=preflight),
|
|
patch("ptlinux.cli.prepare_runtime_view") as prepare,
|
|
self.assertRaisesRegex(LauncherError, "game/data.bin: hash mismatch"),
|
|
):
|
|
_play_locked(
|
|
paths,
|
|
None,
|
|
False,
|
|
False,
|
|
False,
|
|
False,
|
|
)
|
|
|
|
prepare.assert_not_called()
|
|
|
|
|
|
class CliImportVerificationTests(unittest.TestCase):
|
|
def test_pending_import_forces_full_repair_and_records_completion(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
paths = AppPaths(
|
|
data=root / "data",
|
|
config=root / "config",
|
|
state=root / "state",
|
|
cache=root / "cache",
|
|
)
|
|
sync_result = DepotSyncResult(
|
|
region="Europe",
|
|
server="cdn.example",
|
|
name="PristonTale EU",
|
|
build=330,
|
|
version="330",
|
|
files_total=1,
|
|
files_planned=0,
|
|
bytes_planned=0,
|
|
files_downloaded=0,
|
|
bytes_downloaded=0,
|
|
files_deleted=0,
|
|
files_created=0,
|
|
files_quarantined=2,
|
|
quarantine_directory="/quarantine/import-1",
|
|
repair=True,
|
|
dry_run=False,
|
|
completed_at="2026-08-30T12:00:00+02:00",
|
|
)
|
|
with (
|
|
patch("ptlinux.cli._operation_guard", return_value=contextlib.nullcontext()),
|
|
patch("ptlinux.cli._load_or_default_settings", return_value=Settings()),
|
|
patch("ptlinux.cli._runner", return_value=MagicMock()),
|
|
patch("ptlinux.cli._maybe_import_game", return_value=None),
|
|
patch("ptlinux.cli.game_import_requires_verification", return_value=True),
|
|
patch("ptlinux.cli.sync_managed_game", return_value=sync_result) as sync,
|
|
patch("ptlinux.cli.mark_game_import_verified") as mark_verified,
|
|
patch("ptlinux.cli._emit"),
|
|
):
|
|
result = _native_update(
|
|
paths,
|
|
False,
|
|
False,
|
|
None,
|
|
False,
|
|
True,
|
|
)
|
|
|
|
self.assertEqual(result, 0)
|
|
sync.assert_called_once_with(
|
|
paths,
|
|
repair=True,
|
|
quarantine_unmanaged=True,
|
|
dry_run=False,
|
|
progress=None,
|
|
)
|
|
mark_verified.assert_called_once_with(
|
|
paths,
|
|
build=330,
|
|
files_quarantined=2,
|
|
quarantine_directory="/quarantine/import-1",
|
|
)
|
|
|
|
|
|
class CliRuntimeIssueTests(unittest.TestCase):
|
|
def test_discovers_a_crash_dump_created_by_the_current_launch(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
directory = Path(temporary)
|
|
crash_path = directory / "crash_Game_20260905_110706.dat"
|
|
crash_path.write_bytes(b"MDMP")
|
|
|
|
report = _current_game_crash(directory, {}, 0)
|
|
|
|
self.assertIsNotNone(report)
|
|
assert report is not None
|
|
self.assertEqual(report["path"], str(crash_path.absolute()))
|
|
self.assertEqual(report["size"], 4)
|
|
|
|
def test_prints_resource_exhaustion_before_asset_repair_advice(self) -> None:
|
|
stderr = io.StringIO()
|
|
with contextlib.redirect_stderr(stderr):
|
|
_print_interrupted_game_log(
|
|
{
|
|
"path": "/tmp/game.log",
|
|
"module_code_sync_error": False,
|
|
"runtime_out_of_memory": True,
|
|
"out_of_memory_effect_paths": [
|
|
r"game\scripts\shaders\UI\Minimap.fx"
|
|
],
|
|
}
|
|
)
|
|
|
|
output = stderr.getvalue()
|
|
self.assertIn("Direct3D resource exhaustion", output)
|
|
self.assertIn("Minimap.fx", output)
|
|
self.assertIn("depot repair will not fix", output)
|
|
|
|
def test_human_failure_summary_does_not_replay_streamed_process_output(self) -> None:
|
|
outcome = CommandOutcome(
|
|
["game"],
|
|
9,
|
|
"already streamed stdout\n",
|
|
"already streamed stderr\n",
|
|
1.0,
|
|
False,
|
|
False,
|
|
)
|
|
issues = _runtime_issue_summary(outcome.stderr)
|
|
stderr = io.StringIO()
|
|
|
|
with contextlib.redirect_stderr(stderr):
|
|
_print_game_outcome(outcome, None, issues)
|
|
|
|
self.assertIn("exited with status 9", stderr.getvalue())
|
|
self.assertNotIn("already streamed stderr", stderr.getvalue())
|
|
|
|
def test_crash_dump_overrides_a_zero_process_status(self) -> None:
|
|
outcome = CommandOutcome(["game"], 0, "", "", 1.0, False, False)
|
|
stderr = io.StringIO()
|
|
|
|
with contextlib.redirect_stderr(stderr):
|
|
_print_game_outcome(
|
|
outcome,
|
|
None,
|
|
_runtime_issue_summary(""),
|
|
{"path": "/tmp/crash_game.dat"},
|
|
)
|
|
|
|
self.assertIn("crashed and wrote a dump", stderr.getvalue())
|
|
self.assertNotIn("exited normally", stderr.getvalue())
|
|
|
|
def test_resource_exhaustion_overrides_the_normal_exit_summary(self) -> None:
|
|
outcome = CommandOutcome(["game"], 0, "", "", 1.0, False, False)
|
|
stdout = io.StringIO()
|
|
stderr = io.StringIO()
|
|
native_log = {
|
|
"path": "/tmp/game.log",
|
|
"module_code_sync_error": False,
|
|
"runtime_out_of_memory": True,
|
|
"out_of_memory_effect_paths": [
|
|
r"game\scripts\shaders\UI\Minimap.fx"
|
|
],
|
|
}
|
|
|
|
with (
|
|
contextlib.redirect_stdout(stdout),
|
|
contextlib.redirect_stderr(stderr),
|
|
):
|
|
_print_game_outcome(
|
|
outcome,
|
|
native_log,
|
|
_runtime_issue_summary(""),
|
|
)
|
|
|
|
combined = stdout.getvalue() + stderr.getvalue()
|
|
self.assertNotIn("exited normally", combined)
|
|
self.assertIn(
|
|
"status 0 after logging Direct3D resource exhaustion",
|
|
combined,
|
|
)
|
|
self.assertIn("condition can recur", combined)
|
|
|
|
def test_default_installs_the_verified_portable_umu_helper(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
paths = AppPaths(root / "data", root / "config", root / "state", root / "cache")
|
|
installed = UmuBootstrapResult(
|
|
path=str(paths.managed_umu_executable),
|
|
version="1.4.4",
|
|
source_url="https://example.invalid/umu.tar",
|
|
archive_sha256="a" * 64,
|
|
executable_sha256="b" * 64,
|
|
changed=True,
|
|
)
|
|
with (
|
|
patch("ptlinux.cli.provision_umu", return_value=installed) as ensure,
|
|
):
|
|
result = _ensure_portable_umu_for_play(
|
|
paths,
|
|
Settings(),
|
|
dry_run=False,
|
|
)
|
|
|
|
self.assertEqual(result, installed)
|
|
ensure.assert_called_once_with(paths)
|
|
|
|
def test_auto_falls_back_to_direct_proton_if_the_umu_download_fails(self) -> None:
|
|
paths = MagicMock()
|
|
with (
|
|
patch(
|
|
"ptlinux.cli.provision_umu",
|
|
side_effect=UmuBootstrapError("network unavailable"),
|
|
),
|
|
patch(
|
|
"ptlinux.cli.select_runtime",
|
|
return_value=RuntimeSelection("proton7", "python", "/proton", "/steam"),
|
|
) as select,
|
|
contextlib.redirect_stderr(io.StringIO()),
|
|
):
|
|
result = _ensure_portable_umu_for_play(
|
|
paths,
|
|
Settings(runtime="auto"),
|
|
dry_run=False,
|
|
)
|
|
|
|
self.assertIsNone(result)
|
|
self.assertEqual(select.call_args.args[0].runtime, "proton7")
|
|
|
|
def test_auto_reports_when_neither_umu_nor_direct_proton_is_available(self) -> None:
|
|
paths = MagicMock()
|
|
with (
|
|
patch(
|
|
"ptlinux.cli.provision_umu",
|
|
side_effect=UmuBootstrapError("network unavailable"),
|
|
),
|
|
patch(
|
|
"ptlinux.cli.select_runtime",
|
|
side_effect=RuntimeErrorPT("Proton 7 missing"),
|
|
),
|
|
self.assertRaisesRegex(RuntimeError, "no usable game runtime"),
|
|
):
|
|
_ensure_portable_umu_for_play(
|
|
paths,
|
|
Settings(runtime="auto"),
|
|
dry_run=False,
|
|
)
|
|
|
|
def test_summarizes_arch_runtime_noise_without_calling_it_authentication(self) -> None:
|
|
report = _runtime_issue_summary(
|
|
"The futex facility returned an unexpected error code.\n"
|
|
"plugin.so: wrong ELF class: ELFCLASS64\n"
|
|
"winegstreamer error: Internal data stream error.\n"
|
|
"GStreamer-WARNING: Failed to load plugin 'libgstlibav.so': "
|
|
"libavfilter.so.7: cannot open shared object file: No such file or directory\n"
|
|
"GStreamer-WARNING: Failed to load plugin 'libgstsoup.so': "
|
|
"libsoup-2.4.so.1: cannot open shared object file: No such file or directory\n"
|
|
"egl: failed to create dri2 screen\n"
|
|
"vulkan: physical device queue doesn't support presenting on our surface\n"
|
|
"vulkan_make_output failed\n"
|
|
"SteamAPI_Init() failed; no appID found.\n"
|
|
"Assertion failed: module->module.SymType != SymDeferred, "
|
|
"file ../src-wine/dlls/dbghelp/module.c, line 383\n"
|
|
)
|
|
|
|
self.assertEqual(report["host_futex_fatal_count"], 1)
|
|
self.assertEqual(report["gstreamer_wrong_elf_class_count"], 1)
|
|
self.assertEqual(report["gstreamer_missing_library_count"], 2)
|
|
self.assertEqual(
|
|
report["gstreamer_missing_libraries"],
|
|
["libavfilter.so.7", "libsoup-2.4.so.1"],
|
|
)
|
|
self.assertTrue(report["gstreamer_stream_error"])
|
|
self.assertTrue(report["mesa_egl_initialization_failed"])
|
|
self.assertTrue(report["gamescope_present_queue_unsupported"])
|
|
self.assertEqual(report["gamescope_vulkan_output_failed_count"], 1)
|
|
self.assertTrue(report["steam_api_app_id_missing"])
|
|
self.assertEqual(report["dbghelp_symdeferred_assertion_count"], 1)
|
|
self.assertFalse(report["bubblewrap_uid_map_permission_denied"])
|
|
self.assertFalse(report["game_window_ready"])
|
|
|
|
def test_recognizes_a_ready_game_window_after_runtime_noise(self) -> None:
|
|
report = _runtime_issue_summary(
|
|
"plugin.so: wrong ELF class: ELFCLASS64\n"
|
|
"Game window resizing is disabled to prevent repeated Direct3D device resets.\n"
|
|
)
|
|
|
|
self.assertTrue(report["game_window_ready"])
|
|
|
|
def test_summarizes_bubblewrap_uid_map_denial(self) -> None:
|
|
report = _runtime_issue_summary(
|
|
"pressure-vessel-wrap: bwrap: setting up uid map: Permission denied\n"
|
|
)
|
|
|
|
self.assertTrue(report["bubblewrap_uid_map_permission_denied"])
|
|
|
|
def test_native_log_telemetry_counts_asset_classifications_without_paths(self) -> None:
|
|
report = game_log_telemetry_summary(
|
|
{
|
|
"failed_texture_file_count": 1,
|
|
"asset_references": {
|
|
"total_events": 1,
|
|
"unique_valid_references": 1,
|
|
"invalid_reference_events": 0,
|
|
"catalog_available": True,
|
|
"managed_integrity_failed": False,
|
|
"managed_present_paths": [],
|
|
"managed_missing_paths": [],
|
|
"publisher_unmanaged_paths": [
|
|
"game/images/UI/combobox/drop_big.png"
|
|
],
|
|
"unclassified_paths": [],
|
|
},
|
|
}
|
|
)
|
|
|
|
self.assertEqual(report["failed_texture_file_count"], 1)
|
|
self.assertEqual(
|
|
report["asset_reference_summary"],
|
|
{
|
|
"total_events": 1,
|
|
"unique_valid_references": 1,
|
|
"invalid_reference_events": 0,
|
|
"catalog_available": True,
|
|
"managed_integrity_failed": False,
|
|
"managed_present_count": 0,
|
|
"managed_missing_count": 0,
|
|
"publisher_unmanaged_count": 1,
|
|
"unclassified_count": 0,
|
|
},
|
|
)
|
|
self.assertNotIn("drop_big.png", json.dumps(report))
|
|
|
|
def test_successful_window_does_not_treat_plugin_scan_noise_as_a_runtime_fix(self) -> None:
|
|
self.assertIsNone(
|
|
recommended_runtime_comparison(
|
|
"proton7",
|
|
{
|
|
"game_window_ready": True,
|
|
"gstreamer_missing_libraries": ["libsoup-2.4.so.1"],
|
|
"gstreamer_stream_error": False,
|
|
},
|
|
)
|
|
)
|
|
|
|
def test_stream_failure_still_recommends_the_container_runtime(self) -> None:
|
|
self.assertEqual(
|
|
recommended_runtime_comparison(
|
|
"proton7",
|
|
{
|
|
"game_window_ready": True,
|
|
"gstreamer_missing_libraries": ["libsoup-2.4.so.1"],
|
|
"gstreamer_stream_error": True,
|
|
},
|
|
),
|
|
"proton7-container",
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|