mirror of
https://github.com/ClassicWasTaken/HaloCE-SteamFrame.git
synced 2026-10-16 07:00:33 +02:00
830 lines
42 KiB
Python
830 lines
42 KiB
Python
"""Uninstall real owned trees and VDF files without touching external data."""
|
|
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import sys
|
|
import types
|
|
from collections import OrderedDict
|
|
from pathlib import Path
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
|
|
RESOURCES = Path(__file__).resolve().parents[1] / "resources"
|
|
RUN_ID = "b" * 32
|
|
|
|
|
|
@pytest.fixture
|
|
def installation(tmp_path, monkeypatch):
|
|
if "pwd" not in sys.modules:
|
|
monkeypatch.setitem(sys.modules, "pwd", types.SimpleNamespace(getpwuid=lambda uid: None))
|
|
if not hasattr(os, "getuid"):
|
|
monkeypatch.setattr(os, "getuid", lambda: 0, raising=False)
|
|
modules = []
|
|
for name, resource in (("uninstall_remote_test", "remote_install"), ("steam_shortcut", "steam_shortcut")):
|
|
spec = importlib.util.spec_from_file_location(name, RESOURCES / (resource + ".py"))
|
|
module = importlib.util.module_from_spec(spec)
|
|
monkeypatch.setitem(sys.modules, name, module)
|
|
spec.loader.exec_module(module)
|
|
modules.append(module)
|
|
remote, steam = modules
|
|
remote.HOME = tmp_path / "home"
|
|
remote.HOME.mkdir()
|
|
remote.CACHE = remote.HOME / ".cache/halo-frame-installer"
|
|
remote.GAME = remote.HOME / "Games/HaloCENativeVR"
|
|
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"})
|
|
monkeypatch.setattr(remote.shutil, "which", lambda executable: "/usr/bin/podman" if executable == "podman" else None)
|
|
monkeypatch.setattr(remote, "command", lambda argv, **kwargs: "[]")
|
|
monkeypatch.setattr(steam, "steam_running", lambda: False)
|
|
if os.name == "nt":
|
|
monkeypatch.setattr(remote, "game_closed", lambda *args: None)
|
|
# Windows rename already refuses an existing destination. Linux tests use
|
|
# the actual renameat2(RENAME_NOREPLACE) implementation.
|
|
monkeypatch.setattr(remote, "rename_noreplace", lambda source, target: os.rename(source, target))
|
|
remote.owned(remote.GAME)
|
|
(remote.GAME / "maps").mkdir()
|
|
(remote.GAME / "save").mkdir()
|
|
(remote.GAME / "halo").write_bytes(b"damaged native executable")
|
|
(remote.GAME / "libSDL3.so.0").write_bytes(b"damaged SDL3 library")
|
|
(remote.GAME / "maps/a10.map").write_bytes(b"damaged or incomplete Xbox maps")
|
|
(remote.GAME / "save/profile.sav").write_bytes(b"campaign progress")
|
|
(remote.GAME / "config.toml").write_text('[paths]\nsaves = "save"\n[video]\ncustom = true\n')
|
|
marker = json.loads((remote.GAME / remote.MARKER).read_text())
|
|
marker["files"] = {"halo": "incorrect old hash"}
|
|
(remote.GAME / remote.MARKER).write_text(json.dumps(marker))
|
|
directory = remote.HOME / ".local/share/Steam"
|
|
configs = [directory / "userdata" / account / "config" for account in ("10", "20")]
|
|
foreign = OrderedDict([("appid", steam.Value(2, 2147483650)), ("AppName", steam.Value(1, "Other game")),
|
|
("Exe", steam.Value(1, '"/other/game"')), ("unknown", steam.Value(7, b"FOREIGN!"))])
|
|
base = steam.dumps(OrderedDict([("shortcuts", steam.Value(0, OrderedDict([("0", steam.Value(0, foreign))])))]))
|
|
appids = []
|
|
for config in configs:
|
|
config.mkdir(parents=True)
|
|
raw, appid = steam.update_shortcut(base, str(remote.GAME / "halo"), str(remote.GAME))
|
|
root = steam.loads(raw)
|
|
root["unknown-root"] = steam.Value(7, b"ROOTDATA")
|
|
root["shortcuts"].value["2"] = steam.Value(0, OrderedDict([
|
|
("appid", steam.Value(2, 0xF1122334)), ("AppName", steam.Value(1, steam.NAME)),
|
|
("Exe", steam.Value(1, '"/external/Halo.exe"'))]))
|
|
(config / "shortcuts.vdf").write_bytes(steam.dumps(root))
|
|
steam.install_artwork(config, appid)
|
|
appids.append(appid)
|
|
(configs[1] / "grid" / f"{appids[1]}.png").write_bytes(b"custom user tile")
|
|
(configs[0] / "grid/10p.jpg").write_bytes(b"other game's art")
|
|
old_pc = remote.HOME / "Games/HaloCEVR"
|
|
old_pc.mkdir()
|
|
(old_pc / "halo.exe").write_bytes(b"old PC version; this operation must not delete it")
|
|
remote.preflight_uninstall()
|
|
retained = remote.CACHE / "runs" / ("c" * 32)
|
|
remote.owned(retained)
|
|
(retained / "native-build.log").write_bytes(b"previous build log retained")
|
|
return remote, steam, configs, appids, foreign
|
|
|
|
|
|
def game_bytes(remote):
|
|
return {str(path.relative_to(remote.GAME)): path.read_bytes() for path in remote.GAME.rglob("*")
|
|
if path.is_file() and not path.is_symlink()}
|
|
|
|
|
|
def record_quarantine(remote, identifier, quarantine, saved=None, *, legacy=False):
|
|
"""Model the journal made by an actual interrupted uninstall, not a label."""
|
|
directory = remote.CACHE / "runs" / identifier
|
|
metadata = remote.owned(directory)
|
|
metadata.update({"operation": "uninstall", "state": "removing", "quarantinePath": str(quarantine),
|
|
"savedBackupPath": str(saved) if saved else None})
|
|
if not legacy:
|
|
metadata["quarantineRoot"] = remote.uninstall_root_identity(quarantine)
|
|
if saved:
|
|
backup = {"owner": remote.OWNER + "-save-backup", "gamePath": str(remote.GAME)}
|
|
if not legacy:
|
|
backup["files"] = {str(path.relative_to(saved)): {"size": path.stat().st_size,
|
|
"sha256": remote.digest(path)}
|
|
for path in saved.rglob("*") if path.is_file() and path.name != remote.MARKER}
|
|
metadata["savedBackupRoot"] = remote.uninstall_root_identity(saved)
|
|
(saved / remote.MARKER).write_text(json.dumps(backup))
|
|
remote.write_uninstall_record(directory, metadata)
|
|
return directory / remote.MARKER
|
|
|
|
|
|
def test_uninstall_keeps_saves_removes_all_exact_native_entries_and_managed_art(installation, capsys):
|
|
remote, steam, configs, appids, foreign = installation
|
|
original_config = (remote.GAME / "config.toml").read_bytes()
|
|
result = remote.uninstall(RUN_ID, keep_saves=True)
|
|
assert result["uninstalled"] and result["steam"]["status"] == "removed"
|
|
assert result["steam"]["accounts"] == ["10", "20"]
|
|
assert not remote.GAME.exists()
|
|
saved = Path(result["savedBackupPath"])
|
|
assert saved == remote.HOME / ("Games/HaloCENativeVR-saves-" + RUN_ID)
|
|
assert (saved / "save/profile.sav").read_bytes() == b"campaign progress"
|
|
assert (saved / "config.toml").read_bytes() == original_config
|
|
assert not (saved / "maps").exists() and not (saved / "halo").exists()
|
|
for config, appid in zip(configs, appids):
|
|
root = steam.loads((config / "shortcuts.vdf").read_bytes())
|
|
assert root["unknown-root"].value == b"ROOTDATA"
|
|
assert root["shortcuts"].value["0"].value == foreign
|
|
assert set(root["shortcuts"].value) == {"0", "2"}
|
|
assert not (config / "grid" / f"{appid}p.jpg").exists()
|
|
backups = list(config.glob("shortcuts.vdf.halo-frame-uninstall-*.bak"))
|
|
assert len(backups) == 1
|
|
assert "1" in steam.loads(backups[0].read_bytes())["shortcuts"].value
|
|
assert not (configs[0] / "grid" / f"{appids[0]}.png").exists()
|
|
assert (configs[1] / "grid" / f"{appids[1]}.png").read_bytes() == b"custom user tile"
|
|
assert (configs[0] / "grid/10p.jpg").read_bytes() == b"other game's art"
|
|
assert (remote.HOME / "Games/HaloCEVR/halo.exe").is_file()
|
|
assert (remote.CACHE / "runs" / ("c" * 32) / "native-build.log").is_file()
|
|
updates = [json.loads(line.split(" ", 1)[1]) for line in capsys.readouterr().out.splitlines()
|
|
if line.startswith("HFI_PROGRESS ")]
|
|
assert updates[-1]["percent"] == 100 and all(update["stage"] == "uninstall" for update in updates)
|
|
assert remote.uninstall(RUN_ID, keep_saves=True)["alreadyAbsent"]
|
|
assert (saved / "save/profile.sav").read_bytes() == b"campaign progress"
|
|
|
|
|
|
def test_uninstall_removes_a_native_entry_the_user_renamed(installation):
|
|
remote, steam, configs, appids, _ = installation
|
|
config = configs[0]
|
|
executable = str(remote.GAME / "halo")
|
|
root = steam.loads((config / "shortcuts.vdf").read_bytes())
|
|
for entry in root["shortcuts"].value.values():
|
|
path = entry.value.get("Exe")
|
|
if path is not None and path.value in (executable, f'"{executable}"'):
|
|
entry.value["AppName"] = steam.Value(1, "My favorite Halo")
|
|
(config / "shortcuts.vdf").write_bytes(steam.dumps(root))
|
|
result = remote.uninstall(RUN_ID, keep_saves=True)
|
|
assert result["uninstalled"] and result["steam"]["status"] == "removed"
|
|
assert result["steam"]["accounts"] == ["10", "20"]
|
|
remaining = steam.loads((config / "shortcuts.vdf").read_bytes())["shortcuts"].value
|
|
assert set(remaining) == {"0", "2"}
|
|
assert "My favorite Halo" not in {entry.value["AppName"].value for entry in remaining.values()}
|
|
assert result["steam"]["appids"] == sorted(set(appids))
|
|
assert not (config / "grid" / f"{appids[0]}p.jpg").exists()
|
|
assert not (config / "grid" / f"{appids[0]}.png").exists()
|
|
assert (configs[1] / "grid" / f"{appids[1]}.png").read_bytes() == b"custom user tile"
|
|
assert (configs[0] / "grid/10p.jpg").read_bytes() == b"other game's art"
|
|
backups = list(config.glob("shortcuts.vdf.halo-frame-uninstall-*.bak"))
|
|
assert len(backups) == 1 and b"My favorite Halo" in backups[0].read_bytes()
|
|
|
|
|
|
def test_uninstall_refuses_exact_native_malformed_ids_without_changing_files_or_art(installation):
|
|
remote, steam, configs, _, _ = installation
|
|
config = configs[0]
|
|
executable = str(remote.GAME / "halo")
|
|
root = steam.loads((config / "shortcuts.vdf").read_bytes())
|
|
root["shortcuts"].value["3"] = steam.Value(0, OrderedDict([
|
|
("appid", steam.Value(1, "not-a-number")), ("AppName", steam.Value(1, "Broken id")),
|
|
("Exe", steam.Value(1, executable))]))
|
|
root["shortcuts"].value["4"] = steam.Value(0, OrderedDict([
|
|
("appid", steam.Value(2, 0xF2233445)), ("AppName", steam.Value(1, "Odd quotes")),
|
|
("Exe", steam.Value(1, executable + '"'))]))
|
|
(config / "shortcuts.vdf").write_bytes(steam.dumps(root))
|
|
before = game_bytes(remote)
|
|
shortcut_bytes = [(path / "shortcuts.vdf").read_bytes() for path in configs]
|
|
artwork = {str(path): path.read_bytes() for directory in configs for path in (directory / "grid").iterdir()}
|
|
result = remote.uninstall(RUN_ID, keep_saves=True)
|
|
assert not result["uninstalled"] and result["steam"]["status"] == "manual"
|
|
assert game_bytes(remote) == before
|
|
assert [(path / "shortcuts.vdf").read_bytes() for path in configs] == shortcut_bytes
|
|
assert {str(path): path.read_bytes() for directory in configs for path in (directory / "grid").iterdir()} == artwork
|
|
remaining = steam.loads((config / "shortcuts.vdf").read_bytes())["shortcuts"].value
|
|
assert set(remaining) == {"0", "1", "2", "3", "4"}
|
|
|
|
|
|
def test_uninstall_without_keep_saves_deletes_contained_saves_only(installation):
|
|
remote, _, _, _, _ = installation
|
|
outside = remote.HOME / "external-saves"
|
|
outside.mkdir()
|
|
(outside / "campaign.sav").write_bytes(b"external campaign")
|
|
(remote.GAME / "config.toml").write_text('[paths]\nsaves = ' + json.dumps(outside.as_posix()) + '\n')
|
|
result = remote.uninstall(RUN_ID)
|
|
assert result["uninstalled"] and result["savedBackupPath"] is None
|
|
assert not remote.GAME.exists()
|
|
assert (outside / "campaign.sav").read_bytes() == b"external campaign"
|
|
assert not list((remote.HOME / "Games").glob("HaloCENativeVR-saves-*"))
|
|
|
|
|
|
@pytest.mark.parametrize("contained", [False, True])
|
|
def test_keep_saves_respects_configured_location_without_reading_external_data(installation, contained):
|
|
remote, _, _, _, _ = installation
|
|
configured = remote.GAME / "profiles/custom" if contained else remote.HOME / "external-saves"
|
|
configured.mkdir(parents=True)
|
|
(configured / "second.sav").write_bytes(b"other profile")
|
|
(remote.GAME / "config.toml").write_text('[paths]\nsaves = ' + json.dumps(configured.as_posix()) + '\n')
|
|
result = remote.uninstall(RUN_ID, keep_saves=True)
|
|
saved = Path(result["savedBackupPath"])
|
|
assert (saved / "save/profile.sav").is_file()
|
|
if contained:
|
|
assert (saved / "profiles/custom/second.sav").read_bytes() == b"other profile"
|
|
assert result["externalSavePathsPreserved"] == []
|
|
else:
|
|
assert (configured / "second.sav").read_bytes() == b"other profile"
|
|
assert not (saved / "external-saves").exists()
|
|
assert result["externalSavePathsPreserved"] == [str(configured)]
|
|
|
|
|
|
def test_previous_guided_native_marker_allows_damaged_game_removal(installation):
|
|
remote, _, _, _, _ = installation
|
|
(remote.GAME / remote.MARKER).unlink()
|
|
(remote.GAME / ".codex-halo-native-install.json").write_text(json.dumps({
|
|
"owner": "codex-halo-native-frame-20261005", "build": {"sourceCommit": "older native revision"}}))
|
|
(remote.GAME / "halo").unlink()
|
|
assert remote.uninstall(RUN_ID)["uninstalled"]
|
|
assert not remote.GAME.exists()
|
|
|
|
|
|
@pytest.mark.parametrize("foreign", [False, True])
|
|
def test_unmarked_or_foreign_folder_is_refused_before_steam_changes(installation, foreign):
|
|
remote, _, configs, _, _ = installation
|
|
marker = remote.GAME / remote.MARKER
|
|
if foreign:
|
|
marker.write_text('{"owner":"another-application"}')
|
|
else:
|
|
marker.unlink()
|
|
before = game_bytes(remote)
|
|
shortcuts = [(config / "shortcuts.vdf").read_bytes() for config in configs]
|
|
with pytest.raises(ValueError, match="ownership|another application"):
|
|
remote.uninstall(RUN_ID)
|
|
assert game_bytes(remote) == before
|
|
assert [(config / "shortcuts.vdf").read_bytes() for config in configs] == shortcuts
|
|
|
|
|
|
def test_live_steam_returns_manual_and_keeps_game_and_staged_saves_untouched(installation, monkeypatch):
|
|
remote, steam, configs, _, _ = installation
|
|
before = game_bytes(remote)
|
|
raw = [(config / "shortcuts.vdf").read_bytes() for config in configs]
|
|
monkeypatch.setattr(steam, "steam_running", lambda: True)
|
|
result = remote.uninstall(RUN_ID, keep_saves=True)
|
|
assert not result["uninstalled"] and result["steam"]["status"] == "manual"
|
|
assert result["savedBackupPath"] is None
|
|
assert game_bytes(remote) == before
|
|
assert [(config / "shortcuts.vdf").read_bytes() for config in configs] == raw
|
|
assert not list((remote.HOME / "Games").glob("*HaloCENativeVR-saves-*"))
|
|
|
|
|
|
@pytest.mark.parametrize("running", ["game", "build"])
|
|
def test_running_native_game_or_owned_build_prevents_all_mutation(installation, monkeypatch, running):
|
|
remote, _, configs, _, _ = installation
|
|
before = game_bytes(remote)
|
|
raw = [(config / "shortcuts.vdf").read_bytes() for config in configs]
|
|
if running == "game":
|
|
def game_closed(*args):
|
|
raise ValueError("Halo is still running")
|
|
monkeypatch.setattr(remote, "game_closed", game_closed)
|
|
else:
|
|
monkeypatch.setattr(remote, "command", lambda argv, **kwargs: '[{"Names":["halo-frame-installer-active"]}]')
|
|
with pytest.raises(ValueError, match="still running"):
|
|
remote.uninstall(RUN_ID)
|
|
assert game_bytes(remote) == before
|
|
assert [(config / "shortcuts.vdf").read_bytes() for config in configs] == raw
|
|
|
|
|
|
@pytest.mark.parametrize("where", ["root", "nested"])
|
|
def test_symlink_paths_are_refused_without_deleting_their_targets(installation, where):
|
|
remote, _, configs, _, _ = installation
|
|
outside = remote.HOME / "outside"
|
|
outside.mkdir()
|
|
protected = outside / "keep.sav"
|
|
protected.write_bytes(b"outside data")
|
|
original = remote.GAME
|
|
link = remote.GAME / "save/linked" if where == "nested" else remote.GAME
|
|
if where == "root":
|
|
original = remote.GAME.with_name("original-native")
|
|
os.rename(remote.GAME, original)
|
|
try:
|
|
link.symlink_to(outside, target_is_directory=True)
|
|
except OSError:
|
|
pytest.skip("Windows symlink creation requires additional privileges.")
|
|
before = [(config / "shortcuts.vdf").read_bytes() for config in configs]
|
|
with pytest.raises(ValueError, match="Symbolic|symbolic"):
|
|
remote.uninstall(RUN_ID)
|
|
assert protected.read_bytes() == b"outside data"
|
|
assert (original / "halo").is_file()
|
|
assert [(config / "shortcuts.vdf").read_bytes() for config in configs] == before
|
|
|
|
|
|
def test_hardlinked_map_is_not_deleted_and_traversal_run_id_is_rejected(installation):
|
|
remote, _, _, _, _ = installation
|
|
outside = remote.HOME / "foreign.map"
|
|
outside.write_bytes(b"outside file")
|
|
os.link(outside, remote.GAME / "maps/linked.map")
|
|
with pytest.raises(ValueError, match="hard link"):
|
|
remote.uninstall(RUN_ID)
|
|
with pytest.raises(ValueError, match="run identifier"):
|
|
remote.uninstall("../" + RUN_ID)
|
|
assert outside.read_bytes() == b"outside file" and remote.GAME.exists()
|
|
|
|
|
|
def test_same_device_bind_mount_is_rejected_before_removal(installation, monkeypatch):
|
|
remote, _, _, _, _ = installation
|
|
before = game_bytes(remote)
|
|
monkeypatch.setattr(remote, "uninstall_mounts", lambda: [remote.GAME / "maps"])
|
|
with pytest.raises(ValueError, match="mount"):
|
|
remote.uninstall(RUN_ID)
|
|
assert game_bytes(remote) == before
|
|
|
|
|
|
def test_partial_file_removal_reports_quarantine_and_save_backup_honestly(installation, monkeypatch):
|
|
remote, _, _, _, _ = installation
|
|
original_unlink = Path.unlink
|
|
|
|
def fail(path, *args, **kwargs):
|
|
if path.parent.name == ".HaloCENativeVR-uninstall-" + RUN_ID and path.name == "halo":
|
|
raise OSError("simulated deletion failure")
|
|
return original_unlink(path, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, "unlink", fail)
|
|
with pytest.raises(RuntimeError, match="Remaining native files are in") as error:
|
|
remote.uninstall(RUN_ID, keep_saves=True)
|
|
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + RUN_ID)
|
|
saved = remote.GAME.parent / ("HaloCENativeVR-saves-" + RUN_ID)
|
|
assert not remote.GAME.exists() and (quarantine / "halo").is_file()
|
|
assert (saved / "save/profile.sav").read_bytes() == b"campaign progress"
|
|
assert str(saved) in str(error.value)
|
|
assert "game was kept" not in str(error.value).lower()
|
|
before = {str(path.relative_to(saved)): path.read_bytes() for path in saved.rglob("*") if path.is_file()}
|
|
monkeypatch.setattr(Path, "unlink", original_unlink)
|
|
result = remote.uninstall(RUN_ID, keep_saves=True)
|
|
assert result["uninstalled"] and not result["alreadyAbsent"] and not quarantine.exists()
|
|
assert result["savedBackupPath"] == str(saved)
|
|
assert {str(path.relative_to(saved)): path.read_bytes() for path in saved.rglob("*") if path.is_file()} == before
|
|
|
|
|
|
@pytest.mark.parametrize("legacy", [False, True])
|
|
def test_interrupted_uninstall_leftovers_are_finished_by_the_next_run(installation, legacy):
|
|
remote, _, _, _, _ = installation
|
|
previous = "d" * 32
|
|
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
|
|
saved = remote.GAME.parent / ("HaloCENativeVR-saves-" + previous)
|
|
os.rename(remote.GAME, quarantine)
|
|
saved.mkdir()
|
|
(saved / remote.MARKER).write_text(json.dumps({"owner": remote.OWNER + "-save-backup"}))
|
|
(saved / "save").mkdir()
|
|
(saved / "save/profile.sav").write_bytes(b"earlier campaign")
|
|
record_quarantine(remote, previous, quarantine, saved, legacy=legacy)
|
|
result = remote.uninstall(RUN_ID, keep_saves=True)
|
|
assert result["uninstalled"] is True and result["alreadyAbsent"] is False
|
|
assert not quarantine.exists() and not remote.GAME.exists()
|
|
assert (saved / "save/profile.sav").read_bytes() == b"earlier campaign"
|
|
assert str(quarantine) in result["warning"] and str(saved) in result["warning"]
|
|
assert result["recoveredSaveBackupPaths"] == [str(saved)]
|
|
assert result["savedBackupPath"] is None and result["removalPending"] is False
|
|
|
|
|
|
def test_reinstall_after_interrupted_uninstall_still_clears_the_old_quarantine(installation):
|
|
remote, _, _, _, _ = installation
|
|
previous = "e" * 32
|
|
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
|
|
saved = remote.GAME.parent / ("HaloCENativeVR-saves-" + previous)
|
|
os.rename(remote.GAME, quarantine)
|
|
saved.mkdir()
|
|
(saved / remote.MARKER).write_text(json.dumps({"owner": remote.OWNER + "-save-backup"}))
|
|
(saved / "config.toml").write_bytes(b"earlier settings")
|
|
record_quarantine(remote, previous, quarantine, saved)
|
|
remote.owned(remote.GAME)
|
|
(remote.GAME / "halo").write_bytes(b"rebuilt native executable")
|
|
result = remote.uninstall(RUN_ID, keep_saves=True)
|
|
assert result["uninstalled"] is True and not result.get("alreadyAbsent", False)
|
|
assert not quarantine.exists() and not remote.GAME.exists()
|
|
assert str(saved) in result["warning"]
|
|
|
|
|
|
def test_quarantine_pattern_folders_without_a_valid_run_or_marker_are_kept(installation):
|
|
remote, _, _, _, _ = installation
|
|
games = remote.GAME.parent
|
|
decoy = games / ".HaloCENativeVR-uninstall-not-a-run-id"
|
|
decoy.mkdir()
|
|
(decoy / "notes.txt").write_bytes(b"user data")
|
|
unmarked = games / (".HaloCENativeVR-uninstall-" + "f" * 32)
|
|
unmarked.mkdir()
|
|
(unmarked / "stray.txt").write_bytes(b"not ours")
|
|
forged = games / (".HaloCENativeVR-uninstall-" + "a" * 32)
|
|
forged.mkdir()
|
|
(forged / remote.MARKER).write_text(json.dumps({"owner": "someone-else"}))
|
|
(forged / "precious.bin").write_bytes(b"user data")
|
|
marker_dir = games / (".HaloCENativeVR-uninstall-" + "1" * 32)
|
|
marker_dir.mkdir()
|
|
(marker_dir / remote.MARKER).mkdir()
|
|
(marker_dir / "note.txt").write_bytes(b"kept")
|
|
result = remote.uninstall(RUN_ID)
|
|
assert not result["uninstalled"] and result["removalPending"]
|
|
assert "manually" in result["warning"]
|
|
assert set(result["retainedQuarantinePaths"]) == {str(decoy), str(unmarked), str(forged), str(marker_dir)}
|
|
assert (decoy / "notes.txt").read_bytes() == b"user data"
|
|
assert (unmarked / "stray.txt").read_bytes() == b"not ours"
|
|
assert (forged / "precious.bin").read_bytes() == b"user data"
|
|
assert (marker_dir / "note.txt").read_bytes() == b"kept"
|
|
|
|
|
|
def test_unfinishable_leftover_is_named_and_skipped_instead_of_blocking(installation):
|
|
remote, _, _, _, _ = installation
|
|
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + "2" * 32)
|
|
quarantine.mkdir()
|
|
(quarantine / remote.MARKER).write_text(json.dumps({"owner": remote.OWNER}))
|
|
record_quarantine(remote, "2" * 32, quarantine)
|
|
outside = remote.HOME / "foreign.map"
|
|
outside.write_bytes(b"outside file")
|
|
os.link(outside, quarantine / "linked.map")
|
|
result = remote.uninstall(RUN_ID, keep_saves=True)
|
|
assert not result["uninstalled"] and result["removalPending"]
|
|
assert (quarantine / "linked.map").is_file() and outside.read_bytes() == b"outside file"
|
|
assert str(quarantine) in result["warning"] and "manually" in result["warning"]
|
|
|
|
|
|
def test_many_leftovers_are_all_removed_with_a_bounded_warning(installation):
|
|
remote, _, _, _, _ = installation
|
|
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + "3" * 32)
|
|
os.rename(remote.GAME, quarantine)
|
|
record_quarantine(remote, "3" * 32, quarantine)
|
|
for index in range(40):
|
|
folder = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + f"{index:032x}")
|
|
folder.mkdir()
|
|
(folder / remote.MARKER).write_text(json.dumps({"owner": remote.OWNER}))
|
|
(folder / "halo").write_bytes(b"earlier binary")
|
|
record_quarantine(remote, f"{index:032x}", folder)
|
|
result = remote.uninstall(RUN_ID, keep_saves=True)
|
|
assert result["uninstalled"] is True and result["alreadyAbsent"] is False
|
|
assert not list((remote.GAME.parent).glob(".HaloCENativeVR-uninstall-*"))
|
|
assert len(result["warning"]) <= remote.MAX_WARNING_CHARS and "additional notes omitted" in result["warning"]
|
|
|
|
|
|
def test_saves_note_requires_an_owned_backup_marker(installation):
|
|
remote, _, _, _, _ = installation
|
|
previous = "4" * 32
|
|
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
|
|
saved = remote.GAME.parent / ("HaloCENativeVR-saves-" + previous)
|
|
os.rename(remote.GAME, quarantine)
|
|
saved.mkdir()
|
|
(saved / remote.MARKER).write_text(json.dumps({"owner": "someone-else"}))
|
|
record_quarantine(remote, previous, quarantine)
|
|
journal = remote.CACHE / "runs" / previous / remote.MARKER
|
|
metadata = json.loads(journal.read_text())
|
|
metadata["savedBackupPath"] = str(saved)
|
|
journal.write_text(json.dumps(metadata))
|
|
result = remote.uninstall(RUN_ID, keep_saves=True)
|
|
assert not result["uninstalled"] and quarantine.exists() and result["removalPending"]
|
|
assert str(quarantine) in result["warning"] and result["recoveredSaveBackupPaths"] == []
|
|
|
|
|
|
@pytest.mark.parametrize("same_run", [False, True])
|
|
@pytest.mark.parametrize("keep_saves", [False, True])
|
|
def test_retry_finishes_markerless_quarantine_after_root_removal_failure(
|
|
installation, monkeypatch, same_run, keep_saves):
|
|
remote, _, _, _, _ = installation
|
|
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + RUN_ID)
|
|
saved = remote.GAME.parent / ("HaloCENativeVR-saves-" + RUN_ID)
|
|
real_rmdir = Path.rmdir
|
|
|
|
def fail_root(path, *args, **kwargs):
|
|
if path == quarantine:
|
|
raise OSError("root directory removal interrupted")
|
|
return real_rmdir(path, *args, **kwargs)
|
|
|
|
with monkeypatch.context() as patch:
|
|
patch.setattr(Path, "rmdir", fail_root)
|
|
with pytest.raises(RuntimeError, match="Remaining native files"):
|
|
remote.uninstall(RUN_ID, keep_saves=keep_saves)
|
|
assert quarantine.is_dir() and not list(quarantine.iterdir())
|
|
journal = json.loads((remote.CACHE / "runs" / RUN_ID / remote.MARKER).read_text())
|
|
assert journal["quarantineRoot"] == remote.uninstall_root_identity(quarantine)
|
|
before = {str(path.relative_to(saved)): path.read_bytes() for path in saved.rglob("*") if path.is_file()}
|
|
result = remote.uninstall(RUN_ID if same_run else "d" * 32, keep_saves=keep_saves)
|
|
assert result["uninstalled"] and not result["alreadyAbsent"] and not result["removalPending"]
|
|
assert not quarantine.exists()
|
|
assert {str(path.relative_to(saved)): path.read_bytes() for path in saved.rglob("*") if path.is_file()} == before
|
|
assert result["recoveredSaveBackupPaths"] == ([str(saved)] if keep_saves else [])
|
|
assert result["savedBackupPath"] == (str(saved) if keep_saves and same_run else None)
|
|
|
|
|
|
def test_quarantine_journal_precedes_the_rename_and_survives_that_crash_window(installation, monkeypatch):
|
|
remote, _, _, _, _ = installation
|
|
real_rename = remote.rename_noreplace
|
|
seen = []
|
|
|
|
def check(source, target):
|
|
if source == remote.GAME:
|
|
metadata = remote.private_json(remote.CACHE / "runs" / RUN_ID / remote.MARKER,
|
|
remote.CACHE / "runs" / RUN_ID)
|
|
assert metadata["state"] == "quarantining"
|
|
assert metadata["quarantinePath"] == str(target)
|
|
assert metadata["quarantineRoot"] == remote.uninstall_root_identity(source)
|
|
seen.append(metadata)
|
|
real_rename(source, target)
|
|
|
|
monkeypatch.setattr(remote, "rename_noreplace", check)
|
|
assert remote.uninstall(RUN_ID, keep_saves=True)["uninstalled"]
|
|
assert len(seen) == 1
|
|
# A process killed after rename but before the next journal update leaves
|
|
# this exact state. It remains sufficient proof even without a game marker.
|
|
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + "d" * 32)
|
|
quarantine.mkdir()
|
|
(quarantine / "remaining.bin").write_bytes(b"native remainder")
|
|
record = record_quarantine(remote, "d" * 32, quarantine)
|
|
metadata = json.loads(record.read_text())
|
|
metadata["state"] = "quarantining"
|
|
record.write_text(json.dumps(metadata))
|
|
assert remote.uninstall("e" * 32)["uninstalled"] and not quarantine.exists()
|
|
|
|
|
|
@pytest.mark.parametrize("damage", ["missing", "corrupt", "foreign-owner", "foreign-operation", "wrong-path",
|
|
"complete", "oversize", "hardlink", "invalid-root", "foreign-marker"])
|
|
def test_unverified_quarantine_journals_are_reported_and_never_adopted(installation, damage):
|
|
remote, _, _, _, _ = installation
|
|
previous = "d" * 32
|
|
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
|
|
os.rename(remote.GAME, quarantine)
|
|
record = record_quarantine(remote, previous, quarantine)
|
|
metadata = json.loads(record.read_text())
|
|
if damage == "missing":
|
|
record.unlink()
|
|
elif damage == "corrupt":
|
|
record.write_text("{broken")
|
|
elif damage == "oversize":
|
|
record.write_bytes(b"x" * (remote.MAX_MANIFEST_BYTES + 1))
|
|
elif damage == "hardlink":
|
|
os.link(record, remote.HOME / "copied-journal.json")
|
|
elif damage == "foreign-marker":
|
|
(quarantine / remote.MARKER).write_text('{"owner":"another-application"}')
|
|
else:
|
|
key, value = {"foreign-owner": ("owner", "other"), "foreign-operation": ("operation", "install"),
|
|
"wrong-path": ("quarantinePath", str(remote.HOME / "unrelated")),
|
|
"complete": ("state", "complete"), "invalid-root": ("quarantineRoot", {"device": True})}[damage]
|
|
metadata[key] = value
|
|
record.write_text(json.dumps(metadata))
|
|
before = {str(path.relative_to(quarantine)): path.read_bytes() for path in quarantine.rglob("*") if path.is_file()}
|
|
result = remote.uninstall(RUN_ID)
|
|
assert not result["uninstalled"] and not result["alreadyAbsent"] and result["removalPending"]
|
|
assert result["retainedQuarantinePaths"] == [str(quarantine)] and str(quarantine) in result["warning"]
|
|
assert {str(path.relative_to(quarantine)): path.read_bytes() for path in quarantine.rglob("*") if path.is_file()} == before
|
|
|
|
|
|
def test_copied_known_owner_marker_does_not_authorize_unrelated_folder_deletion(installation):
|
|
remote, _, _, _, _ = installation
|
|
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + "d" * 32)
|
|
quarantine.mkdir()
|
|
(quarantine / remote.MARKER).write_bytes((remote.GAME / remote.MARKER).read_bytes())
|
|
(quarantine / "precious.bin").write_bytes(b"user data")
|
|
result = remote.uninstall(RUN_ID)
|
|
assert result["removalPending"] and not result["uninstalled"]
|
|
assert (quarantine / "precious.bin").read_bytes() == b"user data"
|
|
assert str(quarantine) in result["warning"]
|
|
|
|
|
|
def test_replaced_quarantine_root_is_kept_even_if_the_old_marker_was_copied(installation):
|
|
remote, _, _, _, _ = installation
|
|
previous = "d" * 32
|
|
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
|
|
os.rename(remote.GAME, quarantine)
|
|
record_quarantine(remote, previous, quarantine)
|
|
intact = remote.GAME.parent / "original-quarantined-game"
|
|
os.rename(quarantine, intact)
|
|
quarantine.mkdir()
|
|
(quarantine / remote.MARKER).write_bytes((intact / remote.MARKER).read_bytes())
|
|
(quarantine / "precious.bin").write_bytes(b"replacement data")
|
|
result = remote.uninstall(RUN_ID)
|
|
assert result["removalPending"] and "identity" in result["warning"]
|
|
assert (quarantine / "precious.bin").read_bytes() == b"replacement data"
|
|
assert (intact / "halo").read_bytes() == b"damaged native executable"
|
|
|
|
|
|
def test_markerless_legacy_quarantine_is_named_and_kept_for_manual_review(installation):
|
|
remote, _, _, _, _ = installation
|
|
previous = "d" * 32
|
|
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
|
|
os.rename(remote.GAME, quarantine)
|
|
record_quarantine(remote, previous, quarantine, legacy=True)
|
|
(quarantine / remote.MARKER).unlink()
|
|
result = remote.uninstall(RUN_ID)
|
|
assert result["removalPending"] and "older uninstall" in result["warning"]
|
|
assert (quarantine / "save/profile.sav").read_bytes() == b"campaign progress"
|
|
|
|
|
|
@pytest.mark.parametrize("damage", ["missing", "corrupt-marker", "oversize-marker", "foreign-marker",
|
|
"hardlink", "changed-content", "empty", "symlink-root", "symlink-child"])
|
|
def test_unverifiable_save_backup_preserves_quarantine_and_all_backup_data(installation, damage):
|
|
remote, _, _, _, _ = installation
|
|
previous = "d" * 32
|
|
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
|
|
saved = remote.GAME.parent / ("HaloCENativeVR-saves-" + previous)
|
|
os.rename(remote.GAME, quarantine)
|
|
saved.mkdir()
|
|
(saved / "campaign.sav").write_bytes(b"previous campaign")
|
|
record_quarantine(remote, previous, quarantine, saved)
|
|
marker = saved / remote.MARKER
|
|
protected = None
|
|
if damage == "missing":
|
|
(saved / "campaign.sav").unlink()
|
|
marker.unlink()
|
|
saved.rmdir()
|
|
elif damage == "corrupt-marker":
|
|
marker.write_text("not-json")
|
|
elif damage == "oversize-marker":
|
|
marker.write_bytes(b"x" * (remote.MAX_MANIFEST_BYTES + 1))
|
|
elif damage == "foreign-marker":
|
|
marker.write_text('{"owner":"another-application"}')
|
|
elif damage == "hardlink":
|
|
protected = remote.HOME / "protected-campaign.sav"
|
|
os.link(saved / "campaign.sav", protected)
|
|
elif damage == "changed-content":
|
|
(saved / "campaign.sav").write_bytes(b"modified campaign")
|
|
elif damage == "empty":
|
|
(saved / "campaign.sav").unlink()
|
|
else:
|
|
protected = remote.HOME / "protected-saves"
|
|
protected.mkdir()
|
|
(protected / "keep.sav").write_bytes(b"external campaign")
|
|
if damage == "symlink-root":
|
|
os.rename(saved, remote.GAME.parent / "actual-backup")
|
|
link = saved
|
|
else:
|
|
link = saved / "linked"
|
|
try:
|
|
link.symlink_to(protected, target_is_directory=True)
|
|
except OSError:
|
|
pytest.skip("Windows symlink creation requires additional privileges.")
|
|
before = {str(path): path.read_bytes() for path in saved.rglob("*") if path.is_file() and not path.is_symlink()}
|
|
result = remote.uninstall(RUN_ID, keep_saves=True)
|
|
assert not result["uninstalled"] and result["removalPending"] and quarantine.exists()
|
|
assert result["recoveredSaveBackupPaths"] == []
|
|
assert (quarantine / "save/profile.sav").read_bytes() == b"campaign progress"
|
|
assert {str(path): path.read_bytes() for path in saved.rglob("*") if path.is_file() and not path.is_symlink()} == before
|
|
if protected:
|
|
assert (protected.read_bytes() if protected.is_file() else (protected / "keep.sav").read_bytes()) in (
|
|
b"previous campaign", b"external campaign")
|
|
|
|
|
|
def test_same_device_bind_mount_in_a_quarantine_is_kept(installation, monkeypatch):
|
|
remote, _, _, _, _ = installation
|
|
previous = "d" * 32
|
|
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
|
|
os.rename(remote.GAME, quarantine)
|
|
record_quarantine(remote, previous, quarantine)
|
|
monkeypatch.setattr(remote, "uninstall_mounts", lambda: [quarantine / "maps"])
|
|
result = remote.uninstall(RUN_ID)
|
|
assert result["removalPending"] and "mount" in result["warning"]
|
|
assert (quarantine / "maps/a10.map").read_bytes() == b"damaged or incomplete Xbox maps"
|
|
|
|
|
|
def test_recovery_and_verified_saves_are_reported_when_steam_removal_is_pending(installation, monkeypatch):
|
|
remote, steam, _, _, _ = installation
|
|
previous = "d" * 32
|
|
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
|
|
saved = remote.GAME.parent / ("HaloCENativeVR-saves-" + previous)
|
|
os.rename(remote.GAME, quarantine)
|
|
saved.mkdir()
|
|
(saved / "campaign.sav").write_bytes(b"earlier campaign")
|
|
record_quarantine(remote, previous, quarantine, saved)
|
|
monkeypatch.setattr(steam, "steam_running", lambda: True)
|
|
result = remote.uninstall(RUN_ID)
|
|
assert not result["uninstalled"] and result["steam"]["status"] == "manual"
|
|
assert not quarantine.exists() and str(quarantine) in result["warning"]
|
|
assert result["savedBackupPath"] is None and result["recoveredSaveBackupPaths"] == [str(saved)]
|
|
assert (saved / "campaign.sav").read_bytes() == b"earlier campaign"
|
|
|
|
|
|
def test_recovery_has_bounded_work_and_can_continue_on_another_run(installation, monkeypatch):
|
|
remote, _, _, _, _ = installation
|
|
previous = "d" * 32
|
|
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + previous)
|
|
os.rename(remote.GAME, quarantine)
|
|
record_quarantine(remote, previous, quarantine)
|
|
monkeypatch.setattr(remote, "MAX_UNINSTALL_RECOVERIES", 4)
|
|
for index in range(5):
|
|
identifier = f"{index:032x}"
|
|
path = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + identifier)
|
|
path.mkdir()
|
|
(path / "remaining.bin").write_bytes(b"verified remainder")
|
|
record_quarantine(remote, identifier, path)
|
|
result = remote.uninstall(RUN_ID)
|
|
assert not result["uninstalled"] and result["removalPending"] and not result["alreadyAbsent"]
|
|
assert len(list(remote.GAME.parent.glob(".HaloCENativeVR-uninstall-*"))) == 2
|
|
assert "one operation" in result["warning"] and len(result["warning"]) <= remote.MAX_WARNING_CHARS
|
|
assert remote.uninstall("e" * 32)["uninstalled"]
|
|
assert not list(remote.GAME.parent.glob(".HaloCENativeVR-uninstall-*"))
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="Windows forbids these Unix filename characters.")
|
|
@pytest.mark.parametrize("suffix", ["bad\nname", "bad\\name", "bad\x7fname"])
|
|
def test_unreportable_quarantine_filenames_stay_pending_without_unsafe_path_fields(installation, suffix):
|
|
remote, _, _, _, _ = installation
|
|
quarantine = remote.GAME.parent / (".HaloCENativeVR-uninstall-" + suffix)
|
|
quarantine.mkdir()
|
|
(quarantine / "precious.bin").write_bytes(b"user data")
|
|
result = remote.uninstall(RUN_ID)
|
|
assert result["removalPending"] and not result["uninstalled"]
|
|
assert result["retainedQuarantinePaths"] == []
|
|
assert (quarantine / "precious.bin").read_bytes() == b"user data"
|
|
assert ascii(quarantine.name) in result["warning"]
|
|
|
|
|
|
def test_absent_games_parent_needs_no_creation_or_cleanup(installation):
|
|
remote, _, _, _, _ = installation
|
|
protected = remote.HOME / "original-games"
|
|
os.rename(remote.GAME.parent, protected)
|
|
result = remote.uninstall(RUN_ID)
|
|
assert result["uninstalled"] and result["alreadyAbsent"] and not result["removalPending"]
|
|
assert not remote.GAME.parent.exists()
|
|
assert (protected / "HaloCEVR/halo.exe").read_bytes() == b"old PC version; this operation must not delete it"
|
|
|
|
|
|
def test_failure_before_first_delete_restores_the_intact_game_folder(installation, monkeypatch):
|
|
remote, _, _, _, _ = installation
|
|
before = game_bytes(remote)
|
|
original_unlink = Path.unlink
|
|
|
|
def fail(path, *args, **kwargs):
|
|
if path.parent.name == ".HaloCENativeVR-uninstall-" + RUN_ID:
|
|
raise OSError("first deletion failed")
|
|
return original_unlink(path, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, "unlink", fail)
|
|
with pytest.raises(OSError, match="first deletion"):
|
|
remote.uninstall(RUN_ID)
|
|
assert game_bytes(remote) == before
|
|
assert not (remote.GAME.parent / (".HaloCENativeVR-uninstall-" + RUN_ID)).exists()
|
|
|
|
|
|
def test_second_steam_account_write_failure_restores_first_account_and_game(installation, monkeypatch):
|
|
remote, steam, configs, _, _ = installation
|
|
game_before = game_bytes(remote)
|
|
raw = [(config / "shortcuts.vdf").read_bytes() for config in configs]
|
|
real = steam._replace_shortcut_bytes
|
|
calls = [0]
|
|
|
|
def replace(path, content):
|
|
calls[0] += 1
|
|
if calls[0] == 2:
|
|
raise OSError("second account write failed")
|
|
return real(path, content)
|
|
|
|
monkeypatch.setattr(steam, "_replace_shortcut_bytes", replace)
|
|
result = remote.uninstall(RUN_ID, keep_saves=True)
|
|
assert not result["uninstalled"] and result["steam"]["status"] == "manual"
|
|
assert game_bytes(remote) == game_before
|
|
assert [(config / "shortcuts.vdf").read_bytes() for config in configs] == raw
|
|
assert not list((remote.HOME / "Games").glob("*HaloCENativeVR-saves-*"))
|
|
|
|
|
|
def test_legacy_close_flag_leaves_active_steam_and_game_running(installation, monkeypatch):
|
|
import subprocess
|
|
remote, steam, _, _, _ = installation
|
|
monkeypatch.setattr(steam, "steam_running", lambda: True)
|
|
session, removal = Mock(), Mock()
|
|
monkeypatch.setattr(steam, "_session_environment", session)
|
|
monkeypatch.setattr(steam, "_write_removed_shortcuts", removal)
|
|
shutdown, restart = Mock(), Mock()
|
|
monkeypatch.setattr(subprocess, "run", shutdown)
|
|
monkeypatch.setattr(subprocess, "Popen", restart)
|
|
assert steam.remove_native_shortcut(remote.HOME, remote.GAME, close_steam=True)["status"] == "manual"
|
|
shutdown.assert_not_called()
|
|
restart.assert_not_called()
|
|
session.assert_not_called()
|
|
removal.assert_not_called()
|
|
assert remote.GAME.exists()
|
|
|
|
|
|
def test_uninstall_preflight_needs_no_game_maps_space_vr_or_compiler(installation, monkeypatch):
|
|
remote, _, _, _, _ = installation
|
|
def forbidden(*args, **kwargs):
|
|
raise AssertionError("Install-only prerequisite was used")
|
|
monkeypatch.setattr(remote, "existing_install", forbidden)
|
|
monkeypatch.setattr(remote, "elf_arm64", forbidden)
|
|
monkeypatch.setattr(remote.shutil, "disk_usage", forbidden)
|
|
monkeypatch.setattr(remote.shutil, "which", forbidden)
|
|
monkeypatch.setattr(remote, "command", forbidden)
|
|
result = remote.preflight_uninstall()
|
|
assert result == {"home": str(remote.HOME), "cachePath": str(remote.CACHE),
|
|
"gamePath": str(remote.GAME), "uninstallSupported": True}
|
|
|
|
|
|
@pytest.mark.parametrize("failure", ["space", "config"])
|
|
def test_save_backup_prerequisite_failure_keeps_game_and_steam_untouched(installation, monkeypatch, failure):
|
|
remote, _, configs, _, _ = installation
|
|
if failure == "space":
|
|
monkeypatch.setattr(remote.shutil, "disk_usage", lambda path: types.SimpleNamespace(free=0))
|
|
else:
|
|
(remote.GAME / "config.toml").write_text("[not valid toml")
|
|
before = game_bytes(remote)
|
|
raw = [(config / "shortcuts.vdf").read_bytes() for config in configs]
|
|
with pytest.raises(ValueError, match="space|damaged"):
|
|
remote.uninstall(RUN_ID, keep_saves=True)
|
|
assert game_bytes(remote) == before
|
|
assert [(config / "shortcuts.vdf").read_bytes() for config in configs] == raw
|
|
|
|
|
|
def test_completed_removal_is_not_reported_as_failed_if_operation_bookkeeping_fails(installation, monkeypatch):
|
|
remote, _, _, _, _ = installation
|
|
real_write = remote.write_uninstall_record
|
|
|
|
def write(directory, metadata, *args, **kwargs):
|
|
if directory.name == RUN_ID and metadata.get("state") == "complete":
|
|
raise OSError("operation record update failed")
|
|
return real_write(directory, metadata, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(remote, "write_uninstall_record", write)
|
|
result = remote.uninstall(RUN_ID)
|
|
assert result["uninstalled"] is True and not remote.GAME.exists()
|
|
assert "operation record" in result["warning"]
|