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("