chore: import deployable mai-bot source tree
This commit is contained in:
553
pytests/test_plugin_config_runtime.py
Normal file
553
pytests/test_plugin_config_runtime.py
Normal file
@@ -0,0 +1,553 @@
|
||||
"""插件配置运行时测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, Mapping, Optional, Tuple, cast
|
||||
|
||||
import tomllib
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_runtime.component_query import component_query_service
|
||||
from src.plugin_runtime.protocol.envelope import (
|
||||
Envelope,
|
||||
InspectPluginConfigPayload,
|
||||
MessageType,
|
||||
RegisterPluginPayload,
|
||||
ValidatePluginConfigPayload,
|
||||
)
|
||||
from src.plugin_runtime.runner.runner_main import PluginRunner
|
||||
from src.webui.routers.plugin.config_routes import get_plugin_config, get_plugin_config_schema, update_plugin_config
|
||||
from src.webui.routers.plugin.schemas import UpdatePluginConfigRequest
|
||||
|
||||
|
||||
class _DemoConfigPlugin:
|
||||
"""用于测试 Runner 配置归一化流程的伪插件。"""
|
||||
|
||||
_config_version: str = "2.0.0"
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""初始化测试插件状态。"""
|
||||
|
||||
self.received_config: Dict[str, Any] = {}
|
||||
|
||||
def normalize_plugin_config(self, config_data: Optional[Mapping[str, Any]]) -> Tuple[Dict[str, Any], bool]:
|
||||
"""补齐测试插件的默认配置。
|
||||
|
||||
Args:
|
||||
config_data: 原始配置数据。
|
||||
|
||||
Returns:
|
||||
Tuple[Dict[str, Any], bool]: 补齐后的配置,以及是否发生变更。
|
||||
"""
|
||||
|
||||
current_config = dict(config_data or {})
|
||||
plugin_section = dict(current_config.get("plugin", {}))
|
||||
changed = "retry_count" not in plugin_section or "config_version" not in plugin_section
|
||||
plugin_section.setdefault("config_version", self._config_version)
|
||||
plugin_section.setdefault("enabled", True)
|
||||
plugin_section.setdefault("retry_count", 3)
|
||||
return {"plugin": plugin_section}, changed
|
||||
|
||||
def set_plugin_config(self, config: Dict[str, Any]) -> None:
|
||||
"""记录 Runner 注入的配置内容。
|
||||
|
||||
Args:
|
||||
config: 当前最新配置。
|
||||
"""
|
||||
|
||||
self.received_config = config
|
||||
|
||||
def get_default_config(self) -> Dict[str, Any]:
|
||||
"""返回测试插件的默认配置。
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 默认配置字典。
|
||||
"""
|
||||
|
||||
return {"plugin": {"config_version": self._config_version, "enabled": True, "retry_count": 3}}
|
||||
|
||||
def get_webui_config_schema(
|
||||
self,
|
||||
*,
|
||||
plugin_id: str = "",
|
||||
plugin_name: str = "",
|
||||
plugin_version: str = "",
|
||||
plugin_description: str = "",
|
||||
plugin_author: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""返回测试插件的 WebUI 配置 Schema。
|
||||
|
||||
Args:
|
||||
plugin_id: 插件 ID。
|
||||
plugin_name: 插件名称。
|
||||
plugin_version: 插件版本。
|
||||
plugin_description: 插件描述。
|
||||
plugin_author: 插件作者。
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 测试配置 Schema。
|
||||
"""
|
||||
|
||||
del plugin_name, plugin_description, plugin_author
|
||||
return {
|
||||
"plugin_id": plugin_id,
|
||||
"plugin_info": {
|
||||
"name": "Demo",
|
||||
"version": plugin_version,
|
||||
"description": "",
|
||||
"author": "",
|
||||
},
|
||||
"sections": {
|
||||
"plugin": {
|
||||
"fields": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"label": "启用",
|
||||
"default": True,
|
||||
"ui_type": "switch",
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"layout": {"type": "auto", "tabs": []},
|
||||
}
|
||||
|
||||
|
||||
class _StrictConfigPlugin:
|
||||
"""用于测试配置校验错误的伪插件。"""
|
||||
|
||||
def normalize_plugin_config(self, config_data: Optional[Mapping[str, Any]]) -> Tuple[Dict[str, Any], bool]:
|
||||
"""校验重试次数不能为负数。
|
||||
|
||||
Args:
|
||||
config_data: 原始配置数据。
|
||||
|
||||
Returns:
|
||||
Tuple[Dict[str, Any], bool]: 规范化配置结果。
|
||||
|
||||
Raises:
|
||||
ValueError: 当重试次数为负数时抛出。
|
||||
"""
|
||||
|
||||
current_config = dict(config_data or {})
|
||||
plugin_section = dict(current_config.get("plugin", {}))
|
||||
plugin_section.setdefault("config_version", "2.0.0")
|
||||
retry_count = int(plugin_section.get("retry_count", 0))
|
||||
if retry_count < 0:
|
||||
raise ValueError("重试次数不能小于 0")
|
||||
plugin_section.setdefault("enabled", True)
|
||||
return {"plugin": plugin_section}, False
|
||||
|
||||
def set_plugin_config(self, config: Dict[str, Any]) -> None:
|
||||
"""兼容 Runner 配置注入接口。
|
||||
|
||||
Args:
|
||||
config: 当前配置字典。
|
||||
"""
|
||||
|
||||
del config
|
||||
|
||||
def get_default_config(self) -> Dict[str, Any]:
|
||||
"""返回测试插件的默认配置。
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 默认配置字典。
|
||||
"""
|
||||
|
||||
return {"plugin": {"config_version": "2.0.0", "enabled": True, "retry_count": 0}}
|
||||
|
||||
|
||||
def test_runner_apply_plugin_config_generates_config_file(tmp_path: Path) -> None:
|
||||
"""Runner 注入配置时应自动补齐并落盘 config.toml。"""
|
||||
|
||||
plugin = _DemoConfigPlugin()
|
||||
runner = PluginRunner(
|
||||
host_address="ipc://unused",
|
||||
session_token="session-token",
|
||||
plugin_dirs=[],
|
||||
)
|
||||
meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir=str(tmp_path), instance=plugin)
|
||||
|
||||
runner._apply_plugin_config(
|
||||
cast(Any, meta),
|
||||
config_data={"plugin": {"config_version": "2.0.0", "enabled": False}},
|
||||
)
|
||||
|
||||
config_path = tmp_path / "config.toml"
|
||||
assert config_path.exists()
|
||||
assert plugin.received_config == {"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}}
|
||||
|
||||
with config_path.open("rb") as handle:
|
||||
saved_config = tomllib.load(handle)
|
||||
assert saved_config == {"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}}
|
||||
|
||||
|
||||
def test_runner_apply_plugin_config_preserves_existing_comments(tmp_path: Path) -> None:
|
||||
"""Runner 在版本升级时应尽量保留现有 config.toml 注释。"""
|
||||
|
||||
plugin = _DemoConfigPlugin()
|
||||
runner = PluginRunner(
|
||||
host_address="ipc://unused",
|
||||
session_token="session-token",
|
||||
plugin_dirs=[],
|
||||
)
|
||||
meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir=str(tmp_path), instance=plugin)
|
||||
config_path = tmp_path / "config.toml"
|
||||
config_path.write_text(
|
||||
'# 插件配置头注释\n[plugin]\nconfig_version = "1.0.0"\nenabled = false # 启用开关注释\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
runner._apply_plugin_config(cast(Any, meta))
|
||||
|
||||
config_text = config_path.read_text(encoding="utf-8")
|
||||
assert "# 插件配置头注释" in config_text
|
||||
assert "# 启用开关注释" in config_text
|
||||
|
||||
with config_path.open("rb") as handle:
|
||||
saved_config = tomllib.load(handle)
|
||||
assert saved_config == {"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}}
|
||||
|
||||
|
||||
def test_runner_apply_plugin_config_same_version_does_not_rewrite_file(tmp_path: Path) -> None:
|
||||
"""Runner 在配置版本未变化时不应仅因补齐默认值而重写文件。"""
|
||||
|
||||
plugin = _DemoConfigPlugin()
|
||||
runner = PluginRunner(
|
||||
host_address="ipc://unused",
|
||||
session_token="session-token",
|
||||
plugin_dirs=[],
|
||||
)
|
||||
meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir=str(tmp_path), instance=plugin)
|
||||
config_path = tmp_path / "config.toml"
|
||||
original_config_text = '# 原始注释\n[plugin]\nconfig_version = "2.0.0"\nenabled = false\n'
|
||||
config_path.write_text(original_config_text, encoding="utf-8")
|
||||
|
||||
runner._apply_plugin_config(cast(Any, meta))
|
||||
|
||||
assert plugin.received_config == {"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}}
|
||||
assert config_path.read_text(encoding="utf-8") == original_config_text
|
||||
|
||||
|
||||
def test_runner_apply_plugin_config_requires_config_version(tmp_path: Path) -> None:
|
||||
"""Runner 应拒绝缺少配置版本号的插件配置文件。"""
|
||||
|
||||
plugin = _DemoConfigPlugin()
|
||||
runner = PluginRunner(
|
||||
host_address="ipc://unused",
|
||||
session_token="session-token",
|
||||
plugin_dirs=[],
|
||||
)
|
||||
meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir=str(tmp_path), instance=plugin)
|
||||
config_path = tmp_path / "config.toml"
|
||||
config_path.write_text("[plugin]\nenabled = true\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="config_version"):
|
||||
runner._apply_plugin_config(cast(Any, meta))
|
||||
|
||||
|
||||
def test_component_query_service_returns_plugin_config_schema(monkeypatch: Any) -> None:
|
||||
"""组件查询服务应支持按插件 ID 返回配置 Schema。"""
|
||||
|
||||
payload = RegisterPluginPayload(
|
||||
plugin_id="demo.plugin",
|
||||
plugin_version="1.0.0",
|
||||
default_config={"plugin": {"enabled": True}},
|
||||
config_schema={
|
||||
"plugin_id": "demo.plugin",
|
||||
"plugin_info": {
|
||||
"name": "Demo",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
},
|
||||
"sections": {"plugin": {"fields": {}}},
|
||||
"layout": {"type": "auto", "tabs": []},
|
||||
},
|
||||
)
|
||||
fake_supervisor = SimpleNamespace(_registered_plugins={"demo.plugin": payload})
|
||||
fake_manager = SimpleNamespace(_get_supervisor_for_plugin=lambda plugin_id: fake_supervisor)
|
||||
|
||||
monkeypatch.setattr(
|
||||
type(component_query_service),
|
||||
"_get_runtime_manager",
|
||||
staticmethod(lambda: fake_manager),
|
||||
)
|
||||
|
||||
assert component_query_service.get_plugin_config_schema("demo.plugin") == payload.config_schema
|
||||
assert component_query_service.get_plugin_default_config("demo.plugin") == payload.default_config
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_validate_plugin_config_handler_returns_normalized_config(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Runner 应返回插件模型归一化后的配置。"""
|
||||
|
||||
plugin = _DemoConfigPlugin()
|
||||
runner = PluginRunner(
|
||||
host_address="ipc://unused",
|
||||
session_token="session-token",
|
||||
plugin_dirs=[],
|
||||
)
|
||||
meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir="", instance=plugin)
|
||||
monkeypatch.setattr(runner._loader, "get_plugin", lambda plugin_id: meta if plugin_id == "demo.plugin" else None)
|
||||
|
||||
envelope = Envelope(
|
||||
request_id=1,
|
||||
message_type=MessageType.REQUEST,
|
||||
method="plugin.validate_config",
|
||||
plugin_id="demo.plugin",
|
||||
payload=ValidatePluginConfigPayload(
|
||||
config_data={"plugin": {"config_version": "2.0.0", "enabled": False}}
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
response = await runner._handle_validate_plugin_config(envelope)
|
||||
|
||||
assert response.error is None
|
||||
assert response.payload["success"] is True
|
||||
assert response.payload["normalized_config"] == {
|
||||
"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_inspect_plugin_config_handler_supports_unloaded_plugin(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Runner 应支持对未加载插件执行冷检查。"""
|
||||
|
||||
plugin = _DemoConfigPlugin()
|
||||
runner = PluginRunner(
|
||||
host_address="ipc://unused",
|
||||
session_token="session-token",
|
||||
plugin_dirs=[],
|
||||
)
|
||||
meta = SimpleNamespace(
|
||||
plugin_id="demo.plugin",
|
||||
plugin_dir="/tmp/demo-plugin",
|
||||
instance=plugin,
|
||||
manifest=SimpleNamespace(
|
||||
name="Demo",
|
||||
description="",
|
||||
author=SimpleNamespace(name="tester"),
|
||||
),
|
||||
version="1.0.0",
|
||||
)
|
||||
purged_plugins: list[tuple[str, str]] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
runner,
|
||||
"_resolve_plugin_meta_for_config_request",
|
||||
lambda plugin_id: (meta, True, None) if plugin_id == "demo.plugin" else (None, False, "not-found"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
runner._loader,
|
||||
"purge_plugin_modules",
|
||||
lambda plugin_id, plugin_dir: purged_plugins.append((plugin_id, plugin_dir)),
|
||||
)
|
||||
|
||||
envelope = Envelope(
|
||||
request_id=1,
|
||||
message_type=MessageType.REQUEST,
|
||||
method="plugin.inspect_config",
|
||||
plugin_id="demo.plugin",
|
||||
payload=InspectPluginConfigPayload(
|
||||
config_data={"plugin": {"enabled": False}},
|
||||
use_provided_config=True,
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
response = await runner._handle_inspect_plugin_config(envelope)
|
||||
|
||||
assert response.error is None
|
||||
assert response.payload["success"] is True
|
||||
assert response.payload["enabled"] is False
|
||||
assert response.payload["normalized_config"] == {
|
||||
"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}
|
||||
}
|
||||
assert response.payload["default_config"] == {
|
||||
"plugin": {"config_version": "2.0.0", "enabled": True, "retry_count": 3}
|
||||
}
|
||||
assert purged_plugins == [("demo.plugin", "/tmp/demo-plugin")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_validate_plugin_config_handler_returns_error_on_invalid_config(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Runner 应在插件拒绝配置时返回错误响应。"""
|
||||
|
||||
plugin = _StrictConfigPlugin()
|
||||
runner = PluginRunner(
|
||||
host_address="ipc://unused",
|
||||
session_token="session-token",
|
||||
plugin_dirs=[],
|
||||
)
|
||||
meta = SimpleNamespace(plugin_id="demo.plugin", plugin_dir="", instance=plugin)
|
||||
monkeypatch.setattr(runner._loader, "get_plugin", lambda plugin_id: meta if plugin_id == "demo.plugin" else None)
|
||||
|
||||
envelope = Envelope(
|
||||
request_id=1,
|
||||
message_type=MessageType.REQUEST,
|
||||
method="plugin.validate_config",
|
||||
plugin_id="demo.plugin",
|
||||
payload=ValidatePluginConfigPayload(
|
||||
config_data={"plugin": {"config_version": "2.0.0", "retry_count": -1}}
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
response = await runner._handle_validate_plugin_config(envelope)
|
||||
|
||||
assert response.error is not None
|
||||
assert response.error["message"] == "重试次数不能小于 0"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_plugin_config_prefers_runtime_validation(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""WebUI 保存插件配置时应优先使用运行时校验结果。"""
|
||||
|
||||
config_path = tmp_path / "config.toml"
|
||||
|
||||
async def _mock_validate_plugin_config(plugin_id: str, config_data: Dict[str, Any]) -> Dict[str, Any] | None:
|
||||
"""返回运行时归一化后的配置。
|
||||
|
||||
Args:
|
||||
plugin_id: 插件 ID。
|
||||
config_data: 原始配置。
|
||||
|
||||
Returns:
|
||||
Dict[str, Any] | None: 归一化后的配置。
|
||||
"""
|
||||
|
||||
assert plugin_id == "demo.plugin"
|
||||
assert config_data == {"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}}
|
||||
return {"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}}
|
||||
|
||||
async def _mock_inspect_plugin_config(
|
||||
plugin_id: str,
|
||||
config_data: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
use_provided_config: bool = False,
|
||||
) -> SimpleNamespace | None:
|
||||
"""返回运行时配置快照。
|
||||
|
||||
Args:
|
||||
plugin_id: 插件 ID。
|
||||
config_data: 可选配置。
|
||||
use_provided_config: 是否使用传入配置。
|
||||
|
||||
Returns:
|
||||
SimpleNamespace | None: 运行时配置快照。
|
||||
"""
|
||||
|
||||
del config_data, use_provided_config
|
||||
if plugin_id != "demo.plugin":
|
||||
return None
|
||||
return SimpleNamespace(
|
||||
normalized_config={"plugin": {"config_version": "2.0.0", "enabled": True, "retry_count": 3}}
|
||||
)
|
||||
|
||||
fake_runtime_manager = SimpleNamespace(
|
||||
inspect_plugin_config=_mock_inspect_plugin_config,
|
||||
validate_plugin_config=_mock_validate_plugin_config,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.webui.routers.plugin.config_routes.require_plugin_token",
|
||||
lambda session: session or "session-token",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.webui.routers.plugin.config_routes.find_plugin_path_by_id",
|
||||
lambda plugin_id: tmp_path if plugin_id == "demo.plugin" else None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.plugin_runtime.integration.get_plugin_runtime_manager",
|
||||
lambda: fake_runtime_manager,
|
||||
)
|
||||
|
||||
response = await update_plugin_config(
|
||||
"demo.plugin",
|
||||
UpdatePluginConfigRequest(config={"plugin.enabled": False}),
|
||||
maibot_session="session-token",
|
||||
)
|
||||
|
||||
assert response["success"] is True
|
||||
with config_path.open("rb") as handle:
|
||||
saved_config = tomllib.load(handle)
|
||||
assert saved_config == {"plugin": {"config_version": "2.0.0", "enabled": False, "retry_count": 3}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_config_endpoints_use_runtime_inspection_for_unloaded_plugin(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""WebUI 在插件未加载时也应从代码定义返回配置与 Schema。"""
|
||||
|
||||
async def _mock_inspect_plugin_config(
|
||||
plugin_id: str,
|
||||
config_data: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
use_provided_config: bool = False,
|
||||
) -> SimpleNamespace | None:
|
||||
"""返回运行时冷检查结果。
|
||||
|
||||
Args:
|
||||
plugin_id: 插件 ID。
|
||||
config_data: 可选配置。
|
||||
use_provided_config: 是否使用传入配置。
|
||||
|
||||
Returns:
|
||||
SimpleNamespace | None: 冷检查结果。
|
||||
"""
|
||||
|
||||
del config_data, use_provided_config
|
||||
if plugin_id != "demo.plugin":
|
||||
return None
|
||||
return SimpleNamespace(
|
||||
config_schema={
|
||||
"plugin_id": "demo.plugin",
|
||||
"plugin_info": {
|
||||
"name": "Demo",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
},
|
||||
"sections": {"plugin": {"fields": {}}},
|
||||
"layout": {"type": "auto", "tabs": []},
|
||||
},
|
||||
normalized_config={"plugin": {"config_version": "2.0.0", "enabled": True, "retry_count": 3}},
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
fake_runtime_manager = SimpleNamespace(inspect_plugin_config=_mock_inspect_plugin_config)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.webui.routers.plugin.config_routes.require_plugin_token",
|
||||
lambda session: session or "session-token",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.webui.routers.plugin.config_routes.find_plugin_path_by_id",
|
||||
lambda plugin_id: tmp_path if plugin_id == "demo.plugin" else None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.plugin_runtime.integration.get_plugin_runtime_manager",
|
||||
lambda: fake_runtime_manager,
|
||||
)
|
||||
|
||||
schema_response = await get_plugin_config_schema("demo.plugin", maibot_session="session-token")
|
||||
config_response = await get_plugin_config("demo.plugin", maibot_session="session-token")
|
||||
|
||||
assert schema_response["success"] is True
|
||||
assert schema_response["schema"]["plugin_id"] == "demo.plugin"
|
||||
assert config_response == {
|
||||
"success": True,
|
||||
"config": {"plugin": {"config_version": "2.0.0", "enabled": True, "retry_count": 3}},
|
||||
"message": "配置文件不存在,已返回默认配置",
|
||||
}
|
||||
Reference in New Issue
Block a user