mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 18:07:07 +01:00
fast install: never report a failed run as success; queue the manifest first
The BG3 fast install of 2026-10-23 downloaded 160 GB, was refused by the installer, and the web job still ended on a full bar, "Done!" and Succeeded while Steam restarted the download from 0%. - A failed run's reason is read from the installer's own run log (its ERROR lines). A waiting job prints "Failed: fast install of <game>: <reason>", which fails the job; `install` no longer says "Done!" after it. - Daemon-started failures stay on the Dashboard's Fast install card (and in ctl status, `error`) until a new run starts or the game is installed. - install_game writes Steam's "needs downloading" manifest before the installer runs: it only finishes a manifest it finds, so fresh installs were refused for want of one. - Command building moved to _fast_install_cmd (250-line cap). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GYmCezqhUjUmMisF5YtM4F
This commit is contained in:
parent
0d93539944
commit
dc22bd557c
@ -205,9 +205,12 @@ hard-codes it.
|
||||
`gaming_unblock{phrase}`, `gaming_reset_arm{phrase}`,
|
||||
`gaming_reset_heartbeat{pending_id}`, `gaming_reset_commit{pending_id, phrase}`,
|
||||
`gaming_reset_cancel{pending_id}`, `restart{}`. There is no `stop`.
|
||||
- `status` also lists the daemon's live steam-game-installer runs as
|
||||
`fast_installs: [{app_id, name, phase, percent, elapsed_seconds}]`, read
|
||||
from its log by the enforce loop; `percent` is the current depot's, or null.
|
||||
- `status` also lists the daemon's steam-game-installer runs as
|
||||
`fast_installs: [{app_id, name, phase, percent, elapsed_seconds, error}]`,
|
||||
read from its log by the enforce loop; `percent` is the current depot's, or
|
||||
null. A failed run stays listed with `phase: "failed"` and `error` set to
|
||||
the installer's own ERROR lines until a new run starts or the game is fully
|
||||
installed; `error` is null while a run is live.
|
||||
|
||||
## Verification record (2026-10-10)
|
||||
|
||||
|
||||
@ -140,6 +140,7 @@ def _fast_install_info(run: dict[str, object]) -> FastInstallInfo:
|
||||
phase=str(run["phase"]),
|
||||
percent=None if percent is None else float(cast("float", percent)),
|
||||
elapsed_seconds=int(cast("int", run["elapsed_seconds"])),
|
||||
error=None if run.get("error") is None else str(run["error"]),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -22,6 +22,8 @@ class FastInstallInfo:
|
||||
phase: str
|
||||
percent: float | None
|
||||
elapsed_seconds: int
|
||||
# Why the run failed (``phase`` is then ``failed``); None while it runs.
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@ -27,29 +27,29 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pwd
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from steam_backlog_enforcer import _fast_install_progress as _fip
|
||||
from steam_backlog_enforcer import config as _config
|
||||
from steam_backlog_enforcer._desktop_env import desktop_user_cmd, resolve_desktop_user
|
||||
from steam_backlog_enforcer._desktop_env import resolve_desktop_user
|
||||
from steam_backlog_enforcer._echo import _echo
|
||||
from steam_backlog_enforcer._fast_install_cmd import build_cmd, installer_path
|
||||
from steam_backlog_enforcer._fast_install_failure import failure_reason
|
||||
from steam_backlog_enforcer._steam_restart_guard import game_is_running
|
||||
from steam_backlog_enforcer._steam_state import STEAMAPPS_PATH
|
||||
from steam_backlog_enforcer._steam_state import is_game_fully_installed
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_INSTALLER = Path("src", "steam-game-installer", "run.sh")
|
||||
_INSTALLER_LOG = "~/.local/state/steam-game-installer/latest.log"
|
||||
# Depot content only: the app itself is ours to choose, the language is not
|
||||
# worth a prompt nobody is there to answer.
|
||||
_LANGUAGE = "english"
|
||||
# depotdownloader prints a line per finished chunk, so a healthy download is
|
||||
# never silent this long; a login prompt waiting on /dev/null is.
|
||||
STALL_SECONDS = 15 * 60
|
||||
@ -67,39 +67,10 @@ class _DetachedRun:
|
||||
|
||||
|
||||
_RUNS: list[_DetachedRun] = []
|
||||
# Apps whose installer run failed in this process. They go straight to the
|
||||
# steam:// fallback from then on, so a broken login cannot close Steam on
|
||||
# every 3-second pass.
|
||||
_FAILED: set[int] = set()
|
||||
|
||||
|
||||
def _installer_path(user: str | None) -> Path | None:
|
||||
"""steam-game-installer's ``run.sh`` in the desktop user's home, if present.
|
||||
|
||||
Resolved from the passwd entry rather than ``Path.home()``: the daemon runs
|
||||
as root, and the installer, its venv and its credentials are per-user.
|
||||
"""
|
||||
try:
|
||||
home = Path(pwd.getpwnam(user).pw_dir) if user else Path.home()
|
||||
except KeyError:
|
||||
return None
|
||||
path = home / _INSTALLER
|
||||
return path if path.is_file() else None
|
||||
|
||||
|
||||
def _steam_account_name() -> str | None:
|
||||
"""The account Steam itself last signed in with, from ``loginusers.vdf``.
|
||||
|
||||
Read from Steam's own state rather than a copy in the enforcer config, so
|
||||
it cannot drift from the account that actually owns the games.
|
||||
"""
|
||||
path = STEAMAPPS_PATH.parent / "config" / "loginusers.vdf"
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return None
|
||||
match = re.search(r'"AccountName"\s+"([^"]+)"', content)
|
||||
return match.group(1) if match else None
|
||||
# Apps whose installer run failed in this process, with the reason. They go
|
||||
# straight to the steam:// fallback from then on, so a broken login cannot
|
||||
# close Steam on every 3-second pass.
|
||||
_FAILED: dict[int, str] = {}
|
||||
|
||||
|
||||
def _refusal() -> str | None:
|
||||
@ -109,32 +80,16 @@ def _refusal() -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _build_cmd(installer: Path, app_id: int, user: str | None) -> list[str]:
|
||||
"""The installer invocation, dropped to the desktop user when root."""
|
||||
cmd = [
|
||||
str(installer),
|
||||
# install.sh reaches for yay; dependencies are installed once, by hand.
|
||||
"--no-deps",
|
||||
"--appid",
|
||||
str(app_id),
|
||||
"--yes",
|
||||
"--language",
|
||||
_LANGUAGE,
|
||||
# Finishes the manifest from depotdownloader's own data, under the same
|
||||
# fail-closed checks: no second login, no 70 GB re-hash by steamcmd.
|
||||
"--skip-steamcmd",
|
||||
]
|
||||
account = _steam_account_name()
|
||||
if account:
|
||||
cmd += ["--username", account]
|
||||
return desktop_user_cmd(cmd, user)
|
||||
|
||||
|
||||
def is_fast_installing(app_id: int) -> bool:
|
||||
"""Whether this process has a live detached installer run for *app_id*."""
|
||||
return any(run.app_id == app_id for run in _RUNS)
|
||||
|
||||
|
||||
def fast_install_failure(app_id: int) -> str | None:
|
||||
"""Why this process's installer run for *app_id* failed, if it did."""
|
||||
return _FAILED.get(app_id)
|
||||
|
||||
|
||||
def _stalled(run: _DetachedRun) -> bool:
|
||||
"""Whether a live run has printed nothing for :data:`STALL_SECONDS`."""
|
||||
try:
|
||||
@ -167,15 +122,16 @@ def poll_fast_installs() -> None:
|
||||
if returncode == 0:
|
||||
logger.info("steam-game-installer finished AppID=%d.", run.app_id)
|
||||
continue
|
||||
_FAILED.add(run.app_id)
|
||||
_FAILED[run.app_id] = failure_reason(run.log_path, returncode)
|
||||
_fip.mark_failed(run.app_id, _FAILED[run.app_id])
|
||||
logger.warning(
|
||||
"steam-game-installer failed for AppID=%d (exit %d); falling back "
|
||||
"to Steam's own download. Log: %s",
|
||||
"steam-game-installer failed for AppID=%d: %s. Falling back to "
|
||||
"Steam's own download. Log: %s",
|
||||
run.app_id,
|
||||
returncode,
|
||||
_FAILED[run.app_id],
|
||||
run.log_path,
|
||||
)
|
||||
_fip.poll_trackers({run.app_id for run in _RUNS})
|
||||
_fip.poll_trackers({run.app_id for run in _RUNS}, is_game_fully_installed)
|
||||
|
||||
|
||||
def _spawn_detached(cmd: list[str], app_id: int, label: str = "") -> None:
|
||||
@ -211,12 +167,26 @@ def _wait_detached(app_id: int, label: str) -> bool:
|
||||
time.sleep(_WAIT_POLL_SECONDS)
|
||||
poll_fast_installs()
|
||||
reporter.report(time.monotonic())
|
||||
return app_id not in _FAILED
|
||||
if app_id not in _FAILED:
|
||||
return True
|
||||
# "Failed" first: a job takes such a line as its verdict, so the run can
|
||||
# no longer end on a full progress bar and "Succeeded".
|
||||
_echo(
|
||||
f"Failed: fast install of {label}: {_FAILED[app_id]}. Steam's own "
|
||||
f"download takes over (log: {_INSTALLER_LOG})."
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def fast_install(app_id: int, label: str) -> bool:
|
||||
def fast_install(
|
||||
app_id: int, label: str, prepare: Callable[[], object] = lambda: None
|
||||
) -> bool:
|
||||
"""Install *app_id* with steam-game-installer.
|
||||
|
||||
*prepare* runs once the run is sure to start, before it does: the
|
||||
installer only finishes a manifest it finds, so this is where a fresh
|
||||
install gets one.
|
||||
|
||||
Returns:
|
||||
True if the game was installed (foreground) or the run was started
|
||||
(detached); False if the caller should fall back to Steam's download.
|
||||
@ -224,7 +194,7 @@ def fast_install(app_id: int, label: str) -> bool:
|
||||
if app_id in _FAILED:
|
||||
return False
|
||||
user = resolve_desktop_user()
|
||||
installer = _installer_path(user)
|
||||
installer = installer_path(user)
|
||||
if installer is None:
|
||||
logger.warning("steam-game-installer not found; using Steam's download.")
|
||||
return False
|
||||
@ -233,7 +203,8 @@ def fast_install(app_id: int, label: str) -> bool:
|
||||
_echo(f" Fast install of {label} skipped: {reason}.")
|
||||
return False
|
||||
|
||||
cmd = _build_cmd(installer, app_id, user)
|
||||
cmd = build_cmd(installer, app_id, user)
|
||||
prepare()
|
||||
if not sys.stdin.isatty():
|
||||
_spawn_detached(cmd, app_id, label)
|
||||
return os.geteuid() == 0 or _wait_detached(app_id, label)
|
||||
@ -242,9 +213,9 @@ def fast_install(app_id: int, label: str) -> bool:
|
||||
returncode = subprocess.run(cmd, check=False).returncode
|
||||
if returncode == 0:
|
||||
return True
|
||||
_FAILED.add(app_id)
|
||||
_FAILED[app_id] = f"exit {returncode}"
|
||||
_echo(
|
||||
f" steam-game-installer failed (exit {returncode}); falling back to "
|
||||
f"Steam's own download. Log: {_INSTALLER_LOG}"
|
||||
f"Failed: fast install of {label} (exit {returncode}, reasons above). "
|
||||
f"Steam's own download takes over (log: {_INSTALLER_LOG})."
|
||||
)
|
||||
return False
|
||||
|
||||
71
steam_backlog_enforcer/_fast_install_cmd.py
Normal file
71
steam_backlog_enforcer/_fast_install_cmd.py
Normal file
@ -0,0 +1,71 @@
|
||||
"""How steam-game-installer is invoked: where it lives, as whom, with what argv.
|
||||
|
||||
Split out of :mod:`_fast_install`, which runs and reaps the installer; this
|
||||
half only decides the command line, every choice it would otherwise prompt for
|
||||
pinned in advance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import pwd
|
||||
import re
|
||||
|
||||
from steam_backlog_enforcer._desktop_env import desktop_user_cmd
|
||||
from steam_backlog_enforcer._steam_state import STEAMAPPS_PATH
|
||||
|
||||
_INSTALLER = Path("src", "steam-game-installer", "run.sh")
|
||||
|
||||
# Depot content only: the app itself is ours to choose, the language is not
|
||||
# worth a prompt nobody is there to answer.
|
||||
_LANGUAGE = "english"
|
||||
|
||||
|
||||
def installer_path(user: str | None) -> Path | None:
|
||||
"""steam-game-installer's ``run.sh`` in the desktop user's home, if present.
|
||||
|
||||
Resolved from the passwd entry rather than ``Path.home()``: the daemon runs
|
||||
as root, and the installer, its venv and its credentials are per-user.
|
||||
"""
|
||||
try:
|
||||
home = Path(pwd.getpwnam(user).pw_dir) if user else Path.home()
|
||||
except KeyError:
|
||||
return None
|
||||
path = home / _INSTALLER
|
||||
return path if path.is_file() else None
|
||||
|
||||
|
||||
def steam_account_name() -> str | None:
|
||||
"""The account Steam itself last signed in with, from ``loginusers.vdf``.
|
||||
|
||||
Read from Steam's own state rather than a copy in the enforcer config, so
|
||||
it cannot drift from the account that actually owns the games.
|
||||
"""
|
||||
path = STEAMAPPS_PATH.parent / "config" / "loginusers.vdf"
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return None
|
||||
match = re.search(r'"AccountName"\s+"([^"]+)"', content)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def build_cmd(installer: Path, app_id: int, user: str | None) -> list[str]:
|
||||
"""The installer invocation, dropped to the desktop user when root."""
|
||||
cmd = [
|
||||
str(installer),
|
||||
# install.sh reaches for yay; dependencies are installed once, by hand.
|
||||
"--no-deps",
|
||||
"--appid",
|
||||
str(app_id),
|
||||
"--yes",
|
||||
"--language",
|
||||
_LANGUAGE,
|
||||
# Finishes the manifest from depotdownloader's own data, under the same
|
||||
# fail-closed checks: no second login, no 70 GB re-hash by steamcmd.
|
||||
"--skip-steamcmd",
|
||||
]
|
||||
account = steam_account_name()
|
||||
if account:
|
||||
cmd += ["--username", account]
|
||||
return desktop_user_cmd(cmd, user)
|
||||
55
steam_backlog_enforcer/_fast_install_failure.py
Normal file
55
steam_backlog_enforcer/_fast_install_failure.py
Normal file
@ -0,0 +1,55 @@
|
||||
"""Why a steam-game-installer run failed, in the installer's own words.
|
||||
|
||||
A bare exit status hid the BG3 run of 2026-10-23: 160 GB downloaded, then a
|
||||
refusal to mark it installed, and the job still read "Succeeded". The console
|
||||
output ends with ``Run log (send this): <path>``; that run log tags every line
|
||||
with its level, so its ERROR lines are exactly the reasons the run gave up.
|
||||
|
||||
Both files are read from the end only: a depotdownloader run writes a line
|
||||
per downloaded file, tens of megabytes of them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Final
|
||||
|
||||
# The reasons and the run log line are printed last; 64 KiB holds them.
|
||||
_TAIL_BYTES: Final = 1 << 16
|
||||
_RUN_LOG: Final = re.compile(r"^Run log(?: \(send this\))?: (/.+)$")
|
||||
_ERROR: Final = re.compile(r"^\S+ \S+ ERROR\s+[\w.]+: (.*)$")
|
||||
|
||||
|
||||
def _tail_lines(path: Path) -> list[str]:
|
||||
"""The last lines of *path* (empty if unreadable), first partial one dropped."""
|
||||
try:
|
||||
with path.open("rb") as log:
|
||||
size = log.seek(0, 2)
|
||||
log.seek(max(0, size - _TAIL_BYTES))
|
||||
data = log.read()
|
||||
except OSError:
|
||||
return []
|
||||
lines = data.decode("utf-8", "replace").splitlines()
|
||||
return lines[1:] if size > _TAIL_BYTES else lines
|
||||
|
||||
|
||||
def _run_log_errors(console: list[str]) -> list[str]:
|
||||
"""ERROR messages of the run log the console output names last."""
|
||||
for line in reversed(console):
|
||||
if match := _RUN_LOG.match(line.strip()):
|
||||
run_log = _tail_lines(Path(match.group(1)))
|
||||
return [m.group(1).strip() for ln in run_log if (m := _ERROR.match(ln))]
|
||||
return []
|
||||
|
||||
|
||||
def failure_reason(console_log: Path, returncode: int) -> str:
|
||||
"""One line saying why the run that just wrote *console_log* failed."""
|
||||
console = _tail_lines(console_log)
|
||||
errors = _run_log_errors(console)
|
||||
if errors:
|
||||
# Callers add their own full stop.
|
||||
return " ".join(errors).rstrip(".")
|
||||
# Died before the installer could log a reason (a traceback, a kill).
|
||||
last = next((ln.strip() for ln in reversed(console) if ln.strip()), "")
|
||||
return f"exit {returncode}: {last}" if last else f"exit {returncode}"
|
||||
@ -40,6 +40,7 @@ from steam_backlog_enforcer._echo import _echo
|
||||
from steam_backlog_enforcer._progress import current_progress
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
STARTING: Final = "starting"
|
||||
@ -146,6 +147,9 @@ class InstallTracker:
|
||||
# Live detached runs of this process by app id, with the name to show. The
|
||||
# enforce loop polls them; ctl connection threads read snapshots.
|
||||
_TRACKERS: dict[int, tuple[str, InstallTracker]] = {}
|
||||
# Failed runs by app id: (name, reason). The daemon has no job to print a
|
||||
# failure to, so it stays listed until a new run or the game is installed.
|
||||
_FAILURES: dict[int, tuple[str, str]] = {}
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
|
||||
@ -153,13 +157,25 @@ def track(app_id: int, name: str, log_path: Path, *, offset: int, now: float) ->
|
||||
"""Start following a run just spawned with its output at *offset* of the log."""
|
||||
with _LOCK:
|
||||
_TRACKERS[app_id] = (name, InstallTracker(log_path, offset=offset, started=now))
|
||||
_FAILURES.pop(app_id, None)
|
||||
|
||||
|
||||
def poll_trackers(live: set[int]) -> None:
|
||||
"""Read new log lines of the *live* runs and forget every other one."""
|
||||
def mark_failed(app_id: int, reason: str) -> None:
|
||||
"""List *app_id*'s run as failed with *reason*, under its tracked name."""
|
||||
with _LOCK:
|
||||
entry = _TRACKERS.get(app_id)
|
||||
_FAILURES[app_id] = (entry[0] if entry else f"AppID={app_id}", reason)
|
||||
|
||||
|
||||
def poll_trackers(
|
||||
live: set[int], installed: Callable[[int], bool] = lambda _app_id: False
|
||||
) -> None:
|
||||
"""Read the *live* runs' new lines; drop other runs and *installed* failures."""
|
||||
with _LOCK:
|
||||
for app_id in [aid for aid in _TRACKERS if aid not in live]:
|
||||
del _TRACKERS[app_id]
|
||||
for app_id in [aid for aid in _FAILURES if installed(aid)]:
|
||||
del _FAILURES[app_id]
|
||||
for _name, tracker in _TRACKERS.values():
|
||||
tracker.poll()
|
||||
|
||||
@ -172,9 +188,15 @@ def progress_of(app_id: int, now: float) -> InstallProgress | None:
|
||||
|
||||
|
||||
def fast_install_status(now: float) -> list[dict[str, object]]:
|
||||
"""Every tracked run as the ctl ``status`` op reports it (no file I/O)."""
|
||||
"""Every tracked and failed run as the ctl ``status`` op reports it (no I/O)."""
|
||||
with _LOCK:
|
||||
entries = [(aid, name, t.snapshot(now)) for aid, (name, t) in _TRACKERS.items()]
|
||||
failed = [
|
||||
{"app_id": aid, "name": name, "phase": "failed", "percent": None}
|
||||
| {"elapsed_seconds": 0, "error": reason}
|
||||
for aid, (name, reason) in _FAILURES.items()
|
||||
if aid not in _TRACKERS
|
||||
]
|
||||
return [
|
||||
{
|
||||
"app_id": app_id,
|
||||
@ -182,9 +204,10 @@ def fast_install_status(now: float) -> list[dict[str, object]]:
|
||||
"phase": snap.phase,
|
||||
"percent": None if snap.percent is None else round(snap.percent, 2),
|
||||
"elapsed_seconds": snap.elapsed_seconds,
|
||||
"error": None,
|
||||
}
|
||||
for app_id, name, snap in entries
|
||||
]
|
||||
] + failed
|
||||
|
||||
|
||||
class WaitReporter:
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
@ -168,7 +169,10 @@ def install_game(
|
||||
if ready is not None:
|
||||
return ready
|
||||
|
||||
if fast_install(app_id, label):
|
||||
# The installer only finishes a manifest it finds: queue a fresh install first.
|
||||
if fast_install(
|
||||
app_id, label, partial(_queue_download, app_id, game_name, steam_id)
|
||||
):
|
||||
return True
|
||||
# Steam already has a download of its own going: let it carry on, rather
|
||||
# than overwrite its manifest with the fabricated one below.
|
||||
@ -181,6 +185,16 @@ def install_game(
|
||||
return True
|
||||
logger.debug("steam:// protocol failed; falling back to manifest")
|
||||
|
||||
if not _queue_download(app_id, game_name, steam_id):
|
||||
return False
|
||||
_ensure_steam_running() # so it picks the manifest up
|
||||
return True
|
||||
|
||||
|
||||
def _queue_download(app_id: int, game_name: str, steam_id: str) -> bool:
|
||||
"""Make Steam download *app_id* with a minimal appmanifest; False if unwritable."""
|
||||
if is_game_installed(app_id):
|
||||
return True
|
||||
# Build a minimal appmanifest. StateFlags 1026 = UpdateRequired (2) +
|
||||
# UpdateStarted (1024), which tells Steam "this app needs downloading".
|
||||
manifest_content = (
|
||||
@ -219,7 +233,6 @@ def install_game(
|
||||
)
|
||||
|
||||
manifest_path = STEAMAPPS_PATH / f"appmanifest_{app_id}.acf"
|
||||
|
||||
try:
|
||||
with manifest_path.open("w", encoding="utf-8") as fh:
|
||||
fh.write(manifest_content)
|
||||
@ -230,13 +243,8 @@ def install_game(
|
||||
if os.geteuid() == 0 and real_user and real_user != "root":
|
||||
uid, gid = _get_uid_gid_for_user(real_user)
|
||||
os.chown(manifest_path, uid, gid)
|
||||
|
||||
logger.info("Created appmanifest for %s — Steam will auto-download", label)
|
||||
logger.info("Created appmanifest for %s — Steam will auto-download", game_name)
|
||||
except OSError:
|
||||
logger.exception("Failed to create appmanifest for %s", label)
|
||||
logger.exception("Failed to create appmanifest for %s", game_name)
|
||||
return False
|
||||
|
||||
# Make sure Steam is running so it picks up the manifest.
|
||||
_ensure_steam_running()
|
||||
|
||||
return True
|
||||
|
||||
@ -6,6 +6,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from steam_backlog_enforcer._actions import allowed_app_ids, allowed_games
|
||||
from steam_backlog_enforcer._enforce_loop import get_all_owned_app_ids
|
||||
from steam_backlog_enforcer._fast_install import fast_install_failure
|
||||
from steam_backlog_enforcer._progress import current_progress
|
||||
from steam_backlog_enforcer._prompter import (
|
||||
PromptOption,
|
||||
@ -115,10 +116,13 @@ def cmd_install(config: Config, state: State) -> None:
|
||||
# install_game only *starts* the download (fast installer or steam://);
|
||||
# there is no download-progress loop to report on yet.
|
||||
current_progress().phase(f"Starting install of {name}")
|
||||
if install_game(app_id, name, config.steam_id, use_steam_protocol=True):
|
||||
if not install_game(app_id, name, config.steam_id, use_steam_protocol=True):
|
||||
_echo("Failed to start the install.")
|
||||
elif fast_install_failure(app_id) is None:
|
||||
_echo("Done!")
|
||||
else:
|
||||
_echo("Failed to start the install.")
|
||||
# The fast install already printed its "Failed: ..." line.
|
||||
_echo("Steam's own download was started instead.")
|
||||
|
||||
|
||||
def cmd_hide(config: Config, state: State) -> None:
|
||||
|
||||
@ -18,7 +18,11 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer import _fast_install, _fast_install_progress
|
||||
from steam_backlog_enforcer import (
|
||||
_fast_install,
|
||||
_fast_install_cmd,
|
||||
_fast_install_progress,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
@ -40,11 +44,14 @@ def _no_fast_install(tmp_path: Path) -> Iterator[None]:
|
||||
_fast_install._RUNS.clear()
|
||||
_fast_install._FAILED.clear()
|
||||
_fast_install_progress._TRACKERS.clear()
|
||||
_fast_install_progress._FAILURES.clear()
|
||||
patches = [patch(name, return_value=False) for name in _RUNNING_BINDINGS]
|
||||
patches += [
|
||||
# Absolute, so `home / _INSTALLER` resolves here and never under ~.
|
||||
patch.object(_fast_install, "_INSTALLER", tmp_path / "no-installer" / "run.sh"),
|
||||
patch.object(_fast_install, "STEAMAPPS_PATH", fake_steamapps),
|
||||
patch.object(
|
||||
_fast_install_cmd, "_INSTALLER", tmp_path / "no-installer" / "run.sh"
|
||||
),
|
||||
patch.object(_fast_install_cmd, "STEAMAPPS_PATH", fake_steamapps),
|
||||
]
|
||||
for p in patches:
|
||||
p.start()
|
||||
@ -56,3 +63,4 @@ def _no_fast_install(tmp_path: Path) -> Iterator[None]:
|
||||
_fast_install._RUNS.clear()
|
||||
_fast_install._FAILED.clear()
|
||||
_fast_install_progress._TRACKERS.clear()
|
||||
_fast_install_progress._FAILURES.clear()
|
||||
|
||||
@ -145,6 +145,14 @@ class TestTypedWrappers:
|
||||
FastInstallInfo(7, "G", "downloading depot 1/8", 46.9, 60),
|
||||
)
|
||||
|
||||
def test_status_failed_fast_install(self) -> None:
|
||||
run = {"app_id": 7, "name": "G", "phase": "failed", "percent": None}
|
||||
run |= {"elapsed_seconds": 0, "error": "exit 1"}
|
||||
data = {"state": "running", "started_at": "t", "pid": 4, "fast_installs": [run]}
|
||||
with self._call(data):
|
||||
info = _ctl_client.status()
|
||||
assert info.fast_installs[0].error == "exit 1"
|
||||
|
||||
def test_journal_tail(self) -> None:
|
||||
with self._call({"lines": ["a", 2]}) as call:
|
||||
assert _ctl_client.journal_tail(9) == ["a", "2"]
|
||||
|
||||
66
steam_backlog_enforcer/tests/test_fast_install_dashboard.py
Normal file
66
steam_backlog_enforcer/tests/test_fast_install_dashboard.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""Tests for a failed daemon-started fast install staying visible.
|
||||
|
||||
The root daemon has no job to print to: before, a failed run's tracker was
|
||||
dropped and the Dashboard card simply vanished, as if nothing had happened.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from steam_backlog_enforcer import _fast_install
|
||||
from steam_backlog_enforcer import _fast_install_progress as fip
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
PKG = "steam_backlog_enforcer._fast_install"
|
||||
|
||||
|
||||
def _failed_run(tmp_path: Path, app_id: int = 7) -> None:
|
||||
"""A tracked daemon run of *app_id* that has just exited 1."""
|
||||
log = tmp_path / "log"
|
||||
log.write_text("Restarting the Steam client...\n")
|
||||
fip.track(app_id, "Baldur's Gate 3", log, offset=0, now=0.0)
|
||||
proc = MagicMock(pid=4321)
|
||||
proc.poll.return_value = 1
|
||||
_fast_install._RUNS.append(_fast_install._DetachedRun(app_id, proc, log))
|
||||
|
||||
|
||||
class TestFailedRunsStayListed:
|
||||
def test_status_carries_the_reason(self, tmp_path: Path) -> None:
|
||||
_failed_run(tmp_path)
|
||||
with patch(f"{PKG}.is_game_fully_installed", return_value=False):
|
||||
_fast_install.poll_fast_installs()
|
||||
assert fip.fast_install_status(now=9.0) == [
|
||||
{
|
||||
"app_id": 7,
|
||||
"name": "Baldur's Gate 3",
|
||||
"phase": "failed",
|
||||
"percent": None,
|
||||
"elapsed_seconds": 0,
|
||||
"error": "exit 1: Restarting the Steam client...",
|
||||
}
|
||||
]
|
||||
|
||||
def test_live_runs_have_no_error(self, tmp_path: Path) -> None:
|
||||
fip.track(8, "G", tmp_path / "log", offset=0, now=0.0)
|
||||
fip.poll_trackers({8})
|
||||
assert fip.fast_install_status(now=1.0)[0]["error"] is None
|
||||
|
||||
def test_gone_once_the_game_is_installed(self, tmp_path: Path) -> None:
|
||||
"""Steam's own download finished: the failure is history."""
|
||||
_failed_run(tmp_path)
|
||||
with patch(f"{PKG}.is_game_fully_installed", return_value=False):
|
||||
_fast_install.poll_fast_installs()
|
||||
with patch(f"{PKG}.is_game_fully_installed", return_value=True):
|
||||
_fast_install.poll_fast_installs()
|
||||
assert fip.fast_install_status(now=9.0) == []
|
||||
|
||||
def test_a_new_run_replaces_the_failure(self, tmp_path: Path) -> None:
|
||||
_failed_run(tmp_path)
|
||||
with patch(f"{PKG}.is_game_fully_installed", return_value=False):
|
||||
_fast_install.poll_fast_installs()
|
||||
fip.track(7, "Baldur's Gate 3", tmp_path / "log", offset=0, now=0.0)
|
||||
assert [s["phase"] for s in fip.fast_install_status(now=1.0)] == [fip.STARTING]
|
||||
54
steam_backlog_enforcer/tests/test_fast_install_failure.py
Normal file
54
steam_backlog_enforcer/tests/test_fast_install_failure.py
Normal file
@ -0,0 +1,54 @@
|
||||
"""Tests for _fast_install_failure: a failed run explained in the installer's words.
|
||||
|
||||
The text is the BG3 run of 2026-10-23, which downloaded 160 GB and then
|
||||
refused; all the job showed was a finished bar and "Succeeded".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from steam_backlog_enforcer._fast_install_failure import failure_reason
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_REFUSAL = (
|
||||
"2026-10-23 22:33:13,889 ERROR steam_installer: Refusing to mark "
|
||||
"Baldur's Gate 3 as installed:\n"
|
||||
"2026-10-23 22:33:13,889 ERROR steam_installer: - depot(s) 1419654 "
|
||||
"have no cached manifest -- the download did not finish\n"
|
||||
"2026-10-23 22:33:13,889 INFO steam_installer: Restarting the Steam client...\n"
|
||||
)
|
||||
|
||||
|
||||
def _console(tmp_path: Path, run_log: Path) -> Path:
|
||||
console = tmp_path / "fast_install-1000.log"
|
||||
console.write_text(
|
||||
f"Run log: {tmp_path / 'old.log'}\n"
|
||||
" 99.99% /x/Gustav.pak (403.89 Mbit/s)\n"
|
||||
"Refusing to mark Baldur's Gate 3 as installed:\n"
|
||||
f"Run log (send this): {run_log}\n"
|
||||
"Restarting the Steam client...\n"
|
||||
)
|
||||
return console
|
||||
|
||||
|
||||
def test_reason_is_the_run_logs_error_lines(tmp_path: Path) -> None:
|
||||
run_log = tmp_path / "run-1086940.log"
|
||||
hello = "2026-10-23 22:13:21,605 INFO steam_installer: hi\n"
|
||||
run_log.write_text(hello + _REFUSAL)
|
||||
assert failure_reason(_console(tmp_path, run_log), 1) == (
|
||||
"Refusing to mark Baldur's Gate 3 as installed: - depot(s) 1419654 have "
|
||||
"no cached manifest -- the download did not finish"
|
||||
)
|
||||
|
||||
|
||||
def test_without_a_run_log_the_last_console_line_is_shown(tmp_path: Path) -> None:
|
||||
console = tmp_path / "fast_install-0.log"
|
||||
console.write_text("Traceback (most recent call last):\nOSError: disk full\n")
|
||||
assert failure_reason(console, 1) == "exit 1: OSError: disk full"
|
||||
|
||||
|
||||
def test_an_unreadable_console_log_still_names_the_exit(tmp_path: Path) -> None:
|
||||
assert failure_reason(tmp_path / "missing.log", 3) == "exit 3"
|
||||
@ -1,4 +1,4 @@
|
||||
"""Tests for _fast_install's pure helpers: paths, account, refusal, argv.
|
||||
"""Tests for the fast install's pure helpers: paths, account, refusal, argv.
|
||||
|
||||
The run lifecycle (spawn, poll, fast_install) lives in test_fast_install_runs.py
|
||||
to keep both files under the 250-line cap.
|
||||
@ -10,9 +10,10 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from steam_backlog_enforcer import _fast_install, _steam_state
|
||||
from steam_backlog_enforcer import _fast_install, _fast_install_cmd, _steam_state
|
||||
|
||||
PKG = "steam_backlog_enforcer._fast_install"
|
||||
CMD = "steam_backlog_enforcer._fast_install_cmd"
|
||||
|
||||
|
||||
class TestInstallerPath:
|
||||
@ -26,44 +27,44 @@ class TestInstallerPath:
|
||||
# real relative one back so the home join itself is what is tested.
|
||||
relative = Path("src", "steam-game-installer", "run.sh")
|
||||
with (
|
||||
patch.object(_fast_install, "_INSTALLER", relative),
|
||||
patch(f"{PKG}.pwd.getpwnam", return_value=SimpleNamespace(pw_dir=tmp_path)),
|
||||
patch.object(_fast_install_cmd, "_INSTALLER", relative),
|
||||
patch(f"{CMD}.pwd.getpwnam", return_value=SimpleNamespace(pw_dir=tmp_path)),
|
||||
):
|
||||
assert _fast_install._installer_path("kuhy") == installer
|
||||
assert _fast_install_cmd.installer_path("kuhy") == installer
|
||||
|
||||
def test_missing_file_is_none(self, tmp_path: Path) -> None:
|
||||
with patch(
|
||||
f"{PKG}.pwd.getpwnam", return_value=SimpleNamespace(pw_dir=tmp_path)
|
||||
f"{CMD}.pwd.getpwnam", return_value=SimpleNamespace(pw_dir=tmp_path)
|
||||
):
|
||||
assert _fast_install._installer_path("kuhy") is None
|
||||
assert _fast_install_cmd.installer_path("kuhy") is None
|
||||
|
||||
def test_unknown_user_is_none(self) -> None:
|
||||
with patch(f"{PKG}.pwd.getpwnam", side_effect=KeyError("nobody")):
|
||||
assert _fast_install._installer_path("nobody") is None
|
||||
with patch(f"{CMD}.pwd.getpwnam", side_effect=KeyError("nobody")):
|
||||
assert _fast_install_cmd.installer_path("nobody") is None
|
||||
|
||||
def test_no_user_uses_own_home(self, tmp_path: Path) -> None:
|
||||
with patch(f"{PKG}.Path.home", return_value=tmp_path):
|
||||
assert _fast_install._installer_path(None) is None
|
||||
with patch(f"{CMD}.Path.home", return_value=tmp_path):
|
||||
assert _fast_install_cmd.installer_path(None) is None
|
||||
|
||||
|
||||
class TestSteamAccountName:
|
||||
"""The username comes from Steam's own loginusers.vdf."""
|
||||
|
||||
def _write(self, content: str) -> None:
|
||||
config = _fast_install.STEAMAPPS_PATH.parent / "config"
|
||||
config = _fast_install_cmd.STEAMAPPS_PATH.parent / "config"
|
||||
config.mkdir(parents=True, exist_ok=True)
|
||||
(config / "loginusers.vdf").write_text(content)
|
||||
|
||||
def test_reads_account_name(self) -> None:
|
||||
self._write('"users"\n{\n\t"7656"\n\t{\n\t\t"AccountName"\t\t"krzys"\n')
|
||||
assert _fast_install._steam_account_name() == "krzys"
|
||||
assert _fast_install_cmd.steam_account_name() == "krzys"
|
||||
|
||||
def test_no_account_field(self) -> None:
|
||||
self._write('"users"\n{\n}\n')
|
||||
assert _fast_install._steam_account_name() is None
|
||||
assert _fast_install_cmd.steam_account_name() is None
|
||||
|
||||
def test_missing_file(self) -> None:
|
||||
assert _fast_install._steam_account_name() is None
|
||||
assert _fast_install_cmd.steam_account_name() is None
|
||||
|
||||
|
||||
class TestRefusal:
|
||||
@ -90,10 +91,10 @@ class TestBuildCmd:
|
||||
|
||||
def test_with_account(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._steam_account_name", return_value="krzys"),
|
||||
patch(f"{PKG}.desktop_user_cmd", side_effect=lambda cmd, _u: cmd),
|
||||
patch(f"{CMD}.steam_account_name", return_value="krzys"),
|
||||
patch(f"{CMD}.desktop_user_cmd", side_effect=lambda cmd, _u: cmd),
|
||||
):
|
||||
cmd = _fast_install._build_cmd(Path("/i/run.sh"), 42, "kuhy")
|
||||
cmd = _fast_install_cmd.build_cmd(Path("/i/run.sh"), 42, "kuhy")
|
||||
assert cmd == [
|
||||
"/i/run.sh",
|
||||
"--no-deps",
|
||||
@ -109,10 +110,10 @@ class TestBuildCmd:
|
||||
|
||||
def test_without_account_and_drops_privileges(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._steam_account_name", return_value=None),
|
||||
patch(f"{PKG}.desktop_user_cmd", return_value=["wrapped"]) as wrap,
|
||||
patch(f"{CMD}.steam_account_name", return_value=None),
|
||||
patch(f"{CMD}.desktop_user_cmd", return_value=["wrapped"]) as wrap,
|
||||
):
|
||||
assert _fast_install._build_cmd(Path("/i/run.sh"), 42, "kuhy") == [
|
||||
assert _fast_install_cmd.build_cmd(Path("/i/run.sh"), 42, "kuhy") == [
|
||||
"wrapped"
|
||||
]
|
||||
argv, user = wrap.call_args.args
|
||||
|
||||
@ -51,13 +51,13 @@ class TestJobWaitsForInstaller:
|
||||
|
||||
def _install(self, tmp_path: Path, spawn: MagicMock, euid: int) -> bool:
|
||||
with (
|
||||
patch(f"{PKG}._installer_path", return_value=_ready(tmp_path)),
|
||||
patch(f"{PKG}.installer_path", return_value=_ready(tmp_path)),
|
||||
patch(f"{PKG}._refusal", return_value=None),
|
||||
patch(f"{PKG}.sys.stdin.isatty", return_value=False),
|
||||
patch(f"{PKG}.os.geteuid", return_value=euid),
|
||||
patch(f"{PKG}._spawn_detached", spawn),
|
||||
patch(f"{PKG}.time.sleep"),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch(f"{PKG}._echo") as self.echo,
|
||||
):
|
||||
return _fast_install.fast_install(7, "G")
|
||||
|
||||
@ -67,7 +67,18 @@ class TestJobWaitsForInstaller:
|
||||
|
||||
def test_failure_falls_back(self, tmp_path: Path) -> None:
|
||||
assert self._install(tmp_path, _fake_spawn(None, 3), 1000) is False
|
||||
assert {7} == _fast_install._FAILED
|
||||
assert {7} == set(_fast_install._FAILED)
|
||||
|
||||
def test_failure_is_an_error_line_with_the_reason(self, tmp_path: Path) -> None:
|
||||
"""A job turns a line starting "Failed" into its failed verdict."""
|
||||
log = b"Refusing to mark G as installed:\nRestarting the Steam client...\n"
|
||||
with patch(f"{PKG}._fip.current_progress"):
|
||||
self._install(tmp_path, _fake_spawn(None, 1, log_text=log), 1000)
|
||||
echoed = [c.args[0] for c in self.echo.call_args_list]
|
||||
assert any(
|
||||
line.startswith("Failed: fast install of G") and "exit 1" in line
|
||||
for line in echoed
|
||||
), echoed
|
||||
|
||||
def test_progress_is_reported_while_waiting(self, tmp_path: Path) -> None:
|
||||
text = b"Shutting down the Steam client...\r\nDownloading via depot\r\n"
|
||||
|
||||
@ -154,6 +154,7 @@ class TestRegistry:
|
||||
"phase": "downloading depot 1/8",
|
||||
"percent": 59.16,
|
||||
"elapsed_seconds": 60,
|
||||
"error": None,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@ -34,13 +34,13 @@ class TestPollFastInstalls:
|
||||
_run(1, 0, tmp_path / "log")
|
||||
_fast_install.poll_fast_installs()
|
||||
assert _fast_install._RUNS == []
|
||||
assert set() == _fast_install._FAILED
|
||||
assert not _fast_install._FAILED
|
||||
|
||||
def test_failure_is_remembered(self, tmp_path: Path) -> None:
|
||||
_run(2, 3, tmp_path / "log")
|
||||
_fast_install.poll_fast_installs()
|
||||
assert _fast_install._RUNS == []
|
||||
assert {2} == _fast_install._FAILED
|
||||
assert {2} == set(_fast_install._FAILED)
|
||||
|
||||
def test_live_and_chatty_is_left_alone(self, tmp_path: Path) -> None:
|
||||
log = tmp_path / "log"
|
||||
@ -123,8 +123,8 @@ class TestFastInstall:
|
||||
return installer
|
||||
|
||||
def test_previously_failed_goes_straight_to_fallback(self) -> None:
|
||||
_fast_install._FAILED.add(7)
|
||||
with patch(f"{PKG}._installer_path") as find:
|
||||
_fast_install._FAILED[7] = "exit 1"
|
||||
with patch(f"{PKG}.installer_path") as find:
|
||||
assert _fast_install.fast_install(7, "G") is False
|
||||
find.assert_not_called()
|
||||
|
||||
@ -134,7 +134,7 @@ class TestFastInstall:
|
||||
|
||||
def test_refused(self, tmp_path: Path) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._installer_path", return_value=self._ready(tmp_path)),
|
||||
patch(f"{PKG}.installer_path", return_value=self._ready(tmp_path)),
|
||||
patch(f"{PKG}._refusal", return_value="a game is running"),
|
||||
patch(f"{PKG}._echo") as echo,
|
||||
patch(f"{PKG}.subprocess.run") as run,
|
||||
@ -145,7 +145,7 @@ class TestFastInstall:
|
||||
|
||||
def test_no_terminal_spawns_detached(self, tmp_path: Path) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._installer_path", return_value=self._ready(tmp_path)),
|
||||
patch(f"{PKG}.installer_path", return_value=self._ready(tmp_path)),
|
||||
patch(f"{PKG}._refusal", return_value=None),
|
||||
patch(f"{PKG}.sys.stdin.isatty", return_value=False),
|
||||
patch(f"{PKG}._spawn_detached") as spawn,
|
||||
@ -157,23 +157,23 @@ class TestFastInstall:
|
||||
|
||||
def test_terminal_runs_in_foreground(self, tmp_path: Path) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._installer_path", return_value=self._ready(tmp_path)),
|
||||
patch(f"{PKG}.installer_path", return_value=self._ready(tmp_path)),
|
||||
patch(f"{PKG}._refusal", return_value=None),
|
||||
patch(f"{PKG}.sys.stdin.isatty", return_value=True),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch(f"{PKG}.subprocess.run", return_value=MagicMock(returncode=0)),
|
||||
):
|
||||
assert _fast_install.fast_install(7, "G") is True
|
||||
assert set() == _fast_install._FAILED
|
||||
assert not _fast_install._FAILED
|
||||
|
||||
def test_foreground_failure_falls_back(self, tmp_path: Path) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._installer_path", return_value=self._ready(tmp_path)),
|
||||
patch(f"{PKG}.installer_path", return_value=self._ready(tmp_path)),
|
||||
patch(f"{PKG}._refusal", return_value=None),
|
||||
patch(f"{PKG}.sys.stdin.isatty", return_value=True),
|
||||
patch(f"{PKG}._echo") as echo,
|
||||
patch(f"{PKG}.subprocess.run", return_value=MagicMock(returncode=2)),
|
||||
):
|
||||
assert _fast_install.fast_install(7, "G") is False
|
||||
assert {7} == _fast_install._FAILED
|
||||
assert {7} == set(_fast_install._FAILED)
|
||||
assert "exit 2" in echo.call_args.args[0]
|
||||
|
||||
50
steam_backlog_enforcer/tests/test_game_install_queue.py
Normal file
50
steam_backlog_enforcer/tests/test_game_install_queue.py
Normal file
@ -0,0 +1,50 @@
|
||||
"""Tests for queueing an install with Steam before the fast installer runs.
|
||||
|
||||
steam-game-installer only finishes a manifest it finds. A fresh install used
|
||||
to reach it with none (Oct 22, BG3: "nothing to finish"), so the enforcer now
|
||||
writes Steam's own "needs downloading" manifest first.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
from steam_backlog_enforcer.game_install import _queue_download, install_game
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
PKG = "steam_backlog_enforcer.game_install"
|
||||
|
||||
|
||||
class TestQueueBeforeFastInstall:
|
||||
def test_fast_install_gets_a_manifest_to_finish(self, tmp_path: Path) -> None:
|
||||
"""steam-game-installer only finishes a manifest it finds, so a fresh
|
||||
install is queued with Steam before the installer runs, not after."""
|
||||
seen: list[bool] = []
|
||||
|
||||
def fast(_app_id: int, _label: str, prepare: Callable[[], object]) -> bool:
|
||||
prepare()
|
||||
seen.append((tmp_path / "appmanifest_440.acf").exists())
|
||||
return True
|
||||
|
||||
with (
|
||||
patch(f"{PKG}.STEAMAPPS_PATH", tmp_path),
|
||||
patch(f"{PKG}.fast_install", side_effect=fast),
|
||||
patch(f"{PKG}.os.geteuid", return_value=1000),
|
||||
):
|
||||
assert install_game(440, "TF2", "s1", use_steam_protocol=True) is True
|
||||
assert seen == [True]
|
||||
|
||||
def test_a_manifest_steam_already_has_is_kept(self, tmp_path: Path) -> None:
|
||||
"""Steam's own download in progress: its manifest is never overwritten."""
|
||||
manifest = tmp_path / "appmanifest_440.acf"
|
||||
manifest.write_text('"StateFlags"\t\t"1042"\n')
|
||||
with (
|
||||
patch(f"{PKG}.STEAMAPPS_PATH", tmp_path),
|
||||
patch("steam_backlog_enforcer._steam_client.STEAMAPPS_PATH", tmp_path),
|
||||
):
|
||||
assert _queue_download(440, "TF2", "s1") is True
|
||||
assert manifest.read_text() == '"StateFlags"\t\t"1042"\n'
|
||||
@ -112,6 +112,19 @@ class TestCmdInstall:
|
||||
):
|
||||
cmd_install(Config(steam_id="i"), state)
|
||||
|
||||
def test_failed_fast_install_never_says_done(self) -> None:
|
||||
"""Steam's fallback download started, but the fast one failed: "Done!"
|
||||
after the "Failed: ..." line was the success the BG3 job showed."""
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{PKG}.is_game_fully_installed", return_value=False),
|
||||
patch(f"{PKG}.install_game", return_value=True),
|
||||
patch(f"{PKG}.fast_install_failure", return_value="exit 1"),
|
||||
patch(f"{PKG}._echo") as echo,
|
||||
):
|
||||
cmd_install(Config(steam_id="i"), state)
|
||||
echo.assert_called_with("Steam's own download was started instead.")
|
||||
|
||||
|
||||
class TestCmdHide:
|
||||
"""Tests for cmd_hide."""
|
||||
|
||||
@ -91,6 +91,7 @@ class TestDaemonView:
|
||||
"phase": "downloading depot 3/8",
|
||||
"percent": 46.9,
|
||||
"elapsed_seconds": 60,
|
||||
"error": None,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@ -173,6 +173,8 @@ export interface FastInstallRun {
|
||||
/** The current depot's percent; null until depotdownloader reports one. */
|
||||
percent: number | null
|
||||
elapsed_seconds: number
|
||||
/** Why the run failed (`phase` is then `failed`); null while it runs. */
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export interface DaemonStatus {
|
||||
|
||||
@ -114,6 +114,14 @@ describe('Dashboard', () => {
|
||||
expect(screen.getByRole('progressbar', { name: "Baldur's Gate 3 download" })).toHaveClass('bar-indeterminate')
|
||||
})
|
||||
|
||||
it('keeps a failed daemon run on screen with its reason', async () => {
|
||||
const failed = { ...bg3, phase: 'failed', percent: null, elapsed_seconds: 0, error: 'Refusing to mark it as installed' }
|
||||
show({ 'GET /api/daemon': daemon([failed]) })
|
||||
expect(await screen.findByText("Fast install of Baldur's Gate 3 failed: Refusing to mark it as installed")).toHaveClass('text-danger')
|
||||
expect(screen.getByText("Steam's own download took over.")).toBeInTheDocument()
|
||||
expect(screen.queryByRole('progressbar', { name: "Baldur's Gate 3 download" })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('stays out of the way when nothing is installing', async () => {
|
||||
show()
|
||||
expect(await screen.findByText('Hollow Knight')).toBeInTheDocument()
|
||||
|
||||
@ -55,13 +55,21 @@ function FastInstalls() {
|
||||
if (!runs.length) return null
|
||||
return (
|
||||
<Card title="Fast install">
|
||||
{runs.map((r) => (
|
||||
<div key={r.app_id}>
|
||||
<p>{fastInstallLine(r)}</p>
|
||||
<ProgressBar fraction={r.percent === null ? null : Math.floor(r.percent) / 100} label={`${r.name} download`} />
|
||||
<p className="field-help">{fmtDuration(r.elapsed_seconds)} elapsed</p>
|
||||
</div>
|
||||
))}
|
||||
{runs.map((r) =>
|
||||
r.error ? (
|
||||
// The daemon has no job to report to: a failure stays here until Steam finishes.
|
||||
<div key={r.app_id}>
|
||||
<p className="text-danger">{`Fast install of ${r.name} failed: ${r.error}`}</p>
|
||||
<p className="field-help">Steam's own download took over.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div key={r.app_id}>
|
||||
<p>{fastInstallLine(r)}</p>
|
||||
<ProgressBar fraction={r.percent === null ? null : Math.floor(r.percent) / 100} label={`${r.name} download`} />
|
||||
<p className="field-help">{fmtDuration(r.elapsed_seconds)} elapsed</p>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user