mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-12 09:15:19 +08:00
Preserve plugin diagnostics without degrading overall status
This commit is contained in:
@@ -44,7 +44,9 @@ class QueryDoctorReportTool(MoviePilotTool):
|
||||
description: str = (
|
||||
"Run MoviePilot Doctor in read-only mode and return a structured diagnostic report for troubleshooting. "
|
||||
"Use this tool when analyzing startup failures, Docker/runtime issues, port conflicts, dependency problems, "
|
||||
"database health, frontend assets, safe mode, or recent log error clues. This tool never applies fixes."
|
||||
"database health, frontend assets, safe mode, or recent log error clues. Plugin-only log findings remain "
|
||||
"visible with affects_report_status=false and do not downgrade the overall status. This tool never applies "
|
||||
"fixes."
|
||||
)
|
||||
require_admin: bool = True
|
||||
args_schema: Type[BaseModel] = QueryDoctorReportInput
|
||||
@@ -73,6 +75,7 @@ class QueryDoctorReportTool(MoviePilotTool):
|
||||
"title": item.get("title"),
|
||||
"fixable": item.get("fixable"),
|
||||
"fixed": item.get("fixed"),
|
||||
"affects_report_status": item.get("affects_report_status", True),
|
||||
}
|
||||
for item in report.get("findings") or []
|
||||
if isinstance(item, dict)
|
||||
|
||||
@@ -988,7 +988,7 @@ def logs(lines: int, follow: bool, stdio: bool, frontend_log: bool) -> None:
|
||||
@click.option("--fix", is_flag=True, help="执行白名单安全修复")
|
||||
@click.option("--deep", is_flag=True, help="执行可能较慢的深度检查")
|
||||
def doctor(json_output: bool, fix: bool, deep: bool) -> None:
|
||||
"""离线诊断本地 MoviePilot 运行环境"""
|
||||
"""离线诊断本地 MoviePilot 运行环境,插件日志告警不影响整体状态"""
|
||||
from app.doctor import run_doctor
|
||||
from app.doctor.formatters import format_json_report, format_text_report
|
||||
|
||||
|
||||
@@ -45,6 +45,14 @@ LOG_ERROR_PATTERNS = (
|
||||
re.compile(r"加载插件.+出错"),
|
||||
re.compile(r"数据库更新失败"),
|
||||
)
|
||||
LOG_RECORD_PATTERN = re.compile(
|
||||
r"(?:【(?:DEBUG|INFO|WARNING|ERROR|CRITICAL)】|(?:DEBUG|INFO|WARNING|ERROR|CRITICAL):)"
|
||||
)
|
||||
CONSOLE_LOGGER_PATTERN = re.compile(r"\[([^\]]+)]")
|
||||
PLUGIN_ERROR_PATTERNS = (
|
||||
re.compile(r"(?:^|\s-\s)plugin\.py\s+-\s", re.IGNORECASE),
|
||||
re.compile(r"插件.+(?:出错|失败|异常|错误)"),
|
||||
)
|
||||
SENSITIVE_PATTERNS = (
|
||||
re.compile(r"(?i)(api[_-]?token|token|password|secret|cookie)(\s*[:=]\s*)[^\s&]+"),
|
||||
re.compile(r"\bghp_[A-Za-z0-9]{20,}\b"),
|
||||
@@ -92,10 +100,23 @@ class DoctorRunnerProtocol:
|
||||
recommendation: str,
|
||||
fixable: bool = False,
|
||||
fixed: bool = False,
|
||||
affects_report_status: bool = True,
|
||||
context: Optional[dict[str, Any]] = None,
|
||||
) -> DoctorFinding:
|
||||
"""
|
||||
添加诊断发现。
|
||||
|
||||
:param finding_id: 诊断项稳定标识
|
||||
:param severity: 诊断严重级别
|
||||
:param status: 单项诊断状态
|
||||
:param title: 诊断项标题
|
||||
:param detail: 诊断详情
|
||||
:param recommendation: 处理建议
|
||||
:param fixable: 是否支持 Doctor 自动修复
|
||||
:param fixed: 本次运行是否已修复
|
||||
:param affects_report_status: 是否参与整体报告状态聚合
|
||||
:param context: 可选结构化上下文
|
||||
:return: 新增的诊断发现
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -275,6 +296,7 @@ def _tail_lines(path: Path, max_lines: int = 120, max_bytes: int = 256 * 1024) -
|
||||
|
||||
|
||||
def _find_error_lines(lines: list[str], max_matches: int = 12) -> list[str]:
|
||||
"""从近期日志中提取错误关键词命中的行。"""
|
||||
matches: list[str] = []
|
||||
for line in lines:
|
||||
if any(pattern.search(line) for pattern in LOG_ERROR_PATTERNS):
|
||||
@@ -282,6 +304,39 @@ def _find_error_lines(lines: list[str], max_matches: int = 12) -> list[str]:
|
||||
return matches[-max_matches:]
|
||||
|
||||
|
||||
def _partition_error_lines(
|
||||
lines: list[str],
|
||||
plugin_logger_names: set[str],
|
||||
max_matches: int = 12,
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""
|
||||
将主日志错误线索拆分为核心错误和插件子系统错误。
|
||||
|
||||
:param lines: 近期日志行
|
||||
:param plugin_logger_names: 已发现的插件控制台 logger 名称
|
||||
:param max_matches: 每类最多保留的错误行数
|
||||
:return: 核心错误行和插件错误行
|
||||
"""
|
||||
core_matches: list[str] = []
|
||||
plugin_matches: list[str] = []
|
||||
plugin_context = False
|
||||
for line in lines:
|
||||
if LOG_RECORD_PATTERN.search(line):
|
||||
logger_match = CONSOLE_LOGGER_PATTERN.search(line)
|
||||
console_logger = logger_match.group(1).strip().lower() if logger_match else ""
|
||||
plugin_context = (
|
||||
console_logger in plugin_logger_names
|
||||
or any(pattern.search(line) for pattern in PLUGIN_ERROR_PATTERNS)
|
||||
)
|
||||
if not any(pattern.search(line) for pattern in LOG_ERROR_PATTERNS):
|
||||
continue
|
||||
if plugin_context or any(pattern.search(line) for pattern in PLUGIN_ERROR_PATTERNS):
|
||||
plugin_matches.append(line)
|
||||
else:
|
||||
core_matches.append(line)
|
||||
return core_matches[-max_matches:], plugin_matches[-max_matches:]
|
||||
|
||||
|
||||
def _frontend_dir() -> Path:
|
||||
root_public = settings.ROOT_PATH / "public"
|
||||
configured = Path(settings.FRONTEND_PATH)
|
||||
@@ -687,14 +742,18 @@ def _check_frontend_assets(runner: DoctorRunnerProtocol) -> None:
|
||||
|
||||
|
||||
def _check_logs(runner: DoctorRunnerProtocol) -> None:
|
||||
"""扫描近期日志,并区分核心运行异常与插件扩展异常。"""
|
||||
log_files = [
|
||||
_backend_app_log_file(),
|
||||
_backend_stdio_log_file(),
|
||||
_frontend_stdio_log_file(),
|
||||
]
|
||||
plugin_log_dir = settings.LOG_PATH / "plugins"
|
||||
plugin_logger_names: set[str] = set()
|
||||
if plugin_log_dir.exists():
|
||||
log_files.extend(sorted(plugin_log_dir.rglob("*.log"))[:20])
|
||||
plugin_log_files = sorted(plugin_log_dir.rglob("*.log"))
|
||||
plugin_logger_names = {path.stem.lower() for path in plugin_log_files}
|
||||
log_files.extend(plugin_log_files[:20])
|
||||
|
||||
found_any = False
|
||||
for path in log_files:
|
||||
@@ -702,23 +761,44 @@ def _check_logs(runner: DoctorRunnerProtocol) -> None:
|
||||
continue
|
||||
found_any = True
|
||||
lines = _tail_lines(path)
|
||||
errors = _find_error_lines(lines)
|
||||
if not errors:
|
||||
is_plugin_log = plugin_log_dir in path.parents
|
||||
if is_plugin_log:
|
||||
scoped_errors = [(True, _find_error_lines(lines))]
|
||||
else:
|
||||
core_errors, plugin_errors = _partition_error_lines(
|
||||
lines,
|
||||
plugin_logger_names,
|
||||
)
|
||||
scoped_errors = [(False, core_errors), (True, plugin_errors)]
|
||||
if not any(errors for _, errors in scoped_errors):
|
||||
continue
|
||||
is_plugin = plugin_log_dir in path.parents
|
||||
runner.add(
|
||||
finding_id=f"logs.{path.stem}.recent_errors",
|
||||
severity=DoctorSeverity.Warn,
|
||||
status=DoctorFindingStatus.Degraded,
|
||||
title="最近日志存在插件异常" if is_plugin else "最近日志存在错误线索",
|
||||
detail="\n".join(errors),
|
||||
recommendation=(
|
||||
"可使用安全模式启动后检查插件配置。"
|
||||
if is_plugin
|
||||
else "结合前后的启动日志定位异常;必要时执行 `moviepilot doctor --json` 交给 Agent 或 Issue 流程。"
|
||||
),
|
||||
context={"log_file": str(path), "matches": len(errors)},
|
||||
)
|
||||
has_core_errors = bool(scoped_errors[0][1]) if not is_plugin_log else False
|
||||
for is_plugin_error, errors in scoped_errors:
|
||||
if not errors:
|
||||
continue
|
||||
finding_suffix = (
|
||||
"plugin_errors"
|
||||
if is_plugin_error and has_core_errors
|
||||
else "recent_errors"
|
||||
)
|
||||
runner.add(
|
||||
finding_id=f"logs.{path.stem}.{finding_suffix}",
|
||||
severity=DoctorSeverity.Warn,
|
||||
status=DoctorFindingStatus.Degraded,
|
||||
title="最近日志存在插件异常" if is_plugin_error else "最近日志存在错误线索",
|
||||
detail="\n".join(errors),
|
||||
recommendation=(
|
||||
"可使用安全模式启动后检查插件配置。"
|
||||
if is_plugin_error
|
||||
else "结合前后的启动日志定位异常;必要时执行 `moviepilot doctor --json` 交给 Agent 或 Issue 流程。"
|
||||
),
|
||||
affects_report_status=not is_plugin_error,
|
||||
context={
|
||||
"log_file": str(path),
|
||||
"matches": len(errors),
|
||||
"component": "plugin" if is_plugin_error else "core",
|
||||
},
|
||||
)
|
||||
|
||||
if not found_any:
|
||||
runner.add(
|
||||
|
||||
@@ -47,6 +47,8 @@ def _format_finding(finding: DoctorFinding) -> list[str]:
|
||||
marker = finding.severity.value.upper()
|
||||
if finding.fixed:
|
||||
marker = "FIXED"
|
||||
elif not finding.affects_report_status:
|
||||
marker = f"{marker}/ADVISORY"
|
||||
lines = [f"[{marker}] {finding.title}", f"ID: {finding.id}"]
|
||||
if finding.detail:
|
||||
lines.append(f"原因: {finding.detail}")
|
||||
|
||||
@@ -52,6 +52,7 @@ class DoctorFinding:
|
||||
recommendation: str
|
||||
fixable: bool = False
|
||||
fixed: bool = False
|
||||
affects_report_status: bool = True
|
||||
context: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
@@ -67,6 +68,7 @@ class DoctorFinding:
|
||||
"recommendation": self.recommendation,
|
||||
"fixable": self.fixable,
|
||||
"fixed": self.fixed,
|
||||
"affects_report_status": self.affects_report_status,
|
||||
}
|
||||
if self.context:
|
||||
payload["context"] = self.context
|
||||
@@ -90,7 +92,11 @@ class DoctorReport:
|
||||
"""
|
||||
根据诊断发现计算整体状态。
|
||||
"""
|
||||
unresolved = [finding for finding in self.findings if not finding.fixed]
|
||||
unresolved = [
|
||||
finding
|
||||
for finding in self.findings
|
||||
if not finding.fixed and finding.affects_report_status
|
||||
]
|
||||
if any(finding.severity == DoctorSeverity.Error for finding in unresolved):
|
||||
return DoctorReportStatus.Failed
|
||||
if any(finding.severity == DoctorSeverity.Warn for finding in unresolved):
|
||||
|
||||
@@ -67,10 +67,23 @@ class DoctorRunner:
|
||||
recommendation: str,
|
||||
fixable: bool = False,
|
||||
fixed: bool = False,
|
||||
affects_report_status: bool = True,
|
||||
context: Optional[dict[str, Any]] = None,
|
||||
) -> DoctorFinding:
|
||||
"""
|
||||
添加诊断发现并返回该对象。
|
||||
|
||||
:param finding_id: 诊断项稳定标识
|
||||
:param severity: 诊断严重级别
|
||||
:param status: 单项诊断状态
|
||||
:param title: 诊断项标题
|
||||
:param detail: 诊断详情
|
||||
:param recommendation: 处理建议
|
||||
:param fixable: 是否支持 Doctor 自动修复
|
||||
:param fixed: 本次运行是否已修复
|
||||
:param affects_report_status: 是否参与整体报告状态聚合
|
||||
:param context: 可选结构化上下文
|
||||
:return: 新增的诊断发现
|
||||
"""
|
||||
finding = DoctorFinding(
|
||||
id=finding_id,
|
||||
@@ -81,6 +94,7 @@ class DoctorRunner:
|
||||
recommendation=recommendation,
|
||||
fixable=fixable,
|
||||
fixed=fixed,
|
||||
affects_report_status=affects_report_status,
|
||||
context=context or {},
|
||||
)
|
||||
self.report.add_finding(finding)
|
||||
@@ -88,6 +102,7 @@ class DoctorRunner:
|
||||
|
||||
@staticmethod
|
||||
def _environment() -> dict[str, Any]:
|
||||
"""收集 Doctor 报告所需的本地运行环境信息。"""
|
||||
return {
|
||||
"runtime": "Docker" if SystemUtils.is_docker() else platform.system(),
|
||||
"platform": platform.platform(),
|
||||
|
||||
@@ -392,6 +392,7 @@ moviepilot doctor --deep
|
||||
- `--json` 输出稳定 JSON,可供 Agent、脚本或 Issue 流程收集
|
||||
- `--fix` 只执行白名单安全修复,例如清理过期 runtime 文件或补齐不合法的 `API_TOKEN`
|
||||
- `--deep` 执行可能较慢的深度探测,例如 PostgreSQL TCP 连通性检查
|
||||
- 插件日志异常会保留为诊断告警并标记 `affects_report_status=false`,但不会单独降低系统整体状态;核心错误仍正常参与状态聚合
|
||||
- Docker 环境可使用 `docker exec <container> moviepilot doctor`;如果容器已退出,也可用镜像挂载同一配置目录运行 `python -m app.cli doctor`
|
||||
|
||||
日志:
|
||||
|
||||
@@ -46,6 +46,8 @@ Doctor 默认执行只读检查:
|
||||
|
||||
`--deep` 会启用可能较慢或更依赖环境的检查,例如 PostgreSQL TCP 连通性。
|
||||
|
||||
整体状态只聚合会影响 MoviePilot 核心运行的诊断项。插件独立日志以及主日志中可明确识别的插件子系统异常仍会作为 `warn/degraded` 诊断项保留,但其 `affects_report_status` 为 `false`,不会单独把整体状态从 `healthy` 降为 `degraded`;同一日志中若还存在核心错误,核心错误仍会参与状态聚合。
|
||||
|
||||
## 自救能力
|
||||
|
||||
`moviepilot doctor --fix` 只做白名单安全修复:
|
||||
|
||||
@@ -243,6 +243,10 @@ Agent 自主任务工具使用数据库中的整数 `task_id`。`query_scheduler
|
||||
]
|
||||
```
|
||||
|
||||
#### 系统诊断工具
|
||||
|
||||
`query_doctor_report` 以只读方式返回 MoviePilot Doctor 诊断报告,可通过 `deep` 启用深度检查,并通过 `include_details` 控制是否返回完整详情。每条诊断项的 `affects_report_status` 表示其是否参与整体状态聚合;插件日志异常会保留为 `warn/degraded` 线索,但该字段为 `false`,不会单独把系统整体状态降为 `degraded`。
|
||||
|
||||
### 2. 调用工具
|
||||
|
||||
**POST** `/api/v1/mcp/tools/call`
|
||||
|
||||
@@ -249,6 +249,7 @@ Options:
|
||||
|
||||
说明:
|
||||
- doctor 是离线诊断入口,不依赖后端服务已经启动
|
||||
- 插件日志异常会保留为告警,但不会单独降低系统整体状态
|
||||
- Docker 环境可执行 docker exec <container> moviepilot doctor
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -120,7 +120,9 @@ you need to show the preview generated in the next step.
|
||||
The collect script also runs `moviepilot doctor --json` or falls back to
|
||||
`python -m app.cli doctor --json`, stores the structured doctor report
|
||||
inside `diagnostics_file`, and later preview/submit steps include a
|
||||
short doctor summary automatically.
|
||||
short doctor summary automatically. Plugin-only log findings remain in
|
||||
the report as diagnostic evidence with `affects_report_status=false`, so
|
||||
they do not by themselves downgrade the overall MoviePilot status.
|
||||
|
||||
If `success=false` with `no_explicit_feedback_intent`, stop this skill
|
||||
and return to local diagnosis.
|
||||
|
||||
@@ -260,5 +260,6 @@ Use `air_date` to find a block of recently-aired episodes that likely correspond
|
||||
## Error handling
|
||||
|
||||
Missing configuration or authentication failure: run `moviepilot doctor` to
|
||||
verify the local MoviePilot installation and settings. Do not ask the user to
|
||||
paste the API key into the prompt for local CLI usage.
|
||||
verify the local MoviePilot installation and settings. Plugin-only log findings
|
||||
remain visible but do not by themselves downgrade the overall Doctor status.
|
||||
Do not ask the user to paste the API key into the prompt for local CLI usage.
|
||||
|
||||
@@ -71,6 +71,7 @@ def test_query_doctor_report_returns_readonly_report():
|
||||
assert payload["report"]["status"] == "degraded"
|
||||
assert payload["report"]["environment"]["runtime"] == "Docker"
|
||||
assert payload["report"]["findings"][0]["detail"] == "ERROR demo Cookie: <REDACTED>"
|
||||
assert payload["report"]["findings"][0]["affects_report_status"] is True
|
||||
run_doctor.assert_called_once_with(deep=True)
|
||||
|
||||
|
||||
@@ -88,6 +89,7 @@ def test_query_doctor_report_compact_mode_omits_details():
|
||||
finding = payload["report"]["findings"][0]
|
||||
assert finding["id"] == "logs.moviepilot.recent_errors"
|
||||
assert finding["title"] == "最近日志存在错误线索"
|
||||
assert finding["affects_report_status"] is True
|
||||
assert "detail" not in finding
|
||||
assert "context" not in finding
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ def test_doctor_report_has_stable_json_shape(tmp_path, monkeypatch):
|
||||
assert payload["environment"]["config_path"] == str(tmp_path)
|
||||
assert isinstance(payload["summary"]["total"], int)
|
||||
assert isinstance(payload["findings"], list)
|
||||
assert all("affects_report_status" in item for item in payload["findings"])
|
||||
assert any(item["id"] == "runtime.paths" for item in payload["findings"])
|
||||
|
||||
|
||||
@@ -86,3 +87,130 @@ def test_doctor_accepts_healthy_unmanaged_backend_port(monkeypatch):
|
||||
assert finding.severity == DoctorSeverity.Info
|
||||
assert finding.context["backend_version"] == "v2-test"
|
||||
assert runner.report.find("port.backend_occupied") is None
|
||||
|
||||
|
||||
def test_doctor_plugin_log_error_does_not_degrade_report(tmp_path, monkeypatch):
|
||||
"""插件独立日志中的错误应保留告警,但不降低系统整体状态。"""
|
||||
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
|
||||
plugin_log = settings.LOG_PATH / "plugins" / "demo.log"
|
||||
plugin_log.parent.mkdir(parents=True, exist_ok=True)
|
||||
plugin_log.write_text(
|
||||
"【ERROR】2026-07-20 08:00:00 - demo.py - 插件任务执行异常\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
runner = DoctorRunner()
|
||||
checks._check_logs(runner)
|
||||
|
||||
finding = runner.report.find("logs.demo.recent_errors")
|
||||
assert finding is not None
|
||||
assert finding.status == DoctorFindingStatus.Degraded
|
||||
assert finding.severity == DoctorSeverity.Warn
|
||||
assert finding.affects_report_status is False
|
||||
assert finding.context["component"] == "plugin"
|
||||
assert runner.report.status.value == "healthy"
|
||||
assert "[WARN/ADVISORY]" in format_text_report(runner.report)
|
||||
|
||||
|
||||
def test_doctor_plugin_load_error_in_main_log_does_not_degrade_report(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""主日志中的插件加载异常应归入插件告警,不降低系统整体状态。"""
|
||||
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
|
||||
app_log = settings.LOG_PATH / "moviepilot.log"
|
||||
app_log.parent.mkdir(parents=True, exist_ok=True)
|
||||
app_log.write_text(
|
||||
"【ERROR】2026-07-20 08:00:00 - plugin.py - 加载插件 Demo 出错:boom - Traceback (most recent call last):\n"
|
||||
"Exception: boom\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
runner = DoctorRunner()
|
||||
checks._check_logs(runner)
|
||||
|
||||
finding = runner.report.find("logs.moviepilot.recent_errors")
|
||||
assert finding is not None
|
||||
assert finding.affects_report_status is False
|
||||
assert finding.context["component"] == "plugin"
|
||||
assert "Exception: boom" in finding.detail
|
||||
assert runner.report.status.value == "healthy"
|
||||
|
||||
|
||||
def test_doctor_plugin_error_mirrored_to_stdio_does_not_degrade_report(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""插件错误镜像到后端 stdio 日志时仍不应降低系统整体状态。"""
|
||||
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
|
||||
plugin_log = settings.LOG_PATH / "plugins" / "DemoPlugin.log"
|
||||
plugin_log.parent.mkdir(parents=True, exist_ok=True)
|
||||
plugin_log.write_text(
|
||||
"【INFO】2026-07-20 08:00:00 - demo.py - 插件已启动\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
stdio_log = settings.LOG_PATH / "moviepilot.stdout.log"
|
||||
stdio_log.write_text(
|
||||
"ERROR: [demoplugin] 2026-07-20 08:01:00 demo.py - task exception\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
runner = DoctorRunner()
|
||||
checks._check_logs(runner)
|
||||
|
||||
finding = runner.report.find("logs.moviepilot.stdout.recent_errors")
|
||||
assert finding is not None
|
||||
assert finding.affects_report_status is False
|
||||
assert finding.context["component"] == "plugin"
|
||||
assert runner.report.status.value == "healthy"
|
||||
|
||||
|
||||
def test_doctor_core_log_error_still_degrades_report(tmp_path, monkeypatch):
|
||||
"""核心日志错误仍应参与系统整体状态聚合。"""
|
||||
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
|
||||
app_log = settings.LOG_PATH / "moviepilot.log"
|
||||
app_log.parent.mkdir(parents=True, exist_ok=True)
|
||||
app_log.write_text(
|
||||
"【ERROR】2026-07-20 08:00:00 - rss.py - 解析 RSS 失败 - Traceback (most recent call last):\n"
|
||||
"RuntimeError: boom\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
runner = DoctorRunner()
|
||||
checks._check_logs(runner)
|
||||
|
||||
finding = runner.report.find("logs.moviepilot.recent_errors")
|
||||
assert finding is not None
|
||||
assert finding.affects_report_status is True
|
||||
assert finding.context["component"] == "core"
|
||||
assert runner.report.status.value == "degraded"
|
||||
|
||||
|
||||
def test_doctor_mixed_plugin_and_core_log_errors_keep_core_status(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""同一主日志混有插件和核心错误时,仅核心错误应影响整体状态。"""
|
||||
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
|
||||
app_log = settings.LOG_PATH / "moviepilot.log"
|
||||
app_log.parent.mkdir(parents=True, exist_ok=True)
|
||||
app_log.write_text(
|
||||
"【ERROR】2026-07-20 08:00:00 - plugin.py - 加载插件 Demo 出错:boom - Traceback (most recent call last):\n"
|
||||
"Exception: plugin boom\n"
|
||||
"【ERROR】2026-07-20 08:01:00 - rss.py - 解析 RSS 失败 - Traceback (most recent call last):\n"
|
||||
"Exception: core boom\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
runner = DoctorRunner()
|
||||
checks._check_logs(runner)
|
||||
|
||||
core_finding = runner.report.find("logs.moviepilot.recent_errors")
|
||||
plugin_finding = runner.report.find("logs.moviepilot.plugin_errors")
|
||||
assert core_finding is not None
|
||||
assert plugin_finding is not None
|
||||
assert core_finding.affects_report_status is True
|
||||
assert plugin_finding.affects_report_status is False
|
||||
assert "core boom" in core_finding.detail
|
||||
assert "plugin boom" in plugin_finding.detail
|
||||
assert runner.report.status.value == "degraded"
|
||||
|
||||
Reference in New Issue
Block a user