Files
MoviePilot/tests/test_plugin_helper.py

226 lines
9.3 KiB
Python

import sys
import tempfile
import threading
import time
from pathlib import Path
from types import ModuleType
from unittest import TestCase
from unittest.mock import patch
from packaging.version import Version
class PluginHelperTest(TestCase):
def test_sanitize_repo_url_for_statistic_keeps_remote_url(self):
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
self.skipTest(f"missing dependency: {exc}")
repo_url = "https://github.com/InfinityPacer/MoviePilot-Plugins"
self.assertEqual(repo_url, PluginHelper.sanitize_repo_url_for_statistic(repo_url))
def test_sanitize_repo_url_for_statistic_strips_local_path(self):
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
self.skipTest(f"missing dependency: {exc}")
repo_url = "local://TestPlugin?path=/Users/InfinityPacer/GitHub/MoviePilot/MoviePilot-Plugins&version=v2"
self.assertEqual(
"local://TestPlugin?version=v2",
PluginHelper.sanitize_repo_url_for_statistic(repo_url)
)
def test_pip_install_keeps_modules_imported_during_install(self):
"""
验证依赖安装窗口内被其他任务导入的运行态模块不会被误删。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
self.skipTest(f"missing dependency: {exc}")
module_names = ["app.plugins.dynamicwechat.helper", "Crypto.Cipher._mode_cbc"]
previous_modules = {name: sys.modules.get(name) for name in module_names}
def fake_execute(_cmd):
for module_name in module_names:
sys.modules[module_name] = ModuleType(module_name)
return True, "ok"
try:
with tempfile.TemporaryDirectory() as temp_dir:
requirements_file = Path(temp_dir) / "requirements.txt"
requirements_file.write_text("demo-package\n", encoding="utf-8")
with patch("app.helper.plugin.SystemUtils.execute_with_subprocess", side_effect=fake_execute):
success, message = PluginHelper.pip_install_with_fallback(requirements_file)
self.assertTrue(success)
self.assertEqual("ok", message)
for module_name in module_names:
self.assertIn(module_name, sys.modules)
finally:
for module_name, previous_module in previous_modules.items():
if previous_module is None:
sys.modules.pop(module_name, None)
else:
sys.modules[module_name] = previous_module
def test_pip_install_serializes_concurrent_calls(self):
"""
验证多个依赖安装请求会复用同一把锁串行执行 pip。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
self.skipTest(f"missing dependency: {exc}")
thread_count = 2
active_installs = 0
max_active_installs = 0
state_lock = threading.Lock()
start_event = threading.Event()
errors = []
def fake_execute(_cmd):
nonlocal active_installs, max_active_installs
with state_lock:
active_installs += 1
max_active_installs = max(max_active_installs, active_installs)
time.sleep(0.05)
with state_lock:
active_installs -= 1
return True, "ok"
def worker(requirements_file: Path):
try:
start_event.wait()
PluginHelper.pip_install_with_fallback(requirements_file)
except Exception as err: # pragma: no cover - 仅用于并发测试失败诊断
errors.append(err)
with tempfile.TemporaryDirectory() as temp_dir:
requirements_files = []
for index in range(thread_count):
requirements_file = Path(temp_dir) / f"requirements-{index}.txt"
requirements_file.write_text("demo-package\n", encoding="utf-8")
requirements_files.append(requirements_file)
threads = [
threading.Thread(target=worker, args=(requirements_file,))
for requirements_file in requirements_files
]
with patch("app.helper.plugin.SystemUtils.execute_with_subprocess", side_effect=fake_execute):
for thread in threads:
thread.start()
start_event.set()
for thread in threads:
thread.join()
self.assertEqual([], errors)
self.assertEqual(1, max_active_installs)
def test_pip_install_rejects_conflicting_runtime_dependency(self):
"""
验证插件如果试图覆盖主程序核心依赖,会在真正执行 pip 前被直接拒绝。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
self.skipTest(f"missing dependency: {exc}")
with tempfile.TemporaryDirectory() as temp_dir:
requirements_file = Path(temp_dir) / "requirements.txt"
requirements_file.write_text("fastapi<0.1\n", encoding="utf-8")
with patch.object(
PluginHelper,
"_PluginHelper__get_installed_packages",
return_value={"fastapi": Version("0.115.14")}
):
success, message = PluginHelper.pip_install_with_fallback(requirements_file)
self.assertFalse(success)
self.assertIn("主程序核心依赖", message)
self.assertIn("fastapi", message)
def test_pip_install_uses_runtime_constraints_file(self):
"""
验证插件依赖安装会固定当前运行环境已安装版本,防止共享 venv 被升级或降级。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
self.skipTest(f"missing dependency: {exc}")
seen_constraints = []
def fake_execute(cmd):
if cmd[:4] == [sys.executable, "-m", "pip", "install"]:
constraint_index = cmd.index("-c") + 1
constraint_file = Path(cmd[constraint_index])
seen_constraints.append(constraint_file)
self.assertTrue(constraint_file.exists())
self.assertIn("fastapi==0.115.14", constraint_file.read_text(encoding="utf-8"))
return True, "ok"
return True, "ok"
with tempfile.TemporaryDirectory() as temp_dir:
requirements_file = Path(temp_dir) / "requirements.txt"
requirements_file.write_text("demo-package\n", encoding="utf-8")
with patch.object(
PluginHelper,
"_PluginHelper__get_installed_packages",
return_value={"fastapi": Version("0.115.14")}
):
with patch("app.helper.plugin.SystemUtils.execute_with_subprocess", side_effect=fake_execute):
success, message = PluginHelper.pip_install_with_fallback(requirements_file)
self.assertTrue(success)
self.assertEqual("ok", message)
self.assertEqual(1, len(seen_constraints))
self.assertFalse(seen_constraints[0].exists())
def test_pip_install_repairs_runtime_when_healthcheck_fails(self):
"""
验证插件依赖安装后若破坏运行环境,会先恢复主程序依赖,再向上层返回失败。
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
self.skipTest(f"missing dependency: {exc}")
repair_commands = []
healthcheck_failed = False
def fake_execute(cmd):
nonlocal healthcheck_failed
if cmd[:4] == [sys.executable, "-m", "pip", "install"]:
if "-c" not in cmd:
repair_commands.append(cmd)
return True, "repaired"
return True, "installed"
if cmd[:4] == [sys.executable, "-m", "pip", "check"]:
if not healthcheck_failed:
healthcheck_failed = True
return False, "broken"
return True, "healthy"
if len(cmd) >= 3 and cmd[1] == "-c":
return True, "probe ok"
raise AssertionError(f"unexpected command: {cmd}")
with tempfile.TemporaryDirectory() as temp_dir:
requirements_file = Path(temp_dir) / "requirements.txt"
requirements_file.write_text("demo-package\n", encoding="utf-8")
with patch.object(
PluginHelper,
"_PluginHelper__get_installed_packages",
return_value={"fastapi": Version("0.115.14")}
):
with patch("app.helper.plugin.SystemUtils.execute_with_subprocess", side_effect=fake_execute):
success, message = PluginHelper.pip_install_with_fallback(requirements_file)
self.assertFalse(success)
self.assertIn("已自动恢复主程序依赖", message)
self.assertEqual(1, len(repair_commands))
self.assertIn("runtime-constraints-", repair_commands[0][-1])