feat: add system diagnostics endpoint.

This commit is contained in:
arabcoders 2026-05-26 19:22:36 +03:00
parent 376e439115
commit 434d3e981d
9 changed files with 1905 additions and 1 deletions

49
API.md
View file

@ -99,6 +99,7 @@ This document describes the available endpoints and their usage. All endpoints r
- [POST /api/notifications/test](#post-apinotificationstest)
- [GET /api/yt-dlp/options](#get-apiyt-dlpoptions)
- [GET /api/system/configuration](#get-apisystemconfiguration)
- [GET /api/system/diagnostics](#get-apisystemdiagnostics)
- [GET /api/system/limits](#get-apisystemlimits)
- [POST /api/system/terminal](#post-apisystemterminal)
- [GET /api/system/terminal](#get-apisystemterminal)
@ -2632,6 +2633,54 @@ or an error:
---
### GET /api/system/diagnostics
**Purpose**: View system information.
**Response**:
```json
{
"status": "error",
"generated_at": 1713000000,
"summary": {
"total": 10,
"pass": 5,
"fail": 2,
"warn": 1,
"skip": 2,
"required_failed": 2
},
"runtime": {
"app_version": "1.0.0",
"app_branch": "main",
"app_commit_sha": "abcdef12",
"app_build_date": "20260526",
"started": 1712999900,
"uptime_seconds": 100,
"platform": "linux",
"platform_release": "6.8.0",
"platform_machine": "x86_64",
"python_version": "3.13.1",
"python_minimum": "3.13",
"is_native": false,
"console_enabled": false
},
"requirements": {
"python": {
"current": "3.13.1",
"required": "3.13",
"supported": true,
"note": ""
}
},
"checks": []
}
```
**Notes**:
- Unexpected collection errors are returned as an `error`.
---
### GET /api/system/limits
**Purpose**: Get the system limits.

760
app/library/diagnostics.py Normal file
View file

@ -0,0 +1,760 @@
from __future__ import annotations
import asyncio
import importlib.metadata
import os
import platform
import re
import shutil
import sqlite3
import subprocess
import sys
import time
import urllib.parse
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal
from app.library.config import Config
from app.library.httpx_client import resolve_curl_transport
CheckStatus = Literal["pass", "fail", "warn", "skip"]
ReportStatus = Literal["ok", "degraded", "error"]
MIN_PYTHON: tuple[int, int] = (3, 13)
@dataclass(kw_only=True)
class DiagnosticCheck:
id: str
label: str
group: str
required: bool
status: CheckStatus
description: str
message: str
details: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"label": self.label,
"group": self.group,
"required": self.required,
"status": self.status,
"description": self.description,
"message": self.message,
"details": self.details,
}
@dataclass(frozen=True, kw_only=True)
class CheckMeta:
group: str
description: str
BINARY_META: dict[str, CheckMeta] = {
"ffmpeg": CheckMeta(
group="core", description="Used for thumbnails and streaming and various media processing tasks."
),
"ffprobe": CheckMeta(group="core", description="Used for media inspection and metadata extraction."),
"deno": CheckMeta(group="youtube", description="Used for yt-dlp YouTube support."),
"yt_dlp_cli": CheckMeta(group="core", description="Used by the built-in terminal."),
"aria2c": CheckMeta(group="advanced", description="External downloader for yt-dlp."),
"mkvpropedit": CheckMeta(group="advanced", description="Edit Matroska container properties without remux."),
"mkvextract": CheckMeta(group="advanced", description="Extract tracks from Matroska containers."),
"mp4box": CheckMeta(group="advanced", description="MP4 container manipulation tool."),
}
DIRECTORY_DESCRIPTIONS: dict[str, str] = {
"config_path": "Directory for config files and app data.",
"download_path": "Directory where downloads are saved.",
"temp_path": "Directory used for temporary files.",
}
def _first_line(*parts: str) -> str:
for part in parts:
for line in part.splitlines():
if line := line.strip():
return line
return ""
def _bin_version(value: str) -> str:
if not (line := value.strip()):
return ""
if match := re.match(r"^deno\s+([0-9][^\s]*)", line, flags=re.IGNORECASE):
return match.group(1).strip()
if match := re.match(r"^mkv\w+\s+v([0-9][^\s]*)", line, flags=re.IGNORECASE):
return match.group(1).strip()
if match := re.search(r"\bversion\s+(.+?)(?:\s+Copyright\b|$)", line, flags=re.IGNORECASE):
return match.group(1).strip()
return line
def _parse_browser_url(value: str) -> tuple[str, str]:
if value.startswith(("selenium+http://", "selenium+https://")):
return "selenium", value.removeprefix("selenium+")
if value.startswith(("playwright+ws://", "playwright+wss://")):
return "playwright", value.removeprefix("playwright+")
if value.startswith("playwright+cdp://"):
return "playwright", f"http://{value.removeprefix('playwright+cdp://')}"
if value.startswith("playwright+cdp+"):
return "playwright", value.removeprefix("playwright+cdp+")
return "unknown", value
def _safe_url(url: str) -> str:
try:
parsed = urllib.parse.urlsplit(url)
except Exception:
return "***"
if not parsed.scheme or not parsed.netloc:
return "***"
netloc = parsed.netloc
if parsed.username or parsed.password:
host = parsed.hostname or ""
if parsed.port:
host = f"{host}:{parsed.port}"
netloc = f"***:***@{host}" if host else "***:***"
path = "/***" if parsed.path and parsed.path != "/" else parsed.path
query = "***" if parsed.query else ""
fragment = "***" if parsed.fragment else ""
return urllib.parse.urlunsplit((parsed.scheme, netloc, path, query, fragment))
def _package_version(name: str) -> str | None:
try:
return importlib.metadata.version(name)
except importlib.metadata.PackageNotFoundError:
return None
def _package_check(
name: str,
*,
label: str,
group: str,
required: bool,
description: str,
present_message: str = "Installed.",
missing_message: str = "Missing.",
missing_status: CheckStatus = "skip",
check_id: str | None = None,
details: dict[str, Any] | None = None,
) -> DiagnosticCheck:
version = _package_version(name)
installed = version is not None
base_details: dict[str, Any] = {"package": name, "version": version}
if details:
base_details.update(details)
return DiagnosticCheck(
id=check_id or name.replace("-", "_"),
label=label,
group=group,
required=required,
status="pass" if installed else ("fail" if required else missing_status),
description=description,
message=present_message if installed else missing_message,
details=base_details,
)
def _check_ytdlp_package() -> DiagnosticCheck:
version = Config._ytdlp_version()
installed = version != "0.0.0"
return DiagnosticCheck(
id="yt_dlp_package",
label="yt-dlp package",
group="core",
required=True,
status="pass" if installed else "fail",
description="Main downloader library used by the app.",
message="Installed." if installed else "Missing.",
details={"version": version},
)
def _check_apprise_package(config: Config) -> DiagnosticCheck:
configured = Path(config.apprise_config).exists()
return _package_check(
"apprise",
label="Apprise",
group="notifications",
required=False,
description="Sends notification targets.",
present_message="Installed.",
missing_message="Missing." if configured else "Not installed.",
missing_status="warn" if configured else "skip",
check_id="apprise",
details={"config": str(config.apprise_config) if configured else None},
)
def _check_curl_transport() -> DiagnosticCheck:
available = resolve_curl_transport(use_curl=True)
version = _package_version("httpx-curl-cffi")
return DiagnosticCheck(
id="curl_transport",
label="curl-cffi transport",
group="advanced",
required=False,
status="pass" if available else "skip",
description="Transport for impersonation support.",
message="Installed." if available else "Not installed.",
details={"package": "httpx-curl-cffi", "version": version},
)
def _check_pot_provider_package() -> DiagnosticCheck:
return _package_check(
"bgutil-ytdlp-pot-provider",
check_id="pot_provider_plugin",
label="POT provider plugin",
group="youtube",
required=False,
description="Optional plugin for external POT token providers for youtube.",
present_message="Installed.",
missing_message="Not installed.",
)
def _check_configured_pip_packages(config: Config) -> list[DiagnosticCheck]:
checks: list[DiagnosticCheck] = []
for raw_pkg in config.pip_packages.split(" "):
pkg = raw_pkg.strip()
if not pkg:
continue
name = pkg.split("==", 1)[0].split(">=", 1)[0].split("<=", 1)[0].strip()
installed = False
version = None
try:
version = importlib.metadata.version(name)
installed = True
except importlib.metadata.PackageNotFoundError:
installed = False
checks.append(
DiagnosticCheck(
id=f"pip_{name.replace('-', '_')}",
label=name,
group="custom",
required=False,
status="pass" if installed else "warn",
description="Configured extra pip package.",
message="Installed." if installed else "Missing.",
details={"package": name, "requested": pkg, "version": version},
)
)
return checks
def _check_browser_endpoint() -> DiagnosticCheck:
raw_url = (os.environ.get("YTP_BROWSER_URL") or "").strip()
if not raw_url:
return DiagnosticCheck(
id="browser_endpoint",
label="Remote browser",
group="advanced",
required=False,
status="skip",
description="Endpoint used by the browser extractor.",
message="Not configured.",
details={},
)
engine, parsed_url = _parse_browser_url(raw_url)
if engine == "unknown":
return DiagnosticCheck(
id="browser_endpoint",
label="Remote browser",
group="advanced",
required=False,
status="warn",
description="Endpoint used by the browser extractor.",
message="Invalid URL.",
details={"endpoint": _safe_url(raw_url)},
)
return DiagnosticCheck(
id="browser_endpoint",
label="Remote browser",
group="advanced",
required=False,
status="pass",
description="Endpoint used by the browser extractor.",
message="Configured.",
details={
"engine": engine,
"endpoint": _safe_url(parsed_url),
},
)
def _check_browser_client() -> DiagnosticCheck:
raw_url = (os.environ.get("YTP_BROWSER_URL") or "").strip()
if not raw_url:
return DiagnosticCheck(
id="browser_client",
label="Browser client",
group="advanced",
required=False,
status="skip",
description="Client package required for the configured browser backend.",
message="Not configured.",
details={},
)
engine, _ = _parse_browser_url(raw_url)
if engine == "unknown":
return DiagnosticCheck(
id="browser_client",
label="Browser client",
group="advanced",
required=False,
status="skip",
description="Client package required for the configured browser backend.",
message="Invalid URL.",
details={},
)
package_name = "selenium" if engine == "selenium" else "playwright"
version = _package_version(package_name)
return DiagnosticCheck(
id="browser_client",
label="Browser client",
group="advanced",
required=False,
status="pass" if version else "warn",
description="Client package required for the configured browser backend.",
message="Installed." if version else "Missing.",
details={"package": package_name, "version": version},
)
def _check_flaresolverr(config: Config) -> DiagnosticCheck:
endpoint = (config.flaresolverr_url or "").strip()
if not endpoint:
return DiagnosticCheck(
id="flaresolverr",
label="FlareSolverr",
group="advanced",
required=False,
status="skip",
description="Optional Cloudflare challenge bypass service.",
message="Not configured.",
details={},
)
parsed = urllib.parse.urlsplit(endpoint)
valid = parsed.scheme in {"http", "https"} and bool(parsed.netloc)
return DiagnosticCheck(
id="flaresolverr",
label="FlareSolverr",
group="advanced",
required=False,
status="pass" if valid else "warn",
description="Optional Cloudflare challenge bypass service.",
message="Configured." if valid else "Invalid URL.",
details={"endpoint": _safe_url(endpoint)},
)
def _probe_directory(path: Path) -> tuple[CheckStatus, str]:
if not path.exists():
return "fail", "Directory does not exist."
if not path.is_dir():
return "fail", "Path is not a directory."
if not os.access(path, os.R_OK | os.W_OK | os.X_OK):
return "fail", "Not readable or writable."
return "pass", "Writable."
async def _check_directory(path: Path, *, check_id: str, label: str) -> DiagnosticCheck:
status, message = await asyncio.to_thread(_probe_directory, path)
return DiagnosticCheck(
id=check_id,
label=label,
group="core",
required=True,
status=status,
description=DIRECTORY_DESCRIPTIONS.get(check_id, "Directory check."),
message=message,
details={"path": str(path)},
)
def _probe_db_file(path: Path) -> tuple[CheckStatus, str]:
if not path.exists():
return "fail", "Missing."
if not path.is_file():
return "fail", "Invalid path."
if not os.access(path, os.R_OK):
return "fail", "Not readable."
if not os.access(path, os.W_OK):
return "fail", "Not writable."
try:
uri = f"file:{urllib.parse.quote(str(path))}?mode=ro"
with sqlite3.connect(uri, timeout=3, uri=True) as conn:
conn.execute("SELECT name FROM sqlite_master LIMIT 1")
except sqlite3.Error as exc:
return "fail", f"SQLite error. {exc!s}"
return "pass", "Ready."
async def _check_db_file(path: Path) -> DiagnosticCheck:
status, message = await asyncio.to_thread(_probe_db_file, path)
return DiagnosticCheck(
id="database",
label="SQLite database",
group="core",
required=True,
status=status,
description="Local database used for app state and history.",
message=message,
details={"path": str(path)},
)
async def _run_command(command: str, *args: str, wait_seconds: float = 5.0) -> tuple[int, str, str]:
proc = await asyncio.create_subprocess_exec(
command,
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=wait_seconds)
except TimeoutError:
proc.kill()
await proc.communicate()
raise
return proc.returncode or 0, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace")
def _binary_meta(check_id: str) -> CheckMeta:
return BINARY_META.get(check_id, CheckMeta(group="core", description="Command line tool."))
def _make_binary_check(
command: str,
*,
check_id: str,
label: str,
required: bool,
status: CheckStatus,
message: str,
path: str | None = None,
version: str | None = None,
) -> DiagnosticCheck:
meta = _binary_meta(check_id)
details: dict[str, Any] = {"command": command}
if path:
details["path"] = path
if version:
details["version"] = version
return DiagnosticCheck(
id=check_id,
label=label,
group=meta.group,
required=required,
status=status,
description=meta.description,
message=message,
details=details,
)
def _resolve_binary(*names: str) -> tuple[str, str | None]:
for name in names:
path = shutil.which(name)
if path:
return name, path
return names[0], None
async def _check_binary(
command: str,
*,
check_id: str,
label: str,
required: bool,
args: tuple[str, ...] = ("--version",),
enabled: bool = True,
disabled_message: str = "Check is not applicable.",
missing_status: CheckStatus | None = None,
aliases: tuple[str, ...] = (),
) -> DiagnosticCheck:
if not enabled:
return _make_binary_check(
command,
check_id=check_id,
label=label,
required=required,
status="skip",
message=disabled_message,
)
resolved_name, command_path = _resolve_binary(command, *aliases)
not_found_status: CheckStatus = missing_status or ("fail" if required else "warn")
if not command_path:
return _make_binary_check(
resolved_name,
check_id=check_id,
label=label,
required=required,
status=not_found_status,
message="Missing." if not_found_status == "fail" else "Not installed.",
)
try:
return_code, stdout, stderr = await _run_command(resolved_name, *args)
except FileNotFoundError:
return _make_binary_check(
resolved_name,
check_id=check_id,
label=label,
required=required,
status=not_found_status,
message="Missing." if not_found_status == "fail" else "Not installed.",
path=command_path,
)
except TimeoutError:
return _make_binary_check(
resolved_name,
check_id=check_id,
label=label,
required=required,
status="fail" if required else "warn",
message="Timed out.",
path=command_path,
)
except Exception as exc:
return _make_binary_check(
resolved_name,
check_id=check_id,
label=label,
required=required,
status="fail" if required else "warn",
message=f"Error. {exc!s}",
path=command_path,
)
version: str = _bin_version(_first_line(stdout, stderr))
status: CheckStatus = "pass" if return_code == 0 else ("fail" if required else "warn")
message: str = "Available." if return_code == 0 else f"Exit code {return_code}."
return _make_binary_check(
resolved_name,
check_id=check_id,
label=label,
required=required,
status=status,
message=message,
path=command_path,
version=version,
)
def _make_runtime(config: Config) -> dict[str, Any]:
started = int(config.started) if config.started else 0
uptime_seconds = max(int(time.time()) - started, 0) if started else 0
return {
"app_version": config.app_version,
"app_branch": config.app_branch,
"app_commit_sha": config.app_commit_sha,
"app_build_date": config.app_build_date,
"started": started,
"uptime_seconds": uptime_seconds,
"platform": platform.system().lower(),
"platform_release": platform.release(),
"platform_machine": platform.machine(),
"python_version": platform.python_version(),
"python_minimum": f"{MIN_PYTHON[0]}.{MIN_PYTHON[1]}",
"is_native": config.is_native,
"console_enabled": config.console_enabled,
}
def _requirements() -> dict[str, Any]:
return {
"python": {
"current": platform.python_version(),
"required": f"{MIN_PYTHON[0]}.{MIN_PYTHON[1]}",
"supported": sys.version_info[:2] >= MIN_PYTHON,
"note": "",
}
}
def _summarize(checks: list[DiagnosticCheck]) -> tuple[ReportStatus, dict[str, int]]:
summary = {"total": len(checks), "pass": 0, "fail": 0, "warn": 0, "skip": 0, "required_failed": 0}
for check in checks:
summary[check.status] += 1
if check.required and check.status == "fail":
summary["required_failed"] += 1
if summary["required_failed"] > 0:
status: ReportStatus = "error"
elif summary["warn"] > 0 or any(check.status == "fail" for check in checks):
status = "degraded"
else:
status = "ok"
return status, summary
def _make_report(config: Config, checks: list[DiagnosticCheck]) -> dict[str, Any]:
status, summary = _summarize(checks)
return {
"status": status,
"generated_at": int(time.time()),
"summary": summary,
"runtime": _make_runtime(config),
"requirements": _requirements(),
"checks": [check.to_dict() for check in checks],
}
def diagnostics_error_report(config: Config) -> dict[str, Any]:
return _make_report(
config,
[
DiagnosticCheck(
id="diagnostics",
label="Diagnostics",
group="core",
required=True,
status="fail",
description="Diagnostics collector.",
message="Diagnostics collection failed.",
details={},
)
],
)
async def collect_diagnostics(config: Config) -> dict[str, Any]:
checks: list[DiagnosticCheck] = [
_check_ytdlp_package(),
_check_apprise_package(config),
_check_curl_transport(),
_check_pot_provider_package(),
_check_browser_endpoint(),
_check_browser_client(),
_check_flaresolverr(config),
]
checks.extend(_check_configured_pip_packages(config))
storage_checks, binary_checks = await asyncio.gather(
asyncio.gather(
_check_directory(Path(config.config_path), check_id="config_path", label="Config directory"),
_check_directory(Path(config.download_path), check_id="download_path", label="Download directory"),
_check_directory(Path(config.temp_path), check_id="temp_path", label="Temp directory"),
_check_db_file(Path(config.db_file)),
),
asyncio.gather(
_check_binary(
"ffmpeg",
check_id="ffmpeg",
label="ffmpeg",
required=True,
args=("-version",),
),
_check_binary(
"ffprobe",
check_id="ffprobe",
label="ffprobe",
required=True,
args=("-version",),
),
_check_binary(
"deno",
check_id="deno",
label="deno",
required=True,
),
_check_binary(
"yt-dlp",
check_id="yt_dlp_cli",
label="yt-dlp CLI",
required=True,
enabled=config.console_enabled,
disabled_message="Disabled.",
),
_check_binary(
"aria2c",
check_id="aria2c",
label="aria2c",
required=False,
missing_status="skip",
),
_check_binary(
"mkvpropedit",
check_id="mkvpropedit",
label="mkvpropedit",
required=False,
missing_status="skip",
),
_check_binary(
"mkvextract",
check_id="mkvextract",
label="mkvextract",
required=False,
missing_status="skip",
),
_check_binary(
"MP4Box",
check_id="mp4box",
label="MP4Box",
required=False,
missing_status="skip",
args=("-version",),
aliases=("mp4box",),
),
),
)
checks.extend(storage_checks)
checks.extend(binary_checks)
return _make_report(config, checks)

View file

@ -10,7 +10,9 @@ from aiohttp.web_runner import GracefulExit
from app.features.dl_fields.service import DLFields
from app.features.presets.service import Presets
from app.library.cache import Cache
from app.library.config import Config
from app.library.diagnostics import collect_diagnostics, diagnostics_error_report
from app.library.downloads import DownloadQueue
from app.library.downloads.core import Download
from app.library.encoder import Encoder
@ -21,6 +23,8 @@ from app.library.UpdateChecker import UpdateChecker
from app.library.Utils import list_folders
LOG: logging.Logger = logging.getLogger(__name__)
DIAGNOSTICS_CACHE_KEY = "system:diagnostics"
DIAGNOSTICS_CACHE_TTL = 5.0
@route("GET", "api/system/configuration", "system.configuration")
@ -225,6 +229,29 @@ async def check_updates(config: Config, encoder: Encoder, update_checker: Update
)
@route("GET", "api/system/diagnostics", "system.diagnostics")
async def system_diagnostics(
config: Config, encoder: Encoder, request: Request | None = None, cache: Cache | None = None
) -> Response:
"""Return user-facing runtime diagnostics for standalone installs."""
cache_key = "system:diagnostics"
cache = cache or Cache.get_instance()
use_cache: bool = request is None or request.query.get("refresh") not in {"1", "true", "yes"}
if use_cache and (data := cache.get(cache_key)) is not None:
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
try:
data = await collect_diagnostics(config)
except Exception:
LOG.exception("Diagnostics collection failed.")
data = diagnostics_error_report(config)
else:
cache.set(cache_key, data, ttl=60)
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
async def _validate_terminal_command_request(request: Request) -> str | Response:
if not request.can_read_body:
return web.json_response(

View file

@ -1,13 +1,15 @@
import json
from dataclasses import dataclass
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from app.library.config import Config
from app.library.cache import Cache
from app.library.encoder import Encoder
from app.library.UpdateChecker import UpdateChecker
from app.routes.api.system import check_updates, system_limits
from app.routes.api.system import check_updates, system_diagnostics, system_limits
@dataclass
@ -193,3 +195,203 @@ class TestSystemLimitsEndpoint:
config = Config.get_instance()
assert config.prevent_live_premiere is False
class TestSystemDiagnosticsEndpoint:
def setup_method(self):
Config._reset_singleton()
Cache.get_instance().clear()
@pytest.mark.asyncio
async def test_diagnostics_always_200(self, tmp_path: Path):
config = Config.get_instance()
config.config_path = str(tmp_path / "config")
config.download_path = str(tmp_path / "downloads")
config.temp_path = str(tmp_path / "tmp")
config.db_file = str(tmp_path / "config" / "ytptube.db")
config.console_enabled = False
Path(config.config_path).mkdir(parents=True)
Path(config.download_path).mkdir(parents=True)
Path(config.temp_path).mkdir(parents=True)
Path(config.db_file).touch()
encoder = Encoder()
with (
patch("app.library.diagnostics.Config._ytdlp_version", return_value="2026.01.01"),
patch("app.library.diagnostics.shutil.which") as mock_which,
patch("app.library.diagnostics._run_command", new_callable=AsyncMock) as mock_run,
):
mock_which.side_effect = lambda cmd: f"/usr/bin/{cmd}" if cmd in {"ffmpeg", "ffprobe", "deno"} else None
mock_run.side_effect = [
(0, "ffmpeg version 7.0", ""),
(0, "ffprobe version 7.0", ""),
(0, "deno 2.3.0", ""),
]
response = await system_diagnostics(config, encoder)
assert 200 == response.status
body = json.loads(response.body.decode("utf-8"))
assert body["status"] == "ok"
assert body["summary"]["required_failed"] == 0
checks = {item["id"]: item for item in body["checks"]}
assert checks["deno"]["status"] == "pass"
assert checks["deno"]["details"]["version"] == "2.3.0"
assert checks["yt_dlp_cli"]["status"] == "skip"
@pytest.mark.asyncio
async def test_diagnostics_error_payload(self):
config = Config.get_instance()
encoder = Encoder()
with patch("app.routes.api.system.collect_diagnostics", new_callable=AsyncMock) as mock_collect:
mock_collect.side_effect = RuntimeError("boom")
response = await system_diagnostics(config, encoder)
assert 200 == response.status
body = json.loads(response.body.decode("utf-8"))
assert body["status"] == "error"
assert body["summary"]["required_failed"] >= 1
assert len(body["checks"]) == 1
assert body["checks"][0]["status"] == "fail"
def test_distribution_package_detection(self):
from app.library.diagnostics import _check_pot_provider_package
with patch("app.library.diagnostics._package_version", return_value="1.3.1"):
check = _check_pot_provider_package()
assert check.status == "pass"
assert check.details["version"] == "1.3.1"
assert "description" not in check.details
def test_safe_details(self):
from app.library.diagnostics import _safe_url
assert _safe_url("https://user:pass@example.test/token/path?x=1#frag") == (
"https://***:***@example.test/***?***#***"
)
@pytest.mark.asyncio
async def test_binary_timeout(self):
from app.library.diagnostics import _check_binary
with (
patch("app.library.diagnostics.shutil.which", return_value="/usr/local/bin/deno"),
patch("app.library.diagnostics._run_command", new_callable=AsyncMock) as mock_run,
):
mock_run.side_effect = TimeoutError
check = await _check_binary("deno", check_id="deno", label="deno", required=True)
assert check.status == "fail"
assert check.details["command"] == "deno"
@pytest.mark.asyncio
async def test_diagnostics_marks_required_missing(self, tmp_path: Path):
config = Config.get_instance()
config.config_path = str(tmp_path / "config")
config.download_path = str(tmp_path / "downloads")
config.temp_path = str(tmp_path / "tmp")
config.db_file = str(tmp_path / "config" / "ytptube.db")
config.console_enabled = True
Path(config.config_path).mkdir(parents=True)
Path(config.download_path).mkdir(parents=True)
Path(config.temp_path).mkdir(parents=True)
Path(config.db_file).touch()
encoder = Encoder()
with (
patch("app.library.diagnostics.Config._ytdlp_version", return_value="2026.01.01"),
patch("app.library.diagnostics.shutil.which") as mock_which,
patch("app.library.diagnostics._run_command", new_callable=AsyncMock) as mock_run,
):
mock_which.side_effect = lambda cmd: (
"/usr/bin/ffprobe" if cmd == "ffprobe" else "/usr/bin/yt-dlp" if cmd == "yt-dlp" else None
)
mock_run.side_effect = [
(0, "ffprobe version 7.0", ""),
(0, "yt-dlp 2026.01.01", ""),
]
response = await system_diagnostics(config, encoder)
assert 200 == response.status
body = json.loads(response.body.decode("utf-8"))
assert body["status"] == "error"
assert body["summary"]["required_failed"] >= 1
checks = {item["id"]: item for item in body["checks"]}
assert checks["ffmpeg"]["status"] == "fail"
assert checks["deno"]["status"] == "fail"
assert checks["yt_dlp_cli"]["status"] == "pass"
assert checks["aria2c"]["status"] == "skip"
assert checks["mkvpropedit"]["status"] == "skip"
assert checks["mkvextract"]["status"] == "skip"
assert checks["mp4box"]["status"] == "skip"
@pytest.mark.asyncio
async def test_alias_fallback_mp4box(self):
from app.library.diagnostics import _check_binary
with (
patch("app.library.diagnostics.shutil.which") as mock_which,
patch("app.library.diagnostics._run_command", new_callable=AsyncMock) as mock_run,
):
mock_which.side_effect = lambda cmd: "/usr/bin/mp4box" if cmd == "mp4box" else None
mock_run.return_value = (0, "MP4Box 2.2.1", "")
check = await _check_binary(
"MP4Box",
check_id="mp4box",
label="MP4Box",
required=False,
missing_status="skip",
aliases=("mp4box",),
)
assert check.status == "pass"
assert check.details["command"] == "mp4box"
assert check.details["version"] == "MP4Box 2.2.1"
@pytest.mark.asyncio
async def test_optional_binary_present(self):
from app.library.diagnostics import _check_binary
with (
patch("app.library.diagnostics.shutil.which", return_value="/usr/bin/aria2c"),
patch("app.library.diagnostics._run_command", new_callable=AsyncMock) as mock_run,
):
mock_run.return_value = (0, "aria2 version 1.37.0", "")
check = await _check_binary(
"aria2c",
check_id="aria2c",
label="aria2c",
required=False,
missing_status="skip",
)
assert check.status == "pass"
assert check.details["version"] == "1.37.0"
@pytest.mark.asyncio
async def test_optional_binary_missing_skip(self):
from app.library.diagnostics import _check_binary
with patch("app.library.diagnostics.shutil.which", return_value=None):
check = await _check_binary(
"mkvpropedit",
check_id="mkvpropedit",
label="mkvpropedit",
required=False,
missing_status="skip",
)
assert check.status == "skip"

View file

@ -0,0 +1,100 @@
import { computed, readonly, ref } from 'vue';
import { useNotification } from '~/composables/useNotification';
import type { DiagnosticCheck, DiagnosticsResponse } from '~/types/diagnostics';
import { parse_api_error, parse_api_response, request } from '~/utils';
const diagnostics = ref<DiagnosticsResponse | null>(null);
const isLoading = ref(false);
const lastError = ref<string | null>(null);
const throwInstead = ref(false);
const groupedChecks = computed<Record<string, Array<DiagnosticCheck>>>(() => {
return (diagnostics.value?.checks ?? []).reduce<Record<string, Array<DiagnosticCheck>>>(
(acc, check) => {
if (!acc[check.group]) {
acc[check.group] = [];
}
acc[check.group]?.push(check);
return acc;
},
{},
);
});
const groupOrder = computed<Array<string>>(() => Object.keys(groupedChecks.value));
const readJson = async (response: Response): Promise<unknown> => {
try {
return await response.clone().json();
} catch {
return null;
}
};
const ensureSuccess = async (response: Response): Promise<void> => {
if (response.ok) {
return;
}
const payload = await readJson(response);
throw new Error(await parse_api_error(payload));
};
const handleError = (error: unknown): void => {
const message = error instanceof Error ? error.message : 'Failed to load diagnostics.';
lastError.value = message;
useNotification().error(message);
};
const loadDiagnostics = async (force: boolean = false): Promise<DiagnosticsResponse | null> => {
if (isLoading.value) {
return diagnostics.value;
}
if (diagnostics.value && !force) {
return diagnostics.value;
}
isLoading.value = true;
lastError.value = null;
try {
const response = await request(`/api/system/diagnostics${force ? '?refresh=1' : ''}`);
await ensureSuccess(response);
diagnostics.value = await parse_api_response<DiagnosticsResponse>(response.json());
return diagnostics.value;
} catch (error) {
handleError(error);
if (throwInstead.value) {
throw error;
}
return null;
} finally {
isLoading.value = false;
}
};
const clearError = (): void => {
lastError.value = null;
};
const __resetForTesting = (): void => {
diagnostics.value = null;
isLoading.value = false;
lastError.value = null;
throwInstead.value = false;
};
export const useDiagnostics = () => ({
diagnostics: readonly(diagnostics),
isLoading: readonly(isLoading),
lastError: readonly(lastError),
groupedChecks,
groupOrder,
loadDiagnostics,
clearError,
throwInstead,
__resetForTesting,
});

View file

@ -0,0 +1,551 @@
<template>
<main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="flex min-w-0 items-start gap-3">
<span
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
>
<UIcon :name="pageShell.icon" class="size-5" />
</span>
<div class="min-w-0 space-y-2">
<div
class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
>
<span>{{ pageShell.sectionLabel }}</span>
<span>/</span>
<span>{{ pageShell.pageLabel }}</span>
</div>
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
</div>
</div>
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2 xl:justify-end">
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-copy"
:disabled="isLoading || !report"
@click="copyDiagnostics"
>
Copy
</UButton>
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-refresh-cw"
:loading="isLoading"
:disabled="isLoading"
@click="void load(true)"
>
Refresh
</UButton>
</div>
</div>
<div
v-if="!report && isLoading"
class="flex min-h-72 items-center justify-center rounded-md border border-default bg-default/90"
>
<div class="flex flex-col items-center gap-3 text-center text-toned">
<UIcon name="i-lucide-loader-circle" class="size-10 animate-spin text-info" />
<div class="space-y-1">
<p class="text-sm font-medium text-default">Loading</p>
<p class="text-xs">Reading tools, paths, and configured extras.</p>
</div>
</div>
</div>
<UAlert
v-else-if="!report && lastError"
color="error"
variant="soft"
icon="i-lucide-triangle-alert"
title="Diagnostics failed"
:description="lastError"
/>
<template v-else-if="report">
<UAlert
v-if="showRequiredAlert"
color="error"
variant="soft"
icon="i-lucide-octagon-alert"
title="Required items missing"
:description="requiredAlertDescription"
/>
<section class="rounded-md border border-default bg-default shadow-sm">
<div class="border-b border-default px-4 py-3 sm:px-5">
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
<UIcon name="i-lucide-gauge" class="size-4 text-toned" />
<span>Overview</span>
</div>
</div>
<div class="grid gap-3 p-4 sm:grid-cols-2 sm:p-5 xl:grid-cols-4">
<article
v-for="item in summaryCards"
:key="item.label"
class="rounded-md border border-default bg-muted/20 px-3 py-3"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-xs font-medium uppercase tracking-wide text-toned">
{{ item.label }}
</p>
<p class="mt-1 text-xs text-toned">{{ item.description }}</p>
</div>
<span
class="inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-default bg-default"
>
<UIcon :name="item.icon" class="size-4 text-toned" />
</span>
</div>
<p :class="['mt-4 text-2xl font-semibold', item.valueClass]">{{ item.value }}</p>
</article>
</div>
</section>
<section class="rounded-md border border-default bg-default shadow-sm">
<div class="border-b border-default px-4 py-3 sm:px-5">
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
<UIcon name="i-lucide-server" class="size-4 text-toned" />
<span>Runtime</span>
</div>
</div>
<div class="grid gap-3 p-4 sm:grid-cols-2 sm:p-5 xl:grid-cols-3">
<article
v-for="row in runtimeRows"
:key="row.label"
class="rounded-md border border-default bg-muted/20 px-3 py-3"
>
<div class="flex items-start gap-3">
<span
class="inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-default bg-default"
>
<UIcon :name="row.icon" class="size-4 text-toned" />
</span>
<div class="min-w-0">
<p class="text-xs font-medium uppercase tracking-wide text-toned">
{{ row.label }}
</p>
<p class="mt-2 text-sm font-semibold text-default">{{ row.value }}</p>
<p class="mt-1 text-xs text-toned">{{ row.description }}</p>
</div>
</div>
</article>
</div>
</section>
<section v-for="section in featureSections" :key="section.id" class="space-y-3">
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div class="space-y-1">
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
<UIcon :name="section.icon" class="size-4 text-toned" />
<span>{{ section.label }}</span>
</div>
<p class="text-sm text-toned">{{ section.description }}</p>
</div>
<div class="flex flex-wrap gap-2 text-xs text-toned">
<span class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1">
<span class="font-medium text-default">Checks:</span>
<span>{{ section.items.length }}</span>
</span>
<span class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1">
<span class="font-medium text-default">Required fails:</span>
<span>{{ requiredFails(section.items) }}</span>
</span>
</div>
</div>
<div class="grid gap-4 lg:grid-cols-2 2xl:grid-cols-3">
<article v-for="item in section.items" :key="item.id" :class="cardClass(item.status)">
<div class="min-w-0 space-y-3">
<div class="space-y-2">
<div class="flex flex-wrap items-center gap-2">
<span :class="tagDotClass(item.status)"></span>
<p class="text-base font-semibold text-default">{{ item.label }}</p>
<span :class="badgeClass(item.status)">{{ statusLabel(item.status) }}</span>
<span
class="inline-flex items-center rounded-md border border-default px-2 py-1 text-xs text-toned"
>
{{ item.required ? 'Required' : 'Optional' }}
</span>
</div>
<p v-if="item.description" class="text-sm text-toned">{{ item.description }}</p>
<p v-if="showMessage(item)" class="text-sm leading-6 text-default">
{{ item.message }}
</p>
</div>
<div v-if="Object.keys(item.details || {}).length > 0" class="flex flex-wrap gap-2">
<span
v-for="(value, key) in item.details"
:key="`${item.id}-${key}`"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 text-xs text-toned"
>
<span class="font-medium text-default">{{ keyLabel(key) }}:</span>
<span>{{ formatValue(value) }}</span>
</span>
</div>
</div>
</article>
</div>
</section>
</template>
</main>
</template>
<script setup lang="ts">
import moment from 'moment';
import type { DiagnosticCheck, DiagnosticStatus } from '~/types/diagnostics';
import { requirePageShell } from '~/utils/topLevelNavigation';
import { copyText } from '~/utils';
type SummaryCard = {
label: string;
description: string;
value: number;
icon: string;
valueClass: string;
};
type DetailRow = {
label: string;
description: string;
value: string;
icon: string;
};
type FeatureMeta = {
label: string;
description: string;
icon: string;
};
type FeatureSection = FeatureMeta & {
id: string;
items: Array<DiagnosticCheck>;
};
const FEATURE_META: Record<string, FeatureMeta> = {
core: {
label: 'Core setup',
description: 'Main tools and local paths.',
icon: 'i-lucide-wrench',
},
youtube: {
label: 'YouTube',
description: 'Support needed for YouTube downloads.',
icon: 'i-lucide-video',
},
notifications: {
label: 'Notifications',
description: 'Apprise support.',
icon: 'i-lucide-bell',
},
advanced: {
label: 'Advanced',
description: 'Optional tools, transports, browser extractor, and plugins.',
icon: 'i-lucide-plug-zap',
},
custom: {
label: 'Custom pip packages',
description: 'Packages requested through config.',
icon: 'i-lucide-package-plus',
},
};
const FEATURE_ORDER = ['core', 'youtube', 'notifications', 'advanced', 'custom'];
const pageShell = requirePageShell('diagnostics');
const diagnosticsState = useDiagnostics();
const report = diagnosticsState.diagnostics;
const isLoading = diagnosticsState.isLoading;
const lastError = diagnosticsState.lastError;
const groupedChecks = diagnosticsState.groupedChecks;
const showRequiredAlert = computed(() => {
return (report.value?.summary.required_failed ?? 0) > 0;
});
const requiredAlertDescription = computed(() => {
const count = report.value?.summary.required_failed ?? 0;
return `${count} required fail${count === 1 ? '' : 's'}.`;
});
const featureSections = computed<Array<FeatureSection>>(() => {
return FEATURE_ORDER.filter((id) => (groupedChecks.value[id] ?? []).length > 0).map((id) => ({
id,
...FEATURE_META[id]!,
items: groupedChecks.value[id] ?? [],
}));
});
const summaryCards = computed<Array<SummaryCard>>(() => {
const current = report.value;
if (!current) {
return [];
}
return [
{
label: 'Passing',
description: 'Checks that passed.',
value: current.summary.pass,
icon: 'i-lucide-badge-check',
valueClass: current.summary.pass > 0 ? 'text-success' : 'text-default',
},
{
label: 'Required fails',
description: 'Items that block core use.',
value: current.summary.required_failed,
icon: 'i-lucide-octagon-alert',
valueClass: current.summary.required_failed > 0 ? 'text-error' : 'text-default',
},
{
label: 'Warnings',
description: 'Optional or incomplete items.',
value: current.summary.warn,
icon: 'i-lucide-triangle-alert',
valueClass: current.summary.warn > 0 ? 'text-warning' : 'text-default',
},
{
label: 'Skipped',
description: 'Not configured or not needed.',
value: current.summary.skip,
icon: 'i-lucide-minus',
valueClass: current.summary.skip > 0 ? 'text-toned' : 'text-default',
},
];
});
const runtimeRows = computed<Array<DetailRow>>(() => {
const runtime = report.value?.runtime;
const generatedAt = report.value?.generated_at;
const python = report.value?.requirements.python;
if (!runtime || !python) {
return [];
}
return [
{
label: 'App',
description: 'Current build.',
value: runtime.app_version || 'Unknown',
icon: 'i-lucide-package',
},
{
label: 'Host',
description: 'OS and machine.',
value: `${runtime.platform} ${runtime.platform_release} (${runtime.platform_machine})`,
icon: 'i-lucide-server',
},
{
label: 'Python',
description: `${python.note} Minimum ${python.required}+`,
value: python.current,
icon: 'i-lucide-square-terminal',
},
{
label: 'Started',
description: 'Process start time.',
value: runtime.started ? moment.unix(runtime.started).fromNow() : 'Unknown',
icon: 'i-lucide-power',
},
{
label: 'Uptime',
description: 'Current process uptime.',
value: moment.duration(runtime.uptime_seconds, 'seconds').humanize(),
icon: 'i-lucide-timer',
},
{
label: 'Snapshot',
description: 'Diagnostics timestamp.',
value: generatedAt ? moment.unix(generatedAt).fromNow() : 'Unknown',
icon: 'i-lucide-clock-3',
},
];
});
const shareText = computed(() => {
const current = report.value;
if (!current) {
return '';
}
const lines = [
'YTPTube diagnostics',
`Generated: ${formatIsoTimestamp(current.generated_at)}`,
'',
];
lines.push('Overview');
for (const item of summaryCards.value) {
lines.push(`- ${item.label}: ${item.value}`);
}
lines.push('', 'Runtime');
lines.push(`- App: ${current.runtime.app_version || 'Unknown'}`);
lines.push(
`- Host: ${current.runtime.platform} ${current.runtime.platform_release} (${current.runtime.platform_machine})`,
);
lines.push(`- Python: ${current.requirements.python.current}`);
lines.push(`- Started: ${formatIsoTimestamp(current.runtime.started)}`);
lines.push(`- Uptime: ${formatIsoDuration(current.runtime.uptime_seconds)}`);
lines.push(`- Snapshot: ${formatIsoTimestamp(current.generated_at)}`);
for (const section of featureSections.value) {
lines.push('', section.label);
for (const item of section.items) {
const versionSuffix = formatShareVersion(item);
lines.push(
`- ${item.label} (${item.required ? 'Required' : 'Optional'}) (${statusLabel(item.status)})${versionSuffix}`,
);
}
}
return lines.join('\n');
});
const load = async (force: boolean = false): Promise<void> => {
await diagnosticsState.loadDiagnostics(force);
};
const copyDiagnostics = (): void => {
if (!shareText.value) {
return;
}
copyText(shareText.value);
};
const requiredFails = (items: Array<DiagnosticCheck>): number => {
return items.filter((item) => item.required && item.status === 'fail').length;
};
const showMessage = (item: DiagnosticCheck): boolean => {
if (item.status === 'pass') {
return false;
}
return Boolean(item.message?.trim());
};
const statusLabel = (status: DiagnosticStatus): string => {
switch (status) {
case 'pass':
return 'Pass';
case 'fail':
return 'Fail';
case 'warn':
return 'Warn';
case 'skip':
default:
return 'Skip';
}
};
const cardClass = (status: DiagnosticStatus): string => {
const base = 'rounded-md border border-default bg-default px-4 py-4 shadow-sm ring-1 ring-inset';
switch (status) {
case 'pass':
return `${base} ring-success/10`;
case 'fail':
return `${base} ring-error/10`;
case 'warn':
return `${base} ring-warning/10`;
case 'skip':
default:
return `${base} ring-default/40`;
}
};
const tagDotClass = (status: DiagnosticStatus): string => {
const base = 'inline-flex size-2.5 shrink-0 rounded-full';
switch (status) {
case 'pass':
return `${base} bg-success`;
case 'fail':
return `${base} bg-error`;
case 'warn':
return `${base} bg-warning`;
case 'skip':
default:
return `${base} bg-muted`;
}
};
const badgeClass = (status: DiagnosticStatus): string => {
const base = 'inline-flex items-center rounded-md border px-2 py-1 text-xs font-medium';
switch (status) {
case 'pass':
return `${base} border-success/30 bg-success/10 text-success`;
case 'fail':
return `${base} border-error/30 bg-error/10 text-error`;
case 'warn':
return `${base} border-warning/30 bg-warning/10 text-warning`;
case 'skip':
default:
return `${base} border-default bg-muted/40 text-toned`;
}
};
const formatValue = (value: DiagnosticCheck['details'][string]): string => {
if (value === null || value === undefined || value === '') {
return 'n/a';
}
return String(value);
};
const keyLabel = (value: string): string => {
return value.replace(/_/g, ' ');
};
const formatIsoTimestamp = (value: number | undefined): string => {
if (typeof value !== 'number' || Number.isNaN(value)) {
return 'Unknown';
}
return moment.unix(value).utc().format('YYYY-MM-DDTHH:mm:ss[Z]');
};
const formatIsoDuration = (value: number | undefined): string => {
if (typeof value !== 'number' || Number.isNaN(value)) {
return 'Unknown';
}
return moment.duration(value, 'seconds').toISOString();
};
const formatShareVersion = (item: DiagnosticCheck): string => {
const version = item.details?.version;
if (version === null || version === undefined || version === '') {
return '';
}
return ` [${String(version)}]`;
};
onMounted(() => {
void load(true);
});
</script>

View file

@ -0,0 +1,56 @@
export type DiagnosticStatus = 'pass' | 'fail' | 'warn' | 'skip';
export type DiagnosticReportStatus = 'ok' | 'degraded' | 'error';
export type DiagnosticCheck = {
id: string;
label: string;
group: string;
required: boolean;
status: DiagnosticStatus;
description: string;
message: string;
details: Record<string, string | number | boolean | null | undefined>;
};
export type DiagnosticSummary = {
total: number;
pass: number;
fail: number;
warn: number;
skip: number;
required_failed: number;
};
export type DiagnosticRuntime = {
app_version: string;
app_branch: string;
app_commit_sha: string;
app_build_date: string;
started: number;
uptime_seconds: number;
platform: string;
platform_release: string;
platform_machine: string;
python_version: string;
python_minimum: string;
is_native: boolean;
console_enabled: boolean;
};
export type DiagnosticRequirements = {
python: {
current: string;
required: string;
supported: boolean;
note: string;
};
};
export type DiagnosticsResponse = {
status: DiagnosticReportStatus;
generated_at: number;
summary: DiagnosticSummary;
runtime: DiagnosticRuntime;
requirements: DiagnosticRequirements;
checks: Array<DiagnosticCheck>;
};

View file

@ -186,6 +186,17 @@ const NavItems: Array<NavDefinition> = [
matchPath: '/console',
requires: 'console_enabled',
},
{
id: 'diagnostics',
section: 'tools',
group: 'tools',
label: 'Diagnostics',
pageLabel: 'Diagnostics',
description: 'View system information.',
icon: 'i-lucide-stethoscope',
to: '/diagnostics',
matchPath: '/diagnostics',
},
...DOCS_ENTRIES.map<NavDefinition>((entry) => ({
id: entry.id,
section: 'docs',

View file

@ -0,0 +1,148 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
const successMock = mock(() => {});
const errorMock = mock(() => {});
mock.module('~/composables/useNotification', () => ({
useNotification: () => ({ success: successMock, error: errorMock }),
}));
type MockResponseInput = {
ok: boolean;
status: number;
jsonData: unknown;
};
const createMockResponse = ({ ok, status, jsonData }: MockResponseInput): Response => {
return {
ok,
status,
headers: new Headers({ 'Content-Type': 'application/json' }),
redirected: false,
statusText: ok ? 'OK' : 'Error',
type: 'basic',
url: '',
body: null,
bodyUsed: false,
clone() {
return this;
},
async json() {
return jsonData;
},
text: async () => JSON.stringify(jsonData),
arrayBuffer: async () => new ArrayBuffer(0),
blob: async () => new Blob(),
formData: async () => new FormData(),
} as Response;
};
let utils: Awaited<typeof import('~/utils/index')>;
let useDiagnostics: typeof import('~/composables/useDiagnostics').useDiagnostics;
beforeAll(async () => {
utils = await import('~/utils/index');
({ useDiagnostics } = await import('~/composables/useDiagnostics'));
});
beforeEach(() => {
successMock.mockClear();
errorMock.mockClear();
useDiagnostics().__resetForTesting();
});
afterEach(() => {
useDiagnostics().__resetForTesting();
});
describe('useDiagnostics', () => {
it('load_diagnostics', async () => {
const requestSpy = spyOn(utils, 'request');
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: {
status: 'error',
generated_at: 1,
summary: { total: 2, pass: 0, fail: 1, warn: 0, skip: 1, required_failed: 1 },
runtime: {
app_version: '1.0.0',
app_branch: 'main',
app_commit_sha: 'abc123',
app_build_date: '20260526',
started: 1,
uptime_seconds: 15,
platform: 'linux',
platform_release: '6.8',
platform_machine: 'x86_64',
python_version: '3.13.1',
python_minimum: '3.13',
is_native: false,
console_enabled: false,
},
requirements: {
python: {
current: '3.13.1',
required: '3.13',
supported: true,
note: 'Python runtime context.',
},
},
checks: [
{
id: 'deno',
label: 'deno',
required: true,
group: 'youtube',
status: 'fail',
description: 'Used for yt-dlp YouTube support.',
message: 'Missing.',
details: { command: 'deno' },
},
{
id: 'browser_endpoint',
label: 'Remote browser',
group: 'browser',
status: 'skip',
description: 'Endpoint used by the browser extractor.',
message: 'Not configured.',
details: {},
},
],
},
}),
);
const diagnostics = useDiagnostics();
const result = await diagnostics.loadDiagnostics(true);
expect(requestSpy).toHaveBeenCalledWith('/api/system/diagnostics?refresh=1');
expect(result?.status).toBe('error');
expect(diagnostics.diagnostics.value?.summary.required_failed).toBe(1);
expect(diagnostics.groupOrder.value).toEqual(['youtube', 'browser']);
expect(diagnostics.groupedChecks.value.youtube?.[0]?.id).toBe('deno');
requestSpy.mockRestore();
});
it('store_load_error', async () => {
const requestSpy = spyOn(utils, 'request');
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 500,
jsonData: { error: 'Backend exploded' },
}),
);
const diagnostics = useDiagnostics();
const result = await diagnostics.loadDiagnostics(true);
expect(result).toBeNull();
expect(diagnostics.lastError.value).toBe('Backend exploded');
expect(errorMock).toHaveBeenCalledWith('Backend exploded');
requestSpy.mockRestore();
});
});