Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d6ce47e87 | |||
| 03b2dd8363 |
@@ -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
|
||||
|
||||
+19
-11
@@ -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
|
||||
|
||||
+10
-5
@@ -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
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
|
||||
|
||||
__version__ = "0.15.0"
|
||||
__version__ = "0.16.0"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user