mirror of
https://github.com/ClassicWasTaken/HaloCE-SteamFrame.git
synced 2026-10-15 10:00:34 +02:00
683 lines
32 KiB
Python
683 lines
32 KiB
Python
import importlib.util
|
|
import json
|
|
import sys
|
|
import struct
|
|
import threading
|
|
import types
|
|
from collections import OrderedDict
|
|
from pathlib import Path
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
from halo_frame_installer.install import EXPECTED_MAPS, Installer, SOURCE_COMMIT, close_connection, map_manifest, parse_result
|
|
from halo_frame_installer.ssh import CancelledError, RemoteTimeoutError, Settings, SSHError
|
|
|
|
RESOURCES = Path(__file__).resolve().parents[1] / "resources"
|
|
|
|
|
|
def load_resource(name):
|
|
spec = importlib.util.spec_from_file_location("resource_" + name, RESOURCES / (name + ".py"))
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[spec.name] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def test_shortcut_roundtrip_preserves_foreign_entry_and_is_idempotent():
|
|
steam = load_resource("steam_shortcut")
|
|
other = OrderedDict([("appid", steam.Value(2, 2147483650)), ("AppName", steam.Value(1, "Other game")),
|
|
("Exe", steam.Value(1, '"/somewhere/game"')), ("custom", steam.Value(7, b"12345678"))])
|
|
root = OrderedDict([("shortcuts", steam.Value(0, OrderedDict([("0", steam.Value(0, other))])))])
|
|
before = steam.dumps(root)
|
|
first, appid = steam.update_shortcut(before, "/home/steamos/Games/HaloCENativeVR/halo", "/home/steamos/Games/HaloCENativeVR")
|
|
after = steam.loads(first)["shortcuts"].value
|
|
assert after["0"].value == other
|
|
assert len(after) == 2
|
|
assert after["1"].value["OpenVR"].value == 1
|
|
assert after["1"].value["LaunchOptions"].value == "SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD=0 %command%"
|
|
assert steam.update_shortcut(first, "/home/steamos/Games/HaloCENativeVR/halo", "/home/steamos/Games/HaloCENativeVR") == (first, appid)
|
|
|
|
|
|
def test_shortcut_rejects_unknown_types_or_duplicate_fields():
|
|
steam = load_resource("steam_shortcut")
|
|
with pytest.raises(ValueError, match="Unsupported"):
|
|
steam.loads(b"\x09unknown\0\x08")
|
|
with pytest.raises(ValueError, match="duplicate"):
|
|
steam.loads(b"\x01a\0b\0\x01a\0c\0\x08")
|
|
|
|
|
|
def test_shortcut_wont_change_live_steam_files(tmp_path, monkeypatch):
|
|
steam = load_resource("steam_shortcut")
|
|
monkeypatch.setattr(steam, "steam_running", lambda: True)
|
|
result = steam.add_native_shortcut(tmp_path, tmp_path / "game")
|
|
assert result["status"] == "manual"
|
|
assert list(tmp_path.iterdir()) == []
|
|
|
|
|
|
def test_forced_proton_mapping_reports_manual_step_without_rewriting_config(tmp_path):
|
|
steam = load_resource("steam_shortcut")
|
|
config = tmp_path / "config/config.vdf"
|
|
config.parent.mkdir()
|
|
before = '"InstallConfigStore" { "Software" { "Valve" { "Steam" { "CompatToolMapping" { "123" { "name" "proton_9" } "456" { "name" "" } } } } } }'
|
|
config.write_text(before)
|
|
warning = steam._compatibility_warning(tmp_path, 123)
|
|
assert "proton_9" in warning and "turn off" in warning
|
|
assert steam._compatibility_warning(tmp_path, 456) is None
|
|
assert config.read_text() == before
|
|
|
|
|
|
def test_legacy_shutdown_request_leaves_running_steam_and_games_untouched(tmp_path, monkeypatch):
|
|
import subprocess
|
|
steam = load_resource("steam_shortcut")
|
|
monkeypatch.setattr(steam, "steam_running", lambda: True)
|
|
shutdown, restart = Mock(), Mock()
|
|
monkeypatch.setattr(subprocess, "run", shutdown)
|
|
monkeypatch.setattr(subprocess, "Popen", restart)
|
|
result = steam.add_native_shortcut(tmp_path, tmp_path / "game", close_steam=True)
|
|
assert result["status"] == "manual"
|
|
shutdown.assert_not_called()
|
|
restart.assert_not_called()
|
|
assert not list(tmp_path.iterdir())
|
|
|
|
|
|
def test_closed_steam_registration_does_not_start_a_new_client(tmp_path, monkeypatch):
|
|
import subprocess
|
|
steam = load_resource("steam_shortcut")
|
|
monkeypatch.setattr(steam, "steam_running", lambda: False)
|
|
monkeypatch.setattr(steam, "_write_native_shortcut", lambda home, game: {"status": "added", "appid": 42})
|
|
normal_shutdown = Mock()
|
|
restart = Mock()
|
|
monkeypatch.setattr(subprocess, "run", normal_shutdown)
|
|
monkeypatch.setattr(subprocess, "Popen", restart)
|
|
assert steam.add_native_shortcut(tmp_path, tmp_path / "game", close_steam=True)["status"] == "added"
|
|
normal_shutdown.assert_not_called()
|
|
restart.assert_not_called()
|
|
|
|
|
|
def test_original_maps_manifest_never_includes_iso_or_symlinks(tmp_path):
|
|
maps = tmp_path / "maps"
|
|
maps.mkdir()
|
|
for name in EXPECTED_MAPS:
|
|
(maps / name).write_bytes(name.encode())
|
|
root, manifest = map_manifest(tmp_path)
|
|
assert root == maps and len(manifest["files"]) == 24
|
|
assert all(file["path"].startswith("maps/") for file in manifest["files"])
|
|
(maps / "game.iso").write_bytes(b"private")
|
|
with pytest.raises(ValueError, match="24 original Xbox"):
|
|
map_manifest(tmp_path)
|
|
|
|
|
|
class FakeConnection:
|
|
def __init__(self, settings, *, existing=True):
|
|
self.settings = settings
|
|
self.existing = existing
|
|
self.commands = []
|
|
self.uploads = []
|
|
self.closed = False
|
|
self.host_fingerprint = "SHA256:public"
|
|
def connect(self):
|
|
pass
|
|
def close(self):
|
|
self.closed = True
|
|
def put(self, local, remote, **kwargs):
|
|
self.uploads.append((str(local), remote))
|
|
def run(self, argv, **kwargs):
|
|
self.commands.append(argv)
|
|
if argv[1] == "-c":
|
|
return "HFI_RESULT " + json.dumps({"home": "/home/steamos", "cachePath": "/home/steamos/.cache/halo-frame-installer",
|
|
"gamePath": "/home/steamos/Games/HaloCENativeVR", "existing": {"gamePath": "/home/steamos/Games/HaloCENativeVR", "reused": True, "mapsVerified": True} if self.existing else None})
|
|
if argv[2] == "shortcut":
|
|
return 'HFI_RESULT {"status":"added","appid":3732925724}'
|
|
if argv[2] == "prepare":
|
|
return "HFI_RESULT " + json.dumps({"uploadPath": "/home/steamos/.cache/halo-frame-installer/runs/" + argv[argv.index("--run-id") + 1] + "/upload"})
|
|
if argv[2] == "build":
|
|
raise SSHError("Native build failed. The existing game was kept.")
|
|
raise AssertionError(argv)
|
|
|
|
|
|
def test_add_to_steam_retry_reuses_files_without_build_or_map_upload():
|
|
fake = FakeConnection(Settings("frame", "private", close_steam_for_shortcut=True))
|
|
result = Installer(lambda settings: fake, RESOURCES).add_to_steam(fake.settings)
|
|
assert result.reused and result.steam_appid == 3732925724
|
|
assert fake.closed
|
|
assert all(not remote.endswith(".map") for _, remote in fake.uploads)
|
|
assert any(remote.endswith("/artwork/halo-ce-cover.jpg") for _, remote in fake.uploads)
|
|
assert any(remote.endswith("/artwork/halo-ce-landscape.png") for _, remote in fake.uploads)
|
|
assert not any("build" in command[2:] for command in fake.commands)
|
|
assert not any("--close-steam" in command for command in fake.commands)
|
|
assert fake.commands[0][-1] == 'preflight-library'
|
|
for name in ('halo-ce-hero.jpg', 'halo-ce-logo.png', 'halo-ce-icon.png', 'steam_notes.py'):
|
|
assert any(remote.endswith('/' + name) for _, remote in fake.uploads)
|
|
assert not any(Path(local).name in ('build-native.sh', 'frame-controls.patch') for local, _ in fake.uploads)
|
|
|
|
|
|
def test_library_update_requires_existing_game_and_cannot_start_build():
|
|
fake = FakeConnection(Settings('frame', 'private'), existing=False)
|
|
with pytest.raises(SSHError, match='before updating'):
|
|
Installer(lambda settings: fake, RESOURCES).add_to_steam(fake.settings)
|
|
assert fake.closed and not fake.uploads
|
|
assert len(fake.commands) == 1 and fake.commands[0][-1] == 'preflight-library'
|
|
|
|
|
|
def test_setup_reports_success_only_after_ssh_disconnects():
|
|
fake = FakeConnection(Settings('frame', 'private', reinstall_existing=False))
|
|
seen = []
|
|
def report(stage, message, percent=None):
|
|
seen.append(stage)
|
|
if stage in ('disconnected', 'complete'):
|
|
assert fake.closed
|
|
elif stage == 'disconnect':
|
|
assert not fake.closed
|
|
Installer(lambda settings: fake, RESOURCES).run(fake.settings, None, report)
|
|
assert seen[-3:] == ['disconnect', 'disconnected', 'complete']
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["fresh", "repair"])
|
|
def test_normal_install_and_repair_automatically_publish_steam_information(tmp_path, mode):
|
|
steam_info = {"status": "added", "appid": 3732925724, "libraryAdded": True,
|
|
"artwork": {"status": "added", "installed": ["cover", "header", "hero", "logo"],
|
|
"icon": {"status": "added"}},
|
|
"notes": {"ok": True, "status": "added", "title": "About and controls"}}
|
|
|
|
class SuccessfulConnection(FakeConnection):
|
|
def __init__(self, settings):
|
|
super().__init__(settings, existing=mode == "repair")
|
|
self.events = []
|
|
|
|
def put(self, local, remote, **kwargs):
|
|
self.events.append(("upload", Path(local).name))
|
|
super().put(local, remote, **kwargs)
|
|
|
|
def run(self, argv, **kwargs):
|
|
if argv[1] != "-c" and argv[2] in ("build", "finalize", "shortcut"):
|
|
self.commands.append(argv)
|
|
self.events.append(("step", argv[2]))
|
|
responses = {"build": {"built": True},
|
|
"finalize": {"gamePath": "/home/steamos/Games/HaloCENativeVR",
|
|
"reused": False, "repaired": mode == "repair"},
|
|
"shortcut": steam_info}
|
|
return "HFI_RESULT " + json.dumps(responses[argv[2]])
|
|
return super().run(argv, **kwargs)
|
|
|
|
settings = Settings("frame", "private", reinstall_existing=mode == "repair",
|
|
close_steam_for_shortcut=True)
|
|
fake = SuccessfulConnection(settings)
|
|
maps = None
|
|
if mode == "fresh":
|
|
maps = tmp_path / "maps"
|
|
maps.mkdir()
|
|
for name in EXPECTED_MAPS:
|
|
(maps / name).write_bytes(name.encode())
|
|
result = Installer(lambda _: fake, RESOURCES).run(settings, maps)
|
|
|
|
steps = [argv[2] for argv in fake.commands if argv[1] != "-c"]
|
|
assert steps == ["prepare", "build", "finalize", "shortcut"]
|
|
assert fake.commands[0][3] == "preflight"
|
|
assert steps.count("shortcut") == 1
|
|
assert fake.events.index(("step", "finalize")) < fake.events.index(("step", "shortcut"))
|
|
for filename in ("steam_notes.py", "halo-ce-cover.jpg", "halo-ce-landscape.png",
|
|
"halo-ce-hero.jpg", "halo-ce-logo.png", "halo-ce-icon.png"):
|
|
assert fake.events.index(("upload", filename)) < fake.events.index(("step", "build"))
|
|
assert result.steam == steam_info
|
|
assert result.repaired is (mode == "repair")
|
|
assert fake.closed
|
|
assert not any("--close-steam" in argv for argv in fake.commands)
|
|
uploaded_maps = [remote for _, remote in fake.uploads if remote.endswith(".map")]
|
|
assert len(uploaded_maps) == (len(EXPECTED_MAPS) if mode == "fresh" else 0)
|
|
|
|
|
|
def test_default_existing_install_rebuilds_without_uploading_maps():
|
|
fake = FakeConnection(Settings("frame", "private"))
|
|
with pytest.raises(SSHError, match="build failed"):
|
|
Installer(lambda settings: fake, RESOURCES).run(fake.settings, None)
|
|
assert any("--use-existing-maps" in command for command in fake.commands)
|
|
assert any(command[2] == "build" and "--repair" in command for command in fake.commands if command[1] != "-c")
|
|
assert all(not remote.endswith(".map") for _, remote in fake.uploads)
|
|
|
|
|
|
def test_failed_build_does_not_finalize_or_modify_game(tmp_path):
|
|
maps = tmp_path / "maps"
|
|
maps.mkdir()
|
|
for name in EXPECTED_MAPS:
|
|
(maps / name).write_bytes(name.encode())
|
|
fake = FakeConnection(Settings("frame", "private"), existing=False)
|
|
with pytest.raises(SSHError, match="build failed"):
|
|
Installer(lambda settings: fake, RESOURCES).run(fake.settings, maps)
|
|
assert not any(command[2] in ("finalize", "shortcut") for command in fake.commands if len(command) > 2 and command[1] != "-c")
|
|
assert fake.closed
|
|
|
|
|
|
def test_step_failure_is_not_replaced_by_a_failed_disconnect():
|
|
class ForwardCleanupFailure(FakeConnection):
|
|
def close(self):
|
|
raise SSHError("USB forward cleanup failed")
|
|
|
|
fake = ForwardCleanupFailure(Settings("frame", "private"))
|
|
with pytest.raises(SSHError, match="build failed"):
|
|
Installer(lambda settings: fake, RESOURCES).run(fake.settings, None)
|
|
|
|
|
|
def test_cancelled_install_is_not_replaced_by_a_failed_disconnect():
|
|
class ForwardCleanupFailure(FakeConnection):
|
|
def close(self):
|
|
raise SSHError("USB forward cleanup failed")
|
|
|
|
event = threading.Event()
|
|
event.set()
|
|
fake = ForwardCleanupFailure(Settings("frame", "private"))
|
|
with pytest.raises(CancelledError):
|
|
Installer(lambda settings: fake, RESOURCES).run(fake.settings, None, cancel_event=event)
|
|
|
|
|
|
def test_close_connection_reports_a_swallowed_cleanup_failure():
|
|
class FailingClose:
|
|
settings = Settings('', 'private', transport='usb')
|
|
def close(self):
|
|
raise SSHError("SSH has closed, but the temporary USB forward could not be removed.")
|
|
|
|
seen = []
|
|
warning = close_connection(FailingClose(), lambda *args: seen.append(args))
|
|
assert warning.startswith('SSH has closed, but the temporary USB forward could not be removed.')
|
|
assert 'Unplug the USB cable' in warning
|
|
assert seen == [('detail', warning, None)]
|
|
|
|
|
|
def test_successful_install_with_failed_cleanup_retains_result_and_needs_attention():
|
|
class ForwardCleanupFailure(FakeConnection):
|
|
def close(self):
|
|
self.closed = True
|
|
raise SSHError('USB forward cleanup failed private')
|
|
fake = ForwardCleanupFailure(Settings('', 'private', transport='usb', reinstall_existing=False))
|
|
events = []
|
|
result = Installer(lambda settings: fake, RESOURCES).run(fake.settings, None,
|
|
lambda *event: events.append(event))
|
|
assert result.steam_appid == 3732925724 and result.reused
|
|
assert result.connection_cleanup_pending and 'Unplug the USB cable' in result.cleanup_warning
|
|
assert 'private' not in result.cleanup_warning
|
|
assert 'disconnected' not in [event[0] for event in events]
|
|
assert 'complete' not in [event[0] for event in events]
|
|
assert events[-1][0] == 'cleanup-pending' and events[-1][2] == 99
|
|
|
|
|
|
@pytest.mark.parametrize('primary_error', [CancelledError, RemoteTimeoutError, SSHError])
|
|
@pytest.mark.parametrize('original_active', [False, True])
|
|
def test_failed_remote_stop_uses_one_pinned_fresh_connection(primary_error, original_active):
|
|
class BrokenConnection(FakeConnection):
|
|
def is_active(self):
|
|
return original_active
|
|
def run(self, argv, **kwargs):
|
|
if argv[1] != '-c' and argv[2] == 'build':
|
|
self.commands.append(argv)
|
|
if primary_error is not SSHError:
|
|
kwargs['on_cancel']()
|
|
messages = {CancelledError: 'Installation cancelled.',
|
|
RemoteTimeoutError: 'The remote step timed out.',
|
|
SSHError: 'The remote connection stopped before this step could be verified.'}
|
|
raise primary_error(messages[primary_error])
|
|
if argv[1] != '-c' and argv[2] == 'cancel':
|
|
self.commands.append(argv)
|
|
raise SSHError('Connection stopped')
|
|
return super().run(argv, **kwargs)
|
|
class RecoveryConnection(FakeConnection):
|
|
def connect(self, *, timeout):
|
|
self.connect_timeout = timeout
|
|
def run(self, argv, **kwargs):
|
|
self.commands.append(argv)
|
|
assert kwargs['timeout'] == 45 and 'cancel_event' not in kwargs
|
|
return 'HFI_RESULT {"cancelled":true}'
|
|
settings = Settings('frame', 'private', accept_host_key=Mock(side_effect=AssertionError('No new approval')))
|
|
original = BrokenConnection(settings)
|
|
created = []
|
|
def factory(current):
|
|
if not created:
|
|
created.append(original)
|
|
return original
|
|
recovery = RecoveryConnection(current)
|
|
created.append(recovery)
|
|
return recovery
|
|
events = []
|
|
with pytest.raises(primary_error) as error:
|
|
Installer(factory, RESOURCES).run(settings, None, lambda *event: events.append(event))
|
|
assert len(created) == 2
|
|
recovery = created[1]
|
|
build = next(command for command in original.commands if command[1] != '-c' and command[2] == 'build')
|
|
expected_id = build[build.index('--run-id') + 1]
|
|
assert recovery.commands == [['python3', '/home/steamos/.cache/halo-frame-installer/resources/remote_install.py', 'cancel', '--run-id', expected_id]]
|
|
assert recovery.connect_timeout == 10
|
|
assert recovery.settings.known_host_fingerprint == 'SHA256:public'
|
|
assert recovery.settings.accept_host_key is None and recovery.settings.known_host_fingerprints == {}
|
|
assert recovery.settings.password == '' and settings.password == 'private'
|
|
assert original.closed and recovery.closed
|
|
assert 'could not be confirmed' not in str(error.value)
|
|
assert not any(command[2] in ('finalize', 'shortcut') for command in original.commands if command[1] != '-c')
|
|
assert any(event[1] == 'Remote build cancellation confirmed.' for event in events)
|
|
|
|
|
|
@pytest.mark.parametrize('failure', ['unapproved', 'changed-key', 'unconfirmed-response'])
|
|
def test_unconfirmed_remote_stop_is_visible_without_masking_cancel(failure):
|
|
class BrokenConnection(FakeConnection):
|
|
def is_active(self):
|
|
return False
|
|
def run(self, argv, **kwargs):
|
|
if argv[1] != '-c' and argv[2] == 'build':
|
|
self.commands.append(argv)
|
|
raise CancelledError('Installation cancelled. Existing games and saves were kept.')
|
|
return super().run(argv, **kwargs)
|
|
settings = Settings('frame', 'private')
|
|
original = BrokenConnection(settings)
|
|
if failure == 'unapproved':
|
|
original.host_fingerprint = None
|
|
created = []
|
|
def factory(current):
|
|
if not created:
|
|
created.append(original)
|
|
return original
|
|
recovery = FakeConnection(current)
|
|
recovery.connect = Mock(side_effect=SSHError('SSH host key has changed.')) if failure == 'changed-key' else Mock()
|
|
recovery.run = Mock(return_value='HFI_RESULT {"cancelled":"true"}')
|
|
created.append(recovery)
|
|
return recovery
|
|
with pytest.raises(CancelledError) as error:
|
|
Installer(factory, RESOURCES).run(settings, None)
|
|
first_line = str(error.value).splitlines()[0]
|
|
assert 'cancelled' in first_line and 'Remote stopping could not be confirmed' in first_line
|
|
assert 'In the Frame\'s terminal, run: python3 /home/steamos/.cache/' in str(error.value)
|
|
assert 'private' not in str(error.value) and original.closed
|
|
assert len(created) == (1 if failure == 'unapproved' else 2)
|
|
if len(created) == 2:
|
|
assert created[1].closed and created[1].settings.password == ''
|
|
|
|
|
|
def test_cleanup_display_failure_cannot_replace_the_primary_error():
|
|
fake = FakeConnection(Settings('frame', 'private'))
|
|
fake.close = Mock(side_effect=EOFError('private'))
|
|
def progress(stage, message, percent=None):
|
|
if stage == 'detail':
|
|
raise ValueError('Display failed')
|
|
with pytest.raises(SSHError, match='build failed') as error:
|
|
Installer(lambda settings: fake, RESOURCES).run(fake.settings, None, progress)
|
|
assert 'private' not in str(error.value)
|
|
|
|
|
|
def test_cancelled_before_connect_has_no_remote_mutation():
|
|
event = threading.Event()
|
|
event.set()
|
|
fake = FakeConnection(Settings("frame", "private"))
|
|
with pytest.raises(CancelledError):
|
|
Installer(lambda settings: fake, RESOURCES).run(fake.settings, None, cancel_event=event)
|
|
assert not fake.commands and not fake.uploads
|
|
|
|
|
|
def test_installer_delivers_live_phase_counts_and_detail_lines_before_build_returns():
|
|
delivered = []
|
|
|
|
class ActivityConnection(FakeConnection):
|
|
def run(self, argv, **kwargs):
|
|
if argv[1] != '-c' and argv[2] == 'build':
|
|
self.commands.append(argv)
|
|
report = kwargs['progress']
|
|
report('HFI_PROGRESS {"stage":"toolchain","message":"Installing LLVM 22"}')
|
|
report('HFI_LOG {"stage":"toolchain","message":"Unpacking clang-22"}')
|
|
report('HFI_PROGRESS {"stage":"compile","message":"Compiled 42 of 100 tasks","percent":42}')
|
|
assert ('compile', 'Compiled 42 of 100 tasks', 42) in delivered
|
|
assert ('detail', 'toolchain: Unpacking clang-22', None) in delivered
|
|
return 'HFI_RESULT {"built":true}'
|
|
if argv[1] != '-c' and argv[2] == 'finalize':
|
|
self.commands.append(argv)
|
|
return 'HFI_RESULT {"gamePath":"/home/steamos/Games/HaloCENativeVR","repaired":true}'
|
|
return super().run(argv, **kwargs)
|
|
|
|
fake = ActivityConnection(Settings('frame', 'private'))
|
|
result = Installer(lambda settings: fake, RESOURCES).run(fake.settings, None,
|
|
lambda stage, message, percent=None: delivered.append((stage, message, percent)))
|
|
assert result.repaired and fake.closed
|
|
assert delivered[-1][0] == 'complete'
|
|
|
|
|
|
def test_result_protocol_requires_one_result():
|
|
with pytest.raises(SSHError):
|
|
parse_result("random text")
|
|
with pytest.raises(SSHError):
|
|
parse_result('HFI_RESULT {}\nHFI_RESULT {}')
|
|
|
|
|
|
@pytest.fixture
|
|
def remote(tmp_path, monkeypatch):
|
|
if "pwd" not in sys.modules:
|
|
monkeypatch.setitem(sys.modules, "pwd", types.SimpleNamespace(getpwuid=lambda uid: None))
|
|
module = load_resource("remote_install")
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
module.HOME = home
|
|
module.CACHE = home / "cache"
|
|
module.GAME = home / "Games/HaloCENativeVR"
|
|
monkeypatch.setattr(module, "game_closed", lambda: None)
|
|
return module
|
|
|
|
|
|
def xbox_maps(directory, remote):
|
|
(directory / "maps").mkdir(parents=True)
|
|
header = bytearray(2048)
|
|
header[:4] = b"daeh"
|
|
header[-4:] = b"toof"
|
|
struct.pack_into("<II", header, 4, 5, 2048)
|
|
header[64:64 + 13] = b"01.10.12.2276"
|
|
files = []
|
|
for relative in sorted(remote.EXPECTED_MAPS):
|
|
target = directory / relative
|
|
target.write_bytes(header)
|
|
files.append({"path": relative, "size": len(header), "sha256": remote.digest(target)})
|
|
return {"files": files, "totalBytes": len(header) * len(files)}
|
|
|
|
|
|
def test_library_preflight_does_not_require_compiler_or_build_space(remote, monkeypatch):
|
|
owner_uid = remote.HOME.stat().st_uid
|
|
monkeypatch.setattr(remote.os, 'getuid', lambda: owner_uid, raising=False)
|
|
monkeypatch.setattr(remote.pwd, 'getpwuid', lambda uid: types.SimpleNamespace(pw_name='steamos'))
|
|
monkeypatch.setattr(remote.pathlib.Path, 'home', lambda: remote.HOME)
|
|
monkeypatch.setattr(remote.platform, 'machine', lambda: 'aarch64')
|
|
monkeypatch.setattr(remote, 'os_release', lambda: {'ID': 'steamos'})
|
|
build_tools = Mock(side_effect=AssertionError('Library info must not require build tools'))
|
|
monkeypatch.setattr(remote.shutil, 'which', build_tools)
|
|
monkeypatch.setattr(remote.shutil, 'disk_usage', build_tools)
|
|
monkeypatch.setattr(remote, 'command', build_tools)
|
|
verified = {'gamePath': str(remote.GAME), 'mapsVerified': True, 'sourceCommit': SOURCE_COMMIT}
|
|
monkeypatch.setattr(remote, 'existing_install', lambda: verified)
|
|
result = remote.preflight_library()
|
|
assert result['existing'] == verified and 'uninstallSupported' not in result
|
|
assert result['cachePath'] == str(remote.CACHE)
|
|
build_tools.assert_not_called()
|
|
|
|
|
|
def test_library_preflight_requires_verified_game(remote, monkeypatch):
|
|
monkeypatch.setattr(remote, 'preflight_uninstall', lambda: {'home': str(remote.HOME)})
|
|
monkeypatch.setattr(remote, 'existing_install', lambda: None)
|
|
with pytest.raises(ValueError, match='before updating'):
|
|
remote.preflight_library()
|
|
|
|
|
|
@pytest.mark.parametrize('shortcut_status', ['added', 'manual'])
|
|
def test_remote_notes_enrichment_is_independent_of_verified_shortcut_status(remote, monkeypatch, shortcut_status):
|
|
monkeypatch.setattr(remote, 'existing_install', lambda: {'mapsVerified': True})
|
|
monkeypatch.setattr(sys, 'path', list(sys.path))
|
|
register = Mock(return_value={'status': shortcut_status, 'appid': 3000000001})
|
|
notes = Mock(return_value={'status': 'manual', 'content': 'Description to copy'})
|
|
monkeypatch.setitem(sys.modules, 'steam_shortcut', types.SimpleNamespace(add_native_shortcut=register))
|
|
monkeypatch.setitem(sys.modules, 'steam_notes', types.SimpleNamespace(add_notes=notes))
|
|
result = remote.shortcut()
|
|
assert result['status'] == shortcut_status
|
|
register.assert_called_once_with(remote.HOME, remote.GAME, close_steam=False)
|
|
if shortcut_status == 'added':
|
|
notes.assert_called_once_with(remote.HOME, remote.GAME, 3000000001)
|
|
assert result['notes']['status'] == 'manual'
|
|
else:
|
|
notes.assert_not_called()
|
|
assert 'notes' not in result
|
|
|
|
|
|
def test_remote_rejects_bogus_maps_and_traversal(remote, tmp_path):
|
|
manifest = xbox_maps(tmp_path / "data", remote)
|
|
assert remote.verify_maps(tmp_path / "data", manifest) == 24 * 2048
|
|
manifest["files"][0]["path"] = "maps/../maps/a10.map"
|
|
with pytest.raises(ValueError, match="24 expected"):
|
|
remote.verify_maps(tmp_path / "data", manifest)
|
|
|
|
|
|
def test_remote_path_guard_rejects_parent_traversal(remote):
|
|
with pytest.raises(ValueError, match="traversal"):
|
|
remote.beneath(remote.HOME / "../outside", remote.HOME)
|
|
|
|
|
|
def test_existing_custom_maps_preserved_but_never_uploaded(remote, tmp_path):
|
|
directory = tmp_path / "data"
|
|
manifest = xbox_maps(directory, remote)
|
|
(directory / "maps/custom.map").write_bytes(b"unrelated user mod")
|
|
with pytest.raises(ValueError, match="extra"):
|
|
remote.verify_maps(directory, manifest)
|
|
assert remote.verify_maps(directory, manifest, allow_extra=True) == 24 * 2048
|
|
|
|
|
|
def test_remote_detects_changed_original_xbox_header(remote, tmp_path):
|
|
directory = tmp_path / "data"
|
|
manifest = xbox_maps(directory, remote)
|
|
target = directory / manifest["files"][0]["path"]
|
|
data = bytearray(target.read_bytes())
|
|
struct.pack_into("<I", data, 4, 7)
|
|
target.write_bytes(data)
|
|
with pytest.raises(ValueError, match="header"):
|
|
remote.verify_maps(directory, manifest)
|
|
|
|
|
|
def test_config_repair_preserves_save_location_and_user_preferences(remote):
|
|
old = '[paths]\nsaves = "/another/save/location"\n\n[audio]\nvolume = 0.4\n\n[vr]\naim="head"\nturn="snap"\nrefresh_rate=90.0\n\n[[plugins]]\nname="custom"\n'
|
|
result = remote.tomllib.loads(remote.merge_config(old))
|
|
assert result["paths"]["saves"] == "/another/save/location"
|
|
assert result["audio"]["volume"] == 0.4
|
|
assert result["vr"]["aim"] == "controller"
|
|
assert result["vr"]["turn"] == "snap" and result["vr"]["refresh_rate"] == 90.0
|
|
assert result["plugins"] == [{"name": "custom"}]
|
|
|
|
|
|
def test_config_repair_refuses_semantic_changes_inside_multiline_string(remote):
|
|
old = '[vr]\nnote = """\n[other]\nexample text\n"""\naim = "head"\nenabled = false\nmelee_gesture = true\n'
|
|
with pytest.raises(ValueError, match="safely|unrelated"):
|
|
remote.merge_config(old)
|
|
|
|
|
|
def test_repair_backup_and_replacements_preserve_saves_and_unrelated_files(remote):
|
|
game = remote.GAME
|
|
game.mkdir(parents=True)
|
|
(game / "halo").write_bytes(b"old executable")
|
|
(game / "libSDL3.so.0").write_bytes(b"old library")
|
|
(game / "save").mkdir()
|
|
(game / "save/checkpoint").write_bytes(b"my campaign")
|
|
(game / "notes.txt").write_bytes(b"my notes")
|
|
directory = remote.CACHE / "runs" / ("a" * 32)
|
|
stage = directory / "stage"
|
|
stage.mkdir(parents=True)
|
|
(stage / "halo").write_bytes(b"new executable")
|
|
(stage / "libSDL3.so.0").write_bytes(b"new library")
|
|
backup = remote.repair_program_files(stage, directory, ["halo", "libSDL3.so.0"], lambda: None)
|
|
assert (game / "halo").read_bytes() == b"new executable"
|
|
assert (Path(backup) / "halo").read_bytes() == b"old executable"
|
|
assert (game / "save/checkpoint").read_bytes() == b"my campaign"
|
|
assert (game / "notes.txt").read_bytes() == b"my notes"
|
|
|
|
|
|
def test_repair_rolls_back_on_failed_second_replacement(remote, monkeypatch):
|
|
game = remote.GAME
|
|
game.mkdir(parents=True)
|
|
for name in ("halo", "libSDL3.so.0"):
|
|
(game / name).write_bytes(("old " + name).encode())
|
|
directory = remote.CACHE / "runs" / ("b" * 32)
|
|
stage = directory / "stage"
|
|
stage.mkdir(parents=True)
|
|
for name in ("halo", "libSDL3.so.0"):
|
|
(stage / name).write_bytes(("new " + name).encode())
|
|
replace = remote.os.replace
|
|
def fail_second(source, target):
|
|
if Path(source) == stage / "libSDL3.so.0":
|
|
raise OSError("simulated disk error")
|
|
replace(source, target)
|
|
monkeypatch.setattr(remote.os, "replace", fail_second)
|
|
with pytest.raises(OSError, match="simulated"):
|
|
remote.repair_program_files(stage, directory, ["halo", "libSDL3.so.0"], lambda: None)
|
|
for name in ("halo", "libSDL3.so.0"):
|
|
assert (game / name).read_bytes() == ("old " + name).encode()
|
|
|
|
|
|
def test_repair_rolls_back_if_cancel_arrives_after_first_file(remote, monkeypatch):
|
|
game = remote.GAME
|
|
game.mkdir(parents=True)
|
|
(game / "halo").write_bytes(b"old halo")
|
|
(game / "config.toml").write_bytes(b"old config")
|
|
directory = remote.CACHE / "runs" / ("c" * 32)
|
|
stage = directory / "stage"
|
|
stage.mkdir(parents=True)
|
|
(stage / "halo").write_bytes(b"new halo")
|
|
(stage / "config.toml").write_bytes(b"new config")
|
|
cancelled = False
|
|
replace = remote.os.replace
|
|
def after_first(source, target):
|
|
nonlocal cancelled
|
|
replace(source, target)
|
|
if Path(source) == stage / "halo":
|
|
cancelled = True
|
|
def cancel_check():
|
|
if cancelled:
|
|
raise ValueError("cancelled")
|
|
monkeypatch.setattr(remote.os, "replace", after_first)
|
|
with pytest.raises(ValueError, match="cancelled"):
|
|
remote.repair_program_files(stage, directory, ["halo", "config.toml"], cancel_check)
|
|
assert (game / "halo").read_bytes() == b"old halo"
|
|
assert (game / "config.toml").read_bytes() == b"old config"
|
|
|
|
|
|
def test_full_repair_rebuilds_damaged_native_files_preserves_maps_saves_and_config(remote, monkeypatch):
|
|
game = remote.GAME
|
|
game.mkdir(parents=True)
|
|
maps = xbox_maps(game, remote)
|
|
(game / "xbox-data-manifest.json").write_text(json.dumps(maps))
|
|
(game / "halo").write_bytes(b"damaged executable")
|
|
(game / "libSDL3.so.0").write_bytes(b"old library")
|
|
(game / "config.toml").write_text('[paths]\nsaves="/my/saves"\n[audio]\nvolume=0.3\n[vr]\naim="head"\nturn="snap"\n')
|
|
(game / "save").mkdir()
|
|
(game / "save/checkpoint").write_bytes(b"campaign progress")
|
|
(game / "maps/custom.map").write_bytes(b"user mod")
|
|
marker = {"owner": remote.OWNER, "sourceCommit": "previous-source-revision", "files": {
|
|
"halo": "0" * 64, "libSDL3.so.0": "0" * 64}}
|
|
(game / remote.MARKER).write_text(json.dumps(marker))
|
|
remote.owned(remote.CACHE)
|
|
resources = remote.CACHE / "resources"
|
|
resources.mkdir()
|
|
(resources / "frame-controls.patch").write_bytes(b"frame controls patch")
|
|
identifier = "d" * 32
|
|
directory = remote.CACHE / "runs" / identifier
|
|
remote.owned(directory)
|
|
run_marker = remote.read_marker(directory / remote.MARKER)
|
|
run_marker["mapsOrigin"] = "existing"
|
|
(directory / remote.MARKER).write_text(json.dumps(run_marker))
|
|
(directory / "stage").mkdir()
|
|
(directory / "upload").mkdir()
|
|
(directory / "upload/xbox-data-manifest.json").write_text(json.dumps(maps))
|
|
build = directory / "src/build/linux_arm64"
|
|
build.mkdir(parents=True)
|
|
header = bytearray(64)
|
|
header[:6] = b"\x7fELF\x02\x01"
|
|
struct.pack_into("<H", header, 18, 183)
|
|
(build / "halo").write_bytes(header + b"new native game")
|
|
(build / "libSDL3.so.0").write_bytes(header + b"new native library")
|
|
monkeypatch.setattr(remote, "command", lambda argv, **kwargs: "native libraries resolved")
|
|
response = remote.finalize(identifier, repair=True)
|
|
assert response["repaired"] is True and response["mapsReinstalled"] is False
|
|
assert (game / "halo").read_bytes() == header + b"new native game"
|
|
assert (game / "save/checkpoint").read_bytes() == b"campaign progress"
|
|
assert (game / "maps/custom.map").read_bytes() == b"user mod"
|
|
merged = remote.tomllib.loads((game / "config.toml").read_text())
|
|
assert merged["paths"]["saves"] == "/my/saves"
|
|
assert merged["audio"]["volume"] == 0.3
|
|
assert merged["vr"]["aim"] == "controller" and merged["vr"]["turn"] == "snap"
|
|
assert remote.read_marker(game / remote.MARKER)["sourceCommit"] == remote.SOURCE_COMMIT
|
|
assert remote.existing_install()["mapsVerified"] is True
|