From 03b2dd8363c794a046a9fc736d2f5df6165ba8ce Mon Sep 17 00:00:00 2001 From: Jessey van Offeren Date: Fri, 22 May 2026 08:59:54 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20D12=20Steam-launch=20wrapper=20for=20au?= =?UTF-8?q?to=20crash-capture=20+=20doc=20status=20fixes=20=E2=80=94=200.1?= =?UTF-8?q?6.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D12 "build first" wrapper: `rigdoctor wrap %command%` (Steam launch option / Lutris/Heroic wrapper field) auto-brackets a focused diagnostic around a game — start a game-tagged capture on launch, clean stop on exit; a hard freeze leaves it unterminated → flagged as a crash next launch. - core/wrap.py: game name from SteamAppId, PATH-proof launch_option(), run() that doesn't disturb an existing capture and returns the game's exit code. - diagnostic.start() preserves an unanalyzed crash to diagnostic-crash.jsonl before clearing, so auto-relaunch can't wipe an unseen crash; pending_crash/ analyze_crash check the archive first. - GUI: "Auto-capture…" helper dialog (copyable launch-option string). - Tests for wrap (name resolution, exit-code passthrough, no-double-start). - docs: fix stale MODULES.md status column (M1/M3/M4/M5/M8/M10/M13 → done), update ROADMAP/MODULES for the wrapper + crash detection. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 16 +++++++ docs/MODULES.md | 30 +++++++----- docs/ROADMAP.md | 15 ++++-- pyproject.toml | 2 +- src/rigdoctor/__init__.py | 2 +- src/rigdoctor/cli.py | 12 +++++ src/rigdoctor/config.py | 3 ++ src/rigdoctor/core/diagnostic.py | 49 +++++++++++++++----- src/rigdoctor/core/wrap.py | 78 ++++++++++++++++++++++++++++++++ src/rigdoctor/gui/games_page.py | 43 ++++++++++++++++++ tests/test_diagnostic.py | 4 ++ tests/test_wrap.py | 68 ++++++++++++++++++++++++++++ 12 files changed, 292 insertions(+), 30 deletions(-) create mode 100644 src/rigdoctor/core/wrap.py create mode 100644 tests/test_wrap.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f11c554..88e3444 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to RigDoctor are recorded here. Format follows (`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git release tag (so the auto-updater, D18, can compare versions). +## [0.16.0] - 2026-05-22 +### Added +- **Automatic crash-capture via a Steam launch wrapper (M6/D12).** Set `rigdoctor wrap + %command%` as a game's Steam launch option (or in Lutris/Heroic's wrapper field) and RigDoctor + starts a focused, game-tagged capture when the game launches and stops it cleanly on exit — no + manual Run Diagnostic / Finish. A hard freeze leaves the capture unterminated, so it's flagged + as a crash next launch. The wrapper resolves the game name from Steam's `SteamAppId`, doesn't + disturb an existing capture, and returns the game's exit code. (`core/wrap.py`, `rigdoctor wrap`.) +- GUI **Auto-capture…** helper on the Games page: shows the exact launch-option line (absolute + path, copy button) and how to set it in Steam. +- Auto-capture preserves an unanalyzed crash (`diagnostic-crash.jsonl`) before starting a new + capture, so relaunching the game can't wipe a crash report you haven't seen yet. +### Fixed +- `docs/MODULES.md` status column was stale — M1, M3, M4, M5, M8, M10, and M13 are done and now + marked ✅ (only M2 and M11 remain not-started; M6/M9/M12 in progress). + ## [0.15.0] - 2026-05-22 ### Added - **Hard-crash detection & recovery for the guided diagnostic.** If a focused capture ends diff --git a/docs/MODULES.md b/docs/MODULES.md index 59fbdaa..587b6d5 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -8,18 +8,18 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done | ID | Module | Bundle | Key deps | GPU scope | Priority | Status | |----|--------|--------|----------|-----------|----------|--------| -| M1 | Sensor core | Essential | none (nvidia-smi, sysfs) | all (NVIDIA first) | P0 | ⬜ | -| M3 | Crash-capture logger | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | 🟨 | -| M4 | Health report (log scan) | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | 🟨 | +| M1 | Sensor core | Essential | none (nvidia-smi, sysfs) | all (NVIDIA first) | P0 | ✅ | +| M3 | Crash-capture logger | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | ✅ | +| M4 | Health report (log scan) | Essential | none (opt: smartmontools) | all (NVIDIA first) | P0 | ✅ | | M2 | Live monitor (TUI) | Monitoring | none (stdlib curses) | all | P1 | ⬜ | -| M8 | Alerting | Monitoring | libnotify (opt) | all | P2 | 🟨 | -| M5 | System inventory | Diagnostics | none (opt: lm-sensors, dmidecode) | all | P1 | 🟨 | +| M8 | Alerting | Monitoring | libnotify (opt) | all | P2 | ✅ | +| M5 | System inventory | Diagnostics | none (opt: lm-sensors, dmidecode) | all | P1 | ✅ | | M6 | Gaming env checks | Diagnostics | none | all | P2 | 🟨 | -| M10 | Desktop GUI | Desktop UI | **python3-pyside6** | all | P2 | 🟨 | +| M10 | Desktop GUI | Desktop UI | **python3-pyside6** | all | P2 | ✅ | | M11 | Tray / menu-bar applet | Desktop UI | **python3-pyside6** (+ AppIndicator on GNOME) | all | P2 | ⬜ | | M9 | Installer | (meta) | none | all | P1 | 🟨 | | M12 | Session sharing / remote assist | Sharing | none (Tier 3: tmate/sshx) | all | P3 | 🟨 | -| M13 | Auto-update | (core) | none (stdlib; user-local file swap) | all | P3 | 🟨 | +| M13 | Auto-update | (core) | none (stdlib; user-local file swap) | all | P3 | ✅ | | ~~M7~~ | ~~Stress / repro~~ | — | — | — | — | ❌ dropped (D7) | ## Notes per module @@ -31,8 +31,10 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done *Implemented (manual trigger):* JSONL log with fsync-per-sample, size-based rotation (`log_max_bytes`/`log_backups`), GPU-lost/recovered event markers, atomic status file, and `rigdoctor record run|start|stop|status|report`. The foreground `run` is the systemd-ready - entrypoint; the service unit + always-on/game-launch triggers (D6/D12) land in Phase 4. - Also fully driven from the GUI's Recording/Logs page (M10) via shared `core.reccontrol`. + entrypoint. The **game-launch trigger** is implemented via the D12 wrapper (`rigdoctor wrap + %command%`, see M6/below); the `systemd --user` service unit + always-on trigger (D6) and the + zero-config watcher (D12) are still pending. Also fully driven from the GUI's Recording/Logs + page (M10) via shared `core.reccontrol`. - **M4 Health report** — turns scattered logs into a prioritized, plain-language findings list with **suggested** fixes (read-only, D9). Reuses M1 for a live snapshot. Also powers the **guided diagnostic session** (with M3): pick a game → focused capture → scan → @@ -56,8 +58,14 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done for the runtime-reversible tunables (governor / NVIDIA persistence / PCIe ASPM / swappiness / THP — dropdown + Apply via a single pkexec prompt, `core/fixes.py`) and **one-click install** of optional tools (GameMode / MangoHud / cpupower, now in the M9 catalog). GRUB/mitigations - stay suggestion-only. *Pending:* non-Steam launchers (Lutris/Heroic) and GPU power-profile - (PowerMizer) checks. + stay suggestion-only. *Guided diagnostic (D12 "pick a game", `core/diagnostic.py`):* a focused + capture tagged with a game → window-scoped report (capture summary + M4 findings), in the CLI + (`rigdoctor diagnose start/status/finish`) and GUI (per-game **Run Diagnostic** → recording + banner → results dialog). **Auto-capture** via the D12 wrapper (`rigdoctor wrap %command%`, + `core/wrap.py`; GUI "Auto-capture…" helper). **Hard crashes are detected** (capture left + without a clean stop) and flagged on next launch with a crash-boot kernel-log analysis + (`pending_crash`/`analyze_crash` + `health.check_previous_boot`). *Pending:* non-Steam + launchers (Lutris/Heroic), GPU power-profile (PowerMizer) checks, and the zero-config watcher. - **M8 Alerting** — threshold/event notifications; integrates with the tray applet (M11). - **M10 Desktop GUI** — PySide6 graphical front-end over the core engine (dashboard, log browser, report viewer, logger controls). Optional; adds the Qt dependency. *Bootstrapped diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 296bf5f..5f03104 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -45,11 +45,16 @@ Ubuntu + NVIDIA first; `.deb` distribution (see `DECISIONS.md`). diagnose start/status/finish`, and a **Run Diagnostic** button per game on the GUI Games page → recording banner → results dialog with the capture summary + findings). Tags a focused capture with the chosen game (own diagnostic log, window-scoped report) and - combines the capture summary with the M4 findings. *Pending:* the tray (M11) entry point, - and auto start/stop via the D12 wrapper/watcher. -- [ ] Logger trigger modes: always-on + game-launch (D12 — wrapper first: - `rigdoctor wrap %command%` + global Steam compat-tool; zero-config watcher - (Steam RunningAppID + /proc) and GameMode hook follow) + combines the capture summary with the M4 findings. **Auto start/stop** via the D12 + wrapper is wired in, and a **hard-crash is detected** (capture left without a clean stop) + → flagged on next launch with a deeper crash-boot log analysis. *Pending:* the tray (M11) + entry point and the zero-config watcher. +- [~] Logger trigger modes: always-on + game-launch (D12) — *game-launch **wrapper** done:* + `rigdoctor wrap %command%` (per-game Steam launch option / Lutris/Heroic wrapper field) + auto-brackets a focused capture around the game; GUI "Auto-capture…" helper shows the + launch-option string. *Pending:* global Steam compat-tool registration, the zero-config + watcher (Steam RunningAppID + /proc), GameMode hook, and the always-on `systemd --user` + service. - [~] M9 interactive installer — *done:* distro/GPU detection + optional-dependency install (`rigdoctor install`, GUI Setup tab); **user-local `install.sh` + self-extracting `.run`** (no-root venv install, handles python3-venv prereq, CI-built). *Pending:* module-selection diff --git a/pyproject.toml b/pyproject.toml index edab750..f330021 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rigdoctor" -version = "0.15.0" +version = "0.16.0" description = "Modular hardware monitoring & crash diagnostics for Linux gamers." readme = "README.md" requires-python = ">=3.11" diff --git a/src/rigdoctor/__init__.py b/src/rigdoctor/__init__.py index 8370be9..0878c5f 100644 --- a/src/rigdoctor/__init__.py +++ b/src/rigdoctor/__init__.py @@ -1,3 +1,3 @@ """RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers.""" -__version__ = "0.15.0" +__version__ = "0.16.0" diff --git a/src/rigdoctor/cli.py b/src/rigdoctor/cli.py index c713f8e..a92c460 100644 --- a/src/rigdoctor/cli.py +++ b/src/rigdoctor/cli.py @@ -417,6 +417,12 @@ def cmd_diagnose(args) -> int: return 0 +def cmd_wrap(args) -> int: + from .core import wrap + + return wrap.run(args.command) + + def cmd_gameenv(args) -> int: from dataclasses import asdict @@ -605,6 +611,12 @@ def build_parser() -> argparse.ArgumentParser: diag_finish.add_argument("--last", type=int, default=10, help="recent samples to show") diag_finish.set_defaults(func=cmd_diagnose) diag_p.set_defaults(func=cmd_diagnose, diagnose_cmd=None, last=10) + + wrap_p = sub.add_parser( + "wrap", help="run a game with automatic crash-capture (Steam launch option, D12)") + wrap_p.add_argument("command", nargs=argparse.REMAINDER, + help="the game command — use `rigdoctor wrap %%command%%` in Steam") + wrap_p.set_defaults(func=cmd_wrap) return p diff --git a/src/rigdoctor/config.py b/src/rigdoctor/config.py index ee1704f..5d29156 100644 --- a/src/rigdoctor/config.py +++ b/src/rigdoctor/config.py @@ -26,6 +26,9 @@ LOG_FILE = LOG_DIR / "capture.jsonl" # Guided diagnostic (M6/D12): a focused capture writes here, separate from the always-on # crash log, so its report covers only that session's window. DIAG_LOG = LOG_DIR / "diagnostic.jsonl" +# A crashed (unterminated, unacknowledged) diagnostic is preserved here when a new capture +# starts, so auto-capture (the Steam wrapper) relaunching the game doesn't wipe it first. +DIAG_CRASH = LOG_DIR / "diagnostic-crash.jsonl" STATUS_FILE = STATE_DIR / "recorder.json" PID_FILE = STATE_DIR / "recorder.pid" SPAWN_LOG = STATE_DIR / "recorder.out" diff --git a/src/rigdoctor/core/diagnostic.py b/src/rigdoctor/core/diagnostic.py index af0809f..469d83b 100644 --- a/src/rigdoctor/core/diagnostic.py +++ b/src/rigdoctor/core/diagnostic.py @@ -53,6 +53,11 @@ def start(game: str | None = None, interval: float | None = None) -> int | None: Returns the pid, or None if a capture is already running.""" if reccontrol.running_pid(): return None + if _crash_from_log(config.DIAG_LOG): # preserve an unanalyzed crash before overwriting it + try: + config.DIAG_LOG.replace(config.DIAG_CRASH) + except OSError: + pass _clear_diag_log() return reccontrol.start_background(interval=interval, out=str(config.DIAG_LOG), game=game) @@ -97,17 +102,11 @@ def finish(last_n: int = 10, log_path=None) -> DiagnosticResult: # --- hard-crash detection & post-crash analysis ----------------------------------- -def pending_crash() -> CrashInfo | None: - """Detect a diagnostic that ended abnormally (no clean stop, no live recorder). - - A focused capture writes `session-start` (+ `game`) and, on a clean stop, `session-stop`. - After a hard freeze that block never runs, so the log has a start with no stop and no - live recorder — that's our hard-crash signal. Returns None if a capture is running, none - is recorded, it stopped cleanly, or the user already acknowledged it. - """ - if is_running() or not config.DIAG_LOG.exists(): +def _crash_from_log(path) -> CrashInfo | None: + """CrashInfo if `path` holds an abnormally-ended session (start, no stop, not acked).""" + if not path.exists(): return None - summary = summarize(config.DIAG_LOG) + summary = summarize(path) kinds = {kind for _ts, kind, _detail in summary.events} if "session-start" not in kinds: return None @@ -121,8 +120,34 @@ def pending_crash() -> CrashInfo | None: ) +def _crash_path(): + """Where the pending crash lives: the preserved archive if present, else the live log.""" + return config.DIAG_CRASH if config.DIAG_CRASH.exists() else config.DIAG_LOG + + +def pending_crash() -> CrashInfo | None: + """Detect a diagnostic that ended abnormally (no clean stop, no live recorder). + + A focused capture writes `session-start` (+ `game`) and, on a clean stop, `session-stop`. + After a hard freeze that block never runs, so the log has a start with no stop and no + live recorder — that's our hard-crash signal. A crash preserved across an auto-relaunch + (`DIAG_CRASH`) is checked first. Returns None if a capture is running, none is recorded, + it stopped cleanly, or the user already acknowledged it. + """ + info = _crash_from_log(config.DIAG_CRASH) # preserved across a relaunch (wrapper) + if info is not None: + return info + if is_running(): + return None + return _crash_from_log(config.DIAG_LOG) + + def acknowledge_crash() -> None: - """Mark the recorded crash as seen so it stops prompting (appends a marker event).""" + """Mark the recorded crash as seen so it stops prompting.""" + try: + config.DIAG_CRASH.unlink() # drop the preserved archive, if any + except OSError: + pass try: config.DIAG_LOG.parent.mkdir(parents=True, exist_ok=True) with open(config.DIAG_LOG, "a", encoding="utf-8") as fh: @@ -154,7 +179,7 @@ def analyze_crash(last_n: int = 15) -> DiagnosticResult: + the rest of the health report (SMART/driver/persistence/temps).""" from .health import check_previous_boot, run_health_checks - summary = summarize(config.DIAG_LOG, last_n=last_n) + summary = summarize(_crash_path(), last_n=last_n) findings: list[Finding] = [_crash_headline(summary)] findings += check_previous_boot() # the crashed boot's kernel log findings += run_health_checks(include_journal=False) # SMART/driver/persistence/temps diff --git a/src/rigdoctor/core/wrap.py b/src/rigdoctor/core/wrap.py new file mode 100644 index 0000000..84898d9 --- /dev/null +++ b/src/rigdoctor/core/wrap.py @@ -0,0 +1,78 @@ +"""Steam-launch wrapper (D12): auto-bracket a focused diagnostic around a game. + +Set as a per-game Steam launch option — `rigdoctor wrap %command%` — or in Lutris/Heroic's +wrapper field. Steam expands `%command%` to the real game command; we start a focused capture +(tagged with the game), run the game, and stop the capture cleanly when it exits. A hard +freeze means the game (and this wrapper) never returns, so the capture is left without a clean +stop — which RigDoctor then flags as a crash on next launch. + +Deterministic and daemonless (D12 "build first"): no polling, and it knows the title. +""" + +from __future__ import annotations + +import os +import signal +import subprocess +import sys +from pathlib import Path + + +def game_name_from_env() -> str | None: + """The launching game's name, resolved from Steam's SteamAppId env var via the scan.""" + appid = os.environ.get("SteamAppId") or os.environ.get("SteamGameId") + if not appid: + return None + from . import steam + + games = steam.cached_games() or steam.scan_games(steam.selected_library_paths()) + for game in games: + if game.appid == str(appid): + return game.name + return f"Steam app {appid}" + + +def launch_option() -> str: + """The exact string to paste into Steam's Launch Options (absolute path → PATH-proof).""" + exe = Path(sys.executable).with_name("rigdoctor") + prog = str(exe) if exe.exists() else "rigdoctor" + quoted = f'"{prog}"' if " " in prog else prog + return f"{quoted} wrap %command%" + + +def run(command: list[str]) -> int: + """Start a focused capture (unless one's already running), run the game, then stop it. + Returns the game's exit code so Steam sees the right status.""" + from . import diagnostic, reccontrol + + if not command: + print("usage: rigdoctor wrap %command% (set as a Steam launch option)", file=sys.stderr) + return 2 + + game = game_name_from_env() or os.path.basename(command[0]) + started = False + if not reccontrol.running_pid(): # don't disturb an existing capture + started = diagnostic.start(game=game) is not None + + proc: subprocess.Popen | None = None + + def _forward(signum, _frame): # pass Steam's stop signal to the game + if proc is not None and proc.poll() is None: + try: + proc.send_signal(signum) + except OSError: + pass + + previous = {sig: signal.signal(sig, _forward) for sig in (signal.SIGTERM, signal.SIGINT)} + try: + proc = subprocess.Popen(command) + rc = proc.wait() + except (OSError, ValueError, subprocess.SubprocessError) as exc: + print(f"rigdoctor wrap: couldn't launch the game: {exc}", file=sys.stderr) + rc = 1 + finally: + for sig, handler in previous.items(): + signal.signal(sig, handler) + if started: + reccontrol.stop_background() # clean stop → no false crash flag + return rc diff --git a/src/rigdoctor/gui/games_page.py b/src/rigdoctor/gui/games_page.py index 3a51dab..8c886a2 100644 --- a/src/rigdoctor/gui/games_page.py +++ b/src/rigdoctor/gui/games_page.py @@ -13,10 +13,13 @@ import time from PySide6.QtCore import Qt, QTimer, Signal from PySide6.QtWidgets import ( + QApplication, QCheckBox, + QDialog, QFrame, QHBoxLayout, QLabel, + QLineEdit, QMessageBox, QPushButton, QScrollArea, @@ -99,6 +102,9 @@ class GamesPage(QWidget): self._status = QLabel("") self._status.setObjectName("Muted") header.addWidget(self._status) + self._autocap_btn = QPushButton("Auto-capture…") + self._autocap_btn.clicked.connect(self._show_autocapture) + header.addWidget(self._autocap_btn) self._rescan_btn = QPushButton("Rescan") self._rescan_btn.setObjectName("PrimaryButton") self._rescan_btn.clicked.connect(self.refresh) @@ -395,6 +401,43 @@ class GamesPage(QWidget): reccontrol.stop_background() self._banner.hide() + def _show_autocapture(self) -> None: + from ..core import wrap + + option = wrap.launch_option() + dlg = QDialog(self) + dlg.setWindowTitle("Auto-capture in Steam") + dlg.resize(580, 250) + v = QVBoxLayout(dlg) + v.setContentsMargins(20, 18, 20, 16) + v.setSpacing(12) + info = QLabel( + "Capture automatically every time you launch a game — no need to click " + "Run Diagnostic.\n\n" + "1. In Steam, right-click the game → Properties → Launch Options.\n" + "2. Paste the line below.\n\n" + "RigDoctor starts a focused capture when the game launches and stops it on exit. " + "If the game hard-freezes, you'll get a crash report next time you open RigDoctor." + ) + info.setWordWrap(True) + v.addWidget(info) + row = QHBoxLayout() + field = QLineEdit(option) + field.setReadOnly(True) + row.addWidget(field, 1) + copy = QPushButton("Copy") + copy.setObjectName("PrimaryButton") + copy.clicked.connect(lambda: QApplication.clipboard().setText(option)) + row.addWidget(copy) + v.addLayout(row) + buttons = QHBoxLayout() + buttons.addStretch(1) + close = QPushButton("Close") + close.clicked.connect(dlg.accept) + buttons.addWidget(close) + v.addLayout(buttons) + dlg.exec() + # --- hard-crash recovery ---------------------------------------------------------- def _check_crash(self) -> None: diff --git a/tests/test_diagnostic.py b/tests/test_diagnostic.py index ff1d40a..129ac50 100644 --- a/tests/test_diagnostic.py +++ b/tests/test_diagnostic.py @@ -66,6 +66,7 @@ class CrashDetectionTests(unittest.TestCase): log = self._diag_log(d) _write_log(str(log), "Tarkov") # has session-start + game, no session-stop with mock.patch.object(diagnostic.config, "DIAG_LOG", log), \ + mock.patch.object(diagnostic.config, "DIAG_CRASH", log.with_suffix(".crash")), \ mock.patch.object(diagnostic.reccontrol, "running_pid", return_value=None): info = diagnostic.pending_crash() self.assertIsNotNone(info) @@ -81,6 +82,7 @@ class CrashDetectionTests(unittest.TestCase): w.write_event("session-stop", "samples=1") w.close() with mock.patch.object(diagnostic.config, "DIAG_LOG", log), \ + mock.patch.object(diagnostic.config, "DIAG_CRASH", log.with_suffix(".crash")), \ mock.patch.object(diagnostic.reccontrol, "running_pid", return_value=None): self.assertIsNone(diagnostic.pending_crash()) @@ -89,6 +91,7 @@ class CrashDetectionTests(unittest.TestCase): log = self._diag_log(d) _write_log(str(log), "Tarkov") with mock.patch.object(diagnostic.config, "DIAG_LOG", log), \ + mock.patch.object(diagnostic.config, "DIAG_CRASH", log.with_suffix(".crash")), \ mock.patch.object(diagnostic.reccontrol, "running_pid", return_value=None): self.assertIsNotNone(diagnostic.pending_crash()) diagnostic.acknowledge_crash() @@ -99,6 +102,7 @@ class CrashDetectionTests(unittest.TestCase): log = self._diag_log(d) _write_log(str(log), "Tarkov") with mock.patch.object(diagnostic.config, "DIAG_LOG", log), \ + mock.patch.object(diagnostic.config, "DIAG_CRASH", log.with_suffix(".crash")), \ mock.patch.object(diagnostic.reccontrol, "running_pid", return_value=4321): self.assertIsNone(diagnostic.pending_crash()) # it's in-progress, not crashed diff --git a/tests/test_wrap.py b/tests/test_wrap.py new file mode 100644 index 0000000..b5ce499 --- /dev/null +++ b/tests/test_wrap.py @@ -0,0 +1,68 @@ +"""Tests for the D12 Steam-launch wrapper (rigdoctor wrap %command%).""" + +import unittest +from unittest import mock + +from rigdoctor.core import wrap +from rigdoctor.core.steam import Game + + +class LaunchOptionTests(unittest.TestCase): + def test_format(self): + opt = wrap.launch_option() + self.assertTrue(opt.endswith("wrap %command%")) + self.assertIn("rigdoctor", opt) + + +class GameNameTests(unittest.TestCase): + def test_resolves_from_steam_appid(self): + g = Game(appid="570", name="Dota 2", library="/x", installdir="dota") + with mock.patch.dict("os.environ", {"SteamAppId": "570"}), \ + mock.patch("rigdoctor.core.steam.cached_games", return_value=[g]): + self.assertEqual(wrap.game_name_from_env(), "Dota 2") + + def test_unknown_appid_falls_back(self): + with mock.patch.dict("os.environ", {"SteamAppId": "999"}), \ + mock.patch("rigdoctor.core.steam.cached_games", return_value=[]), \ + mock.patch("rigdoctor.core.steam.scan_games", return_value=[]): + self.assertEqual(wrap.game_name_from_env(), "Steam app 999") + + def test_none_without_steam_env(self): + with mock.patch.dict("os.environ", {}, clear=True): + self.assertIsNone(wrap.game_name_from_env()) + + +class RunTests(unittest.TestCase): + def test_brackets_capture_and_returns_exit_code(self): + with mock.patch("rigdoctor.core.reccontrol.running_pid", return_value=None), \ + mock.patch("rigdoctor.core.diagnostic.start", return_value=123) as start, \ + mock.patch("rigdoctor.core.reccontrol.stop_background") as stop, \ + mock.patch.dict("os.environ", {}, clear=True): + rc = wrap.run(["true"]) + self.assertEqual(rc, 0) + start.assert_called_once() + stop.assert_called_once() + + def test_propagates_game_failure(self): + with mock.patch("rigdoctor.core.reccontrol.running_pid", return_value=None), \ + mock.patch("rigdoctor.core.diagnostic.start", return_value=123), \ + mock.patch("rigdoctor.core.reccontrol.stop_background"), \ + mock.patch.dict("os.environ", {}, clear=True): + self.assertEqual(wrap.run(["false"]), 1) + + def test_does_not_touch_an_existing_capture(self): + with mock.patch("rigdoctor.core.reccontrol.running_pid", return_value=999), \ + mock.patch("rigdoctor.core.diagnostic.start") as start, \ + mock.patch("rigdoctor.core.reccontrol.stop_background") as stop, \ + mock.patch.dict("os.environ", {}, clear=True): + rc = wrap.run(["true"]) + self.assertEqual(rc, 0) + start.assert_not_called() + stop.assert_not_called() + + def test_empty_command_is_usage_error(self): + self.assertEqual(wrap.run([]), 2) + + +if __name__ == "__main__": + unittest.main()