diff --git a/steam_backlog_enforcer/_cdp.py b/steam_backlog_enforcer/_cdp.py new file mode 100644 index 0000000..b45011a --- /dev/null +++ b/steam_backlog_enforcer/_cdp.py @@ -0,0 +1,95 @@ +"""Chrome DevTools Protocol transport for Steam's embedded browser. + +Modern Steam runs its UI in CEF, so collections can only be changed by +evaluating JS inside it. Split out of +:mod:`steam_backlog_enforcer.library_hider` to keep both files under the +250-line cap: this module is pure transport and knows nothing about +collections. +""" + +from __future__ import annotations + +import asyncio +import json +import logging + +import requests +import websockets + +logger = logging.getLogger(__name__) + +# NOTE: was 8080, which collided with a different local service (Open WebUI) +# already bound to 0.0.0.0:8080. requests to 127.0.0.1:8080 resolved to that +# service instead of steamwebhelper's CDP endpoint (which only bound +# [::1]:8080), so CDP detection silently never worked. 9222 is the +# conventional Chrome DevTools debug port and was confirmed free. +_CDP_PORT = 9222 +_CDP_TIMEOUT = 120 + + +# ────────────────────────────────────────────────────────────── +# CDP (Chrome DevTools Protocol) helpers +# ────────────────────────────────────────────────────────────── + + +def _get_shared_js_ws_url() -> str | None: + """Query the CDP HTTP endpoint and return the SharedJSContext WS URL.""" + try: + resp = requests.get(f"http://127.0.0.1:{_CDP_PORT}/json", timeout=5) + targets = resp.json() + except (OSError, ValueError): + return None + + for target in targets: + if target.get("title") == "SharedJSContext": + ws_url: str = target["webSocketDebuggerUrl"] + return ws_url + return None + + +async def _evaluate_js_async(ws_url: str, expression: str) -> dict: + """Connect to a CDP WebSocket target and evaluate *expression*.""" + async with websockets.connect(ws_url) as ws: + msg = json.dumps( + { + "id": 1, + "method": "Runtime.evaluate", + "params": { + "expression": expression, + "returnByValue": True, + "awaitPromise": True, + }, + } + ) + await ws.send(msg) + resp = await asyncio.wait_for(ws.recv(), timeout=_CDP_TIMEOUT) + return json.loads(resp) + + +def _evaluate_js(expression: str) -> dict: + """Synchronous wrapper around :func:`_evaluate_js_async`.""" + ws_url = _get_shared_js_ws_url() + if ws_url is None: + msg = "SharedJSContext not found on CDP port" + raise RuntimeError(msg) + return asyncio.run(_evaluate_js_async(ws_url, expression)) + + +def _cdp_result_value(result: dict) -> str: + """Extract the return value from a CDP Runtime.evaluate response.""" + outer = result.get("result", {}) + inner = outer.get("result", {}) + if "exceptionDetails" in outer: + exc_details = outer["exceptionDetails"] + exc = exc_details.get("exception", {}) + desc = ( + inner.get("description") + or exc.get("description") + or exc_details.get("text") + or repr(exc_details) + ) + logger.debug("CDP exception details: %s", exc_details) + msg = f"JS evaluation error: {desc}" + raise RuntimeError(msg) + value: str = inner.get("value", "") + return value diff --git a/steam_backlog_enforcer/_desktop_env.py b/steam_backlog_enforcer/_desktop_env.py index 0824fb5..7b130f9 100644 --- a/steam_backlog_enforcer/_desktop_env.py +++ b/steam_backlog_enforcer/_desktop_env.py @@ -27,6 +27,7 @@ from __future__ import annotations import os from pathlib import Path +import pwd def desktop_runtime_dir(uid: int) -> str: @@ -70,3 +71,50 @@ def desktop_env_args(user: str, uid: int) -> list[str]: f"DBUS_SESSION_BUS_ADDRESS={dbus_addr}", f"XDG_RUNTIME_DIR={runtime_dir}", ] + + +def desktop_uid(user: str | None) -> int: + """Resolve *user* to a uid, defaulting to the usual first desktop uid.""" + if user: + try: + return pwd.getpwnam(user).pw_uid + except KeyError: + pass + return 1000 + + +def resolve_desktop_user() -> str | None: + """Resolve which desktop user owns the Steam/X11 session. + + Prefers the explicit STEAM_ENFORCER_DESKTOP_USER (set by the systemd + unit, which has no SUDO_USER/USER of its own since it is started + directly by systemd rather than via `sudo`), then falls back to + SUDO_USER/USER for interactive `sudo` invocations. + """ + return ( + os.environ.get("STEAM_ENFORCER_DESKTOP_USER") + or os.environ.get("SUDO_USER") + or os.environ.get("USER") + ) + + +def desktop_user_cmd(cmd: list[str], user: str | None) -> list[str]: + """Wrap *cmd* so it runs as *user* when the caller is root. + + Returns *cmd* unchanged when already unprivileged or when no desktop user + resolves; otherwise prefixes ``sudo -u env ``. + + This is argv construction only — deliberately no ``subprocess`` call, so + every spawn site stays in a module covered by the test suite's + ``_no_subprocess`` guard. + """ + if os.geteuid() == 0 and user and user != "root": + return [ + "sudo", + "-u", + user, + "env", + *desktop_env_args(user, desktop_uid(user)), + *cmd, + ] + return cmd diff --git a/steam_backlog_enforcer/_echo.py b/steam_backlog_enforcer/_echo.py new file mode 100644 index 0000000..00f061c --- /dev/null +++ b/steam_backlog_enforcer/_echo.py @@ -0,0 +1,26 @@ +"""User-facing CLI output. + +A one-function leaf module so every command can print without importing the +install machinery. Split out of :mod:`steam_backlog_enforcer.game_install` to +keep that module under the 250-line cap. +""" + +from __future__ import annotations + +import sys + + +def _echo(msg: str = "", *, end: str = "\n", flush: bool = False) -> None: + """Write user-facing CLI output to stdout. + + Args: + msg: Text to output. + end: String appended after the message. + flush: Whether to flush stdout immediately. + """ + sys.stdout.write(msg + end) + if flush: + sys.stdout.flush() + + +# Steam infrastructure app IDs that should NEVER be uninstalled. diff --git a/steam_backlog_enforcer/_enforce_loop.py b/steam_backlog_enforcer/_enforce_loop.py index 65cb0f9..b7b7d24 100644 --- a/steam_backlog_enforcer/_enforce_loop.py +++ b/steam_backlog_enforcer/_enforce_loop.py @@ -5,10 +5,19 @@ from __future__ import annotations import json import logging import time -from typing import Any from steam_backlog_enforcer._actions import allowed_app_ids, allowed_games +from steam_backlog_enforcer._echo import _echo +from steam_backlog_enforcer._enforce_steps import ( + _enforce_setup, + _reinstall_missing_allowed, +) + +# Re-exported: five modules and the test suite import get_all_owned_app_ids +# from here, so splitting it into _owned_apps_cache stays invisible to them. +from steam_backlog_enforcer._owned_apps_cache import get_all_owned_app_ids from steam_backlog_enforcer._playtime import playtime_tick +from steam_backlog_enforcer._steam_launch import steam_is_installed from steam_backlog_enforcer._total_block import ( end_total_block_cleanup, enforce_total_block_tick, @@ -19,132 +28,31 @@ from steam_backlog_enforcer._whitelist import ( lock_enforcement_files, ) from steam_backlog_enforcer.config import ( - CONFIG_DIR, CONFIG_FILE, Config, State, - _atomic_write, - load_snapshot, ) from steam_backlog_enforcer.enforcer import ( enforce_allowed_game, send_notification, ) -from steam_backlog_enforcer.game_install import ( - _echo, +from steam_backlog_enforcer.game_uninstall import ( get_installed_games, - install_game, - is_game_installed, is_protected_app, uninstall_game, - uninstall_other_games, ) -from steam_backlog_enforcer.library_hider import ( - steam_is_installed, - try_hide_other_games, -) -from steam_backlog_enforcer.steam_api import SteamAPIClient -from steam_backlog_enforcer.store_blocker import block_store logger = logging.getLogger(__name__) -_OWNED_IDS_CACHE_FILE = CONFIG_DIR / "owned_app_ids_cache.json" -_OWNED_IDS_CACHE_TTL_SECONDS = 3600 - -def _load_owned_app_ids_cache(steam_id: str) -> list[int] | None: - """Return fresh cached owned app IDs for this steam_id, if available.""" - if not steam_id or not _OWNED_IDS_CACHE_FILE.exists(): - return None - - try: - data: dict[str, Any] = json.loads( - _OWNED_IDS_CACHE_FILE.read_text(encoding="utf-8") - ) - except (json.JSONDecodeError, OSError, ValueError): - return None - - cached_steam_id = str(data.get("steam_id", "")) - if cached_steam_id != steam_id: - return None - - fetched_at = float(data.get("fetched_at", 0.0) or 0.0) - age = time.time() - fetched_at - if age > _OWNED_IDS_CACHE_TTL_SECONDS: - return None - - raw_ids = data.get("app_ids", []) - if not isinstance(raw_ids, list): - return None - - return [int(app_id) for app_id in raw_ids] - - -def _save_owned_app_ids_cache(steam_id: str, app_ids: list[int]) -> None: - """Persist owned app IDs cache for this steam_id.""" - payload = { - "steam_id": steam_id, - "fetched_at": time.time(), - "app_ids": app_ids, - } - _atomic_write(_OWNED_IDS_CACHE_FILE, json.dumps(payload, indent=2) + "\n") - - -# ────────────────────────────────────────────────────────────── -# Helpers -# ────────────────────────────────────────────────────────────── - - -def get_all_owned_app_ids(config: Config) -> list[int]: - """Get all owned game app IDs from Steam API plus snapshot fallback. - - Snapshot data contains only games with achievements, so API data is the - primary source for library hiding. Snapshot IDs are merged in to keep - behavior resilient when the API result is partial. - """ - snapshot = load_snapshot() or [] - snapshot_ids = [int(d["app_id"]) for d in snapshot if "app_id" in d] - cached_ids = _load_owned_app_ids_cache(config.steam_id) - - if cached_ids is not None: - merged_ids: list[int] = [] - seen: set[int] = set() - for app_id in [*cached_ids, *snapshot_ids]: - if app_id in seen: - continue - seen.add(app_id) - merged_ids.append(app_id) - logger.info("Using cached Steam owned IDs (%d entries).", len(cached_ids)) - return merged_ids - - try: - client = SteamAPIClient(config.steam_api_key, config.steam_id) - owned = client.get_owned_games() - api_ids = [int(g["appid"]) for g in owned if "appid" in g] - _save_owned_app_ids_cache(config.steam_id, api_ids) - - merged_ids: list[int] = [] - seen: set[int] = set() - for app_id in [*api_ids, *snapshot_ids]: - if app_id in seen: - continue - seen.add(app_id) - merged_ids.append(app_id) - except (OSError, RuntimeError, ValueError): - if snapshot_ids: - return snapshot_ids - logger.warning("Could not fetch owned game list for hiding.") - return [] - else: - return merged_ids - - -# ────────────────────────────────────────────────────────────── -# Enforce mode (daemon loop) -# ────────────────────────────────────────────────────────────── - -# How often the enforce loop runs (seconds). +# Seconds between enforce passes. ENFORCE_INTERVAL = 3 +__all__ = [ + "ENFORCE_INTERVAL", + "do_enforce", + "get_all_owned_app_ids", +] + def _allowed_names(state: State) -> str: """Return a human-readable list of the games the user may play. @@ -191,99 +99,6 @@ def _guard_installed_games(allowed_app_ids: set[int]) -> int: return count -def _reinstall_missing_allowed(config: Config, state: State) -> None: - """Re-install any allowed game that vanished between loop iterations. - - Args: - config: Enforcer configuration. - state: Current enforcer state. - """ - for app_id, name in allowed_games(state): - if is_game_installed(app_id): - continue - logger.info("Allowed game disappeared — re-installing %s", name) - install_game(app_id, name, config.steam_id) - - -def _enforce_setup(config: Config, state: State) -> None: - """Perform initial setup for enforcement mode. - - Args: - config: Enforcer configuration. - state: Current enforcer state. - """ - # Initial store block. - if config.block_store: - if block_store(): - _echo(" Steam store: BLOCKED") - else: - _echo(" Steam store: FAILED (need sudo?)") - - # Initial cleanup. - if config.uninstall_other_games: - _echo(" Uninstalling non-assigned games...") - count = uninstall_other_games(allowed_app_ids(state)) - _echo(f" Uninstalled {count} games") - - # Auto-install the assigned game. - _enforce_auto_install(config, state) - - # Hide all other games in the Steam library. - _enforce_hide_games(config, state) - - -def _enforce_auto_install(config: Config, state: State) -> None: - """Auto-install every allowed game that is not installed yet. - - Args: - config: Enforcer configuration. - state: Current enforcer state. - """ - for app_id, name in allowed_games(state): - if is_game_installed(app_id): - _echo(f" Allowed game already installed: {name}") - continue - _echo(f" Auto-installing {name}...") - if install_game( - app_id, - name, - config.steam_id, - use_steam_protocol=True, - ): - send_notification("Game Installing", f"{name} is being downloaded.") - else: - _echo(" Could not auto-install. Install manually from Steam.") - - -def _enforce_hide_games(config: Config, state: State) -> None: - """Hide non-assigned games in the Steam library. - - Args: - config: Enforcer configuration. - state: Current enforcer state. - """ - owned_ids = get_all_owned_app_ids(config) - if not owned_ids: - _echo(" Library hiding: skipped (no owned game list — run 'scan' first)") - return - - # An unreachable Steam is not fatal: with no client there is no library to - # hide, and everything else the enforcer does (store block, install guard) - # still works. Letting this escape used to exit(1) into Restart=always, - # which spun the service through ~1000 restarts against a Steam that had - # been uninstalled - each attempt leaving a dead process named "steam" - # behind that /proc scanners misread as a live Steam. - hidden, skipped = try_hide_other_games(owned_ids, allowed_app_ids(state)) - if skipped is not None: - _echo(f" Library hiding: skipped ({skipped})") - return - - if hidden > 0: - _echo(f" Library: hid {hidden} games (only assigned game visible)") - else: - _echo(" Library: games already hidden") - - def _enforce_loop_iteration( config: Config, state: State, diff --git a/steam_backlog_enforcer/_enforce_steps.py b/steam_backlog_enforcer/_enforce_steps.py new file mode 100644 index 0000000..5b60457 --- /dev/null +++ b/steam_backlog_enforcer/_enforce_steps.py @@ -0,0 +1,127 @@ +"""The individual steps one enforce pass performs. + +Split out of :mod:`steam_backlog_enforcer._enforce_loop` to keep both files +under the 250-line cap: this module holds *what* each pass does, while the +loop module owns the cadence and the surrounding lifecycle. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from steam_backlog_enforcer._actions import allowed_app_ids, allowed_games +from steam_backlog_enforcer._echo import _echo +from steam_backlog_enforcer._owned_apps_cache import get_all_owned_app_ids +from steam_backlog_enforcer._steam_client import is_game_installed +from steam_backlog_enforcer._steam_state import steam_library_ready +from steam_backlog_enforcer.enforcer import send_notification +from steam_backlog_enforcer.game_install import install_game +from steam_backlog_enforcer.game_uninstall import uninstall_other_games +from steam_backlog_enforcer.library_hider import try_hide_other_games +from steam_backlog_enforcer.store_blocker import block_store + +if TYPE_CHECKING: + from steam_backlog_enforcer.config import Config, State + +logger = logging.getLogger(__name__) + + +def _reinstall_missing_allowed(config: Config, state: State) -> None: + """Re-install any allowed game that vanished between loop iterations. + + Args: + config: Enforcer configuration. + state: Current enforcer state. + """ + # Nothing is reinstallable without a Steam library, and every game looks + # "missing" when steamapps does not exist — logging per game per 3s pass + # is just noise. install_game guards this too; returning here keeps the + # log quiet as well. + if not steam_library_ready(): + return + + for app_id, name in allowed_games(state): + if is_game_installed(app_id): + continue + logger.info("Allowed game disappeared — re-installing %s", name) + install_game(app_id, name, config.steam_id) + + +def _enforce_setup(config: Config, state: State) -> None: + """Perform initial setup for enforcement mode. + + Args: + config: Enforcer configuration. + state: Current enforcer state. + """ + # Initial store block. + if config.block_store: + if block_store(): + _echo(" Steam store: BLOCKED") + else: + _echo(" Steam store: FAILED (need sudo?)") + + # Initial cleanup. + if config.uninstall_other_games: + _echo(" Uninstalling non-assigned games...") + count = uninstall_other_games(allowed_app_ids(state)) + _echo(f" Uninstalled {count} games") + + # Auto-install the assigned game. + _enforce_auto_install(config, state) + + # Hide all other games in the Steam library. + _enforce_hide_games(config, state) + + +def _enforce_auto_install(config: Config, state: State) -> None: + """Auto-install every allowed game that is not installed yet. + + Args: + config: Enforcer configuration. + state: Current enforcer state. + """ + for app_id, name in allowed_games(state): + if is_game_installed(app_id): + _echo(f" Allowed game already installed: {name}") + continue + _echo(f" Auto-installing {name}...") + if install_game( + app_id, + name, + config.steam_id, + use_steam_protocol=True, + ): + send_notification("Game Installing", f"{name} is being downloaded.") + else: + _echo(" Could not auto-install. Install manually from Steam.") + + +def _enforce_hide_games(config: Config, state: State) -> None: + """Hide non-assigned games in the Steam library. + + Args: + config: Enforcer configuration. + state: Current enforcer state. + """ + owned_ids = get_all_owned_app_ids(config) + if not owned_ids: + _echo(" Library hiding: skipped (no owned game list — run 'scan' first)") + return + + # An unreachable Steam is not fatal: with no client there is no library to + # hide, and everything else the enforcer does (store block, install guard) + # still works. Letting this escape used to exit(1) into Restart=always, + # which spun the service through ~1000 restarts against a Steam that had + # been uninstalled - each attempt leaving a dead process named "steam" + # behind that /proc scanners misread as a live Steam. + hidden, skipped = try_hide_other_games(owned_ids, allowed_app_ids(state)) + if skipped is not None: + _echo(f" Library hiding: skipped ({skipped})") + return + + if hidden > 0: + _echo(f" Library: hid {hidden} games (only assigned game visible)") + else: + _echo(" Library: games already hidden") diff --git a/steam_backlog_enforcer/_game_names.py b/steam_backlog_enforcer/_game_names.py new file mode 100644 index 0000000..156f344 --- /dev/null +++ b/steam_backlog_enforcer/_game_names.py @@ -0,0 +1,101 @@ +"""Game-name normalisation and the protected-name check. + +Split out of :mod:`steam_backlog_enforcer.game_install` to keep it under the +250-line cap. Pure string handling with no filesystem or Steam knowledge, so +it stays a leaf module. +""" + +from __future__ import annotations + +import difflib +import logging +import re + +# _allowed_games, not _actions: _actions imports game_uninstall, which +# imports this module. The leaf module has the same function and no cycle. +from steam_backlog_enforcer._allowed_games import allowed_games +from steam_backlog_enforcer.config import State + +logger = logging.getLogger(__name__) + +_NAME_FUZZY_MATCH_THRESHOLD = 0.82 + +_NUMBER_WORDS = { + "one": "1", + "two": "2", + "three": "3", + "four": "4", + "five": "5", + "six": "6", + "seven": "7", + "eight": "8", + "nine": "9", + "ten": "10", +} + +_ROMAN_NUMERALS = { + "i": "1", + "ii": "2", + "iii": "3", + "iv": "4", + "v": "5", + "vi": "6", + "vii": "7", + "viii": "8", + "ix": "9", + "x": "10", +} + + +def _normalize_game_name(name: str) -> str: + """Canonicalize a game/folder name for fuzzy comparison. + + Splits into alphanumeric words, maps spelled-out numbers ("two") and + roman numerals ("II") to digits, then joins with no separators. Makes + "Kingdom Come: Deliverance II", "KingdomComeDeliverance2", and "kingdom + come deliverance two" all normalize to the same string. + """ + words = re.findall(r"[A-Za-z0-9]+", name.lower()) + mapped = [_NUMBER_WORDS.get(w) or _ROMAN_NUMERALS.get(w) or w for w in words] + return "".join(mapped) + + +def _protected_name_stems() -> list[str]: + """Every name of every currently-allowed game, for the deletion safety net. + + Returns [] (protects nothing extra beyond exact matches already checked + by callers) if state can't be loaded -- this is a best-effort safety net, + not a hard dependency. + """ + try: + return [name for _, name in allowed_games(State.load()) if name] + except Exception: + logger.exception("Could not load allowed games for the deletion safety net") + return [] + + +def _is_protected_name(candidate: str) -> bool: + """True if *candidate* plausibly names one of the currently-allowed games. + + Errs toward over-matching on purpose: a false positive here just skips a + deletion that can be done manually; a false negative deletes real files. + """ + normalized_candidate = _normalize_game_name(candidate) + if not normalized_candidate: + return False + for protected in _protected_name_stems(): + normalized_protected = _normalize_game_name(protected) + if not normalized_protected: + continue + if normalized_candidate == normalized_protected: + return True + ratio = difflib.SequenceMatcher( + None, normalized_candidate, normalized_protected + ).ratio() + if ratio >= _NAME_FUZZY_MATCH_THRESHOLD: + return True + return False + + +# Real Steam directory — used as a safety check to block destructive +# operations that leak through during testing. diff --git a/steam_backlog_enforcer/_owned_apps_cache.py b/steam_backlog_enforcer/_owned_apps_cache.py new file mode 100644 index 0000000..d8e82d2 --- /dev/null +++ b/steam_backlog_enforcer/_owned_apps_cache.py @@ -0,0 +1,121 @@ +"""Cached lookup of the Steam account's full owned-app list. + +Split out of :mod:`steam_backlog_enforcer._enforce_loop` to keep both files +under the 250-line cap. The enforce loop runs every 3s but the owned-games +list changes rarely, so it is cached on disk with a TTL rather than re-fetched +from the Steam Web API each pass. +""" + +from __future__ import annotations + +import json +import logging +import time +from typing import Any + +from steam_backlog_enforcer.config import ( + CONFIG_DIR, + Config, + _atomic_write, + load_snapshot, +) +from steam_backlog_enforcer.steam_api import SteamAPIClient + +logger = logging.getLogger(__name__) + +_OWNED_IDS_CACHE_FILE = CONFIG_DIR / "owned_app_ids_cache.json" +_OWNED_IDS_CACHE_TTL_SECONDS = 3600 + + +def _load_owned_app_ids_cache(steam_id: str) -> list[int] | None: + """Return fresh cached owned app IDs for this steam_id, if available.""" + if not steam_id or not _OWNED_IDS_CACHE_FILE.exists(): + return None + + try: + data: dict[str, Any] = json.loads( + _OWNED_IDS_CACHE_FILE.read_text(encoding="utf-8") + ) + except (json.JSONDecodeError, OSError, ValueError): + return None + + cached_steam_id = str(data.get("steam_id", "")) + if cached_steam_id != steam_id: + return None + + fetched_at = float(data.get("fetched_at", 0.0) or 0.0) + age = time.time() - fetched_at + if age > _OWNED_IDS_CACHE_TTL_SECONDS: + return None + + raw_ids = data.get("app_ids", []) + if not isinstance(raw_ids, list): + return None + + return [int(app_id) for app_id in raw_ids] + + +def _save_owned_app_ids_cache(steam_id: str, app_ids: list[int]) -> None: + """Persist owned app IDs cache for this steam_id.""" + payload = { + "steam_id": steam_id, + "fetched_at": time.time(), + "app_ids": app_ids, + } + _atomic_write(_OWNED_IDS_CACHE_FILE, json.dumps(payload, indent=2) + "\n") + + +# ────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────── + + +def get_all_owned_app_ids(config: Config) -> list[int]: + """Get all owned game app IDs from Steam API plus snapshot fallback. + + Snapshot data contains only games with achievements, so API data is the + primary source for library hiding. Snapshot IDs are merged in to keep + behavior resilient when the API result is partial. + """ + snapshot = load_snapshot() or [] + snapshot_ids = [int(d["app_id"]) for d in snapshot if "app_id" in d] + cached_ids = _load_owned_app_ids_cache(config.steam_id) + + if cached_ids is not None: + merged_ids: list[int] = [] + seen: set[int] = set() + for app_id in [*cached_ids, *snapshot_ids]: + if app_id in seen: + continue + seen.add(app_id) + merged_ids.append(app_id) + logger.info("Using cached Steam owned IDs (%d entries).", len(cached_ids)) + return merged_ids + + try: + client = SteamAPIClient(config.steam_api_key, config.steam_id) + owned = client.get_owned_games() + api_ids = [int(g["appid"]) for g in owned if "appid" in g] + _save_owned_app_ids_cache(config.steam_id, api_ids) + + merged_ids: list[int] = [] + seen: set[int] = set() + for app_id in [*api_ids, *snapshot_ids]: + if app_id in seen: + continue + seen.add(app_id) + merged_ids.append(app_id) + except (OSError, RuntimeError, ValueError): + if snapshot_ids: + return snapshot_ids + logger.warning("Could not fetch owned game list for hiding.") + return [] + else: + return merged_ids + + +# ────────────────────────────────────────────────────────────── +# Enforce mode (daemon loop) +# ────────────────────────────────────────────────────────────── + +# How often the enforce loop runs (seconds). diff --git a/steam_backlog_enforcer/_playtime_notify.py b/steam_backlog_enforcer/_playtime_notify.py index ec2baf4..ed1405a 100644 --- a/steam_backlog_enforcer/_playtime_notify.py +++ b/steam_backlog_enforcer/_playtime_notify.py @@ -9,7 +9,8 @@ from __future__ import annotations import logging -from steam_backlog_enforcer.library_hider import _resolve_desktop_user, _run_as_user +from steam_backlog_enforcer._steam_launch import _resolve_desktop_user +from steam_backlog_enforcer._steam_process import _run_as_user logger = logging.getLogger(__name__) @@ -20,10 +21,9 @@ _MINUTES_PER_HOUR = 60 def notify_desktop_user(title: str, body: str) -> None: """Send a desktop notification into the real user's session. - ``enforcer.send_notification`` runs ``notify-send`` as root with no - ``DBUS_SESSION_BUS_ADDRESS``, which cannot reach the user's session bus from - a system service. This routes through the same ``sudo -u env ...`` - mechanism that ``library_hider`` uses to launch Steam. + ``enforcer.send_notification`` now drops privileges too, so this differs + only in delivery: it is fire-and-forget (``Popen``) where that one waits up + to 5s, which matters on the playtime-cutoff path. Args: title: Notification title. diff --git a/steam_backlog_enforcer/_protected_apps.py b/steam_backlog_enforcer/_protected_apps.py new file mode 100644 index 0000000..2ba8f6e --- /dev/null +++ b/steam_backlog_enforcer/_protected_apps.py @@ -0,0 +1,44 @@ +"""Steam AppIDs that must never be uninstalled. + +Runtimes and compatibility tools, not games: removing one silently breaks +every Proton title. Split out of :mod:`steam_backlog_enforcer.game_install` +to keep that module under the 250-line cap; this is data, not logic. +""" + +from __future__ import annotations + +PROTECTED_APP_IDS = { + # Steam runtimes and tooling (never uninstall these) + 228980, # Steamworks Common Redistributables + 1070560, # Steam Linux Runtime 1.0 (scout) + 1391110, # Steam Linux Runtime 2.0 (soldier) + 1628350, # Steam Linux Runtime 3.0 (sniper) + 4183110, # Steam Linux Runtime 4.0 + 4185400, # Steam Linux Runtime 4.0 - Arm64 + 961940, # Steam Linux Runtime (legacy) + 4690330, # Legacy Steam Runtime + 613220, # Steam 360 Video Player + 250820, # SteamVR + 1007, # Steamworks SDK Redist + # Proton versions (never uninstall these) + 858280, # Proton 3.7 (Beta) + 930400, # Proton 3.16 (Beta) + 1054830, # Proton 4.2 + 1113280, # Proton 4.11 + 1245040, # Proton 5.0 + 1420170, # Proton 5.13 + 1580130, # Proton 6.3 + 1887720, # Proton 7.0 + 2230260, # Proton 7.0 (alt) + 2348590, # Proton 8.0 + 2805730, # Proton 9.0 + 3201940, # Proton 9.0 (alt) + 3658110, # Proton 10.0 + 4628710, # Proton 11.0 + 4628740, # Proton 11.0 (ARM64) + 2180100, # Proton Hotfix + 1493710, # Proton Experimental + 1161040, # Proton BattlEye Runtime + 1007020, # Proton EasyAntiCheat Runtime + 1826330, # Proton EasyAntiCheat Runtime +} diff --git a/steam_backlog_enforcer/_steam_client.py b/steam_backlog_enforcer/_steam_client.py new file mode 100644 index 0000000..0304d67 --- /dev/null +++ b/steam_backlog_enforcer/_steam_client.py @@ -0,0 +1,123 @@ +"""Starting the Steam client as the desktop user, and install-state queries. + +Split out of :mod:`steam_backlog_enforcer.game_install` to keep it under the +250-line cap. The enforcer runs as root, so everything here is about resolving +*which* user to become before touching the desktop session. +""" + +from __future__ import annotations + +import logging +import os +import pwd +import subprocess +import time + +from steam_backlog_enforcer._desktop_env import ( + desktop_env_args, + desktop_runtime_dir, + desktop_session_ready, + resolve_desktop_user, +) +from steam_backlog_enforcer._steam_launch import steam_is_installed +from steam_backlog_enforcer._steam_state import STEAMAPPS_PATH + +logger = logging.getLogger(__name__) + + +def _get_real_user() -> str | None: + """Get the desktop user to drop privileges to. + + Delegates to the shared resolver so this module honours + STEAM_ENFORCER_DESKTOP_USER. Reading only SUDO_USER/USER returned None + under systemd (which sets neither), so every ``geteuid() == 0 and + real_user`` guard below silently fell through to running as root. + """ + return resolve_desktop_user() + + +def _get_uid_gid_for_user(username: str) -> tuple[int, int]: + """Get (uid, gid) for a username.""" + try: + pw = pwd.getpwnam(username) + except KeyError: + return 1000, 1000 + else: + return pw.pw_uid, pw.pw_gid + + +def is_game_installed(app_id: int) -> bool: + """Check if a game is installed by looking for its appmanifest. + + A manifest with StateFlags != 4 (FullyInstalled) means the game is + still downloading or queued, which still counts as "install triggered". + """ + manifest = STEAMAPPS_PATH / f"appmanifest_{app_id}.acf" + return manifest.exists() + + +def _ensure_steam_running() -> None: + """Start the Steam client if it is not already running. + + Does nothing if Steam is not installed - there is no client to start, and + trying anyway only sleeps 15s waiting on a process that died on exec. + """ + if not steam_is_installed(): + logger.info("Steam is not installed — skipping client start.") + return + + # Check if any steam process is running (main client, not just helpers). + try: + result = subprocess.run( + ["/usr/bin/pgrep", "-f", "steam.sh"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + logger.debug("Steam client already running") + return + except FileNotFoundError: + pass + + real_user = _get_real_user() + logger.info("Starting Steam client...") + + try: + if os.geteuid() == 0 and real_user and real_user != "root": + uid, _ = _get_uid_gid_for_user(real_user) + # Defer rather than launch a Steam that would come up without a + # runtime dir, and so without working audio, for the session. + if not desktop_session_ready(uid): + logger.info( + "Deferring Steam start: %s does not exist yet.", + desktop_runtime_dir(uid), + ) + return + cmd = [ + "sudo", + "-u", + real_user, + "env", + *desktop_env_args(real_user, uid), + "steam", + "-silent", + ] + else: + cmd = ["steam", "-silent"] + + subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + # Give Steam time to initialize and start scanning manifests. + time.sleep(15) + except FileNotFoundError: + logger.exception("Steam executable not found") + + +# Latches the "no Steam library" warning so a 3s enforce loop logs it once +# rather than once per game per pass. A set (rather than a module-level bool +# reassigned via `global`) keeps the mutation local to the container. +_LIBRARY_WARNED: set[str] = set() diff --git a/steam_backlog_enforcer/_steam_errors.py b/steam_backlog_enforcer/_steam_errors.py new file mode 100644 index 0000000..b174ab4 --- /dev/null +++ b/steam_backlog_enforcer/_steam_errors.py @@ -0,0 +1,40 @@ +"""Errors raised when Steam cannot be driven. + +A leaf module so both :mod:`steam_backlog_enforcer.library_hider` and +:mod:`steam_backlog_enforcer._steam_launch` can raise and catch these without +importing each other. Split out to keep both under the 250-line cap. +""" + +from __future__ import annotations + + +class SteamUnavailableError(RuntimeError): + """Raised when Steam cannot be driven over CDP. + + Covers both "Steam is not installed" and "Steam is installed but never + opened its debug port". Callers are expected to degrade gracefully rather + than abort: an unreachable Steam means there is no library to hide, which + is not a fatal condition for the enforcer. + """ + + +class SteamUpdateInProgressError(SteamUnavailableError): + """Raised to defer a Steam restart while a game update is in flight. + + Subclasses :class:`SteamUnavailableError` so existing callers already + degrade gracefully (skip this pass, retry next loop). Restarting Steam + mid-update suspends and can corrupt the transfer, so the enforcer waits + for updates to finish before bouncing Steam to open the CDP port. + """ + + +class DesktopSessionNotReadyError(SteamUnavailableError): + """Raised to defer a Steam launch until the user's session exists. + + Subclasses :class:`SteamUnavailableError` so existing callers already + degrade gracefully (skip this pass, retry next loop). Launching Steam + before ``/run/user/`` exists hands every Wine child a runtime dir + that is not there, so it falls back from winepulse to winealsa and stays + that way for the whole session — the Kingdom Come: Deliverance II startup + crash. Waiting a pass costs 3s; getting it wrong costs a reboot. + """ diff --git a/steam_backlog_enforcer/_steam_launch.py b/steam_backlog_enforcer/_steam_launch.py new file mode 100644 index 0000000..f7101c9 --- /dev/null +++ b/steam_backlog_enforcer/_steam_launch.py @@ -0,0 +1,248 @@ +"""Getting Steam running with its devtools port open. + +Split out of :mod:`steam_backlog_enforcer.library_hider` to keep both files +under the 250-line cap: this module owns the Steam *lifecycle* (is it up, does +it have a CDP port, restart it as the desktop user), not what to do with it. +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +import shutil +import subprocess +import time + +from steam_backlog_enforcer._cdp import ( + _CDP_PORT, + _cdp_result_value, + _evaluate_js, + _get_shared_js_ws_url, +) +from steam_backlog_enforcer._desktop_env import ( + desktop_runtime_dir, + desktop_session_ready, + desktop_uid, +) +from steam_backlog_enforcer._steam_errors import ( + DesktopSessionNotReadyError, + SteamUnavailableError, + SteamUpdateInProgressError, +) +from steam_backlog_enforcer._steam_process import _run_as_user +from steam_backlog_enforcer._steam_state import steam_update_in_progress + +logger = logging.getLogger(__name__) + +_CDP_TIMEOUT = 120 +_STEAM_STARTUP_WAIT = 45 +# Real Steam client binary, as shipped by the distro's `steam` package. +# +# Deliberately NOT probed with shutil.which("steam"): a launcher wrapper on +# $PATH (e.g. /usr/local/bin/steam adding -cef-* flags) keeps `which` truthy +# long after the package itself is gone - as happens when a total block +# uninstalls Steam. Checking the real binary is what actually answers +# "can we launch Steam at all?". +_STEAM_BINARY = "/usr/bin/steam" + + +def steam_is_installed() -> bool: + """Return True if the real Steam client binary is present.""" + return Path(_STEAM_BINARY).exists() + + +# Handles for fire-and-forget launches, kept only so they can be reaped. +_SPAWNED: list[subprocess.Popen[bytes]] = [] + + +# ────────────────────────────────────────────────────────────── +# Ensure Steam is running with devtools port +# ────────────────────────────────────────────────────────────── + + +def _is_steam_running() -> bool: + """Check whether any Steam process is alive.""" + pgrep = shutil.which("pgrep") or "/usr/bin/pgrep" + result = subprocess.run( + [pgrep, "-x", "steam"], + capture_output=True, + check=False, + ) + return result.returncode == 0 + + +def _steam_has_debug_port() -> bool: + """Check whether steamwebhelper is listening on the CDP port.""" + return _get_shared_js_ws_url() is not None + + +def _wait_for_cdp_ready() -> bool: + """Wait up to *_STEAM_STARTUP_WAIT* seconds for CDP to become ready.""" + for _ in range(_STEAM_STARTUP_WAIT): + if _get_shared_js_ws_url() is not None: + return True + time.sleep(1) + return False + + +def _wait_for_collections_ready() -> bool: + """Wait until ``collectionStore`` is fully initialised. + + Right after Steam starts, the CDP port may be open but the + internal collection data hasn't loaded yet. Poll a lightweight + JS check until ``GetCollection`` stops throwing. + """ + js = ( + "(() => { try { collectionStore.GetCollection('hidden');" + " return 'ok'; } catch(e) { return 'not_ready'; } })()" + ) + for _ in range(_STEAM_STARTUP_WAIT): + try: + result = _evaluate_js(js) + if _cdp_result_value(result) == "ok": + return True + except RuntimeError: + pass + time.sleep(1) + return False + + +def _resolve_desktop_user() -> str | None: + """Resolve which desktop user owns the Steam/X11 session. + + Prefers the explicit STEAM_ENFORCER_DESKTOP_USER (set by the systemd + unit, which has no SUDO_USER/USER of its own since it is started + directly by systemd rather than via `sudo`), then falls back to + SUDO_USER/USER for interactive `sudo` invocations. + """ + return ( + os.environ.get("STEAM_ENFORCER_DESKTOP_USER") + or os.environ.get("SUDO_USER") + or os.environ.get("USER") + ) + + +def _shutdown_steam() -> None: + """Send ``steam -shutdown`` and wait for the process to exit.""" + real_user = _resolve_desktop_user() + try: + _run_as_user(["steam", "-shutdown"], real_user) + except FileNotFoundError: + return + + pgrep = shutil.which("pgrep") or "/usr/bin/pgrep" + for _ in range(30): + result = subprocess.run( + [pgrep, "-x", "steam"], + capture_output=True, + check=False, + ) + if result.returncode != 0: + return + time.sleep(1) + + +def _launch_steam_with_debug() -> None: + """Launch Steam with CEF debugging enabled.""" + real_user = _resolve_desktop_user() + _run_as_user( + [ + "steam", + "-cef-enable-debugging", + f"-devtools-port={_CDP_PORT}", + "-silent", + ], + real_user, + ) + + +def ensure_steam_debug_port() -> None: + """Make sure Steam is running with the CDP debug port open. + + If Steam is running without the port, it is restarted. + If Steam is not running, it is launched. + + Raises: + SteamUnavailableError: If Steam is not installed, or is installed but + never opens its debug port. + """ + if _steam_has_debug_port(): + logger.debug("Steam CDP port already available.") + return + + # Bail out before the ~45s launch-and-wait: with no binary to exec there + # is nothing to wait for, and retrying every pass only burns time and + # leaves dead processes behind. + if not steam_is_installed(): + msg = f"Steam is not installed ({_STEAM_BINARY} does not exist)" + raise SteamUnavailableError(msg) + + # Never launch Steam into a session that does not exist yet: it would get + # a missing XDG_RUNTIME_DIR, silently fall back to winealsa, and stay + # audio-broken until the next restart. Defer instead — the loop retries. + real_user = _resolve_desktop_user() + if os.geteuid() == 0 and not desktop_session_ready(desktop_uid(real_user)): + msg = ( + "Deferring Steam launch: the desktop session's runtime directory " + f"({desktop_runtime_dir(desktop_uid(real_user))}) does not exist " + "yet. Launching now would leave Steam without audio; will retry." + ) + logger.info(msg) + raise DesktopSessionNotReadyError(msg) + + logger.info("Steam CDP port not available — (re)starting Steam...") + if _is_steam_running(): + # Never bounce a running Steam while a game update is downloading or + # committing: the shutdown suspends it and can leave a partially + # written install (the root cause of the AoE2 launch crash). Defer and + # retry on the next enforce pass. + if steam_update_in_progress(): + msg = ( + "Deferring Steam restart: a game update is in progress. " + "Restarting now would interrupt and can corrupt it; will " + "retry once the update settles." + ) + logger.info(msg) + raise SteamUpdateInProgressError(msg) + _shutdown_steam() + + _launch_steam_with_debug() + + if not _wait_for_cdp_ready(): + msg = "Timed out waiting for Steam CDP port to become ready" + raise SteamUnavailableError(msg) + logger.info("Steam CDP port ready.") + + if not _wait_for_collections_ready(): + msg = "Timed out waiting for Steam collections to initialise" + raise SteamUnavailableError(msg) + logger.info("Steam collection store ready.") + + +# ────────────────────────────────────────────────────────────── +# Steam restart helper +# ────────────────────────────────────────────────────────────── + + +def restart_steam() -> None: + """Gracefully restart the Steam client with CEF debugging enabled. + + Skips the restart if a game update is downloading or committing, so the + update is not interrupted (interrupting it can corrupt the install). + """ + if steam_update_in_progress(): + logger.warning( + "Skipping Steam restart — a game update is in progress; " + "restarting now could corrupt it.", + ) + return + + logger.info("Restarting Steam client with debug port...") + _shutdown_steam() + _launch_steam_with_debug() + + if not _wait_for_cdp_ready(): + logger.warning("Steam restarted but CDP port not ready.") + else: + logger.info("Steam restarted with CDP port ready.") diff --git a/steam_backlog_enforcer/_steam_process.py b/steam_backlog_enforcer/_steam_process.py new file mode 100644 index 0000000..6a30a9d --- /dev/null +++ b/steam_backlog_enforcer/_steam_process.py @@ -0,0 +1,58 @@ +"""Spawning processes as the desktop user, and reaping them. + +Split out of :mod:`steam_backlog_enforcer._steam_launch` to keep both under +the 250-line cap. Owns the fire-and-forget process registry so it has a single +owner rather than a global shared across responsibilities. +""" + +from __future__ import annotations + +import logging +import os +import subprocess + +from steam_backlog_enforcer._desktop_env import desktop_user_cmd + +logger = logging.getLogger(__name__) + +# Handles for fire-and-forget launches, kept only so they can be reaped. +_SPAWNED: list[subprocess.Popen[bytes]] = [] + + +def _reap_spawned() -> None: + """Clear out previously launched processes that have since exited. + + Launches here are fire-and-forget: Steam is meant to outlive the call, so + it is never waited on. That leaves any launch which dies immediately - a + missing binary, a broken wrapper - sitting as a zombie that still carries + the name ``steam``, which anything scanning /proc reads as "Steam is + running". Polling the old handles reaps them and retires the name. + """ + _SPAWNED[:] = [proc for proc in _SPAWNED if proc.poll() is None] + + +def _run_as_user(cmd: list[str], user: str | None) -> None: + """Run a command, dropping to *user* if currently root. + + Refuses to run at all when root and no desktop user resolves: the command + would otherwise execute *as root* on the user's session, which is how the + enforcer put a root Steam ("Cannot run as root user") on screen. + Failing closed costs one skipped pass; failing open costs a modal. + """ + _reap_spawned() + if os.geteuid() == 0 and (not user or user == "root"): + logger.error( + "Refusing to run %s as root: no desktop user resolved " + "(is STEAM_ENFORCER_DESKTOP_USER set?)", + cmd[0] if cmd else "", + ) + return + full_cmd = desktop_user_cmd(cmd, user) + + _SPAWNED.append( + subprocess.Popen( + full_cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + ) diff --git a/steam_backlog_enforcer/_steam_state.py b/steam_backlog_enforcer/_steam_state.py index be58155..3964ff2 100644 --- a/steam_backlog_enforcer/_steam_state.py +++ b/steam_backlog_enforcer/_steam_state.py @@ -8,11 +8,50 @@ Steam library location. from __future__ import annotations +import os from pathlib import Path import re STEAMAPPS_PATH = Path("~/.local/share/Steam/steamapps").expanduser() +# The real library path, captured before any test fixture redirects +# STEAMAPPS_PATH, so destructive helpers can refuse to touch it. +_REAL_STEAMAPPS = Path("~/.local/share/Steam/steamapps").expanduser() + + +def _assert_not_real_steam(path: Path) -> None: + """Raise if *path* is inside the real Steam directory during tests. + + Defence-in-depth guard: when running under pytest, even if test + fixtures fail to redirect ``STEAMAPPS_PATH``, destructive + operations (uninstall, rmtree, unlink) will refuse to touch + real files. In production runs this is a no-op. + """ + if "PYTEST_CURRENT_TEST" not in os.environ: + return # production run — real Steam paths are expected + try: + path.resolve().relative_to(_REAL_STEAMAPPS.resolve()) + except ValueError: + return # path is NOT under real Steam — safe to proceed + if STEAMAPPS_PATH.resolve() == _REAL_STEAMAPPS.resolve(): + msg = ( + f"SAFETY: refusing destructive operation on real Steam path " + f"{path!s} — STEAMAPPS_PATH was not redirected by test fixtures" + ) + raise RuntimeError(msg) + + +def steam_library_ready() -> bool: + """Return True if Steam has an initialised library directory. + + When ``steamapps`` does not exist Steam has never completed first-run + setup, so an install cannot succeed by any route: the ``steam://`` handler + has nowhere to install to, and writing an appmanifest raises + FileNotFoundError. Callers use this to skip provably-futile work rather + than retrying it on every enforce pass. + """ + return STEAMAPPS_PATH.is_dir() + def _manifest_transfer_active(manifest: Path) -> bool: """Return True if *manifest* shows unfinished download or staging bytes. diff --git a/steam_backlog_enforcer/enforcer.py b/steam_backlog_enforcer/enforcer.py index f584242..05d4ca6 100644 --- a/steam_backlog_enforcer/enforcer.py +++ b/steam_backlog_enforcer/enforcer.py @@ -9,9 +9,11 @@ import shutil import signal import subprocess -from steam_backlog_enforcer.game_install import ( - is_protected_app, +from steam_backlog_enforcer._desktop_env import ( + desktop_user_cmd, + resolve_desktop_user, ) +from steam_backlog_enforcer.game_uninstall import is_protected_app logger = logging.getLogger(__name__) @@ -149,14 +151,28 @@ def kill_process(pid: int, app_id: int) -> None: def send_notification(title: str, body: str) -> None: - """Send a desktop notification.""" + """Send a desktop notification into the real user's session. + + The enforcer runs as root under systemd, where a bare ``notify-send`` has + no DBUS_SESSION_BUS_ADDRESS and so cannot reach the user's session bus — + it failed silently for every caller. Dropping to the desktop user supplies + the session variables it needs. + """ _notify_send = shutil.which("notify-send") or "/usr/bin/notify-send" + cmd = desktop_user_cmd( + [_notify_send, title, body, "--icon=dialog-warning"], + resolve_desktop_user(), + ) try: subprocess.run( - [_notify_send, title, body, "--icon=dialog-warning"], + cmd, capture_output=True, timeout=5, check=False, ) except (FileNotFoundError, OSError): logger.debug("notify-send not available.") + except subprocess.TimeoutExpired: + # sudo can block where a bare notify-send would not; a dropped + # notification is better than an exception escaping the enforce loop. + logger.debug("notify-send timed out.") diff --git a/steam_backlog_enforcer/game_install.py b/steam_backlog_enforcer/game_install.py index 9a81c0d..4e650d4 100644 --- a/steam_backlog_enforcer/game_install.py +++ b/steam_backlog_enforcer/game_install.py @@ -2,192 +2,68 @@ from __future__ import annotations -import contextlib -import difflib import logging import os -from pathlib import Path -import pwd -import re import shutil import subprocess -import sys -import time -from steam_backlog_enforcer._allowed_games import allowed_games from steam_backlog_enforcer._desktop_env import ( - desktop_env_args, - desktop_runtime_dir, desktop_session_ready, + desktop_uid, + desktop_user_cmd, +) +from steam_backlog_enforcer._echo import _echo +from steam_backlog_enforcer._steam_client import ( + _ensure_steam_running, + _get_real_user, + _get_uid_gid_for_user, + is_game_installed, +) +from steam_backlog_enforcer._steam_state import ( + STEAMAPPS_PATH, + steam_library_ready, +) +from steam_backlog_enforcer.game_uninstall import ( + get_installed_games, + is_protected_app, + uninstall_game, + uninstall_other_games, ) -from steam_backlog_enforcer._steam_state import STEAMAPPS_PATH -from steam_backlog_enforcer._whitelist import get_approved_exception_ids -from steam_backlog_enforcer.config import State -from steam_backlog_enforcer.library_hider import steam_is_installed logger = logging.getLogger(__name__) +# Re-exported for callers that predate the split of this module: many modules +# still do `from ...game_install import _echo`. __all__ is what keeps the +# linter from deleting these as unused -- they exist purely to be re-exported. +__all__ = [ + "_echo", + "get_installed_games", + "install_game", + "is_game_installed", + "is_protected_app", + "uninstall_game", + "uninstall_other_games", +] + +# Latches the "no Steam library" warning so a 3s enforce loop logs it once +# rather than once per game per pass. +_LIBRARY_WARNED: set[str] = set() + +_UNINSTALL_EXPORTS = frozenset( + { + "get_installed_games", + "is_protected_app", + "uninstall_game", + "uninstall_other_games", + } +) + # Folder-name safety net for _remove_game_dirs. Independent of the app-id # gating callers already do (uninstall_other_games skips allowed app ids) -- # this protects against deleting the *wrong* directory for an allowed game # when its name has been written inconsistently (e.g. "KingdomComeDeliverance2" # vs "Kingdom Come: Deliverance II" vs a typo'd variant), which is exactly the # kind of multi-name confusion that caused real data loss once already. -_NAME_FUZZY_MATCH_THRESHOLD = 0.82 - -_NUMBER_WORDS = { - "one": "1", - "two": "2", - "three": "3", - "four": "4", - "five": "5", - "six": "6", - "seven": "7", - "eight": "8", - "nine": "9", - "ten": "10", -} - -_ROMAN_NUMERALS = { - "i": "1", - "ii": "2", - "iii": "3", - "iv": "4", - "v": "5", - "vi": "6", - "vii": "7", - "viii": "8", - "ix": "9", - "x": "10", -} - - -def _normalize_game_name(name: str) -> str: - """Canonicalize a game/folder name for fuzzy comparison. - - Splits into alphanumeric words, maps spelled-out numbers ("two") and - roman numerals ("II") to digits, then joins with no separators. Makes - "Kingdom Come: Deliverance II", "KingdomComeDeliverance2", and "kingdom - come deliverance two" all normalize to the same string. - """ - words = re.findall(r"[A-Za-z0-9]+", name.lower()) - mapped = [_NUMBER_WORDS.get(w) or _ROMAN_NUMERALS.get(w) or w for w in words] - return "".join(mapped) - - -def _protected_name_stems() -> list[str]: - """Every name of every currently-allowed game, for the deletion safety net. - - Returns [] (protects nothing extra beyond exact matches already checked - by callers) if state can't be loaded -- this is a best-effort safety net, - not a hard dependency. - """ - try: - return [name for _, name in allowed_games(State.load()) if name] - except Exception: - logger.exception("Could not load allowed games for the deletion safety net") - return [] - - -def _is_protected_name(candidate: str) -> bool: - """True if *candidate* plausibly names one of the currently-allowed games. - - Errs toward over-matching on purpose: a false positive here just skips a - deletion that can be done manually; a false negative deletes real files. - """ - normalized_candidate = _normalize_game_name(candidate) - if not normalized_candidate: - return False - for protected in _protected_name_stems(): - normalized_protected = _normalize_game_name(protected) - if not normalized_protected: - continue - if normalized_candidate == normalized_protected: - return True - ratio = difflib.SequenceMatcher( - None, normalized_candidate, normalized_protected - ).ratio() - if ratio >= _NAME_FUZZY_MATCH_THRESHOLD: - return True - return False - - -# Real Steam directory — used as a safety check to block destructive -# operations that leak through during testing. -_REAL_STEAMAPPS = Path("~/.local/share/Steam/steamapps").expanduser() - - -def _assert_not_real_steam(path: Path) -> None: - """Raise if *path* is inside the real Steam directory during tests. - - Defence-in-depth guard: when running under pytest, even if test - fixtures fail to redirect ``STEAMAPPS_PATH``, destructive - operations (uninstall, rmtree, unlink) will refuse to touch - real files. In production runs this is a no-op. - """ - if "PYTEST_CURRENT_TEST" not in os.environ: - return # production run — real Steam paths are expected - try: - path.resolve().relative_to(_REAL_STEAMAPPS.resolve()) - except ValueError: - return # path is NOT under real Steam — safe to proceed - if STEAMAPPS_PATH.resolve() == _REAL_STEAMAPPS.resolve(): - msg = ( - f"SAFETY: refusing destructive operation on real Steam path " - f"{path!s} — STEAMAPPS_PATH was not redirected by test fixtures" - ) - raise RuntimeError(msg) - - -def _echo(msg: str = "", *, end: str = "\n", flush: bool = False) -> None: - """Write user-facing CLI output to stdout. - - Args: - msg: Text to output. - end: String appended after the message. - flush: Whether to flush stdout immediately. - """ - sys.stdout.write(msg + end) - if flush: - sys.stdout.flush() - - -# Steam infrastructure app IDs that should NEVER be uninstalled. -PROTECTED_APP_IDS = { - # Steam runtimes and tooling (never uninstall these) - 228980, # Steamworks Common Redistributables - 1070560, # Steam Linux Runtime 1.0 (scout) - 1391110, # Steam Linux Runtime 2.0 (soldier) - 1628350, # Steam Linux Runtime 3.0 (sniper) - 4183110, # Steam Linux Runtime 4.0 - 4185400, # Steam Linux Runtime 4.0 - Arm64 - 961940, # Steam Linux Runtime (legacy) - 4690330, # Legacy Steam Runtime - 613220, # Steam 360 Video Player - 250820, # SteamVR - 1007, # Steamworks SDK Redist - # Proton versions (never uninstall these) - 858280, # Proton 3.7 (Beta) - 930400, # Proton 3.16 (Beta) - 1054830, # Proton 4.2 - 1113280, # Proton 4.11 - 1245040, # Proton 5.0 - 1420170, # Proton 5.13 - 1580130, # Proton 6.3 - 1887720, # Proton 7.0 - 2230260, # Proton 7.0 (alt) - 2348590, # Proton 8.0 - 2805730, # Proton 9.0 - 3201940, # Proton 9.0 (alt) - 3658110, # Proton 10.0 - 4628710, # Proton 11.0 - 4628740, # Proton 11.0 (ARM64) - 2180100, # Proton Hotfix - 1493710, # Proton Experimental - 1161040, # Proton BattlEye Runtime - 1007020, # Proton EasyAntiCheat Runtime - 1826330, # Proton EasyAntiCheat Runtime -} def _trigger_steam_install(app_id: int, label: str) -> bool: @@ -196,9 +72,17 @@ def _trigger_steam_install(app_id: int, label: str) -> bool: Returns True if the URI handler was invoked successfully. """ xdg_open = shutil.which("xdg-open") or "/usr/bin/xdg-open" + real_user = _get_real_user() + + # Without the drop this opens steam://install as root, and Steam answers + # with a "Cannot run as root user" modal on the user's display. + if os.geteuid() == 0 and not desktop_session_ready(desktop_uid(real_user)): + logger.debug("Deferring Steam install for %s: no desktop session.", label) + return False + try: subprocess.run( - [xdg_open, f"steam://install/{app_id}"], + desktop_user_cmd([xdg_open, f"steam://install/{app_id}"], real_user), capture_output=True, timeout=15, check=False, @@ -215,92 +99,6 @@ def _trigger_steam_install(app_id: int, label: str) -> bool: # ────────────────────────────────────────────────────────────── -def _get_real_user() -> str | None: - """Get the real (non-root) user when running under sudo.""" - return os.environ.get("SUDO_USER") or os.environ.get("USER") - - -def _get_uid_gid_for_user(username: str) -> tuple[int, int]: - """Get (uid, gid) for a username.""" - try: - pw = pwd.getpwnam(username) - except KeyError: - return 1000, 1000 - else: - return pw.pw_uid, pw.pw_gid - - -def is_game_installed(app_id: int) -> bool: - """Check if a game is installed by looking for its appmanifest. - - A manifest with StateFlags != 4 (FullyInstalled) means the game is - still downloading or queued, which still counts as "install triggered". - """ - manifest = STEAMAPPS_PATH / f"appmanifest_{app_id}.acf" - return manifest.exists() - - -def _ensure_steam_running() -> None: - """Start the Steam client if it is not already running. - - Does nothing if Steam is not installed - there is no client to start, and - trying anyway only sleeps 15s waiting on a process that died on exec. - """ - if not steam_is_installed(): - logger.info("Steam is not installed — skipping client start.") - return - - # Check if any steam process is running (main client, not just helpers). - try: - result = subprocess.run( - ["/usr/bin/pgrep", "-f", "steam.sh"], - capture_output=True, - text=True, - check=False, - ) - if result.returncode == 0: - logger.debug("Steam client already running") - return - except FileNotFoundError: - pass - - real_user = _get_real_user() - logger.info("Starting Steam client...") - - try: - if os.geteuid() == 0 and real_user and real_user != "root": - uid, _ = _get_uid_gid_for_user(real_user) - # Defer rather than launch a Steam that would come up without a - # runtime dir, and so without working audio, for the session. - if not desktop_session_ready(uid): - logger.info( - "Deferring Steam start: %s does not exist yet.", - desktop_runtime_dir(uid), - ) - return - cmd = [ - "sudo", - "-u", - real_user, - "env", - *desktop_env_args(real_user, uid), - "steam", - "-silent", - ] - else: - cmd = ["steam", "-silent"] - - subprocess.Popen( - cmd, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - # Give Steam time to initialize and start scanning manifests. - time.sleep(15) - except FileNotFoundError: - logger.exception("Steam executable not found") - - def install_game( app_id: int, game_name: str, @@ -336,6 +134,23 @@ def install_game( logger.info("Game already installed: %s", label) return True + # No library means no install can succeed — neither the steam:// handler + # nor the appmanifest write below. Without this the enforce loop retried + # every allowed game every pass (measured: 1656 times in 30 minutes). + if not steam_library_ready(): + if "missing" not in _LIBRARY_WARNED: + logger.warning( + "Steam library not initialised (%s missing) — skipping installs " + "until Steam has been signed in to at least once.", + STEAMAPPS_PATH, + ) + _LIBRARY_WARNED.add("missing") + else: + logger.debug("Skipping install of %s: no Steam library.", label) + return False + + _LIBRARY_WARNED.discard("missing") + if use_steam_protocol: _ensure_steam_running() if _trigger_steam_install(app_id, label): @@ -401,153 +216,3 @@ def install_game( _ensure_steam_running() return True - - -# ────────────────────────────────────────────────────────────── -# Game uninstall management -# ────────────────────────────────────────────────────────────── - - -def get_installed_games() -> list[tuple[int, str]]: - """Parse appmanifest files to find installed games. - - Returns: list of (app_id, game_name) tuples. - """ - installed: list[tuple[int, str]] = [] - - for manifest_file in STEAMAPPS_PATH.glob("appmanifest_*.acf"): - with contextlib.suppress(OSError): - content = manifest_file.read_text(encoding="utf-8") - app_id_match = re.search(r'"appid"\s+"(\d+)"', content) - name_match = re.search(r'"name"\s+"([^"]+)"', content) - if app_id_match: - app_id = int(app_id_match.group(1)) - name = name_match.group(1) if name_match else f"Unknown ({app_id})" - installed.append((app_id, name)) - - installed.sort(key=lambda x: x[1].lower()) - return installed - - -def _read_install_dir(manifest: Path) -> Path | None: - """Read installdir from a game's appmanifest file.""" - if not manifest.exists(): - return None - try: - content = manifest.read_text(encoding="utf-8") - match = re.search(r'"installdir"\s+"([^"]+)"', content) - if match: - return STEAMAPPS_PATH / "common" / match.group(1) - except OSError: - pass - return None - - -def _remove_manifest(manifest: Path, game_name: str, app_id: int) -> bool: - """Remove a game manifest file. - - Args: - manifest: Path to the appmanifest file. - game_name: Human-readable game name for logging. - app_id: Steam application ID. - """ - _assert_not_real_steam(manifest) - try: - if manifest.exists(): - manifest.unlink() - logger.info( - "Removed manifest for %s (AppID=%d)", game_name or app_id, app_id - ) - except OSError: - logger.exception("Failed to remove manifest for AppID=%d", app_id) - return False - return True - - -def _remove_game_dirs(install_dir: Path | None, app_id: int) -> bool: - """Remove game installation directory and cache directories. - - Args: - install_dir: Path to the game's install directory, or None. - app_id: Steam application ID. - """ - success = True - if install_dir and install_dir.is_dir(): - _assert_not_real_steam(install_dir) - if _is_protected_name(install_dir.name): - logger.warning( - "Refusing to remove %s: name matches an allowed game", install_dir - ) - return False - try: - shutil.rmtree(install_dir) - logger.info("Removed game files: %s", install_dir) - except OSError: - logger.exception("Failed to remove game dir %s", install_dir) - success = False - - for subdir in ("shadercache", "compatdata"): - cache_path = STEAMAPPS_PATH / subdir / str(app_id) - if cache_path.is_dir(): - _assert_not_real_steam(cache_path) - with contextlib.suppress(OSError): - shutil.rmtree(cache_path) - logger.debug("Removed %s/%d", subdir, app_id) - - return success - - -def uninstall_game(app_id: int, game_name: str = "") -> bool: - """Uninstall a single game by removing its manifest and game files. - - Uses direct file removal instead of ``steam://uninstall`` URI to avoid - GUI popups and to work when Steam is not running. - """ - manifest = STEAMAPPS_PATH / f"appmanifest_{app_id}.acf" - install_dir = _read_install_dir(manifest) - success = _remove_manifest(manifest, game_name, app_id) - if not _remove_game_dirs(install_dir, app_id): - success = False - return success - - -def uninstall_other_games(allowed_app_ids: set[int]) -> int: - """Uninstall all installed games except the allowed ones and protected IDs. - - Args: - allowed_app_ids: Every app id that must survive — the assignment plus - any concurrent manual picks. Empty means "keep nothing". - - Returns: number of games uninstalled. - """ - installed = get_installed_games() - count = 0 - - for app_id, name in installed: - if app_id in allowed_app_ids: - logger.info("KEEPING allowed game: %s (AppID=%d)", name, app_id) - continue - if is_protected_app(app_id): - logger.debug("Skipping protected: %s (AppID=%d)", name, app_id) - continue - - logger.info("UNINSTALLING: %s (AppID=%d)", name, app_id) - if uninstall_game(app_id, name): - count += 1 - - return count - - -def is_protected_app(app_id: int) -> bool: - """Return True if *app_id* must never be uninstalled. - - Combines the hardcoded Steam infrastructure set with any app IDs that - have been approved via the time-locked exception mechanism. - - Args: - app_id: Steam application ID to check. - - Returns: - True if the app should be left alone by the enforcer. - """ - return app_id in PROTECTED_APP_IDS or app_id in get_approved_exception_ids() diff --git a/steam_backlog_enforcer/game_uninstall.py b/steam_backlog_enforcer/game_uninstall.py new file mode 100644 index 0000000..618c342 --- /dev/null +++ b/steam_backlog_enforcer/game_uninstall.py @@ -0,0 +1,174 @@ +"""Uninstalling games and querying what is installed. + +Split out of :mod:`steam_backlog_enforcer.game_install` to keep both files +under the 250-line cap: that module owns getting a game *onto* disk, this one +owns taking it off and reporting what is there. +""" + +from __future__ import annotations + +import contextlib +import logging +import re +import shutil +from typing import TYPE_CHECKING + +from steam_backlog_enforcer._game_names import _is_protected_name +from steam_backlog_enforcer._protected_apps import PROTECTED_APP_IDS +from steam_backlog_enforcer._steam_state import STEAMAPPS_PATH, _assert_not_real_steam +from steam_backlog_enforcer._whitelist import get_approved_exception_ids + +if TYPE_CHECKING: + from pathlib import Path + +logger = logging.getLogger(__name__) + + +# ────────────────────────────────────────────────────────────── +# Game uninstall management +# ────────────────────────────────────────────────────────────── + + +def get_installed_games() -> list[tuple[int, str]]: + """Parse appmanifest files to find installed games. + + Returns: list of (app_id, game_name) tuples. + """ + installed: list[tuple[int, str]] = [] + + for manifest_file in STEAMAPPS_PATH.glob("appmanifest_*.acf"): + with contextlib.suppress(OSError): + content = manifest_file.read_text(encoding="utf-8") + app_id_match = re.search(r'"appid"\s+"(\d+)"', content) + name_match = re.search(r'"name"\s+"([^"]+)"', content) + if app_id_match: + app_id = int(app_id_match.group(1)) + name = name_match.group(1) if name_match else f"Unknown ({app_id})" + installed.append((app_id, name)) + + installed.sort(key=lambda x: x[1].lower()) + return installed + + +def _read_install_dir(manifest: Path) -> Path | None: + """Read installdir from a game's appmanifest file.""" + if not manifest.exists(): + return None + try: + content = manifest.read_text(encoding="utf-8") + match = re.search(r'"installdir"\s+"([^"]+)"', content) + if match: + return STEAMAPPS_PATH / "common" / match.group(1) + except OSError: + pass + return None + + +def _remove_manifest(manifest: Path, game_name: str, app_id: int) -> bool: + """Remove a game manifest file. + + Args: + manifest: Path to the appmanifest file. + game_name: Human-readable game name for logging. + app_id: Steam application ID. + """ + _assert_not_real_steam(manifest) + try: + if manifest.exists(): + manifest.unlink() + logger.info( + "Removed manifest for %s (AppID=%d)", game_name or app_id, app_id + ) + except OSError: + logger.exception("Failed to remove manifest for AppID=%d", app_id) + return False + return True + + +def _remove_game_dirs(install_dir: Path | None, app_id: int) -> bool: + """Remove game installation directory and cache directories. + + Args: + install_dir: Path to the game's install directory, or None. + app_id: Steam application ID. + """ + success = True + if install_dir and install_dir.is_dir(): + _assert_not_real_steam(install_dir) + if _is_protected_name(install_dir.name): + logger.warning( + "Refusing to remove %s: name matches an allowed game", install_dir + ) + return False + try: + shutil.rmtree(install_dir) + logger.info("Removed game files: %s", install_dir) + except OSError: + logger.exception("Failed to remove game dir %s", install_dir) + success = False + + for subdir in ("shadercache", "compatdata"): + cache_path = STEAMAPPS_PATH / subdir / str(app_id) + if cache_path.is_dir(): + _assert_not_real_steam(cache_path) + with contextlib.suppress(OSError): + shutil.rmtree(cache_path) + logger.debug("Removed %s/%d", subdir, app_id) + + return success + + +def uninstall_game(app_id: int, game_name: str = "") -> bool: + """Uninstall a single game by removing its manifest and game files. + + Uses direct file removal instead of ``steam://uninstall`` URI to avoid + GUI popups and to work when Steam is not running. + """ + manifest = STEAMAPPS_PATH / f"appmanifest_{app_id}.acf" + install_dir = _read_install_dir(manifest) + success = _remove_manifest(manifest, game_name, app_id) + if not _remove_game_dirs(install_dir, app_id): + success = False + return success + + +def uninstall_other_games(allowed_app_ids: set[int]) -> int: + """Uninstall all installed games except the allowed ones and protected IDs. + + Args: + allowed_app_ids: Every app id that must survive — the assignment plus + any concurrent manual picks. Empty means "keep nothing". + + Returns: number of games uninstalled. + """ + installed = get_installed_games() + count = 0 + + for app_id, name in installed: + if app_id in allowed_app_ids: + logger.info("KEEPING allowed game: %s (AppID=%d)", name, app_id) + continue + if is_protected_app(app_id): + logger.debug("Skipping protected: %s (AppID=%d)", name, app_id) + continue + + logger.info("UNINSTALLING: %s (AppID=%d)", name, app_id) + if uninstall_game(app_id, name): + count += 1 + + return count + + +def is_protected_app(app_id: int) -> bool: + """Return True if *app_id* must never be uninstalled. + + Combines the hardcoded Steam infrastructure set with any app IDs that + have been approved via the time-locked exception mechanism. + + Args: + app_id: Steam application ID to check. + + Returns: + True if the app should be left alone by the enforcer. + """ + return app_id in PROTECTED_APP_IDS or app_id in get_approved_exception_ids() diff --git a/steam_backlog_enforcer/library_hider.py b/steam_backlog_enforcer/library_hider.py index 4872ec6..19ac0b5 100644 --- a/steam_backlog_enforcer/library_hider.py +++ b/steam_backlog_enforcer/library_hider.py @@ -16,328 +16,35 @@ will shut Steam down and relaunch it with the required flags. from __future__ import annotations -import asyncio import json import logging -import os -from pathlib import Path -import pwd -import shutil -import subprocess -import time -import requests -import websockets - -from steam_backlog_enforcer._desktop_env import ( - desktop_env_args, - desktop_runtime_dir, - desktop_session_ready, +from steam_backlog_enforcer._cdp import _cdp_result_value, _evaluate_js +from steam_backlog_enforcer._steam_errors import ( + SteamUnavailableError, +) +from steam_backlog_enforcer._steam_launch import ( + ensure_steam_debug_port, + restart_steam, + steam_is_installed, ) -from steam_backlog_enforcer._steam_state import steam_update_in_progress logger = logging.getLogger(__name__) +# Re-exported: the Steam lifecycle now lives in _steam_launch, but callers +# still import these from here. __all__ stops the linter deleting them. +__all__ = [ + "hide_other_games", + "restart_steam", + "steam_is_installed", + "try_hide_other_games", + "unhide_all_games", +] + _CDP_PORT = 9222 -# NOTE: was 8080, which collided with a different local service (Open WebUI) -# already bound to 0.0.0.0:8080. requests to 127.0.0.1:8080 resolved to that -# service instead of steamwebhelper's CDP endpoint (which only bound -# [::1]:8080), so CDP detection silently never worked. 9222 is the -# conventional Chrome DevTools debug port and was confirmed free. _CDP_TIMEOUT = 120 _STEAM_STARTUP_WAIT = 45 -# Real Steam client binary, as shipped by the distro's `steam` package. -# -# Deliberately NOT probed with shutil.which("steam"): a launcher wrapper on -# $PATH (e.g. /usr/local/bin/steam adding -cef-* flags) keeps `which` truthy -# long after the package itself is gone - as happens when a total block -# uninstalls Steam. Checking the real binary is what actually answers -# "can we launch Steam at all?". -_STEAM_BINARY = "/usr/bin/steam" - -# Handles for fire-and-forget launches, kept only so they can be reaped. -_SPAWNED: list[subprocess.Popen[bytes]] = [] - - -class SteamUnavailableError(RuntimeError): - """Raised when Steam cannot be driven over CDP. - - Covers both "Steam is not installed" and "Steam is installed but never - opened its debug port". Callers are expected to degrade gracefully rather - than abort: an unreachable Steam means there is no library to hide, which - is not a fatal condition for the enforcer. - """ - - -class SteamUpdateInProgressError(SteamUnavailableError): - """Raised to defer a Steam restart while a game update is in flight. - - Subclasses :class:`SteamUnavailableError` so existing callers already - degrade gracefully (skip this pass, retry next loop). Restarting Steam - mid-update suspends and can corrupt the transfer, so the enforcer waits - for updates to finish before bouncing Steam to open the CDP port. - """ - - -class DesktopSessionNotReadyError(SteamUnavailableError): - """Raised to defer a Steam launch until the user's session exists. - - Subclasses :class:`SteamUnavailableError` so existing callers already - degrade gracefully (skip this pass, retry next loop). Launching Steam - before ``/run/user/`` exists hands every Wine child a runtime dir - that is not there, so it falls back from winepulse to winealsa and stays - that way for the whole session — the Kingdom Come: Deliverance II startup - crash. Waiting a pass costs 3s; getting it wrong costs a reboot. - """ - - -def _desktop_uid(user: str | None) -> int: - """Resolve *user* to a uid, defaulting to the usual first desktop uid.""" - if user: - try: - return pwd.getpwnam(user).pw_uid - except KeyError: - pass - return 1000 - - -def steam_is_installed() -> bool: - """Return True if the real Steam client binary is present.""" - return Path(_STEAM_BINARY).exists() - - -# ────────────────────────────────────────────────────────────── -# CDP (Chrome DevTools Protocol) helpers -# ────────────────────────────────────────────────────────────── - - -def _get_shared_js_ws_url() -> str | None: - """Query the CDP HTTP endpoint and return the SharedJSContext WS URL.""" - try: - resp = requests.get(f"http://127.0.0.1:{_CDP_PORT}/json", timeout=5) - targets = resp.json() - except (OSError, ValueError): - return None - - for target in targets: - if target.get("title") == "SharedJSContext": - ws_url: str = target["webSocketDebuggerUrl"] - return ws_url - return None - - -async def _evaluate_js_async(ws_url: str, expression: str) -> dict: - """Connect to a CDP WebSocket target and evaluate *expression*.""" - async with websockets.connect(ws_url) as ws: - msg = json.dumps( - { - "id": 1, - "method": "Runtime.evaluate", - "params": { - "expression": expression, - "returnByValue": True, - "awaitPromise": True, - }, - } - ) - await ws.send(msg) - resp = await asyncio.wait_for(ws.recv(), timeout=_CDP_TIMEOUT) - return json.loads(resp) - - -def _evaluate_js(expression: str) -> dict: - """Synchronous wrapper around :func:`_evaluate_js_async`.""" - ws_url = _get_shared_js_ws_url() - if ws_url is None: - msg = "SharedJSContext not found on CDP port" - raise RuntimeError(msg) - return asyncio.run(_evaluate_js_async(ws_url, expression)) - - -def _cdp_result_value(result: dict) -> str: - """Extract the return value from a CDP Runtime.evaluate response.""" - outer = result.get("result", {}) - inner = outer.get("result", {}) - if "exceptionDetails" in outer: - exc_details = outer["exceptionDetails"] - exc = exc_details.get("exception", {}) - desc = ( - inner.get("description") - or exc.get("description") - or exc_details.get("text") - or repr(exc_details) - ) - logger.debug("CDP exception details: %s", exc_details) - msg = f"JS evaluation error: {desc}" - raise RuntimeError(msg) - value: str = inner.get("value", "") - return value - - -# ────────────────────────────────────────────────────────────── -# Ensure Steam is running with devtools port -# ────────────────────────────────────────────────────────────── - - -def _is_steam_running() -> bool: - """Check whether any Steam process is alive.""" - pgrep = shutil.which("pgrep") or "/usr/bin/pgrep" - result = subprocess.run( - [pgrep, "-x", "steam"], - capture_output=True, - check=False, - ) - return result.returncode == 0 - - -def _steam_has_debug_port() -> bool: - """Check whether steamwebhelper is listening on the CDP port.""" - return _get_shared_js_ws_url() is not None - - -def _wait_for_cdp_ready() -> bool: - """Wait up to *_STEAM_STARTUP_WAIT* seconds for CDP to become ready.""" - for _ in range(_STEAM_STARTUP_WAIT): - if _get_shared_js_ws_url() is not None: - return True - time.sleep(1) - return False - - -def _wait_for_collections_ready() -> bool: - """Wait until ``collectionStore`` is fully initialised. - - Right after Steam starts, the CDP port may be open but the - internal collection data hasn't loaded yet. Poll a lightweight - JS check until ``GetCollection`` stops throwing. - """ - js = ( - "(() => { try { collectionStore.GetCollection('hidden');" - " return 'ok'; } catch(e) { return 'not_ready'; } })()" - ) - for _ in range(_STEAM_STARTUP_WAIT): - try: - result = _evaluate_js(js) - if _cdp_result_value(result) == "ok": - return True - except RuntimeError: - pass - time.sleep(1) - return False - - -def _resolve_desktop_user() -> str | None: - """Resolve which desktop user owns the Steam/X11 session. - - Prefers the explicit STEAM_ENFORCER_DESKTOP_USER (set by the systemd - unit, which has no SUDO_USER/USER of its own since it is started - directly by systemd rather than via `sudo`), then falls back to - SUDO_USER/USER for interactive `sudo` invocations. - """ - return ( - os.environ.get("STEAM_ENFORCER_DESKTOP_USER") - or os.environ.get("SUDO_USER") - or os.environ.get("USER") - ) - - -def _shutdown_steam() -> None: - """Send ``steam -shutdown`` and wait for the process to exit.""" - real_user = _resolve_desktop_user() - try: - _run_as_user(["steam", "-shutdown"], real_user) - except FileNotFoundError: - return - - pgrep = shutil.which("pgrep") or "/usr/bin/pgrep" - for _ in range(30): - result = subprocess.run( - [pgrep, "-x", "steam"], - capture_output=True, - check=False, - ) - if result.returncode != 0: - return - time.sleep(1) - - -def _launch_steam_with_debug() -> None: - """Launch Steam with CEF debugging enabled.""" - real_user = _resolve_desktop_user() - _run_as_user( - [ - "steam", - "-cef-enable-debugging", - f"-devtools-port={_CDP_PORT}", - "-silent", - ], - real_user, - ) - - -def ensure_steam_debug_port() -> None: - """Make sure Steam is running with the CDP debug port open. - - If Steam is running without the port, it is restarted. - If Steam is not running, it is launched. - - Raises: - SteamUnavailableError: If Steam is not installed, or is installed but - never opens its debug port. - """ - if _steam_has_debug_port(): - logger.debug("Steam CDP port already available.") - return - - # Bail out before the ~45s launch-and-wait: with no binary to exec there - # is nothing to wait for, and retrying every pass only burns time and - # leaves dead processes behind. - if not steam_is_installed(): - msg = f"Steam is not installed ({_STEAM_BINARY} does not exist)" - raise SteamUnavailableError(msg) - - # Never launch Steam into a session that does not exist yet: it would get - # a missing XDG_RUNTIME_DIR, silently fall back to winealsa, and stay - # audio-broken until the next restart. Defer instead — the loop retries. - real_user = _resolve_desktop_user() - if os.geteuid() == 0 and not desktop_session_ready(_desktop_uid(real_user)): - msg = ( - "Deferring Steam launch: the desktop session's runtime directory " - f"({desktop_runtime_dir(_desktop_uid(real_user))}) does not exist " - "yet. Launching now would leave Steam without audio; will retry." - ) - logger.info(msg) - raise DesktopSessionNotReadyError(msg) - - logger.info("Steam CDP port not available — (re)starting Steam...") - if _is_steam_running(): - # Never bounce a running Steam while a game update is downloading or - # committing: the shutdown suspends it and can leave a partially - # written install (the root cause of the AoE2 launch crash). Defer and - # retry on the next enforce pass. - if steam_update_in_progress(): - msg = ( - "Deferring Steam restart: a game update is in progress. " - "Restarting now would interrupt and can corrupt it; will " - "retry once the update settles." - ) - logger.info(msg) - raise SteamUpdateInProgressError(msg) - _shutdown_steam() - - _launch_steam_with_debug() - - if not _wait_for_cdp_ready(): - msg = "Timed out waiting for Steam CDP port to become ready" - raise SteamUnavailableError(msg) - logger.info("Steam CDP port ready.") - - if not _wait_for_collections_ready(): - msg = "Timed out waiting for Steam collections to initialise" - raise SteamUnavailableError(msg) - logger.info("Steam collection store ready.") - # ────────────────────────────────────────────────────────────── # Hide / unhide logic @@ -488,68 +195,3 @@ def unhide_all_games(owned_app_ids: list[int]) -> int: count: int = parsed["count"] logger.info("Unhidden %d games via CDP.", count) return count - - -# ────────────────────────────────────────────────────────────── -# Steam restart helper -# ────────────────────────────────────────────────────────────── - - -def restart_steam() -> None: - """Gracefully restart the Steam client with CEF debugging enabled. - - Skips the restart if a game update is downloading or committing, so the - update is not interrupted (interrupting it can corrupt the install). - """ - if steam_update_in_progress(): - logger.warning( - "Skipping Steam restart — a game update is in progress; " - "restarting now could corrupt it.", - ) - return - - logger.info("Restarting Steam client with debug port...") - _shutdown_steam() - _launch_steam_with_debug() - - if not _wait_for_cdp_ready(): - logger.warning("Steam restarted but CDP port not ready.") - else: - logger.info("Steam restarted with CDP port ready.") - - -def _reap_spawned() -> None: - """Clear out previously launched processes that have since exited. - - Launches here are fire-and-forget: Steam is meant to outlive the call, so - it is never waited on. That leaves any launch which dies immediately - a - missing binary, a broken wrapper - sitting as a zombie that still carries - the name ``steam``, which anything scanning /proc reads as "Steam is - running". Polling the old handles reaps them and retires the name. - """ - _SPAWNED[:] = [proc for proc in _SPAWNED if proc.poll() is None] - - -def _run_as_user(cmd: list[str], user: str | None) -> None: - """Run a command, dropping to *user* if currently root.""" - _reap_spawned() - if os.geteuid() == 0 and user and user != "root": - uid = _desktop_uid(user) - full_cmd = [ - "sudo", - "-u", - user, - "env", - *desktop_env_args(user, uid), - *cmd, - ] - else: - full_cmd = cmd - - _SPAWNED.append( - subprocess.Popen( - full_cmd, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - ) diff --git a/steam_backlog_enforcer/tests/_no_subprocess.py b/steam_backlog_enforcer/tests/_no_subprocess.py index b656386..4bd8453 100644 --- a/steam_backlog_enforcer/tests/_no_subprocess.py +++ b/steam_backlog_enforcer/tests/_no_subprocess.py @@ -59,12 +59,28 @@ def _block_real_subprocesses() -> Iterator[None]: "steam_backlog_enforcer._total_block_iptables.subprocess.run", noop_run, ), + # library_hider no longer spawns: its launch/process code lives in + # _steam_launch / _steam_process, and _steam_client starts the client. + # Every module that can spawn must be patched here, or a test could + # fire a real `sudo -u kuhy steam`. patch( - "steam_backlog_enforcer.library_hider.subprocess.run", + "steam_backlog_enforcer._steam_launch.subprocess.run", noop_run, ), patch( - "steam_backlog_enforcer.library_hider.subprocess.Popen", + "steam_backlog_enforcer._steam_launch.subprocess.Popen", + noop_popen, + ), + patch( + "steam_backlog_enforcer._steam_process.subprocess.Popen", + noop_popen, + ), + patch( + "steam_backlog_enforcer._steam_client.subprocess.run", + noop_run, + ), + patch( + "steam_backlog_enforcer._steam_client.subprocess.Popen", noop_popen, ), # The single most important patch in this file. _playtime_block._run diff --git a/steam_backlog_enforcer/tests/conftest.py b/steam_backlog_enforcer/tests/conftest.py index cee8f03..e31bc96 100644 --- a/steam_backlog_enforcer/tests/conftest.py +++ b/steam_backlog_enforcer/tests/conftest.py @@ -164,8 +164,8 @@ def _no_real_sleep() -> Iterator[None]: """ noop = MagicMock() with ( - patch("steam_backlog_enforcer.game_install.time.sleep", noop), - patch("steam_backlog_enforcer.library_hider.time.sleep", noop), + patch("steam_backlog_enforcer._steam_client.time.sleep", noop), + patch("steam_backlog_enforcer._steam_launch.time.sleep", noop), patch("steam_backlog_enforcer.steam_api.time.sleep", noop), patch("steam_backlog_enforcer._enforce_loop.time.sleep", noop), ): diff --git a/steam_backlog_enforcer/tests/test_desktop_env.py b/steam_backlog_enforcer/tests/test_desktop_env.py index 3b3515a..3478d7b 100644 --- a/steam_backlog_enforcer/tests/test_desktop_env.py +++ b/steam_backlog_enforcer/tests/test_desktop_env.py @@ -5,21 +5,16 @@ from __future__ import annotations import os from unittest.mock import MagicMock, patch -import pytest - from steam_backlog_enforcer._desktop_env import ( desktop_env_args, - desktop_runtime_dir, - desktop_session_ready, -) -from steam_backlog_enforcer.library_hider import ( - DesktopSessionNotReadyError, - _desktop_uid, - _run_as_user, - ensure_steam_debug_port, ) +from steam_backlog_enforcer._desktop_env import desktop_uid as _desktop_uid +from steam_backlog_enforcer._steam_process import _run_as_user PKG = "steam_backlog_enforcer.library_hider" +DESKTOP_ENV_PKG = "steam_backlog_enforcer._desktop_env" +STEAM_LAUNCH_PKG = "steam_backlog_enforcer._steam_launch" +STEAM_PROCESS_PKG = "steam_backlog_enforcer._steam_process" ENV_PKG = "steam_backlog_enforcer._desktop_env" @@ -120,9 +115,9 @@ class TestRunAsUserUsesSharedBuilder: mock_pw = MagicMock() mock_pw.pw_uid = 1000 with ( - patch(f"{PKG}.os.geteuid", return_value=0), - patch(f"{PKG}.pwd.getpwnam", return_value=mock_pw), - patch(f"{PKG}.subprocess.Popen") as mock_popen, + patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=0), + patch(f"{DESKTOP_ENV_PKG}.pwd.getpwnam", return_value=mock_pw), + patch(f"{STEAM_PROCESS_PKG}.subprocess.Popen") as mock_popen, ): _run_as_user(["steam"], "bob") cmd = mock_popen.call_args[0][0] @@ -133,9 +128,9 @@ class TestRunAsUserUsesSharedBuilder: def test_unknown_user_falls_back_to_uid_1000(self) -> None: """An unresolvable user still yields a usable runtime dir.""" with ( - patch(f"{PKG}.os.geteuid", return_value=0), - patch(f"{PKG}.pwd.getpwnam", side_effect=KeyError), - patch(f"{PKG}.subprocess.Popen") as mock_popen, + patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=0), + patch(f"{DESKTOP_ENV_PKG}.pwd.getpwnam", side_effect=KeyError), + patch(f"{STEAM_PROCESS_PKG}.subprocess.Popen") as mock_popen, ): _run_as_user(["steam"], "ghost") cmd = mock_popen.call_args[0][0] @@ -148,88 +143,12 @@ class TestDesktopUid: def test_resolves_known_user(self) -> None: mock_pw = MagicMock() mock_pw.pw_uid = 1234 - with patch(f"{PKG}.pwd.getpwnam", return_value=mock_pw): + with patch(f"{DESKTOP_ENV_PKG}.pwd.getpwnam", return_value=mock_pw): assert _desktop_uid("bob") == 1234 def test_unknown_user_falls_back(self) -> None: - with patch(f"{PKG}.pwd.getpwnam", side_effect=KeyError): + with patch(f"{DESKTOP_ENV_PKG}.pwd.getpwnam", side_effect=KeyError): assert _desktop_uid("ghost") == 1000 def test_no_user_falls_back(self) -> None: assert _desktop_uid(None) == 1000 - - -class TestDesktopSessionReady: - """Tests for desktop_session_ready.""" - - def test_ready_when_runtime_dir_exists(self) -> None: - with patch(f"{ENV_PKG}.Path.is_dir", return_value=True): - assert desktop_session_ready(1000) is True - - def test_not_ready_when_runtime_dir_missing(self) -> None: - with patch(f"{ENV_PKG}.Path.is_dir", return_value=False): - assert desktop_session_ready(1000) is False - - def test_runtime_dir_path(self) -> None: - assert desktop_runtime_dir(1000) == "/run/user/1000" - - -class TestLaunchDefersUntilSessionReady: - """The enforcer must not launch Steam into a session that does not exist. - - Regression guard for the boot race: the enforcer is a system service - ordered only after the network, and on a measured boot it started 54ms - before user-runtime-dir@1000. A Steam launched in that window comes up - with a runtime dir that is not there, silently falls back to winealsa, - and stays audio-broken until something restarts it. - """ - - def test_defers_when_runtime_dir_missing(self) -> None: - with ( - patch(f"{PKG}._steam_has_debug_port", return_value=False), - patch(f"{PKG}.steam_is_installed", return_value=True), - patch(f"{PKG}.os.geteuid", return_value=0), - patch(f"{PKG}._resolve_desktop_user", return_value="bob"), - patch(f"{PKG}._desktop_uid", return_value=1000), - patch(f"{PKG}.desktop_session_ready", return_value=False), - patch(f"{PKG}._shutdown_steam") as mock_shutdown, - patch(f"{PKG}._launch_steam_with_debug") as mock_launch, - pytest.raises(DesktopSessionNotReadyError), - ): - ensure_steam_debug_port() - - # Neither bounce a working Steam nor start a broken one. - mock_launch.assert_not_called() - mock_shutdown.assert_not_called() - - def test_launches_when_runtime_dir_present(self) -> None: - with ( - patch(f"{PKG}._steam_has_debug_port", return_value=False), - patch(f"{PKG}.steam_is_installed", return_value=True), - patch(f"{PKG}.os.geteuid", return_value=0), - patch(f"{PKG}._resolve_desktop_user", return_value="bob"), - patch(f"{PKG}._desktop_uid", return_value=1000), - patch(f"{PKG}.desktop_session_ready", return_value=True), - patch(f"{PKG}._is_steam_running", return_value=False), - patch(f"{PKG}._launch_steam_with_debug") as mock_launch, - patch(f"{PKG}._wait_for_cdp_ready", return_value=True), - patch(f"{PKG}._wait_for_collections_ready", return_value=True), - ): - ensure_steam_debug_port() - mock_launch.assert_called_once() - - def test_non_root_does_not_gate(self) -> None: - """Run interactively, the ambient session is already the user's.""" - with ( - patch(f"{PKG}._steam_has_debug_port", return_value=False), - patch(f"{PKG}.steam_is_installed", return_value=True), - patch(f"{PKG}.os.geteuid", return_value=1000), - patch(f"{PKG}._resolve_desktop_user", return_value="bob"), - patch(f"{PKG}.desktop_session_ready", return_value=False), - patch(f"{PKG}._is_steam_running", return_value=False), - patch(f"{PKG}._launch_steam_with_debug") as mock_launch, - patch(f"{PKG}._wait_for_cdp_ready", return_value=True), - patch(f"{PKG}._wait_for_collections_ready", return_value=True), - ): - ensure_steam_debug_port() - mock_launch.assert_called_once() diff --git a/steam_backlog_enforcer/tests/test_desktop_session_gating.py b/steam_backlog_enforcer/tests/test_desktop_session_gating.py new file mode 100644 index 0000000..adbdef3 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_desktop_session_gating.py @@ -0,0 +1,161 @@ +"""Tests for gating launches on the desktop session being ready. + +Split to keep every test file under the 250-line cap. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from steam_backlog_enforcer._desktop_env import ( + desktop_runtime_dir, + desktop_session_ready, +) +from steam_backlog_enforcer._steam_errors import DesktopSessionNotReadyError +from steam_backlog_enforcer._steam_launch import ensure_steam_debug_port + +PKG = "steam_backlog_enforcer.library_hider" +DESKTOP_ENV_PKG = "steam_backlog_enforcer._desktop_env" +STEAM_LAUNCH_PKG = "steam_backlog_enforcer._steam_launch" +STEAM_PROCESS_PKG = "steam_backlog_enforcer._steam_process" +ENV_PKG = "steam_backlog_enforcer._desktop_env" + + +def _env_value(args: list[str], name: str) -> str | None: + """Return the value assigned to *name* in a list of ``VAR=value`` args.""" + prefix = f"{name}=" + for arg in args: + if arg.startswith(prefix): + return arg[len(prefix) :] + return None + + +class TestDesktopSessionReady: + """Tests for desktop_session_ready.""" + + def test_ready_when_runtime_dir_exists(self) -> None: + with patch(f"{ENV_PKG}.Path.is_dir", return_value=True): + assert desktop_session_ready(1000) is True + + def test_not_ready_when_runtime_dir_missing(self) -> None: + with patch(f"{ENV_PKG}.Path.is_dir", return_value=False): + assert desktop_session_ready(1000) is False + + def test_runtime_dir_path(self) -> None: + assert desktop_runtime_dir(1000) == "/run/user/1000" + + +class TestLaunchDefersUntilSessionReady: + """The enforcer must not launch Steam into a session that does not exist. + + Regression guard for the boot race: the enforcer is a system service + ordered only after the network, and on a measured boot it started 54ms + before user-runtime-dir@1000. A Steam launched in that window comes up + with a runtime dir that is not there, silently falls back to winealsa, + and stays audio-broken until something restarts it. + """ + + def test_defers_when_runtime_dir_missing(self) -> None: + with ( + patch( + "steam_backlog_enforcer._steam_launch._steam_has_debug_port", + return_value=False, + ), + patch( + "steam_backlog_enforcer._steam_launch.steam_is_installed", + return_value=True, + ), + patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=0), + patch( + "steam_backlog_enforcer._steam_launch._resolve_desktop_user", + return_value="bob", + ), + patch(f"{DESKTOP_ENV_PKG}.desktop_uid", return_value=1000), + patch(f"{STEAM_LAUNCH_PKG}.desktop_session_ready", return_value=False), + patch( + "steam_backlog_enforcer._steam_launch._shutdown_steam" + ) as mock_shutdown, + patch( + "steam_backlog_enforcer._steam_launch._launch_steam_with_debug" + ) as mock_launch, + pytest.raises(DesktopSessionNotReadyError), + ): + ensure_steam_debug_port() + + # Neither bounce a working Steam nor start a broken one. + mock_launch.assert_not_called() + mock_shutdown.assert_not_called() + + def test_launches_when_runtime_dir_present(self) -> None: + with ( + patch( + "steam_backlog_enforcer._steam_launch._steam_has_debug_port", + return_value=False, + ), + patch( + "steam_backlog_enforcer._steam_launch.steam_is_installed", + return_value=True, + ), + patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=0), + patch( + "steam_backlog_enforcer._steam_launch._resolve_desktop_user", + return_value="bob", + ), + patch(f"{DESKTOP_ENV_PKG}.desktop_uid", return_value=1000), + patch(f"{STEAM_LAUNCH_PKG}.desktop_session_ready", return_value=True), + patch( + "steam_backlog_enforcer._steam_launch._is_steam_running", + return_value=False, + ), + patch( + "steam_backlog_enforcer._steam_launch._launch_steam_with_debug" + ) as mock_launch, + patch( + "steam_backlog_enforcer._steam_launch._wait_for_cdp_ready", + return_value=True, + ), + patch( + "steam_backlog_enforcer._steam_launch._wait_for_collections_ready", + return_value=True, + ), + ): + ensure_steam_debug_port() + mock_launch.assert_called_once() + + def test_non_root_does_not_gate(self) -> None: + """Run interactively, the ambient session is already the user's.""" + with ( + patch( + "steam_backlog_enforcer._steam_launch._steam_has_debug_port", + return_value=False, + ), + patch( + "steam_backlog_enforcer._steam_launch.steam_is_installed", + return_value=True, + ), + patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=1000), + patch( + "steam_backlog_enforcer._steam_launch._resolve_desktop_user", + return_value="bob", + ), + patch(f"{STEAM_LAUNCH_PKG}.desktop_session_ready", return_value=False), + patch( + "steam_backlog_enforcer._steam_launch._is_steam_running", + return_value=False, + ), + patch( + "steam_backlog_enforcer._steam_launch._launch_steam_with_debug" + ) as mock_launch, + patch( + "steam_backlog_enforcer._steam_launch._wait_for_cdp_ready", + return_value=True, + ), + patch( + "steam_backlog_enforcer._steam_launch._wait_for_collections_ready", + return_value=True, + ), + ): + ensure_steam_debug_port() + mock_launch.assert_called_once() diff --git a/steam_backlog_enforcer/tests/test_desktop_user_cmd.py b/steam_backlog_enforcer/tests/test_desktop_user_cmd.py new file mode 100644 index 0000000..f5b3004 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_desktop_user_cmd.py @@ -0,0 +1,113 @@ +"""Tests for the desktop privilege-drop builder. + +Split from test_desktop_env.py to keep both files under the 250-line cap. +""" + +from __future__ import annotations + +import os +from unittest.mock import patch + +from steam_backlog_enforcer._desktop_env import ( + desktop_uid, + desktop_user_cmd, + resolve_desktop_user, +) + +ENV_PKG = "steam_backlog_enforcer._desktop_env" + + +def _env_value(args: list[str], name: str) -> str | None: + """Return the value assigned to *name* in a list of ``VAR=value`` args.""" + prefix = f"{name}=" + for arg in args: + if arg.startswith(prefix): + return arg[len(prefix) :] + return None + + +class TestResolveDesktopUser: + """Tests for resolve_desktop_user's precedence.""" + + def test_prefers_enforcer_var(self) -> None: + """The systemd unit's variable wins: it is the only one systemd sets.""" + with patch.dict( + os.environ, + { + "STEAM_ENFORCER_DESKTOP_USER": "kuhy", + "SUDO_USER": "alice", + "USER": "bob", + }, + ): + assert resolve_desktop_user() == "kuhy" + + def test_falls_back_to_sudo_user(self) -> None: + env = { + k: v for k, v in os.environ.items() if k != "STEAM_ENFORCER_DESKTOP_USER" + } + env.update({"SUDO_USER": "alice", "USER": "bob"}) + with patch.dict(os.environ, env, clear=True): + assert resolve_desktop_user() == "alice" + + def test_falls_back_to_user(self) -> None: + env = { + k: v + for k, v in os.environ.items() + if k not in ("STEAM_ENFORCER_DESKTOP_USER", "SUDO_USER") + } + env["USER"] = "bob" + with patch.dict(os.environ, env, clear=True): + assert resolve_desktop_user() == "bob" + + def test_returns_none_when_unset(self) -> None: + env = { + k: v + for k, v in os.environ.items() + if k not in ("STEAM_ENFORCER_DESKTOP_USER", "SUDO_USER", "USER") + } + with patch.dict(os.environ, env, clear=True): + assert resolve_desktop_user() is None + + +class TestDesktopUserCmd: + """Tests for desktop_user_cmd — all four branches.""" + + def test_non_root_returns_cmd_unchanged(self) -> None: + with patch(f"{ENV_PKG}.os.geteuid", return_value=1000): + assert desktop_user_cmd(["steam"], "kuhy") == ["steam"] + + def test_root_with_user_wraps_in_sudo(self) -> None: + with ( + patch(f"{ENV_PKG}.os.geteuid", return_value=0), + patch(f"{ENV_PKG}.desktop_uid", return_value=1000), + ): + result = desktop_user_cmd(["steam", "-silent"], "kuhy") + assert result[:4] == ["sudo", "-u", "kuhy", "env"] + assert result[-2:] == ["steam", "-silent"] + assert _env_value(result, "XDG_RUNTIME_DIR") == "/run/user/1000" + + def test_root_without_user_returns_cmd_unchanged(self) -> None: + """Callers must gate on this: bare argv as root is what the guard exists for.""" + with patch(f"{ENV_PKG}.os.geteuid", return_value=0): + assert desktop_user_cmd(["steam"], None) == ["steam"] + + def test_root_with_root_user_returns_cmd_unchanged(self) -> None: + with patch(f"{ENV_PKG}.os.geteuid", return_value=0): + assert desktop_user_cmd(["steam"], "root") == ["steam"] + + +class TestDesktopUid: + """Tests for desktop_uid's lookup and fallbacks.""" + + def test_known_user(self) -> None: + with patch(f"{ENV_PKG}.pwd.getpwnam") as mock_getpwnam: + mock_getpwnam.return_value.pw_uid = 1234 + assert desktop_uid("kuhy") == 1234 + + def test_unknown_user_falls_back(self) -> None: + """An unknown name must not raise — the enforce loop would die.""" + with patch(f"{ENV_PKG}.pwd.getpwnam", side_effect=KeyError): + assert desktop_uid("nosuchuser") == 1000 + + def test_none_falls_back(self) -> None: + assert desktop_uid(None) == 1000 diff --git a/steam_backlog_enforcer/tests/test_do_enforce.py b/steam_backlog_enforcer/tests/test_do_enforce.py new file mode 100644 index 0000000..b3bee12 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_do_enforce.py @@ -0,0 +1,153 @@ +"""Tests for the do_enforce driver loop. + +Split to keep every test file under the 250-line cap. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from steam_backlog_enforcer._enforce_loop import ( + do_enforce, +) +from steam_backlog_enforcer.config import Config, State + +if TYPE_CHECKING: + from collections.abc import Iterator + +PKG = "steam_backlog_enforcer._enforce_loop" +ENFORCE_STEPS_PKG = "steam_backlog_enforcer._enforce_steps" + + +class TestDoEnforce: + """Tests for do_enforce.""" + + @pytest.fixture(autouse=True) + def _steam_present(self) -> Iterator[None]: + """Pretend Steam is installed for every test in this class. + + Without it do_enforce takes the "not installed" branch and never + reaches the normal enforcement path these tests are about. The + absent case is covered by test_steam_absent_idles_without_setup, + which opts back out. + """ + with patch(f"{PKG}.steam_is_installed", return_value=True): + yield + + def test_no_game_says_so_but_keeps_looping(self) -> None: + """No assignment is reported, but the loop still runs. + + The daily gaming budget is accounted for from inside this loop, so + returning here would stop enforcing it whenever a game is finished + but not yet rescanned. + """ + state = State() + with ( + patch(f"{PKG}._echo") as mock_echo, + patch.object(State, "load", return_value=state), + patch( + f"{PKG}._enforce_loop_iteration", + side_effect=KeyboardInterrupt, + ) as mock_iter, + patch(f"{PKG}.time.sleep"), + ): + do_enforce(Config(), state) + assert any("No game" in str(c) for c in mock_echo.call_args_list) + mock_iter.assert_called_once() + + def test_steam_absent_idles_without_setup(self) -> None: + """With Steam gone, say so and keep looping - never exit. + + Returning here would end the process, and under Restart=always that + is the crash loop again by another name. Staying alive also lets a + later reinstall be picked up without a restart. + """ + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{PKG}.is_total_block_active", return_value=False), + patch(f"{PKG}.steam_is_installed", return_value=False), + patch(f"{PKG}._enforce_setup") as mock_setup, + patch(f"{PKG}._echo") as mock_echo, + patch.object(State, "load", return_value=state), + patch( + f"{PKG}._enforce_loop_iteration", + side_effect=KeyboardInterrupt, + ), + patch(f"{PKG}.time.sleep"), + ): + do_enforce(Config(), state) + + mock_setup.assert_not_called() + assert any("not installed" in str(c) for c in mock_echo.call_args_list) + + def test_keyboard_interrupt(self) -> None: + state = State(current_app_id=1, current_game_name="G") + config = Config() + fresh = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{PKG}._enforce_setup"), + patch(f"{PKG}._echo"), + patch.object(State, "load", return_value=fresh), + patch( + f"{PKG}._enforce_loop_iteration", + side_effect=KeyboardInterrupt, + ), + patch(f"{PKG}.time.sleep"), + ): + do_enforce(config, state) + + def test_runs_iterations(self) -> None: + state = State(current_app_id=1, current_game_name="G") + config = Config() + fresh = State(current_app_id=1, current_game_name="G") + call_count = 0 + + def side_effect(*_args: object, **_kwargs: object) -> None: + nonlocal call_count + call_count += 1 + if call_count >= 2: + raise KeyboardInterrupt + + with ( + patch(f"{PKG}._enforce_setup"), + patch(f"{PKG}._echo"), + patch.object(State, "load", return_value=fresh), + patch( + f"{PKG}._enforce_loop_iteration", + side_effect=side_effect, + ), + patch(f"{PKG}.time.sleep"), + ): + do_enforce(config, state) + assert call_count == 2 + + def test_state_load_failure_continues(self) -> None: + """Corrupt state file should not crash the daemon.""" + import json as json_mod + + state = State(current_app_id=1, current_game_name="G") + config = Config() + call_count = 0 + + def load_side_effect() -> State: + nonlocal call_count + call_count += 1 + if call_count == 1: + msg = "bad" + raise json_mod.JSONDecodeError(msg, "", 0) + if call_count == 2: + raise KeyboardInterrupt + return State(current_app_id=1) # pragma: no cover + + with ( + patch(f"{PKG}._enforce_setup"), + patch(f"{PKG}._echo"), + patch.object(State, "load", side_effect=load_side_effect), + patch(f"{PKG}._enforce_loop_iteration") as mock_iter, + patch(f"{PKG}.time.sleep"), + ): + do_enforce(config, state) + mock_iter.assert_not_called() diff --git a/steam_backlog_enforcer/tests/test_enforce_loop.py b/steam_backlog_enforcer/tests/test_enforce_loop.py index 06699ec..065dc27 100644 --- a/steam_backlog_enforcer/tests/test_enforce_loop.py +++ b/steam_backlog_enforcer/tests/test_enforce_loop.py @@ -6,21 +6,19 @@ import json from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch -from steam_backlog_enforcer._enforce_loop import ( - _enforce_auto_install, - _enforce_hide_games, - _enforce_setup, - _guard_installed_games, +from steam_backlog_enforcer._owned_apps_cache import ( _load_owned_app_ids_cache, _save_owned_app_ids_cache, get_all_owned_app_ids, ) -from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.config import Config if TYPE_CHECKING: from pathlib import Path PKG = "steam_backlog_enforcer._enforce_loop" +ENFORCE_STEPS_PKG = "steam_backlog_enforcer._enforce_steps" +OWNED_APPS_CACHE_PKG = "steam_backlog_enforcer._owned_apps_cache" class TestGetAllOwnedAppIds: @@ -29,9 +27,13 @@ class TestGetAllOwnedAppIds: def test_snapshot_used_when_api_fails(self) -> None: snap = [{"app_id": 1}, {"app_id": 2}] with ( - patch(f"{PKG}.load_snapshot", return_value=snap), - patch(f"{PKG}._load_owned_app_ids_cache", return_value=None), - patch(f"{PKG}.SteamAPIClient", side_effect=OSError("boom")), + patch(f"{OWNED_APPS_CACHE_PKG}.load_snapshot", return_value=snap), + patch( + f"{OWNED_APPS_CACHE_PKG}._load_owned_app_ids_cache", return_value=None + ), + patch( + f"{OWNED_APPS_CACHE_PKG}.SteamAPIClient", side_effect=OSError("boom") + ), ): assert get_all_owned_app_ids(Config()) == [1, 2] @@ -42,9 +44,11 @@ class TestGetAllOwnedAppIds: {"appid": 20}, ] with ( - patch(f"{PKG}.load_snapshot", return_value=None), - patch(f"{PKG}._load_owned_app_ids_cache", return_value=None), - patch(f"{PKG}.SteamAPIClient", return_value=mock_client), + patch(f"{OWNED_APPS_CACHE_PKG}.load_snapshot", return_value=None), + patch( + f"{OWNED_APPS_CACHE_PKG}._load_owned_app_ids_cache", return_value=None + ), + patch(f"{OWNED_APPS_CACHE_PKG}.SteamAPIClient", return_value=mock_client), ): result = get_all_owned_app_ids( Config(steam_api_key="k", steam_id="i"), @@ -53,10 +57,12 @@ class TestGetAllOwnedAppIds: def test_api_fails(self) -> None: with ( - patch(f"{PKG}.load_snapshot", return_value=None), - patch(f"{PKG}._load_owned_app_ids_cache", return_value=None), + patch(f"{OWNED_APPS_CACHE_PKG}.load_snapshot", return_value=None), patch( - f"{PKG}.SteamAPIClient", + f"{OWNED_APPS_CACHE_PKG}._load_owned_app_ids_cache", return_value=None + ), + patch( + f"{OWNED_APPS_CACHE_PKG}.SteamAPIClient", side_effect=OSError("fail"), ), ): @@ -66,9 +72,11 @@ class TestGetAllOwnedAppIds: mock_client = MagicMock() mock_client.get_owned_games.return_value = [{"appid": 5}] with ( - patch(f"{PKG}.load_snapshot", return_value=[]), - patch(f"{PKG}._load_owned_app_ids_cache", return_value=None), - patch(f"{PKG}.SteamAPIClient", return_value=mock_client), + patch(f"{OWNED_APPS_CACHE_PKG}.load_snapshot", return_value=[]), + patch( + f"{OWNED_APPS_CACHE_PKG}._load_owned_app_ids_cache", return_value=None + ), + patch(f"{OWNED_APPS_CACHE_PKG}.SteamAPIClient", return_value=mock_client), ): assert get_all_owned_app_ids(Config(steam_api_key="k", steam_id="i")) == [5] @@ -77,10 +85,13 @@ class TestGetAllOwnedAppIds: mock_client.get_owned_games.return_value = [{"appid": 10}, {"appid": 20}] with ( patch( - f"{PKG}.load_snapshot", return_value=[{"app_id": 20}, {"app_id": 30}] + f"{OWNED_APPS_CACHE_PKG}.load_snapshot", + return_value=[{"app_id": 20}, {"app_id": 30}], ), - patch(f"{PKG}._load_owned_app_ids_cache", return_value=None), - patch(f"{PKG}.SteamAPIClient", return_value=mock_client), + patch( + f"{OWNED_APPS_CACHE_PKG}._load_owned_app_ids_cache", return_value=None + ), + patch(f"{OWNED_APPS_CACHE_PKG}.SteamAPIClient", return_value=mock_client), ): assert get_all_owned_app_ids(Config(steam_api_key="k", steam_id="i")) == [ 10, @@ -90,9 +101,14 @@ class TestGetAllOwnedAppIds: def test_uses_owned_ids_cache_without_api_call(self) -> None: with ( - patch(f"{PKG}.load_snapshot", return_value=[{"app_id": 30}]), - patch(f"{PKG}._load_owned_app_ids_cache", return_value=[10, 20]), - patch(f"{PKG}.SteamAPIClient") as mock_client, + patch( + f"{OWNED_APPS_CACHE_PKG}.load_snapshot", return_value=[{"app_id": 30}] + ), + patch( + f"{OWNED_APPS_CACHE_PKG}._load_owned_app_ids_cache", + return_value=[10, 20], + ), + patch(f"{OWNED_APPS_CACHE_PKG}.SteamAPIClient") as mock_client, ): result = get_all_owned_app_ids(Config(steam_api_key="k", steam_id="i")) @@ -102,10 +118,14 @@ class TestGetAllOwnedAppIds: def test_cached_ids_merge_deduplicates_entries(self) -> None: with ( patch( - f"{PKG}.load_snapshot", return_value=[{"app_id": 20}, {"app_id": 30}] + f"{OWNED_APPS_CACHE_PKG}.load_snapshot", + return_value=[{"app_id": 20}, {"app_id": 30}], ), - patch(f"{PKG}._load_owned_app_ids_cache", return_value=[10, 20, 20]), - patch(f"{PKG}.SteamAPIClient") as mock_client, + patch( + f"{OWNED_APPS_CACHE_PKG}._load_owned_app_ids_cache", + return_value=[10, 20, 20], + ), + patch(f"{OWNED_APPS_CACHE_PKG}.SteamAPIClient") as mock_client, ): result = get_all_owned_app_ids(Config(steam_api_key="k", steam_id="i")) @@ -116,10 +136,12 @@ class TestGetAllOwnedAppIds: mock_client = MagicMock() mock_client.get_owned_games.return_value = [{"appid": 10}, {"appid": 20}] with ( - patch(f"{PKG}.load_snapshot", return_value=[]), - patch(f"{PKG}._load_owned_app_ids_cache", return_value=None), - patch(f"{PKG}.SteamAPIClient", return_value=mock_client), - patch(f"{PKG}._save_owned_app_ids_cache") as mock_save, + patch(f"{OWNED_APPS_CACHE_PKG}.load_snapshot", return_value=[]), + patch( + f"{OWNED_APPS_CACHE_PKG}._load_owned_app_ids_cache", return_value=None + ), + patch(f"{OWNED_APPS_CACHE_PKG}.SteamAPIClient", return_value=mock_client), + patch(f"{OWNED_APPS_CACHE_PKG}._save_owned_app_ids_cache") as mock_save, ): result = get_all_owned_app_ids(Config(steam_api_key="k", steam_id="i")) @@ -131,17 +153,21 @@ class TestOwnedIdsCacheHelpers: """Tests for owned app IDs cache helper functions.""" def test_load_cache_no_steam_id(self, tmp_path: Path) -> None: - with patch(f"{PKG}._OWNED_IDS_CACHE_FILE", tmp_path / "owned.json"): + with patch( + f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_FILE", tmp_path / "owned.json" + ): assert _load_owned_app_ids_cache("") is None def test_load_cache_missing_file(self, tmp_path: Path) -> None: - with patch(f"{PKG}._OWNED_IDS_CACHE_FILE", tmp_path / "owned.json"): + with patch( + f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_FILE", tmp_path / "owned.json" + ): assert _load_owned_app_ids_cache("sid") is None def test_load_cache_invalid_json(self, tmp_path: Path) -> None: cache_file = tmp_path / "owned.json" cache_file.write_text("{invalid", encoding="utf-8") - with patch(f"{PKG}._OWNED_IDS_CACHE_FILE", cache_file): + with patch(f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_FILE", cache_file): assert _load_owned_app_ids_cache("sid") is None def test_load_cache_wrong_steam_id(self, tmp_path: Path) -> None: @@ -150,7 +176,7 @@ class TestOwnedIdsCacheHelpers: json.dumps({"steam_id": "other", "fetched_at": 1e12, "app_ids": [1]}), encoding="utf-8", ) - with patch(f"{PKG}._OWNED_IDS_CACHE_FILE", cache_file): + with patch(f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_FILE", cache_file): assert _load_owned_app_ids_cache("sid") is None def test_load_cache_stale(self, tmp_path: Path) -> None: @@ -160,9 +186,9 @@ class TestOwnedIdsCacheHelpers: encoding="utf-8", ) with ( - patch(f"{PKG}._OWNED_IDS_CACHE_FILE", cache_file), + patch(f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_FILE", cache_file), patch(f"{PKG}.time.time", return_value=10_000.0), - patch(f"{PKG}._OWNED_IDS_CACHE_TTL_SECONDS", 60), + patch(f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_TTL_SECONDS", 60), ): assert _load_owned_app_ids_cache("sid") is None @@ -173,9 +199,9 @@ class TestOwnedIdsCacheHelpers: encoding="utf-8", ) with ( - patch(f"{PKG}._OWNED_IDS_CACHE_FILE", cache_file), + patch(f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_FILE", cache_file), patch(f"{PKG}.time.time", return_value=10_010.0), - patch(f"{PKG}._OWNED_IDS_CACHE_TTL_SECONDS", 60), + patch(f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_TTL_SECONDS", 60), ): assert _load_owned_app_ids_cache("sid") is None @@ -188,18 +214,18 @@ class TestOwnedIdsCacheHelpers: encoding="utf-8", ) with ( - patch(f"{PKG}._OWNED_IDS_CACHE_FILE", cache_file), + patch(f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_FILE", cache_file), patch(f"{PKG}.time.time", return_value=10_010.0), - patch(f"{PKG}._OWNED_IDS_CACHE_TTL_SECONDS", 60), + patch(f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_TTL_SECONDS", 60), ): assert _load_owned_app_ids_cache("sid") == [1, 2] def test_save_cache_writes_atomic_payload(self, tmp_path: Path) -> None: cache_file = tmp_path / "owned.json" with ( - patch(f"{PKG}._OWNED_IDS_CACHE_FILE", cache_file), + patch(f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_FILE", cache_file), patch(f"{PKG}.time.time", return_value=123.0), - patch(f"{PKG}._atomic_write") as mock_atomic, + patch(f"{OWNED_APPS_CACHE_PKG}._atomic_write") as mock_atomic, ): _save_owned_app_ids_cache("sid", [10, 20]) @@ -209,185 +235,3 @@ class TestOwnedIdsCacheHelpers: assert path_arg == cache_file assert '"steam_id": "sid"' in payload_arg assert '"app_ids": [\n 10,\n 20\n ]' in payload_arg - - -class TestGuardInstalledGames: - """Tests for _guard_installed_games.""" - - def test_removes_unauthorized(self) -> None: - with ( - patch( - f"{PKG}.get_installed_games", - return_value=[(999, "Bad Game")], - ), - patch(f"{PKG}.uninstall_game", return_value=True), - patch(f"{PKG}.send_notification"), - ): - assert _guard_installed_games({440}) == 1 - - def test_skips_allowed(self) -> None: - with patch( - f"{PKG}.get_installed_games", - return_value=[(440, "TF2")], - ): - assert _guard_installed_games({440}) == 0 - - def test_skips_protected(self) -> None: - with ( - patch( - f"{PKG}.get_installed_games", - return_value=[(228980, "Runtime")], - ), - patch(f"{PKG}.is_protected_app", side_effect=lambda aid: aid == 228980), - ): - assert _guard_installed_games({440}) == 0 - - def test_uninstall_fails(self) -> None: - with ( - patch( - f"{PKG}.get_installed_games", - return_value=[(999, "Bad")], - ), - patch(f"{PKG}.uninstall_game", return_value=False), - ): - assert _guard_installed_games({440}) == 0 - - def test_allowed_none_skips(self) -> None: - assert _guard_installed_games(set()) == 0 - - -class TestEnforceSetup: - """Tests for _enforce_setup.""" - - def test_block_store_success(self) -> None: - config = Config(block_store=True, uninstall_other_games=False) - state = State(current_app_id=1, current_game_name="G") - with ( - patch(f"{PKG}.block_store", return_value=True), - patch(f"{PKG}._echo"), - patch(f"{PKG}._enforce_auto_install"), - patch(f"{PKG}._enforce_hide_games"), - ): - _enforce_setup(config, state) - - def test_block_store_fail(self) -> None: - config = Config(block_store=True, uninstall_other_games=False) - state = State() - with ( - patch(f"{PKG}.block_store", return_value=False), - patch(f"{PKG}._echo") as mock_echo, - patch(f"{PKG}._enforce_auto_install"), - patch(f"{PKG}._enforce_hide_games"), - ): - _enforce_setup(config, state) - assert any("FAILED" in str(c) for c in mock_echo.call_args_list) - - def test_no_block_store(self) -> None: - config = Config(block_store=False, uninstall_other_games=False) - state = State() - with ( - patch(f"{PKG}.block_store") as mock_block, - patch(f"{PKG}._echo"), - patch(f"{PKG}._enforce_auto_install"), - patch(f"{PKG}._enforce_hide_games"), - ): - _enforce_setup(config, state) - mock_block.assert_not_called() - - def test_uninstall_other_games(self) -> None: - config = Config(uninstall_other_games=True, block_store=False) - state = State(current_app_id=1) - with ( - patch(f"{PKG}.uninstall_other_games", return_value=3), - patch(f"{PKG}._echo"), - patch(f"{PKG}._enforce_auto_install"), - patch(f"{PKG}._enforce_hide_games"), - ): - _enforce_setup(config, state) - - -class TestEnforceAutoInstall: - """Tests for _enforce_auto_install.""" - - def test_no_app_id(self) -> None: - _enforce_auto_install(Config(), State()) - - def test_already_installed(self) -> None: - state = State(current_app_id=1, current_game_name="G") - with ( - patch(f"{PKG}.is_game_installed", return_value=True), - patch(f"{PKG}._echo"), - ): - _enforce_auto_install(Config(), state) - - def test_installs_successfully(self) -> None: - state = State(current_app_id=1, current_game_name="G") - with ( - patch(f"{PKG}.is_game_installed", return_value=False), - patch(f"{PKG}.install_game", return_value=True), - patch(f"{PKG}.send_notification"), - patch(f"{PKG}._echo"), - ): - _enforce_auto_install(Config(steam_id="i"), state) - - def test_install_fails(self) -> None: - state = State(current_app_id=1, current_game_name="G") - with ( - patch(f"{PKG}.is_game_installed", return_value=False), - patch(f"{PKG}.install_game", return_value=False), - patch(f"{PKG}._echo") as mock_echo, - ): - _enforce_auto_install(Config(steam_id="i"), state) - assert any("manually" in str(c) for c in mock_echo.call_args_list) - - -class TestEnforceHideGames: - """Tests for _enforce_hide_games.""" - - def test_hides_some(self) -> None: - state = State(current_app_id=1) - with ( - patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]), - patch(f"{PKG}.try_hide_other_games", return_value=(2, None)), - patch(f"{PKG}._echo"), - ): - _enforce_hide_games(Config(), state) - - def test_already_hidden(self) -> None: - state = State(current_app_id=1) - with ( - patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]), - patch(f"{PKG}.try_hide_other_games", return_value=(0, None)), - patch(f"{PKG}._echo") as mock_echo, - ): - _enforce_hide_games(Config(), state) - assert any("already" in str(c) for c in mock_echo.call_args_list) - - def test_no_owned_ids(self) -> None: - state = State(current_app_id=1) - with ( - patch(f"{PKG}.get_all_owned_app_ids", return_value=[]), - patch(f"{PKG}._echo") as mock_echo, - ): - _enforce_hide_games(Config(), state) - assert any("skipped" in str(c) for c in mock_echo.call_args_list) - - def test_unreachable_steam_is_not_fatal(self) -> None: - """An unreachable Steam must degrade, never propagate. - - Regression guard: this exception used to escape all the way out of - do_enforce, exiting the process into Restart=always - which spun the - service through ~1000 restarts against an uninstalled Steam. - """ - state = State(current_app_id=1) - with ( - patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]), - patch( - f"{PKG}.try_hide_other_games", - return_value=(0, "Steam is not installed"), - ), - patch(f"{PKG}._echo") as mock_echo, - ): - _enforce_hide_games(Config(), state) - - assert any("skipped" in str(c) for c in mock_echo.call_args_list) diff --git a/steam_backlog_enforcer/tests/test_enforce_loop_part2.py b/steam_backlog_enforcer/tests/test_enforce_loop_part2.py index 9d25f07..0379380 100644 --- a/steam_backlog_enforcer/tests/test_enforce_loop_part2.py +++ b/steam_backlog_enforcer/tests/test_enforce_loop_part2.py @@ -9,7 +9,6 @@ import pytest from steam_backlog_enforcer._enforce_loop import ( _enforce_loop_iteration, - do_enforce, ) from steam_backlog_enforcer.config import Config, State @@ -17,6 +16,7 @@ if TYPE_CHECKING: from collections.abc import Iterator PKG = "steam_backlog_enforcer._enforce_loop" +ENFORCE_STEPS_PKG = "steam_backlog_enforcer._enforce_steps" class TestEnforceLoopIteration: @@ -44,9 +44,9 @@ class TestEnforceLoopIteration: f"{PKG}.enforce_allowed_game", return_value=[(1234, 999)], ), - patch(f"{PKG}.send_notification"), + patch(f"{ENFORCE_STEPS_PKG}.send_notification"), patch(f"{PKG}._echo"), - patch(f"{PKG}.is_game_installed", return_value=True), + patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=True), ): _enforce_loop_iteration(config, state) @@ -58,7 +58,7 @@ class TestEnforceLoopIteration: state = State(current_app_id=1, current_game_name="G") with ( patch(f"{PKG}.enforce_allowed_game") as mock_enforce, - patch(f"{PKG}.is_game_installed", return_value=True), + patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=True), ): _enforce_loop_iteration(config, state) mock_enforce.assert_not_called() @@ -72,7 +72,7 @@ class TestEnforceLoopIteration: with ( patch(f"{PKG}._guard_installed_games", return_value=1), patch(f"{PKG}._echo"), - patch(f"{PKG}.is_game_installed", return_value=True), + patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=True), ): _enforce_loop_iteration(config, state) @@ -84,7 +84,7 @@ class TestEnforceLoopIteration: state = State(current_app_id=1, current_game_name="G") with ( patch(f"{PKG}._guard_installed_games", return_value=0), - patch(f"{PKG}.is_game_installed", return_value=True), + patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=True), ): _enforce_loop_iteration(config, state) @@ -103,8 +103,8 @@ class TestEnforceLoopIteration: patch(f"{PKG}.steam_is_installed", return_value=False), patch(f"{PKG}.enforce_allowed_game") as mock_enforce, patch(f"{PKG}._guard_installed_games") as mock_guard, - patch(f"{PKG}.is_game_installed") as mock_installed, - patch(f"{PKG}.install_game") as mock_install, + patch(f"{ENFORCE_STEPS_PKG}.is_game_installed") as mock_installed, + patch(f"{ENFORCE_STEPS_PKG}.install_game") as mock_install, ): _enforce_loop_iteration(config, state) @@ -120,8 +120,8 @@ class TestEnforceLoopIteration: ) state = State(current_app_id=1, current_game_name="G") with ( - patch(f"{PKG}.is_game_installed", return_value=False), - patch(f"{PKG}.install_game") as mock_install, + patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=False), + patch(f"{ENFORCE_STEPS_PKG}.install_game") as mock_install, ): _enforce_loop_iteration(config, state) mock_install.assert_called_once() @@ -135,140 +135,9 @@ class TestEnforceLoopIteration: with ( patch(f"{PKG}.enforce_allowed_game") as mock_enforce, patch(f"{PKG}._guard_installed_games") as mock_guard, - patch(f"{PKG}.is_game_installed") as mock_installed, + patch(f"{ENFORCE_STEPS_PKG}.is_game_installed") as mock_installed, ): _enforce_loop_iteration(config, state) mock_enforce.assert_not_called() mock_guard.assert_not_called() mock_installed.assert_not_called() - - -class TestDoEnforce: - """Tests for do_enforce.""" - - @pytest.fixture(autouse=True) - def _steam_present(self) -> Iterator[None]: - """Pretend Steam is installed for every test in this class. - - Without it do_enforce takes the "not installed" branch and never - reaches the normal enforcement path these tests are about. The - absent case is covered by test_steam_absent_idles_without_setup, - which opts back out. - """ - with patch(f"{PKG}.steam_is_installed", return_value=True): - yield - - def test_no_game_says_so_but_keeps_looping(self) -> None: - """No assignment is reported, but the loop still runs. - - The daily gaming budget is accounted for from inside this loop, so - returning here would stop enforcing it whenever a game is finished - but not yet rescanned. - """ - state = State() - with ( - patch(f"{PKG}._echo") as mock_echo, - patch.object(State, "load", return_value=state), - patch( - f"{PKG}._enforce_loop_iteration", - side_effect=KeyboardInterrupt, - ) as mock_iter, - patch(f"{PKG}.time.sleep"), - ): - do_enforce(Config(), state) - assert any("No game" in str(c) for c in mock_echo.call_args_list) - mock_iter.assert_called_once() - - def test_steam_absent_idles_without_setup(self) -> None: - """With Steam gone, say so and keep looping - never exit. - - Returning here would end the process, and under Restart=always that - is the crash loop again by another name. Staying alive also lets a - later reinstall be picked up without a restart. - """ - state = State(current_app_id=1, current_game_name="G") - with ( - patch(f"{PKG}.is_total_block_active", return_value=False), - patch(f"{PKG}.steam_is_installed", return_value=False), - patch(f"{PKG}._enforce_setup") as mock_setup, - patch(f"{PKG}._echo") as mock_echo, - patch.object(State, "load", return_value=state), - patch( - f"{PKG}._enforce_loop_iteration", - side_effect=KeyboardInterrupt, - ), - patch(f"{PKG}.time.sleep"), - ): - do_enforce(Config(), state) - - mock_setup.assert_not_called() - assert any("not installed" in str(c) for c in mock_echo.call_args_list) - - def test_keyboard_interrupt(self) -> None: - state = State(current_app_id=1, current_game_name="G") - config = Config() - fresh = State(current_app_id=1, current_game_name="G") - with ( - patch(f"{PKG}._enforce_setup"), - patch(f"{PKG}._echo"), - patch.object(State, "load", return_value=fresh), - patch( - f"{PKG}._enforce_loop_iteration", - side_effect=KeyboardInterrupt, - ), - patch(f"{PKG}.time.sleep"), - ): - do_enforce(config, state) - - def test_runs_iterations(self) -> None: - state = State(current_app_id=1, current_game_name="G") - config = Config() - fresh = State(current_app_id=1, current_game_name="G") - call_count = 0 - - def side_effect(*_args: object, **_kwargs: object) -> None: - nonlocal call_count - call_count += 1 - if call_count >= 2: - raise KeyboardInterrupt - - with ( - patch(f"{PKG}._enforce_setup"), - patch(f"{PKG}._echo"), - patch.object(State, "load", return_value=fresh), - patch( - f"{PKG}._enforce_loop_iteration", - side_effect=side_effect, - ), - patch(f"{PKG}.time.sleep"), - ): - do_enforce(config, state) - assert call_count == 2 - - def test_state_load_failure_continues(self) -> None: - """Corrupt state file should not crash the daemon.""" - import json as json_mod - - state = State(current_app_id=1, current_game_name="G") - config = Config() - call_count = 0 - - def load_side_effect() -> State: - nonlocal call_count - call_count += 1 - if call_count == 1: - msg = "bad" - raise json_mod.JSONDecodeError(msg, "", 0) - if call_count == 2: - raise KeyboardInterrupt - return State(current_app_id=1) # pragma: no cover - - with ( - patch(f"{PKG}._enforce_setup"), - patch(f"{PKG}._echo"), - patch.object(State, "load", side_effect=load_side_effect), - patch(f"{PKG}._enforce_loop_iteration") as mock_iter, - patch(f"{PKG}.time.sleep"), - ): - do_enforce(config, state) - mock_iter.assert_not_called() diff --git a/steam_backlog_enforcer/tests/test_enforce_loop_part3.py b/steam_backlog_enforcer/tests/test_enforce_loop_part3.py index 8f114df..2671307 100644 --- a/steam_backlog_enforcer/tests/test_enforce_loop_part3.py +++ b/steam_backlog_enforcer/tests/test_enforce_loop_part3.py @@ -4,10 +4,14 @@ from __future__ import annotations from unittest.mock import patch -from steam_backlog_enforcer._enforce_loop import _enforce_loop_iteration, do_enforce +from steam_backlog_enforcer._enforce_loop import ( + _enforce_loop_iteration, + do_enforce, +) from steam_backlog_enforcer.config import Config, State PKG = "steam_backlog_enforcer._enforce_loop" +ENFORCE_STEPS_PKG = "steam_backlog_enforcer._enforce_steps" class TestEnforceLoopIterationTotalBlock: @@ -21,7 +25,7 @@ class TestEnforceLoopIterationTotalBlock: patch(f"{PKG}.enforce_total_block_tick") as mock_tick, patch(f"{PKG}.enforce_allowed_game") as mock_enforce, patch(f"{PKG}._guard_installed_games") as mock_guard, - patch(f"{PKG}.is_game_installed") as mock_installed, + patch(f"{ENFORCE_STEPS_PKG}.is_game_installed") as mock_installed, ): _enforce_loop_iteration(config, state) mock_tick.assert_called_once() @@ -36,7 +40,7 @@ class TestEnforceLoopIterationTotalBlock: patch(f"{PKG}.is_total_block_active", return_value=False), patch(f"{PKG}.total_block_needs_cleanup", return_value=False), patch(f"{PKG}.enforce_total_block_tick") as mock_tick, - patch(f"{PKG}.is_game_installed", return_value=True), + patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=True), ): _enforce_loop_iteration(config, state) mock_tick.assert_not_called() @@ -48,7 +52,7 @@ class TestEnforceLoopIterationTotalBlock: patch(f"{PKG}.is_total_block_active", return_value=False), patch(f"{PKG}.total_block_needs_cleanup", return_value=True), patch(f"{PKG}.end_total_block_cleanup") as mock_cleanup, - patch(f"{PKG}.is_game_installed", return_value=True), + patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=True), ): _enforce_loop_iteration(config, state) mock_cleanup.assert_called_once() @@ -60,7 +64,7 @@ class TestEnforceLoopIterationTotalBlock: patch(f"{PKG}.is_total_block_active", return_value=False), patch(f"{PKG}.total_block_needs_cleanup", return_value=False), patch(f"{PKG}.end_total_block_cleanup") as mock_cleanup, - patch(f"{PKG}.is_game_installed", return_value=True), + patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=True), ): _enforce_loop_iteration(config, state) mock_cleanup.assert_not_called() diff --git a/steam_backlog_enforcer/tests/test_enforce_steps.py b/steam_backlog_enforcer/tests/test_enforce_steps.py new file mode 100644 index 0000000..a7f1d5a --- /dev/null +++ b/steam_backlog_enforcer/tests/test_enforce_steps.py @@ -0,0 +1,202 @@ +"""Tests for the individual steps one enforce pass performs. + +Split to keep every test file under the 250-line cap. +""" + +from __future__ import annotations + +from unittest.mock import patch + +from steam_backlog_enforcer._enforce_loop import _guard_installed_games +from steam_backlog_enforcer._enforce_steps import ( + _enforce_auto_install, + _enforce_hide_games, + _enforce_setup, +) +from steam_backlog_enforcer.config import Config, State + +PKG = "steam_backlog_enforcer._enforce_loop" +ENFORCE_STEPS_PKG = "steam_backlog_enforcer._enforce_steps" +OWNED_APPS_CACHE_PKG = "steam_backlog_enforcer._owned_apps_cache" + + +class TestGuardInstalledGames: + """Tests for _guard_installed_games.""" + + def test_removes_unauthorized(self) -> None: + with ( + patch( + f"{PKG}.get_installed_games", + return_value=[(999, "Bad Game")], + ), + patch(f"{PKG}.uninstall_game", return_value=True), + patch(f"{ENFORCE_STEPS_PKG}.send_notification"), + ): + assert _guard_installed_games({440}) == 1 + + def test_skips_allowed(self) -> None: + with patch( + f"{PKG}.get_installed_games", + return_value=[(440, "TF2")], + ): + assert _guard_installed_games({440}) == 0 + + def test_skips_protected(self) -> None: + with ( + patch( + f"{PKG}.get_installed_games", + return_value=[(228980, "Runtime")], + ), + patch(f"{PKG}.is_protected_app", side_effect=lambda aid: aid == 228980), + ): + assert _guard_installed_games({440}) == 0 + + def test_uninstall_fails(self) -> None: + with ( + patch( + f"{PKG}.get_installed_games", + return_value=[(999, "Bad")], + ), + patch(f"{PKG}.uninstall_game", return_value=False), + ): + assert _guard_installed_games({440}) == 0 + + def test_allowed_none_skips(self) -> None: + assert _guard_installed_games(set()) == 0 + + +class TestEnforceSetup: + """Tests for _enforce_setup.""" + + def test_block_store_success(self) -> None: + config = Config(block_store=True, uninstall_other_games=False) + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{ENFORCE_STEPS_PKG}.block_store", return_value=True), + patch(f"{ENFORCE_STEPS_PKG}._echo"), + patch(f"{ENFORCE_STEPS_PKG}._enforce_auto_install"), + patch(f"{ENFORCE_STEPS_PKG}._enforce_hide_games"), + ): + _enforce_setup(config, state) + + def test_block_store_fail(self) -> None: + config = Config(block_store=True, uninstall_other_games=False) + state = State() + with ( + patch(f"{ENFORCE_STEPS_PKG}.block_store", return_value=False), + patch(f"{ENFORCE_STEPS_PKG}._echo") as mock_echo, + patch(f"{ENFORCE_STEPS_PKG}._enforce_auto_install"), + patch(f"{ENFORCE_STEPS_PKG}._enforce_hide_games"), + ): + _enforce_setup(config, state) + assert any("FAILED" in str(c) for c in mock_echo.call_args_list) + + def test_no_block_store(self) -> None: + config = Config(block_store=False, uninstall_other_games=False) + state = State() + with ( + patch(f"{ENFORCE_STEPS_PKG}.block_store") as mock_block, + patch(f"{ENFORCE_STEPS_PKG}._echo"), + patch(f"{ENFORCE_STEPS_PKG}._enforce_auto_install"), + patch(f"{ENFORCE_STEPS_PKG}._enforce_hide_games"), + ): + _enforce_setup(config, state) + mock_block.assert_not_called() + + def test_uninstall_other_games(self) -> None: + config = Config(uninstall_other_games=True, block_store=False) + state = State(current_app_id=1) + with ( + patch(f"{ENFORCE_STEPS_PKG}.uninstall_other_games", return_value=3), + patch(f"{ENFORCE_STEPS_PKG}._echo"), + patch(f"{ENFORCE_STEPS_PKG}._enforce_auto_install"), + patch(f"{ENFORCE_STEPS_PKG}._enforce_hide_games"), + ): + _enforce_setup(config, state) + + +class TestEnforceAutoInstall: + """Tests for _enforce_auto_install.""" + + def test_no_app_id(self) -> None: + _enforce_auto_install(Config(), State()) + + def test_already_installed(self) -> None: + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=True), + patch(f"{ENFORCE_STEPS_PKG}._echo"), + ): + _enforce_auto_install(Config(), state) + + def test_installs_successfully(self) -> None: + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=False), + patch(f"{ENFORCE_STEPS_PKG}.install_game", return_value=True), + patch(f"{ENFORCE_STEPS_PKG}.send_notification"), + patch(f"{ENFORCE_STEPS_PKG}._echo"), + ): + _enforce_auto_install(Config(steam_id="i"), state) + + def test_install_fails(self) -> None: + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=False), + patch(f"{ENFORCE_STEPS_PKG}.install_game", return_value=False), + patch(f"{ENFORCE_STEPS_PKG}._echo") as mock_echo, + ): + _enforce_auto_install(Config(steam_id="i"), state) + assert any("manually" in str(c) for c in mock_echo.call_args_list) + + +class TestEnforceHideGames: + """Tests for _enforce_hide_games.""" + + def test_hides_some(self) -> None: + state = State(current_app_id=1) + with ( + patch(f"{ENFORCE_STEPS_PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]), + patch(f"{ENFORCE_STEPS_PKG}.try_hide_other_games", return_value=(2, None)), + patch(f"{ENFORCE_STEPS_PKG}._echo"), + ): + _enforce_hide_games(Config(), state) + + def test_already_hidden(self) -> None: + state = State(current_app_id=1) + with ( + patch(f"{ENFORCE_STEPS_PKG}.get_all_owned_app_ids", return_value=[1, 2]), + patch(f"{ENFORCE_STEPS_PKG}.try_hide_other_games", return_value=(0, None)), + patch(f"{ENFORCE_STEPS_PKG}._echo") as mock_echo, + ): + _enforce_hide_games(Config(), state) + assert any("already" in str(c) for c in mock_echo.call_args_list) + + def test_no_owned_ids(self) -> None: + state = State(current_app_id=1) + with ( + patch(f"{ENFORCE_STEPS_PKG}.get_all_owned_app_ids", return_value=[]), + patch(f"{ENFORCE_STEPS_PKG}._echo") as mock_echo, + ): + _enforce_hide_games(Config(), state) + assert any("skipped" in str(c) for c in mock_echo.call_args_list) + + def test_unreachable_steam_is_not_fatal(self) -> None: + """An unreachable Steam must degrade, never propagate. + + Regression guard: this exception used to escape all the way out of + do_enforce, exiting the process into Restart=always - which spun the + service through ~1000 restarts against an uninstalled Steam. + """ + state = State(current_app_id=1) + with ( + patch(f"{ENFORCE_STEPS_PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]), + patch( + f"{ENFORCE_STEPS_PKG}.try_hide_other_games", + return_value=(0, "Steam is not installed"), + ), + patch(f"{ENFORCE_STEPS_PKG}._echo") as mock_echo, + ): + _enforce_hide_games(Config(), state) + + assert any("skipped" in str(c) for c in mock_echo.call_args_list) diff --git a/steam_backlog_enforcer/tests/test_enforcer.py b/steam_backlog_enforcer/tests/test_enforcer.py index 15a0626..5f2c988 100644 --- a/steam_backlog_enforcer/tests/test_enforcer.py +++ b/steam_backlog_enforcer/tests/test_enforcer.py @@ -2,6 +2,7 @@ from __future__ import annotations +import subprocess from typing import TYPE_CHECKING from unittest.mock import patch @@ -193,3 +194,45 @@ class TestSendNotification: side_effect=OSError, ): send_notification("Title", "Body") # Should not raise + + +class TestSendNotificationPrivilegeDrop: + """As root, notifications must be delivered into the user's session bus. + + A bare root ``notify-send`` has no DBUS_SESSION_BUS_ADDRESS and silently + reached nobody, for every caller of send_notification. + """ + + _PKG = "steam_backlog_enforcer.enforcer" + _ENV = "steam_backlog_enforcer._desktop_env" + + def test_root_drops_to_desktop_user(self) -> None: + with ( + patch(f"{self._ENV}.os.geteuid", return_value=0), + patch(f"{self._ENV}.desktop_uid", return_value=1000), + patch(f"{self._PKG}.resolve_desktop_user", return_value="kuhy"), + patch(f"{self._PKG}.subprocess.run") as mock_run, + ): + send_notification("Title", "Body") + + argv = mock_run.call_args[0][0] + assert argv[:4] == ["sudo", "-u", "kuhy", "env"] + assert "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus" in argv + + def test_non_root_does_not_wrap(self) -> None: + with ( + patch(f"{self._ENV}.os.geteuid", return_value=1000), + patch(f"{self._PKG}.resolve_desktop_user", return_value="kuhy"), + patch(f"{self._PKG}.subprocess.run") as mock_run, + ): + send_notification("Title", "Body") + + assert mock_run.call_args[0][0][0] != "sudo" + + def test_handles_timeout(self) -> None: + """sudo can block where a bare notify-send would not.""" + with patch( + f"{self._PKG}.subprocess.run", + side_effect=subprocess.TimeoutExpired("cmd", 5), + ): + send_notification("Title", "Body") # Should not raise diff --git a/steam_backlog_enforcer/tests/test_ensure_debug_port.py b/steam_backlog_enforcer/tests/test_ensure_debug_port.py new file mode 100644 index 0000000..3deba79 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_ensure_debug_port.py @@ -0,0 +1,167 @@ +"""Tests for ensure_steam_debug_port: getting Steam drivable over CDP. + +Split to keep every test file under the 250-line cap. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from steam_backlog_enforcer._steam_errors import SteamUnavailableError +from steam_backlog_enforcer._steam_launch import ( + ensure_steam_debug_port, +) + +if TYPE_CHECKING: + from collections.abc import Iterator + + +class TestEnsureSteamDebugPort: + """Tests for ensure_steam_debug_port.""" + + @pytest.fixture(autouse=True) + def _steam_present(self) -> Iterator[None]: + """Pretend Steam is installed for every test in this class. + + Without it the function raises before reaching any launch logic, + which on a test machine without Steam would make the cases below + vacuous. The absent case has its own test. + """ + with patch( + "steam_backlog_enforcer._steam_launch.steam_is_installed", + return_value=True, + ): + yield + + def test_raises_when_steam_absent(self) -> None: + """An uninstalled Steam must fail fast, before any launch attempt. + + Regression guard: without this the caller spent ~45s waiting on a + CDP port that no process was ever going to open. + """ + with ( + patch( + "steam_backlog_enforcer._steam_launch._steam_has_debug_port", + return_value=False, + ), + patch( + "steam_backlog_enforcer._steam_launch.steam_is_installed", + return_value=False, + ), + patch( + "steam_backlog_enforcer._steam_launch._launch_steam_with_debug", + ) as mock_launch, + pytest.raises(SteamUnavailableError, match="not installed"), + ): + ensure_steam_debug_port() + + mock_launch.assert_not_called() + + def test_already_available(self) -> None: + with patch( + "steam_backlog_enforcer._steam_launch._steam_has_debug_port", + return_value=True, + ): + ensure_steam_debug_port() + + def test_starts_fresh(self) -> None: + with ( + patch( + "steam_backlog_enforcer._steam_launch._steam_has_debug_port", + return_value=False, + ), + patch( + "steam_backlog_enforcer._steam_launch._is_steam_running", + return_value=False, + ), + patch( + "steam_backlog_enforcer._steam_launch._launch_steam_with_debug", + ), + patch( + "steam_backlog_enforcer._steam_launch._wait_for_cdp_ready", + return_value=True, + ), + patch( + "steam_backlog_enforcer._steam_launch._wait_for_collections_ready", + return_value=True, + ), + ): + ensure_steam_debug_port() + + def test_restarts_running_steam(self) -> None: + with ( + patch( + "steam_backlog_enforcer._steam_launch._steam_has_debug_port", + return_value=False, + ), + patch( + "steam_backlog_enforcer._steam_launch._is_steam_running", + return_value=True, + ), + patch( + "steam_backlog_enforcer._steam_launch._shutdown_steam", + ), + patch( + "steam_backlog_enforcer._steam_launch._launch_steam_with_debug", + ), + patch( + "steam_backlog_enforcer._steam_launch._wait_for_cdp_ready", + return_value=True, + ), + patch( + "steam_backlog_enforcer._steam_launch._wait_for_collections_ready", + return_value=True, + ), + ): + ensure_steam_debug_port() + + def test_cdp_timeout(self) -> None: + with ( + patch( + "steam_backlog_enforcer._steam_launch._steam_has_debug_port", + return_value=False, + ), + patch( + "steam_backlog_enforcer._steam_launch._is_steam_running", + return_value=False, + ), + patch( + "steam_backlog_enforcer._steam_launch._launch_steam_with_debug", + ), + patch( + "steam_backlog_enforcer._steam_launch._wait_for_cdp_ready", + return_value=False, + ), + pytest.raises(RuntimeError, match="Timed out waiting for Steam CDP"), + ): + ensure_steam_debug_port() + + def test_collections_timeout(self) -> None: + with ( + patch( + "steam_backlog_enforcer._steam_launch._steam_has_debug_port", + return_value=False, + ), + patch( + "steam_backlog_enforcer._steam_launch._is_steam_running", + return_value=False, + ), + patch( + "steam_backlog_enforcer._steam_launch._launch_steam_with_debug", + ), + patch( + "steam_backlog_enforcer._steam_launch._wait_for_cdp_ready", + return_value=True, + ), + patch( + "steam_backlog_enforcer._steam_launch._wait_for_collections_ready", + return_value=False, + ), + pytest.raises( + RuntimeError, match="Timed out waiting for Steam collections" + ), + ): + ensure_steam_debug_port() diff --git a/steam_backlog_enforcer/tests/test_ensure_steam_running.py b/steam_backlog_enforcer/tests/test_ensure_steam_running.py new file mode 100644 index 0000000..39d3160 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_ensure_steam_running.py @@ -0,0 +1,189 @@ +"""Tests for _ensure_steam_running and the steam:// privilege drop. + +Split from test_game_install.py to keep both files under the 250-line cap. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from steam_backlog_enforcer._steam_client import _ensure_steam_running + +if TYPE_CHECKING: + from collections.abc import Iterator + +PKG = "steam_backlog_enforcer._steam_client" + + +class TestEnsureSteamRunning: + """Tests for _ensure_steam_running.""" + + @pytest.fixture(autouse=True) + def _steam_present(self) -> Iterator[None]: + """Pretend Steam is installed for every test in this class. + + Without it the function returns before any launch, which on a test + machine without Steam would make the launch assertions vacuous. The + absent case is covered by test_skips_when_steam_absent. + """ + with patch(f"{PKG}.steam_is_installed", return_value=True): + yield + + def test_skips_when_steam_absent(self) -> None: + """With Steam uninstalled, do not try to launch a missing client. + + Regression guard: this path used to exec a dead launcher wrapper and + then sleep 15s, leaving a zombie named "steam" behind each time. + """ + with ( + patch(f"{PKG}.steam_is_installed", return_value=False), + patch(f"{PKG}.subprocess.Popen") as mock_popen, + patch(f"{PKG}.time.sleep") as mock_sleep, + ): + _ensure_steam_running() + + mock_popen.assert_not_called() + mock_sleep.assert_not_called() + + def test_already_running(self) -> None: + mock_result = MagicMock(returncode=0) + with patch( + "steam_backlog_enforcer._steam_client.subprocess.run", + return_value=mock_result, + ): + _ensure_steam_running() + + def test_not_running_starts_as_non_root(self) -> None: + mock_result = MagicMock(returncode=1) + with ( + patch( + "steam_backlog_enforcer._steam_client.subprocess.run", + return_value=mock_result, + ), + patch( + "steam_backlog_enforcer._steam_client.subprocess.Popen" + ) as mock_popen, + patch( + "steam_backlog_enforcer._steam_client.os.geteuid", + return_value=1000, + ), + patch("steam_backlog_enforcer._steam_client.time.sleep"), + ): + _ensure_steam_running() + mock_popen.assert_called_once() + + def test_not_running_starts_as_root(self) -> None: + mock_result = MagicMock(returncode=1) + mock_pw = MagicMock() + mock_pw.pw_uid = 1000 + mock_pw.pw_gid = 1000 + with ( + patch( + "steam_backlog_enforcer._steam_client.subprocess.run", + return_value=mock_result, + ), + patch( + "steam_backlog_enforcer._steam_client.subprocess.Popen" + ) as mock_popen, + patch( + "steam_backlog_enforcer._steam_client.os.geteuid", + return_value=0, + ), + patch( + "steam_backlog_enforcer._steam_client._get_real_user", + return_value="alice", + ), + patch( + "steam_backlog_enforcer._steam_client._get_uid_gid_for_user", + return_value=(1000, 1000), + ), + patch( + "steam_backlog_enforcer._steam_client.desktop_session_ready", + return_value=True, + ), + patch("steam_backlog_enforcer._steam_client.time.sleep"), + ): + _ensure_steam_running() + mock_popen.assert_called_once() + + # Assert the env vector, not just that a launch happened: dropping + # XDG_RUNTIME_DIR here is silent, and cost a Proton game its audio + # backend (winepulse -> winealsa) and a crash on startup. + cmd = mock_popen.call_args[0][0] + assert cmd[:4] == ["sudo", "-u", "alice", "env"] + assert "XDG_RUNTIME_DIR=/run/user/1000" in cmd + + def test_defers_when_runtime_dir_missing(self) -> None: + """Do not start Steam before the desktop session's runtime dir exists. + + Launching in that window yields a Steam whose Wine children cannot + find PulseAudio, and it stays that way for the whole session. The + enforce loop retries every 3s, so deferring is nearly free. + """ + mock_result = MagicMock(returncode=1) + with ( + patch( + "steam_backlog_enforcer._steam_client.subprocess.run", + return_value=mock_result, + ), + patch( + "steam_backlog_enforcer._steam_client.subprocess.Popen" + ) as mock_popen, + patch( + "steam_backlog_enforcer._steam_client.os.geteuid", + return_value=0, + ), + patch( + "steam_backlog_enforcer._steam_client._get_real_user", + return_value="alice", + ), + patch( + "steam_backlog_enforcer._steam_client._get_uid_gid_for_user", + return_value=(1000, 1000), + ), + patch( + "steam_backlog_enforcer._steam_client.desktop_session_ready", + return_value=False, + ), + patch("steam_backlog_enforcer._steam_client.time.sleep") as mock_sleep, + ): + _ensure_steam_running() + + mock_popen.assert_not_called() + mock_sleep.assert_not_called() + + def test_pgrep_not_found(self) -> None: + with ( + patch( + "steam_backlog_enforcer._steam_client.subprocess.run", + side_effect=FileNotFoundError, + ), + patch("steam_backlog_enforcer._steam_client.subprocess.Popen"), + patch( + "steam_backlog_enforcer._steam_client.os.geteuid", + return_value=1000, + ), + patch("steam_backlog_enforcer._steam_client.time.sleep"), + ): + _ensure_steam_running() + + def test_steam_executable_not_found(self) -> None: + mock_result = MagicMock(returncode=1) + with ( + patch( + "steam_backlog_enforcer._steam_client.subprocess.run", + return_value=mock_result, + ), + patch( + "steam_backlog_enforcer._steam_client.subprocess.Popen", + side_effect=FileNotFoundError, + ), + patch( + "steam_backlog_enforcer._steam_client.os.geteuid", + return_value=1000, + ), + ): + _ensure_steam_running() diff --git a/steam_backlog_enforcer/tests/test_game_install.py b/steam_backlog_enforcer/tests/test_game_install.py index 096640c..7ba92a8 100644 --- a/steam_backlog_enforcer/tests/test_game_install.py +++ b/steam_backlog_enforcer/tests/test_game_install.py @@ -8,22 +8,24 @@ from unittest.mock import MagicMock, patch import pytest -from steam_backlog_enforcer.game_install import ( - _assert_not_real_steam, - _echo, - _ensure_steam_running, +from steam_backlog_enforcer._echo import _echo +from steam_backlog_enforcer._steam_client import ( _get_real_user, _get_uid_gid_for_user, - _trigger_steam_install, is_game_installed, ) +from steam_backlog_enforcer._steam_state import _assert_not_real_steam +from steam_backlog_enforcer.game_install import ( + _trigger_steam_install, +) if TYPE_CHECKING: - from collections.abc import Iterator from pathlib import Path PKG = "steam_backlog_enforcer.game_install" +ECHO_PKG = "steam_backlog_enforcer._echo" +STEAM_CLIENT_PKG = "steam_backlog_enforcer._steam_client" class TestAssertNotRealSteam: @@ -40,8 +42,8 @@ class TestAssertNotRealSteam: fake_manifest = real / "appmanifest_440.acf" fake_manifest.touch() with ( - patch(f"{PKG}._REAL_STEAMAPPS", real), - patch(f"{PKG}.STEAMAPPS_PATH", real), + patch("steam_backlog_enforcer._steam_state._REAL_STEAMAPPS", real), + patch("steam_backlog_enforcer._steam_state.STEAMAPPS_PATH", real), pytest.raises(RuntimeError, match="SAFETY"), ): _assert_not_real_steam(fake_manifest) @@ -55,8 +57,8 @@ class TestAssertNotRealSteam: redirected = tmp_path / "fake_steam" redirected.mkdir() with ( - patch(f"{PKG}._REAL_STEAMAPPS", real), - patch(f"{PKG}.STEAMAPPS_PATH", redirected), + patch("steam_backlog_enforcer._steam_state._REAL_STEAMAPPS", real), + patch("steam_backlog_enforcer._steam_state.STEAMAPPS_PATH", redirected), ): _assert_not_real_steam(fake_manifest) @@ -69,8 +71,8 @@ class TestAssertNotRealSteam: env = {k: v for k, v in os.environ.items() if k != "PYTEST_CURRENT_TEST"} with ( patch.dict(os.environ, env, clear=True), - patch(f"{PKG}._REAL_STEAMAPPS", real), - patch(f"{PKG}.STEAMAPPS_PATH", real), + patch("steam_backlog_enforcer._steam_state._REAL_STEAMAPPS", real), + patch("steam_backlog_enforcer._steam_state.STEAMAPPS_PATH", real), ): _assert_not_real_steam(fake_manifest) @@ -154,14 +156,14 @@ class TestGetUidGid: mock_pw.pw_uid = 1001 mock_pw.pw_gid = 1001 with patch( - "steam_backlog_enforcer.game_install.pwd.getpwnam", + "steam_backlog_enforcer._steam_client.pwd.getpwnam", return_value=mock_pw, ): assert _get_uid_gid_for_user("alice") == (1001, 1001) def test_unknown_user(self) -> None: with patch( - "steam_backlog_enforcer.game_install.pwd.getpwnam", + "steam_backlog_enforcer._steam_client.pwd.getpwnam", side_effect=KeyError, ): assert _get_uid_gid_for_user("nobody") == (1000, 1000) @@ -173,174 +175,55 @@ class TestIsGameInstalled: def test_installed(self, tmp_path: Path) -> None: manifest = tmp_path / "appmanifest_440.acf" manifest.touch() - with patch("steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path): + with patch("steam_backlog_enforcer._steam_client.STEAMAPPS_PATH", tmp_path): assert is_game_installed(440) is True def test_not_installed(self, tmp_path: Path) -> None: - with patch("steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path): + with patch("steam_backlog_enforcer._steam_client.STEAMAPPS_PATH", tmp_path): assert is_game_installed(440) is False -class TestEnsureSteamRunning: - """Tests for _ensure_steam_running.""" +class TestTriggerSteamInstallPrivilegeDrop: + """Regression tests: never invoke the steam:// handler as root. - @pytest.fixture(autouse=True) - def _steam_present(self) -> Iterator[None]: - """Pretend Steam is installed for every test in this class. + A bare root ``xdg-open steam://install/...`` makes Steam answer with a + "Cannot run as root user" modal on the user's display. + """ - Without it the function returns before any launch, which on a test - machine without Steam would make the launch assertions vacuous. The - absent case is covered by test_skips_when_steam_absent. - """ - with patch(f"{PKG}.steam_is_installed", return_value=True): - yield + _PKG = "steam_backlog_enforcer.game_install" - def test_skips_when_steam_absent(self) -> None: - """With Steam uninstalled, do not try to launch a missing client. - - Regression guard: this path used to exec a dead launcher wrapper and - then sleep 15s, leaving a zombie named "steam" behind each time. - """ + def test_root_drops_to_desktop_user(self) -> None: with ( - patch(f"{PKG}.steam_is_installed", return_value=False), - patch(f"{PKG}.subprocess.Popen") as mock_popen, - patch(f"{PKG}.time.sleep") as mock_sleep, + patch(f"{self._PKG}.os.geteuid", return_value=0), + patch(f"{self._PKG}._get_real_user", return_value="kuhy"), + patch(f"{self._PKG}.desktop_session_ready", return_value=True), + patch(f"{self._PKG}.desktop_uid", return_value=1000), + patch(f"{self._PKG}.subprocess.run") as mock_run, ): - _ensure_steam_running() + assert _trigger_steam_install(440, "TF2") is True - mock_popen.assert_not_called() - mock_sleep.assert_not_called() + argv = mock_run.call_args[0][0] + assert argv[:4] == ["sudo", "-u", "kuhy", "env"] + assert argv[-1] == "steam://install/440" - def test_already_running(self) -> None: - mock_result = MagicMock(returncode=0) - with patch( - "steam_backlog_enforcer.game_install.subprocess.run", - return_value=mock_result, - ): - _ensure_steam_running() - - def test_not_running_starts_as_non_root(self) -> None: - mock_result = MagicMock(returncode=1) + def test_non_root_does_not_wrap(self) -> None: with ( - patch( - "steam_backlog_enforcer.game_install.subprocess.run", - return_value=mock_result, - ), - patch("steam_backlog_enforcer.game_install.subprocess.Popen") as mock_popen, - patch( - "steam_backlog_enforcer.game_install.os.geteuid", - return_value=1000, - ), - patch("steam_backlog_enforcer.game_install.time.sleep"), + patch(f"{self._PKG}.os.geteuid", return_value=1000), + patch(f"{self._PKG}._get_real_user", return_value="kuhy"), + patch(f"{self._PKG}.subprocess.run") as mock_run, ): - _ensure_steam_running() - mock_popen.assert_called_once() + assert _trigger_steam_install(440, "TF2") is True - def test_not_running_starts_as_root(self) -> None: - mock_result = MagicMock(returncode=1) - mock_pw = MagicMock() - mock_pw.pw_uid = 1000 - mock_pw.pw_gid = 1000 + assert mock_run.call_args[0][0][0] != "sudo" + + def test_defers_when_session_not_ready(self) -> None: + """No runtime dir means a Steam launched now loses audio all session.""" with ( - patch( - "steam_backlog_enforcer.game_install.subprocess.run", - return_value=mock_result, - ), - patch("steam_backlog_enforcer.game_install.subprocess.Popen") as mock_popen, - patch( - "steam_backlog_enforcer.game_install.os.geteuid", - return_value=0, - ), - patch( - "steam_backlog_enforcer.game_install._get_real_user", - return_value="alice", - ), - patch( - "steam_backlog_enforcer.game_install._get_uid_gid_for_user", - return_value=(1000, 1000), - ), - patch( - "steam_backlog_enforcer.game_install.desktop_session_ready", - return_value=True, - ), - patch("steam_backlog_enforcer.game_install.time.sleep"), + patch(f"{self._PKG}.os.geteuid", return_value=0), + patch(f"{self._PKG}._get_real_user", return_value="kuhy"), + patch(f"{self._PKG}.desktop_session_ready", return_value=False), + patch(f"{self._PKG}.subprocess.run") as mock_run, ): - _ensure_steam_running() - mock_popen.assert_called_once() + assert _trigger_steam_install(440, "TF2") is False - # Assert the env vector, not just that a launch happened: dropping - # XDG_RUNTIME_DIR here is silent, and cost a Proton game its audio - # backend (winepulse -> winealsa) and a crash on startup. - cmd = mock_popen.call_args[0][0] - assert cmd[:4] == ["sudo", "-u", "alice", "env"] - assert "XDG_RUNTIME_DIR=/run/user/1000" in cmd - - def test_defers_when_runtime_dir_missing(self) -> None: - """Do not start Steam before the desktop session's runtime dir exists. - - Launching in that window yields a Steam whose Wine children cannot - find PulseAudio, and it stays that way for the whole session. The - enforce loop retries every 3s, so deferring is nearly free. - """ - mock_result = MagicMock(returncode=1) - with ( - patch( - "steam_backlog_enforcer.game_install.subprocess.run", - return_value=mock_result, - ), - patch("steam_backlog_enforcer.game_install.subprocess.Popen") as mock_popen, - patch( - "steam_backlog_enforcer.game_install.os.geteuid", - return_value=0, - ), - patch( - "steam_backlog_enforcer.game_install._get_real_user", - return_value="alice", - ), - patch( - "steam_backlog_enforcer.game_install._get_uid_gid_for_user", - return_value=(1000, 1000), - ), - patch( - "steam_backlog_enforcer.game_install.desktop_session_ready", - return_value=False, - ), - patch("steam_backlog_enforcer.game_install.time.sleep") as mock_sleep, - ): - _ensure_steam_running() - - mock_popen.assert_not_called() - mock_sleep.assert_not_called() - - def test_pgrep_not_found(self) -> None: - with ( - patch( - "steam_backlog_enforcer.game_install.subprocess.run", - side_effect=FileNotFoundError, - ), - patch("steam_backlog_enforcer.game_install.subprocess.Popen"), - patch( - "steam_backlog_enforcer.game_install.os.geteuid", - return_value=1000, - ), - patch("steam_backlog_enforcer.game_install.time.sleep"), - ): - _ensure_steam_running() - - def test_steam_executable_not_found(self) -> None: - mock_result = MagicMock(returncode=1) - with ( - patch( - "steam_backlog_enforcer.game_install.subprocess.run", - return_value=mock_result, - ), - patch( - "steam_backlog_enforcer.game_install.subprocess.Popen", - side_effect=FileNotFoundError, - ), - patch( - "steam_backlog_enforcer.game_install.os.geteuid", - return_value=1000, - ), - ): - _ensure_steam_running() + mock_run.assert_not_called() diff --git a/steam_backlog_enforcer/tests/test_game_install_guard.py b/steam_backlog_enforcer/tests/test_game_install_guard.py new file mode 100644 index 0000000..296638d --- /dev/null +++ b/steam_backlog_enforcer/tests/test_game_install_guard.py @@ -0,0 +1,86 @@ +"""Tests for install_game's Steam-library readiness guard. + +Split from test_game_install_part3.py to keep both files under the 250-line cap. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from steam_backlog_enforcer.game_install import install_game + +if TYPE_CHECKING: + from pathlib import Path + + +class TestInstallGameLibraryGuard: + """install_game must not retry when Steam has no library at all. + + Without this the enforce loop re-triggered every allowed game on every + pass (measured: 1656 attempts in 30 minutes), each one launching Steam + as root and raising FileNotFoundError on the manifest write. + """ + + _PKG = "steam_backlog_enforcer.game_install" + + def test_skips_when_library_missing(self, tmp_path: Path) -> None: + missing = tmp_path / "steamapps" # deliberately not created + with ( + patch(f"{self._PKG}.STEAMAPPS_PATH", missing), + patch(f"{self._PKG}.steam_library_ready", return_value=False), + patch(f"{self._PKG}._ensure_steam_running") as mock_ensure, + patch(f"{self._PKG}._trigger_steam_install") as mock_trigger, + ): + assert ( + install_game(440, "TF2", "steam123", use_steam_protocol=True) is False + ) + + mock_ensure.assert_not_called() + mock_trigger.assert_not_called() + + def test_proceeds_when_library_present(self, tmp_path: Path) -> None: + with ( + patch(f"{self._PKG}.STEAMAPPS_PATH", tmp_path), + patch(f"{self._PKG}.steam_library_ready", return_value=True), + patch(f"{self._PKG}._ensure_steam_running"), + patch(f"{self._PKG}._trigger_steam_install", return_value=True), + ): + result = install_game(440, "TF2", "steam123", use_steam_protocol=True) + assert result is True + + +class TestReinstallMissingAllowedGuard: + """_reinstall_missing_allowed must stay quiet when there is no library. + + install_game guards this too, but the caller logs one INFO line per game + *before* calling it — at a 3s cadence that is its own log storm. + """ + + _PKG = "steam_backlog_enforcer._enforce_steps" + + def test_skips_when_library_missing(self) -> None: + from steam_backlog_enforcer._enforce_steps import _reinstall_missing_allowed + + with ( + patch(f"{self._PKG}.steam_library_ready", return_value=False), + patch(f"{self._PKG}.allowed_games") as mock_allowed, + patch(f"{self._PKG}.install_game") as mock_install, + ): + _reinstall_missing_allowed(MagicMock(), MagicMock()) + + mock_allowed.assert_not_called() + mock_install.assert_not_called() + + def test_proceeds_when_library_present(self) -> None: + from steam_backlog_enforcer._enforce_steps import _reinstall_missing_allowed + + with ( + patch(f"{self._PKG}.steam_library_ready", return_value=True), + patch(f"{self._PKG}.allowed_games", return_value=[(440, "TF2")]), + patch(f"{self._PKG}.is_game_installed", return_value=False), + patch(f"{self._PKG}.install_game") as mock_install, + ): + _reinstall_missing_allowed(MagicMock(), MagicMock()) + + mock_install.assert_called_once() diff --git a/steam_backlog_enforcer/tests/test_game_install_part2.py b/steam_backlog_enforcer/tests/test_game_install_part2.py index da8e806..10edf33 100644 --- a/steam_backlog_enforcer/tests/test_game_install_part2.py +++ b/steam_backlog_enforcer/tests/test_game_install_part2.py @@ -5,10 +5,7 @@ from __future__ import annotations from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch -from steam_backlog_enforcer.game_install import ( - _is_protected_name, - _normalize_game_name, - _protected_name_stems, +from steam_backlog_enforcer.game_uninstall import ( _remove_game_dirs, uninstall_game, uninstall_other_games, @@ -18,6 +15,8 @@ if TYPE_CHECKING: from pathlib import Path PKG = "steam_backlog_enforcer.game_install" +GAME_NAMES_PKG = "steam_backlog_enforcer._game_names" +GAME_UNINSTALL_PKG = "steam_backlog_enforcer.game_uninstall" class TestRemoveGameDirs: @@ -27,19 +26,19 @@ class TestRemoveGameDirs: install_dir = tmp_path / "common" / "MyGame" install_dir.mkdir(parents=True) (install_dir / "game.exe").touch() - with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path): + with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path): result = _remove_game_dirs(install_dir, 440) assert result is True assert not install_dir.exists() def test_install_dir_none(self, tmp_path: Path) -> None: - with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path): + with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path): result = _remove_game_dirs(None, 440) assert result is True def test_install_dir_not_exists(self, tmp_path: Path) -> None: missing = tmp_path / "common" / "Missing" - with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path): + with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path): result = _remove_game_dirs(missing, 440) assert result is True @@ -47,7 +46,7 @@ class TestRemoveGameDirs: install_dir = tmp_path / "common" / "MyGame" install_dir.mkdir(parents=True) with ( - patch(f"{PKG}.STEAMAPPS_PATH", tmp_path), + patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path), patch(f"{PKG}.shutil.rmtree", side_effect=OSError("perm")), ): result = _remove_game_dirs(install_dir, 440) @@ -56,7 +55,7 @@ class TestRemoveGameDirs: def test_removes_cache_dirs(self, tmp_path: Path) -> None: for subdir in ("shadercache", "compatdata"): (tmp_path / subdir / "440").mkdir(parents=True) - with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path): + with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path): result = _remove_game_dirs(None, 440) assert result is True assert not (tmp_path / "shadercache" / "440").exists() @@ -73,7 +72,7 @@ class TestRemoveGameDirs: raise OSError(msg) with ( - patch(f"{PKG}.STEAMAPPS_PATH", tmp_path), + patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path), patch(f"{PKG}.shutil.rmtree", side_effect=fake_rmtree), ): result = _remove_game_dirs(None, 440) @@ -88,7 +87,7 @@ class TestUninstallGame: manifest.write_text('"installdir"\t\t"TF2"\n', encoding="utf-8") install_dir = tmp_path / "common" / "TF2" install_dir.mkdir(parents=True) - with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path): + with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path): result = uninstall_game(440, "TF2") assert result is True @@ -97,19 +96,19 @@ class TestUninstallGame: mock_manifest.exists.return_value = True mock_manifest.unlink.side_effect = OSError with ( - patch(f"{PKG}.STEAMAPPS_PATH", MagicMock()), - patch(f"{PKG}._read_install_dir", return_value=None), - patch(f"{PKG}._remove_manifest", return_value=False), - patch(f"{PKG}._remove_game_dirs", return_value=True), + patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", MagicMock()), + patch(f"{GAME_UNINSTALL_PKG}._read_install_dir", return_value=None), + patch(f"{GAME_UNINSTALL_PKG}._remove_manifest", return_value=False), + patch(f"{GAME_UNINSTALL_PKG}._remove_game_dirs", return_value=True), ): result = uninstall_game(440, "TF2") assert result is False def test_game_dirs_removal_fails(self) -> None: with ( - patch(f"{PKG}._read_install_dir", return_value=None), - patch(f"{PKG}._remove_manifest", return_value=True), - patch(f"{PKG}._remove_game_dirs", return_value=False), + patch(f"{GAME_UNINSTALL_PKG}._read_install_dir", return_value=None), + patch(f"{GAME_UNINSTALL_PKG}._remove_manifest", return_value=True), + patch(f"{GAME_UNINSTALL_PKG}._remove_game_dirs", return_value=False), ): result = uninstall_game(440, "TF2") assert result is False @@ -121,10 +120,12 @@ class TestUninstallOtherGames: def test_keeps_allowed(self) -> None: with ( patch( - f"{PKG}.get_installed_games", + f"{GAME_UNINSTALL_PKG}.get_installed_games", return_value=[(440, "TF2"), (730, "CS")], ), - patch(f"{PKG}.uninstall_game", return_value=True) as mock_uninstall, + patch( + f"{GAME_UNINSTALL_PKG}.uninstall_game", return_value=True + ) as mock_uninstall, ): count = uninstall_other_games({440}) assert count == 1 @@ -133,10 +134,10 @@ class TestUninstallOtherGames: def test_skips_protected(self) -> None: with ( patch( - f"{PKG}.get_installed_games", + f"{GAME_UNINSTALL_PKG}.get_installed_games", return_value=[(228980, "Redist")], ), - patch(f"{PKG}.uninstall_game") as mock_uninstall, + patch(f"{GAME_UNINSTALL_PKG}.uninstall_game") as mock_uninstall, ): count = uninstall_other_games(set()) assert count == 0 @@ -145,10 +146,10 @@ class TestUninstallOtherGames: def test_uninstall_fails(self) -> None: with ( patch( - f"{PKG}.get_installed_games", + f"{GAME_UNINSTALL_PKG}.get_installed_games", return_value=[(999, "GameX")], ), - patch(f"{PKG}.uninstall_game", return_value=False), + patch(f"{GAME_UNINSTALL_PKG}.uninstall_game", return_value=False), ): count = uninstall_other_games(set()) assert count == 0 @@ -156,100 +157,11 @@ class TestUninstallOtherGames: def test_all_allowed_or_protected(self) -> None: with ( patch( - f"{PKG}.get_installed_games", + f"{GAME_UNINSTALL_PKG}.get_installed_games", return_value=[(440, "TF2"), (228980, "Redist")], ), - patch(f"{PKG}.uninstall_game") as mock_uninstall, + patch(f"{GAME_UNINSTALL_PKG}.uninstall_game") as mock_uninstall, ): count = uninstall_other_games({440}) assert count == 0 mock_uninstall.assert_not_called() - - -class TestNormalizeGameName: - """Tests for _normalize_game_name.""" - - def test_lowercases_and_strips_punctuation(self) -> None: - assert ( - _normalize_game_name("Kingdom Come: Deliverance II") - == "kingdomcomedeliverance2" - ) - - def test_matches_no_space_variant(self) -> None: - assert _normalize_game_name("KingdomComeDeliverance2") == _normalize_game_name( - "Kingdom Come: Deliverance II" - ) - - def test_spelled_out_number_matches_digit(self) -> None: - assert _normalize_game_name("Portal Two") == _normalize_game_name("Portal 2") - - -class TestProtectedNameStems: - """Tests for _protected_name_stems.""" - - def test_returns_allowed_game_names(self) -> None: - with ( - patch("steam_backlog_enforcer.config.State.load", return_value=MagicMock()), - patch( - f"{PKG}.allowed_games", - return_value=[(1771300, "Kingdom Come: Deliverance II"), (440, "")], - ), - ): - stems = _protected_name_stems() - assert stems == ["Kingdom Come: Deliverance II"] - - def test_returns_empty_list_when_state_cannot_load(self) -> None: - with patch( - "steam_backlog_enforcer.config.State.load", side_effect=RuntimeError("boom") - ): - stems = _protected_name_stems() - assert stems == [] - - -class TestIsProtectedName: - """Tests for _is_protected_name.""" - - def test_blank_candidate_is_not_protected(self) -> None: - assert _is_protected_name(" ") is False - - def test_matches_exact_normalized_name(self) -> None: - with patch( - f"{PKG}._protected_name_stems", - return_value=["Kingdom Come: Deliverance II"], - ): - assert _is_protected_name("KingdomComeDeliverance2") is True - - def test_matches_typo_via_fuzzy_ratio(self) -> None: - with patch( - f"{PKG}._protected_name_stems", - return_value=["Kingdom Come: Deliverance II"], - ): - assert _is_protected_name("Kingdom Come Delievarnce 2") is True - - def test_no_match_for_unrelated_name(self) -> None: - with patch( - f"{PKG}._protected_name_stems", - return_value=["Kingdom Come: Deliverance II"], - ): - assert _is_protected_name("Cogmind") is False - - def test_skips_blank_protected_name(self) -> None: - with patch(f"{PKG}._protected_name_stems", return_value=["", "Cogmind"]): - assert _is_protected_name("Cogmind") is True - - -class TestRemoveGameDirsProtectedName: - """Tests for the _is_protected_name safety net inside _remove_game_dirs.""" - - def test_refuses_to_remove_protected_name(self, tmp_path: Path) -> None: - install_dir = tmp_path / "common" / "Kingdom Come: Deliverance II" - install_dir.mkdir(parents=True) - (install_dir / "game.exe").touch() - with ( - patch(f"{PKG}.STEAMAPPS_PATH", tmp_path), - patch(f"{PKG}._is_protected_name", return_value=True), - ): - result = _remove_game_dirs(install_dir, 1771300) - assert result is False - assert install_dir.exists() - assert (install_dir / "game.exe").exists() diff --git a/steam_backlog_enforcer/tests/test_game_install_part3.py b/steam_backlog_enforcer/tests/test_game_install_part3.py index 6ba14bb..ed05d20 100644 --- a/steam_backlog_enforcer/tests/test_game_install_part3.py +++ b/steam_backlog_enforcer/tests/test_game_install_part3.py @@ -5,11 +5,11 @@ from __future__ import annotations from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch -from steam_backlog_enforcer.game_install import ( +from steam_backlog_enforcer.game_install import install_game +from steam_backlog_enforcer.game_uninstall import ( _read_install_dir, _remove_manifest, get_installed_games, - install_game, ) if TYPE_CHECKING: @@ -158,26 +158,26 @@ class TestGetInstalledGames: def test_parses_manifests(self, tmp_path: Path) -> None: manifest = tmp_path / "appmanifest_440.acf" manifest.write_text('"appid"\t\t"440"\n"name"\t\t"Team Fortress 2"\n') - with patch("steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path): + with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path): result = get_installed_games() assert result == [(440, "Team Fortress 2")] def test_no_name(self, tmp_path: Path) -> None: manifest = tmp_path / "appmanifest_440.acf" manifest.write_text('"appid"\t\t"440"\n') - with patch("steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path): + with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path): result = get_installed_games() assert result == [(440, "Unknown (440)")] def test_empty_dir(self, tmp_path: Path) -> None: - with patch("steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path): + with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path): result = get_installed_games() assert result == [] def test_no_appid_match(self, tmp_path: Path) -> None: manifest = tmp_path / "appmanifest_440.acf" manifest.write_text('"name"\t\t"NoAppId"\n') - with patch("steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path): + with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path): result = get_installed_games() assert result == [] @@ -188,14 +188,14 @@ class TestReadInstallDir: def test_reads_dir(self, tmp_path: Path) -> None: manifest = tmp_path / "appmanifest_440.acf" manifest.write_text('"installdir"\t\t"Team Fortress 2"\n') - with patch("steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path): + with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path): result = _read_install_dir(manifest) assert result == tmp_path / "common" / "Team Fortress 2" def test_no_match(self, tmp_path: Path) -> None: manifest = tmp_path / "appmanifest_440.acf" manifest.write_text('"appid"\t\t"440"\n') - with patch("steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path): + with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path): assert _read_install_dir(manifest) is None def test_missing_file(self, tmp_path: Path) -> None: diff --git a/steam_backlog_enforcer/tests/test_game_names.py b/steam_backlog_enforcer/tests/test_game_names.py new file mode 100644 index 0000000..f49f956 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_game_names.py @@ -0,0 +1,119 @@ +"""Tests for game-name normalisation and the protected-name safety net. + +Split to keep every test file under the 250-line cap. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from steam_backlog_enforcer._game_names import ( + _is_protected_name, + _normalize_game_name, + _protected_name_stems, +) +from steam_backlog_enforcer.game_uninstall import ( + _remove_game_dirs, +) + +if TYPE_CHECKING: + from pathlib import Path + +PKG = "steam_backlog_enforcer.game_install" +GAME_NAMES_PKG = "steam_backlog_enforcer._game_names" +GAME_UNINSTALL_PKG = "steam_backlog_enforcer.game_uninstall" + + +class TestNormalizeGameName: + """Tests for _normalize_game_name.""" + + def test_lowercases_and_strips_punctuation(self) -> None: + assert ( + _normalize_game_name("Kingdom Come: Deliverance II") + == "kingdomcomedeliverance2" + ) + + def test_matches_no_space_variant(self) -> None: + assert _normalize_game_name("KingdomComeDeliverance2") == _normalize_game_name( + "Kingdom Come: Deliverance II" + ) + + def test_spelled_out_number_matches_digit(self) -> None: + assert _normalize_game_name("Portal Two") == _normalize_game_name("Portal 2") + + +class TestProtectedNameStems: + """Tests for _protected_name_stems.""" + + def test_returns_allowed_game_names(self) -> None: + with ( + patch("steam_backlog_enforcer.config.State.load", return_value=MagicMock()), + patch( + "steam_backlog_enforcer._game_names.allowed_games", + return_value=[(1771300, "Kingdom Come: Deliverance II"), (440, "")], + ), + ): + stems = _protected_name_stems() + assert stems == ["Kingdom Come: Deliverance II"] + + def test_returns_empty_list_when_state_cannot_load(self) -> None: + with patch( + "steam_backlog_enforcer.config.State.load", side_effect=RuntimeError("boom") + ): + stems = _protected_name_stems() + assert stems == [] + + +class TestIsProtectedName: + """Tests for _is_protected_name.""" + + def test_blank_candidate_is_not_protected(self) -> None: + assert _is_protected_name(" ") is False + + def test_matches_exact_normalized_name(self) -> None: + with patch( + f"{GAME_NAMES_PKG}._protected_name_stems", + return_value=["Kingdom Come: Deliverance II"], + ): + assert _is_protected_name("KingdomComeDeliverance2") is True + + def test_matches_typo_via_fuzzy_ratio(self) -> None: + with patch( + f"{GAME_NAMES_PKG}._protected_name_stems", + return_value=["Kingdom Come: Deliverance II"], + ): + assert _is_protected_name("Kingdom Come Delievarnce 2") is True + + def test_no_match_for_unrelated_name(self) -> None: + with patch( + f"{GAME_NAMES_PKG}._protected_name_stems", + return_value=["Kingdom Come: Deliverance II"], + ): + assert _is_protected_name("Cogmind") is False + + def test_skips_blank_protected_name(self) -> None: + with patch( + f"{GAME_NAMES_PKG}._protected_name_stems", return_value=["", "Cogmind"] + ): + assert _is_protected_name("Cogmind") is True + + +class TestRemoveGameDirsProtectedName: + """Tests for the _is_protected_name safety net inside _remove_game_dirs.""" + + def test_refuses_to_remove_protected_name(self, tmp_path: Path) -> None: + install_dir = tmp_path / "common" / "Kingdom Come: Deliverance II" + install_dir.mkdir(parents=True) + (install_dir / "game.exe").touch() + with ( + patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path), + patch( + "steam_backlog_enforcer.game_uninstall._is_protected_name", + return_value=True, + ), + ): + result = _remove_game_dirs(install_dir, 1771300) + assert result is False + assert install_dir.exists() + assert (install_dir / "game.exe").exists() diff --git a/steam_backlog_enforcer/tests/test_library_hider.py b/steam_backlog_enforcer/tests/test_library_hider.py index c989bd5..9ab8371 100644 --- a/steam_backlog_enforcer/tests/test_library_hider.py +++ b/steam_backlog_enforcer/tests/test_library_hider.py @@ -4,29 +4,17 @@ from __future__ import annotations import asyncio import json -from typing import TYPE_CHECKING from unittest.mock import AsyncMock, MagicMock, patch import pytest -from steam_backlog_enforcer.library_hider import ( - SteamUnavailableError, +from steam_backlog_enforcer._cdp import ( _cdp_result_value, _evaluate_js, _evaluate_js_async, _get_shared_js_ws_url, - _is_steam_running, - _launch_steam_with_debug, - _shutdown_steam, - _steam_has_debug_port, - _wait_for_cdp_ready, - _wait_for_collections_ready, - ensure_steam_debug_port, ) -if TYPE_CHECKING: - from collections.abc import Iterator - class TestGetSharedJsWsUrl: """Tests for _get_shared_js_ws_url.""" @@ -42,7 +30,7 @@ class TestGetSharedJsWsUrl: mock_resp = MagicMock() mock_resp.json.return_value = targets with patch( - "steam_backlog_enforcer.library_hider.requests.get", + "steam_backlog_enforcer._cdp.requests.get", return_value=mock_resp, ): result = _get_shared_js_ws_url() @@ -53,14 +41,14 @@ class TestGetSharedJsWsUrl: mock_resp = MagicMock() mock_resp.json.return_value = targets with patch( - "steam_backlog_enforcer.library_hider.requests.get", + "steam_backlog_enforcer._cdp.requests.get", return_value=mock_resp, ): assert _get_shared_js_ws_url() is None def test_connection_error(self) -> None: with patch( - "steam_backlog_enforcer.library_hider.requests.get", + "steam_backlog_enforcer._cdp.requests.get", side_effect=OSError, ): assert _get_shared_js_ws_url() is None @@ -79,7 +67,7 @@ class TestEvaluateJsAsync: mock_ws.__aexit__ = AsyncMock(return_value=False) with patch( - "steam_backlog_enforcer.library_hider.websockets.connect", + "steam_backlog_enforcer._cdp.websockets.connect", return_value=mock_ws, ): result = asyncio.run(_evaluate_js_async("ws://test", "1+1")) @@ -92,11 +80,11 @@ class TestEvaluateJs: def test_success(self) -> None: with ( patch( - "steam_backlog_enforcer.library_hider._get_shared_js_ws_url", + "steam_backlog_enforcer._cdp._get_shared_js_ws_url", return_value="ws://test", ), patch( - "steam_backlog_enforcer.library_hider.asyncio.run", + "steam_backlog_enforcer._cdp.asyncio.run", return_value={"result": {"result": {"value": "ok"}}}, ), ): @@ -106,7 +94,7 @@ class TestEvaluateJs: def test_no_ws_url(self) -> None: with ( patch( - "steam_backlog_enforcer.library_hider._get_shared_js_ws_url", + "steam_backlog_enforcer._cdp._get_shared_js_ws_url", return_value=None, ), pytest.raises(RuntimeError, match="SharedJSContext not found"), @@ -133,336 +121,3 @@ class TestCdpResultValue: def test_empty(self) -> None: assert _cdp_result_value({}) == "" - - -class TestIsSteamRunning: - """Tests for _is_steam_running.""" - - def test_running(self) -> None: - mock_result = MagicMock(returncode=0) - with patch( - "steam_backlog_enforcer.library_hider.subprocess.run", - return_value=mock_result, - ): - assert _is_steam_running() is True - - def test_not_running(self) -> None: - mock_result = MagicMock(returncode=1) - with patch( - "steam_backlog_enforcer.library_hider.subprocess.run", - return_value=mock_result, - ): - assert _is_steam_running() is False - - -class TestSteamHasDebugPort: - """Tests for _steam_has_debug_port.""" - - def test_has_port(self) -> None: - with patch( - "steam_backlog_enforcer.library_hider._get_shared_js_ws_url", - return_value="ws://test", - ): - assert _steam_has_debug_port() is True - - def test_no_port(self) -> None: - with patch( - "steam_backlog_enforcer.library_hider._get_shared_js_ws_url", - return_value=None, - ): - assert _steam_has_debug_port() is False - - -class TestWaitForCdpReady: - """Tests for _wait_for_cdp_ready.""" - - def test_ready_immediately(self) -> None: - with patch( - "steam_backlog_enforcer.library_hider._get_shared_js_ws_url", - return_value="ws://test", - ): - assert _wait_for_cdp_ready() is True - - def test_timeout(self) -> None: - with ( - patch( - "steam_backlog_enforcer.library_hider._get_shared_js_ws_url", - return_value=None, - ), - patch( - "steam_backlog_enforcer.library_hider.time.sleep", - ), - patch( - "steam_backlog_enforcer.library_hider._STEAM_STARTUP_WAIT", - 2, - ), - ): - assert _wait_for_cdp_ready() is False - - -class TestWaitForCollectionsReady: - """Tests for _wait_for_collections_ready.""" - - def test_ready(self) -> None: - with ( - patch( - "steam_backlog_enforcer.library_hider._evaluate_js", - return_value={"result": {"result": {"value": "ok"}}}, - ), - patch( - "steam_backlog_enforcer.library_hider._cdp_result_value", - return_value="ok", - ), - ): - assert _wait_for_collections_ready() is True - - def test_not_ready_then_ready(self) -> None: - with ( - patch( - "steam_backlog_enforcer.library_hider._evaluate_js", - return_value={"result": {"result": {"value": "not_ready"}}}, - ), - patch( - "steam_backlog_enforcer.library_hider._cdp_result_value", - side_effect=["not_ready", "ok"], - ), - patch( - "steam_backlog_enforcer.library_hider.time.sleep", - ), - patch( - "steam_backlog_enforcer.library_hider._STEAM_STARTUP_WAIT", - 2, - ), - ): - assert _wait_for_collections_ready() is True - - def test_timeout(self) -> None: - with ( - patch( - "steam_backlog_enforcer.library_hider._evaluate_js", - side_effect=RuntimeError, - ), - patch( - "steam_backlog_enforcer.library_hider.time.sleep", - ), - patch( - "steam_backlog_enforcer.library_hider._STEAM_STARTUP_WAIT", - 2, - ), - ): - assert _wait_for_collections_ready() is False - - -class TestShutdownSteam: - """Tests for _shutdown_steam.""" - - def test_exits_immediately(self) -> None: - mock_result = MagicMock(returncode=1) # Not running - with ( - patch( - "steam_backlog_enforcer.library_hider._run_as_user", - ), - patch( - "steam_backlog_enforcer.library_hider.subprocess.run", - return_value=mock_result, - ), - ): - _shutdown_steam() - - def test_waits_for_exit(self) -> None: - results = [MagicMock(returncode=0), MagicMock(returncode=1)] - with ( - patch( - "steam_backlog_enforcer.library_hider._run_as_user", - ), - patch( - "steam_backlog_enforcer.library_hider.subprocess.run", - side_effect=results, - ), - patch( - "steam_backlog_enforcer.library_hider.time.sleep", - ), - ): - _shutdown_steam() - - def test_file_not_found(self) -> None: - with patch( - "steam_backlog_enforcer.library_hider._run_as_user", - side_effect=FileNotFoundError, - ): - _shutdown_steam() # Should not raise - - def test_timeout(self) -> None: - mock_result = MagicMock(returncode=0) # Still running - with ( - patch( - "steam_backlog_enforcer.library_hider._run_as_user", - ), - patch( - "steam_backlog_enforcer.library_hider.subprocess.run", - return_value=mock_result, - ), - patch( - "steam_backlog_enforcer.library_hider.time.sleep", - ), - ): - _shutdown_steam() # Should complete loop without raising - - -class TestLaunchSteamWithDebug: - """Tests for _launch_steam_with_debug.""" - - def test_launches(self) -> None: - with patch( - "steam_backlog_enforcer.library_hider._run_as_user", - ) as mock_run: - _launch_steam_with_debug() - mock_run.assert_called_once() - - -class TestEnsureSteamDebugPort: - """Tests for ensure_steam_debug_port.""" - - @pytest.fixture(autouse=True) - def _steam_present(self) -> Iterator[None]: - """Pretend Steam is installed for every test in this class. - - Without it the function raises before reaching any launch logic, - which on a test machine without Steam would make the cases below - vacuous. The absent case has its own test. - """ - with patch( - "steam_backlog_enforcer.library_hider.steam_is_installed", - return_value=True, - ): - yield - - def test_raises_when_steam_absent(self) -> None: - """An uninstalled Steam must fail fast, before any launch attempt. - - Regression guard: without this the caller spent ~45s waiting on a - CDP port that no process was ever going to open. - """ - with ( - patch( - "steam_backlog_enforcer.library_hider._steam_has_debug_port", - return_value=False, - ), - patch( - "steam_backlog_enforcer.library_hider.steam_is_installed", - return_value=False, - ), - patch( - "steam_backlog_enforcer.library_hider._launch_steam_with_debug", - ) as mock_launch, - pytest.raises(SteamUnavailableError, match="not installed"), - ): - ensure_steam_debug_port() - - mock_launch.assert_not_called() - - def test_already_available(self) -> None: - with patch( - "steam_backlog_enforcer.library_hider._steam_has_debug_port", - return_value=True, - ): - ensure_steam_debug_port() - - def test_starts_fresh(self) -> None: - with ( - patch( - "steam_backlog_enforcer.library_hider._steam_has_debug_port", - return_value=False, - ), - patch( - "steam_backlog_enforcer.library_hider._is_steam_running", - return_value=False, - ), - patch( - "steam_backlog_enforcer.library_hider._launch_steam_with_debug", - ), - patch( - "steam_backlog_enforcer.library_hider._wait_for_cdp_ready", - return_value=True, - ), - patch( - "steam_backlog_enforcer.library_hider._wait_for_collections_ready", - return_value=True, - ), - ): - ensure_steam_debug_port() - - def test_restarts_running_steam(self) -> None: - with ( - patch( - "steam_backlog_enforcer.library_hider._steam_has_debug_port", - return_value=False, - ), - patch( - "steam_backlog_enforcer.library_hider._is_steam_running", - return_value=True, - ), - patch( - "steam_backlog_enforcer.library_hider._shutdown_steam", - ), - patch( - "steam_backlog_enforcer.library_hider._launch_steam_with_debug", - ), - patch( - "steam_backlog_enforcer.library_hider._wait_for_cdp_ready", - return_value=True, - ), - patch( - "steam_backlog_enforcer.library_hider._wait_for_collections_ready", - return_value=True, - ), - ): - ensure_steam_debug_port() - - def test_cdp_timeout(self) -> None: - with ( - patch( - "steam_backlog_enforcer.library_hider._steam_has_debug_port", - return_value=False, - ), - patch( - "steam_backlog_enforcer.library_hider._is_steam_running", - return_value=False, - ), - patch( - "steam_backlog_enforcer.library_hider._launch_steam_with_debug", - ), - patch( - "steam_backlog_enforcer.library_hider._wait_for_cdp_ready", - return_value=False, - ), - pytest.raises(RuntimeError, match="Timed out waiting for Steam CDP"), - ): - ensure_steam_debug_port() - - def test_collections_timeout(self) -> None: - with ( - patch( - "steam_backlog_enforcer.library_hider._steam_has_debug_port", - return_value=False, - ), - patch( - "steam_backlog_enforcer.library_hider._is_steam_running", - return_value=False, - ), - patch( - "steam_backlog_enforcer.library_hider._launch_steam_with_debug", - ), - patch( - "steam_backlog_enforcer.library_hider._wait_for_cdp_ready", - return_value=True, - ), - patch( - "steam_backlog_enforcer.library_hider._wait_for_collections_ready", - return_value=False, - ), - pytest.raises( - RuntimeError, match="Timed out waiting for Steam collections" - ), - ): - ensure_steam_debug_port() diff --git a/steam_backlog_enforcer/tests/test_library_hider_ops.py b/steam_backlog_enforcer/tests/test_library_hider_ops.py new file mode 100644 index 0000000..24dbc27 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_library_hider_ops.py @@ -0,0 +1,154 @@ +"""Tests for library_hider's hide/unhide operations. + +Split from test_library_hider_part2.py to keep both files under the 250-line cap. +""" + +from __future__ import annotations + +from unittest.mock import patch + +from steam_backlog_enforcer._steam_errors import ( + SteamUnavailableError, + SteamUpdateInProgressError, +) +from steam_backlog_enforcer._steam_launch import restart_steam +from steam_backlog_enforcer.library_hider import ( + hide_other_games, + try_hide_other_games, + unhide_all_games, +) + +PKG = "steam_backlog_enforcer.library_hider" +STEAM_LAUNCH_PKG = "steam_backlog_enforcer._steam_launch" +LIBRARY_HIDER_PKG = "steam_backlog_enforcer.library_hider" + + +class TestRestartSteam: + """Tests for restart_steam.""" + + def test_cdp_ready(self) -> None: + with ( + patch("steam_backlog_enforcer._steam_launch._shutdown_steam"), + patch("steam_backlog_enforcer._steam_launch._launch_steam_with_debug"), + patch( + "steam_backlog_enforcer._steam_launch._wait_for_cdp_ready", + return_value=True, + ), + ): + restart_steam() + + def test_cdp_not_ready(self) -> None: + with ( + patch("steam_backlog_enforcer._steam_launch._shutdown_steam"), + patch("steam_backlog_enforcer._steam_launch._launch_steam_with_debug"), + patch( + "steam_backlog_enforcer._steam_launch._wait_for_cdp_ready", + return_value=False, + ), + ): + restart_steam() + + +class TestHideOtherGames: + """Tests for hide_other_games.""" + + def test_hides(self) -> None: + with ( + patch("steam_backlog_enforcer.library_hider.ensure_steam_debug_port"), + patch( + "steam_backlog_enforcer.library_hider._evaluate_js", + return_value={ + "result": {"result": {"value": '{"totalHidden": 5}'}}, + }, + ), + patch( + "steam_backlog_enforcer.library_hider._cdp_result_value", + return_value='{"totalHidden": 5}', + ), + ): + count = hide_other_games([1, 2, 3], {1}) + assert count == 5 + + def test_empty_list(self) -> None: + with ( + patch("steam_backlog_enforcer.library_hider.ensure_steam_debug_port"), + patch( + "steam_backlog_enforcer.library_hider._evaluate_js", + return_value={ + "result": {"result": {"value": '{"totalHidden": 0}'}}, + }, + ), + patch( + "steam_backlog_enforcer.library_hider._cdp_result_value", + return_value='{"totalHidden": 0}', + ), + ): + count = hide_other_games([1], {1}) + assert count == 0 + + def test_no_allowed(self) -> None: + with ( + patch("steam_backlog_enforcer.library_hider.ensure_steam_debug_port"), + patch( + "steam_backlog_enforcer.library_hider._evaluate_js", + return_value={ + "result": {"result": {"value": '{"totalHidden": 2}'}}, + }, + ), + patch( + "steam_backlog_enforcer.library_hider._cdp_result_value", + return_value='{"totalHidden": 2}', + ), + ): + count = hide_other_games([1, 2], set()) + assert count == 2 + + +class TestTryHideOtherGames: + """Tests for the graceful wrapper around hide_other_games. + + Regression guard: an unreachable Steam (or a deferred restart while a game + update is in flight) used to escape as a traceback out of every + interactive command that reconciles the library. + """ + + def test_success_returns_count_and_no_reason(self) -> None: + with patch(f"{PKG}.hide_other_games", return_value=7): + assert try_hide_other_games([1, 2], {1}) == (7, None) + + def test_steam_unavailable_is_reported_not_raised(self) -> None: + with patch( + f"{PKG}.hide_other_games", + side_effect=SteamUnavailableError("Steam is not installed"), + ): + hidden, reason = try_hide_other_games([1, 2], {1}) + assert hidden == 0 + assert reason == "Steam is not installed" + + def test_update_in_progress_is_reported_not_raised(self) -> None: + with patch( + f"{PKG}.hide_other_games", + side_effect=SteamUpdateInProgressError("update in progress"), + ): + hidden, reason = try_hide_other_games([1, 2], {1}) + assert hidden == 0 + assert reason == "update in progress" + + +class TestUnhideAllGames: + """Tests for unhide_all_games.""" + + def test_unhides(self) -> None: + with ( + patch("steam_backlog_enforcer.library_hider.ensure_steam_debug_port"), + patch( + "steam_backlog_enforcer.library_hider._evaluate_js", + return_value={"result": {"result": {"value": '{"count": 10}'}}}, + ), + patch( + "steam_backlog_enforcer.library_hider._cdp_result_value", + return_value='{"count": 10}', + ), + ): + count = unhide_all_games([1, 2, 3]) + assert count == 10 diff --git a/steam_backlog_enforcer/tests/test_library_hider_part2.py b/steam_backlog_enforcer/tests/test_library_hider_part2.py index be01eeb..a936307 100644 --- a/steam_backlog_enforcer/tests/test_library_hider_part2.py +++ b/steam_backlog_enforcer/tests/test_library_hider_part2.py @@ -6,21 +6,22 @@ import os import tempfile from unittest.mock import MagicMock, patch -from steam_backlog_enforcer.library_hider import ( - _SPAWNED, - SteamUnavailableError, - SteamUpdateInProgressError, - _reap_spawned, +from steam_backlog_enforcer._steam_launch import ( _resolve_desktop_user, - _run_as_user, - hide_other_games, - restart_steam, steam_is_installed, - try_hide_other_games, - unhide_all_games, +) +from steam_backlog_enforcer._steam_process import ( + _SPAWNED, + _reap_spawned, + _run_as_user, ) PKG = "steam_backlog_enforcer.library_hider" +CDP_PKG = "steam_backlog_enforcer._cdp" +STEAM_LAUNCH_PKG = "steam_backlog_enforcer._steam_launch" +STEAM_PROCESS_PKG = "steam_backlog_enforcer._steam_process" +LIBRARY_HIDER_PKG = "steam_backlog_enforcer.library_hider" +ENV_PKG = "steam_backlog_enforcer._desktop_env" class TestResolveDesktopUser: @@ -69,12 +70,12 @@ class TestSteamIsInstalled: """Tests for steam_is_installed.""" def test_true_when_binary_exists(self) -> None: - with patch(f"{PKG}.Path") as mock_path: + with patch("steam_backlog_enforcer._steam_launch.Path") as mock_path: mock_path.return_value.exists.return_value = True assert steam_is_installed() is True def test_false_when_binary_missing(self) -> None: - with patch(f"{PKG}.Path") as mock_path: + with patch("steam_backlog_enforcer._steam_launch.Path") as mock_path: mock_path.return_value.exists.return_value = False assert steam_is_installed() is False @@ -85,7 +86,7 @@ class TestSteamIsInstalled: package is uninstalled - which is exactly how a dead Steam went on looking installed and got launched ~1000 times. """ - with patch(f"{PKG}.Path") as mock_path: + with patch("steam_backlog_enforcer._steam_launch.Path") as mock_path: mock_path.return_value.exists.return_value = False steam_is_installed() mock_path.assert_called_once_with("/usr/bin/steam") @@ -127,8 +128,8 @@ class TestRunAsUser: """Every launch must be tracked, or it can never be reaped.""" _SPAWNED.clear() with ( - patch(f"{PKG}.os.geteuid", return_value=1000), - patch(f"{PKG}.subprocess.Popen") as mock_popen, + patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=1000), + patch(f"{STEAM_PROCESS_PKG}.subprocess.Popen") as mock_popen, ): _run_as_user(["steam"], "alice") try: @@ -138,8 +139,8 @@ class TestRunAsUser: def test_non_root_runs_directly(self) -> None: with ( - patch(f"{PKG}.os.geteuid", return_value=1000), - patch(f"{PKG}.subprocess.Popen") as mock_popen, + patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=1000), + patch(f"{STEAM_PROCESS_PKG}.subprocess.Popen") as mock_popen, ): _run_as_user(["steam", "-shutdown"], "alice") mock_popen.assert_called_once() @@ -150,13 +151,13 @@ class TestRunAsUser: mock_pw = MagicMock() mock_pw.pw_uid = 1001 with ( - patch(f"{PKG}.os.geteuid", return_value=0), - patch(f"{PKG}.pwd.getpwnam", return_value=mock_pw), + patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=0), + patch(f"{ENV_PKG}.pwd.getpwnam", return_value=mock_pw), patch.dict( os.environ, {"DISPLAY": ":1", "XAUTHORITY": tempfile.gettempdir() + "/.X"}, ), - patch(f"{PKG}.subprocess.Popen") as mock_popen, + patch(f"{STEAM_PROCESS_PKG}.subprocess.Popen") as mock_popen, ): _run_as_user(["steam", "-shutdown"], "alice") mock_popen.assert_called_once() @@ -167,9 +168,9 @@ class TestRunAsUser: def test_root_user_key_error(self) -> None: with ( - patch(f"{PKG}.os.geteuid", return_value=0), - patch(f"{PKG}.pwd.getpwnam", side_effect=KeyError("no user")), - patch(f"{PKG}.subprocess.Popen") as mock_popen, + patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=0), + patch(f"{ENV_PKG}.pwd.getpwnam", side_effect=KeyError("no user")), + patch(f"{STEAM_PROCESS_PKG}.subprocess.Popen") as mock_popen, ): _run_as_user(["steam"], "unknownuser") mock_popen.assert_called_once() @@ -178,24 +179,26 @@ class TestRunAsUser: assert "sudo" in cmd[0] def test_root_user_none(self) -> None: - """When user is None and euid is 0, runs directly.""" + """When user is None and euid is 0, refuses rather than running as root. + + Running directly here is what put a root Steam — and its "Cannot run + as root user" modal — on the user's display. + """ with ( - patch(f"{PKG}.os.geteuid", return_value=0), - patch(f"{PKG}.subprocess.Popen") as mock_popen, + patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=0), + patch(f"{STEAM_PROCESS_PKG}.subprocess.Popen") as mock_popen, ): _run_as_user(["steam"], None) - cmd = mock_popen.call_args[0][0] - assert cmd == ["steam"] + mock_popen.assert_not_called() def test_root_user_is_root(self) -> None: - """When user is 'root', runs directly.""" + """When user is 'root', refuses rather than running as root.""" with ( - patch(f"{PKG}.os.geteuid", return_value=0), - patch(f"{PKG}.subprocess.Popen") as mock_popen, + patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=0), + patch(f"{STEAM_PROCESS_PKG}.subprocess.Popen") as mock_popen, ): _run_as_user(["steam"], "root") - cmd = mock_popen.call_args[0][0] - assert cmd == ["steam"] + mock_popen.assert_not_called() def test_root_uses_env_defaults(self) -> None: """When DBUS/XAUTHORITY/DISPLAY not in env, uses defaults.""" @@ -206,137 +209,12 @@ class TestRunAsUser: env_copy.pop("XAUTHORITY", None) env_copy.pop("DISPLAY", None) with ( - patch(f"{PKG}.os.geteuid", return_value=0), - patch(f"{PKG}.pwd.getpwnam", return_value=mock_pw), + patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=0), + patch(f"{ENV_PKG}.pwd.getpwnam", return_value=mock_pw), patch.dict(os.environ, env_copy, clear=True), - patch(f"{PKG}.subprocess.Popen") as mock_popen, + patch(f"{STEAM_PROCESS_PKG}.subprocess.Popen") as mock_popen, ): _run_as_user(["steam"], "bob") cmd = mock_popen.call_args[0][0] assert any("DISPLAY=:0" in arg for arg in cmd) assert any("/home/bob/.Xauthority" in arg for arg in cmd) - - -class TestRestartSteam: - """Tests for restart_steam.""" - - def test_cdp_ready(self) -> None: - with ( - patch(f"{PKG}._shutdown_steam"), - patch(f"{PKG}._launch_steam_with_debug"), - patch(f"{PKG}._wait_for_cdp_ready", return_value=True), - ): - restart_steam() - - def test_cdp_not_ready(self) -> None: - with ( - patch(f"{PKG}._shutdown_steam"), - patch(f"{PKG}._launch_steam_with_debug"), - patch(f"{PKG}._wait_for_cdp_ready", return_value=False), - ): - restart_steam() - - -class TestHideOtherGames: - """Tests for hide_other_games.""" - - def test_hides(self) -> None: - with ( - patch(f"{PKG}.ensure_steam_debug_port"), - patch( - f"{PKG}._evaluate_js", - return_value={ - "result": {"result": {"value": '{"totalHidden": 5}'}}, - }, - ), - patch( - f"{PKG}._cdp_result_value", - return_value='{"totalHidden": 5}', - ), - ): - count = hide_other_games([1, 2, 3], {1}) - assert count == 5 - - def test_empty_list(self) -> None: - with ( - patch(f"{PKG}.ensure_steam_debug_port"), - patch( - f"{PKG}._evaluate_js", - return_value={ - "result": {"result": {"value": '{"totalHidden": 0}'}}, - }, - ), - patch( - f"{PKG}._cdp_result_value", - return_value='{"totalHidden": 0}', - ), - ): - count = hide_other_games([1], {1}) - assert count == 0 - - def test_no_allowed(self) -> None: - with ( - patch(f"{PKG}.ensure_steam_debug_port"), - patch( - f"{PKG}._evaluate_js", - return_value={ - "result": {"result": {"value": '{"totalHidden": 2}'}}, - }, - ), - patch( - f"{PKG}._cdp_result_value", - return_value='{"totalHidden": 2}', - ), - ): - count = hide_other_games([1, 2], set()) - assert count == 2 - - -class TestTryHideOtherGames: - """Tests for the graceful wrapper around hide_other_games. - - Regression guard: an unreachable Steam (or a deferred restart while a game - update is in flight) used to escape as a traceback out of every - interactive command that reconciles the library. - """ - - def test_success_returns_count_and_no_reason(self) -> None: - with patch(f"{PKG}.hide_other_games", return_value=7): - assert try_hide_other_games([1, 2], {1}) == (7, None) - - def test_steam_unavailable_is_reported_not_raised(self) -> None: - with patch( - f"{PKG}.hide_other_games", - side_effect=SteamUnavailableError("Steam is not installed"), - ): - hidden, reason = try_hide_other_games([1, 2], {1}) - assert hidden == 0 - assert reason == "Steam is not installed" - - def test_update_in_progress_is_reported_not_raised(self) -> None: - with patch( - f"{PKG}.hide_other_games", - side_effect=SteamUpdateInProgressError("update in progress"), - ): - hidden, reason = try_hide_other_games([1, 2], {1}) - assert hidden == 0 - assert reason == "update in progress" - - -class TestUnhideAllGames: - """Tests for unhide_all_games.""" - - def test_unhides(self) -> None: - with ( - patch(f"{PKG}.ensure_steam_debug_port"), - patch( - f"{PKG}._evaluate_js", - return_value={"result": {"result": {"value": '{"count": 10}'}}}, - ), - patch( - f"{PKG}._cdp_result_value", - return_value='{"count": 10}', - ), - ): - count = unhide_all_games([1, 2, 3]) - assert count == 10 diff --git a/steam_backlog_enforcer/tests/test_steam_launch_lifecycle.py b/steam_backlog_enforcer/tests/test_steam_launch_lifecycle.py new file mode 100644 index 0000000..b533679 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_steam_launch_lifecycle.py @@ -0,0 +1,133 @@ +"""Tests for the Steam lifecycle: is it running, has it a CDP port, restart it. + +Split to keep every test file under the 250-line cap. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from steam_backlog_enforcer._steam_launch import ( + _is_steam_running, + _steam_has_debug_port, + _wait_for_cdp_ready, + _wait_for_collections_ready, +) + + +class TestIsSteamRunning: + """Tests for _is_steam_running.""" + + def test_running(self) -> None: + mock_result = MagicMock(returncode=0) + with patch( + "steam_backlog_enforcer._steam_launch.subprocess.run", + return_value=mock_result, + ): + assert _is_steam_running() is True + + def test_not_running(self) -> None: + mock_result = MagicMock(returncode=1) + with patch( + "steam_backlog_enforcer._steam_launch.subprocess.run", + return_value=mock_result, + ): + assert _is_steam_running() is False + + +class TestSteamHasDebugPort: + """Tests for _steam_has_debug_port.""" + + def test_has_port(self) -> None: + with patch( + "steam_backlog_enforcer._steam_launch._get_shared_js_ws_url", + return_value="ws://test", + ): + assert _steam_has_debug_port() is True + + def test_no_port(self) -> None: + with patch( + "steam_backlog_enforcer._steam_launch._get_shared_js_ws_url", + return_value=None, + ): + assert _steam_has_debug_port() is False + + +class TestWaitForCdpReady: + """Tests for _wait_for_cdp_ready.""" + + def test_ready_immediately(self) -> None: + with patch( + "steam_backlog_enforcer._steam_launch._get_shared_js_ws_url", + return_value="ws://test", + ): + assert _wait_for_cdp_ready() is True + + def test_timeout(self) -> None: + with ( + patch( + "steam_backlog_enforcer._steam_launch._get_shared_js_ws_url", + return_value=None, + ), + patch( + "steam_backlog_enforcer._steam_launch.time.sleep", + ), + patch( + "steam_backlog_enforcer.library_hider._STEAM_STARTUP_WAIT", + 2, + ), + ): + assert _wait_for_cdp_ready() is False + + +class TestWaitForCollectionsReady: + """Tests for _wait_for_collections_ready.""" + + def test_ready(self) -> None: + with ( + patch( + "steam_backlog_enforcer._steam_launch._evaluate_js", + return_value={"result": {"result": {"value": "ok"}}}, + ), + patch( + "steam_backlog_enforcer._steam_launch._cdp_result_value", + return_value="ok", + ), + ): + assert _wait_for_collections_ready() is True + + def test_not_ready_then_ready(self) -> None: + with ( + patch( + "steam_backlog_enforcer._steam_launch._evaluate_js", + return_value={"result": {"result": {"value": "not_ready"}}}, + ), + patch( + "steam_backlog_enforcer._steam_launch._cdp_result_value", + side_effect=["not_ready", "ok"], + ), + patch( + "steam_backlog_enforcer._steam_launch.time.sleep", + ), + patch( + "steam_backlog_enforcer.library_hider._STEAM_STARTUP_WAIT", + 2, + ), + ): + assert _wait_for_collections_ready() is True + + def test_timeout(self) -> None: + with ( + patch( + "steam_backlog_enforcer._steam_launch._evaluate_js", + side_effect=RuntimeError, + ), + patch( + "steam_backlog_enforcer._steam_launch.time.sleep", + ), + patch( + "steam_backlog_enforcer.library_hider._STEAM_STARTUP_WAIT", + 2, + ), + ): + assert _wait_for_collections_ready() is False diff --git a/steam_backlog_enforcer/tests/test_steam_restart.py b/steam_backlog_enforcer/tests/test_steam_restart.py new file mode 100644 index 0000000..68a7179 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_steam_restart.py @@ -0,0 +1,80 @@ +"""Tests for shutting Steam down and relaunching it with the debug port. + +Split to keep every test file under the 250-line cap. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from steam_backlog_enforcer._steam_launch import ( + _launch_steam_with_debug, + _shutdown_steam, +) + + +class TestShutdownSteam: + """Tests for _shutdown_steam.""" + + def test_exits_immediately(self) -> None: + mock_result = MagicMock(returncode=1) # Not running + with ( + patch( + "steam_backlog_enforcer._steam_launch._run_as_user", + ), + patch( + "steam_backlog_enforcer._steam_launch.subprocess.run", + return_value=mock_result, + ), + ): + _shutdown_steam() + + def test_waits_for_exit(self) -> None: + results = [MagicMock(returncode=0), MagicMock(returncode=1)] + with ( + patch( + "steam_backlog_enforcer._steam_launch._run_as_user", + ), + patch( + "steam_backlog_enforcer._steam_launch.subprocess.run", + side_effect=results, + ), + patch( + "steam_backlog_enforcer._steam_launch.time.sleep", + ), + ): + _shutdown_steam() + + def test_file_not_found(self) -> None: + with patch( + "steam_backlog_enforcer._steam_launch._run_as_user", + side_effect=FileNotFoundError, + ): + _shutdown_steam() # Should not raise + + def test_timeout(self) -> None: + mock_result = MagicMock(returncode=0) # Still running + with ( + patch( + "steam_backlog_enforcer._steam_launch._run_as_user", + ), + patch( + "steam_backlog_enforcer._steam_launch.subprocess.run", + return_value=mock_result, + ), + patch( + "steam_backlog_enforcer._steam_launch.time.sleep", + ), + ): + _shutdown_steam() # Should complete loop without raising + + +class TestLaunchSteamWithDebug: + """Tests for _launch_steam_with_debug.""" + + def test_launches(self) -> None: + with patch( + "steam_backlog_enforcer._steam_launch._run_as_user", + ) as mock_run: + _launch_steam_with_debug() + mock_run.assert_called_once() diff --git a/steam_backlog_enforcer/tests/test_update_restart_guard.py b/steam_backlog_enforcer/tests/test_update_restart_guard.py index a123434..c7f6f5f 100644 --- a/steam_backlog_enforcer/tests/test_update_restart_guard.py +++ b/steam_backlog_enforcer/tests/test_update_restart_guard.py @@ -12,21 +12,18 @@ from unittest.mock import patch import pytest +from steam_backlog_enforcer._steam_errors import SteamUpdateInProgressError +from steam_backlog_enforcer._steam_launch import ensure_steam_debug_port, restart_steam from steam_backlog_enforcer._steam_state import ( _manifest_transfer_active, steam_update_in_progress, ) -from steam_backlog_enforcer.library_hider import ( - SteamUpdateInProgressError, - ensure_steam_debug_port, - restart_steam, -) if TYPE_CHECKING: from pathlib import Path SS = "steam_backlog_enforcer._steam_state" -LH = "steam_backlog_enforcer.library_hider" +LH = "steam_backlog_enforcer._steam_launch" def _manifest(*, to_download: int, downloaded: int, to_stage: int, staged: int) -> str: