feat: add plugin dependency pipeline and HTML rendering service

- Implemented a new dependency pipeline for plugins to manage Python package dependencies, including conflict detection and automatic installation of missing dependencies.
- Introduced an HTML rendering service that utilizes existing browsers to render HTML content as PNG images, with support for various configurations and error handling.
This commit is contained in:
DrSmoothl
2026-04-03 01:48:23 +08:00
parent fbc2fba6ff
commit 4ec06ece56
17 changed files with 2585 additions and 93 deletions

View File

@@ -0,0 +1,194 @@
"""HTML 浏览器渲染服务测试。"""
from pathlib import Path
from typing import Any, Dict, List
import pytest
from src.config.official_configs import PluginRuntimeRenderConfig
from src.services import html_render_service as html_render_service_module
from src.services.html_render_service import HTMLRenderService, ManagedBrowserRecord
class _FakeChromium:
"""用于模拟 Playwright Chromium 启动器的测试桩。"""
def __init__(self, effects: List[Any]) -> None:
"""初始化 Chromium 启动测试桩。
Args:
effects: 每次调用 ``launch`` 时依次返回或抛出的结果。
"""
self._effects: List[Any] = list(effects)
self.calls: List[Dict[str, Any]] = []
async def launch(self, **kwargs: Any) -> Any:
"""模拟 Playwright Chromium 的启动过程。
Args:
**kwargs: 浏览器启动参数。
Returns:
Any: 预设的浏览器对象。
Raises:
Exception: 当预设结果为异常对象时抛出。
"""
self.calls.append(dict(kwargs))
effect = self._effects.pop(0)
if isinstance(effect, Exception):
raise effect
return effect
class _FakePlaywright:
"""用于模拟 Playwright 根对象的测试桩。"""
def __init__(self, chromium: _FakeChromium) -> None:
"""初始化 Playwright 测试桩。
Args:
chromium: Chromium 启动器测试桩。
"""
self.chromium = chromium
def _build_render_config(**kwargs: Any) -> PluginRuntimeRenderConfig:
"""构造用于测试的浏览器渲染配置。
Args:
**kwargs: 需要覆盖的配置字段。
Returns:
PluginRuntimeRenderConfig: 测试使用的配置对象。
"""
payload: Dict[str, Any] = {
"auto_download_chromium": True,
"browser_install_root": "data/test-playwright-browsers",
}
payload.update(kwargs)
return PluginRuntimeRenderConfig(**payload)
@pytest.mark.asyncio
async def test_launch_browser_auto_downloads_chromium_when_missing(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""未检测到可用浏览器时,应自动下载 Chromium 并记录状态。"""
monkeypatch.setattr(html_render_service_module, "PROJECT_ROOT", tmp_path)
service = HTMLRenderService()
config = _build_render_config()
fake_browser = object()
fake_chromium = _FakeChromium(
[
RuntimeError("browserType.launch: Executable doesn't exist at /tmp/chromium"),
fake_browser,
]
)
install_calls: List[str] = []
monkeypatch.setattr(service, "_resolve_executable_path", lambda _config: "")
async def fake_install(_config: PluginRuntimeRenderConfig) -> None:
"""模拟 Chromium 自动下载。
Args:
_config: 当前浏览器渲染配置。
"""
install_calls.append(_config.browser_install_root)
browsers_path = service._get_managed_browsers_path(_config)
(browsers_path / "chromium-1234").mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(service, "_install_chromium_browser", fake_install)
browser = await service._launch_browser(_FakePlaywright(fake_chromium), config)
assert browser is fake_browser
assert install_calls == ["data/test-playwright-browsers"]
assert len(fake_chromium.calls) == 2
browser_record = service._load_managed_browser_record()
assert browser_record is not None
assert browser_record.install_source == "auto_download"
assert browser_record.browser_name == "chromium"
@pytest.mark.asyncio
async def test_launch_browser_reuses_existing_managed_browser(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""已存在 Playwright 托管浏览器时,不应重复下载。"""
monkeypatch.setattr(html_render_service_module, "PROJECT_ROOT", tmp_path)
service = HTMLRenderService()
config = _build_render_config()
browsers_path = service._get_managed_browsers_path(config)
(browsers_path / "chrome-headless-shell-1234").mkdir(parents=True, exist_ok=True)
fake_browser = object()
fake_chromium = _FakeChromium([fake_browser])
monkeypatch.setattr(service, "_resolve_executable_path", lambda _config: "")
async def fail_install(_config: PluginRuntimeRenderConfig) -> None:
"""若被错误调用则立即失败。
Args:
_config: 当前浏览器渲染配置。
Raises:
AssertionError: 表示本测试不期望进入下载逻辑。
"""
raise AssertionError("不应触发自动下载")
monkeypatch.setattr(service, "_install_chromium_browser", fail_install)
browser = await service._launch_browser(_FakePlaywright(fake_chromium), config)
assert browser is fake_browser
assert len(fake_chromium.calls) == 1
browser_record = service._load_managed_browser_record()
assert browser_record is not None
assert browser_record.install_source == "existing_cache"
assert browser_record.browsers_path == str(browsers_path)
@pytest.mark.asyncio
async def test_launch_browser_prefers_local_executable(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""探测到本机浏览器时,应优先使用可执行文件路径启动。"""
monkeypatch.setattr(html_render_service_module, "PROJECT_ROOT", tmp_path)
service = HTMLRenderService()
config = _build_render_config()
fake_browser = object()
fake_chromium = _FakeChromium([fake_browser])
executable_path = "/usr/bin/google-chrome"
monkeypatch.setattr(service, "_resolve_executable_path", lambda _config: executable_path)
browser = await service._launch_browser(_FakePlaywright(fake_chromium), config)
assert browser is fake_browser
assert len(fake_chromium.calls) == 1
assert fake_chromium.calls[0]["executable_path"] == executable_path
assert service._load_managed_browser_record() is None
def test_managed_browser_record_roundtrip() -> None:
"""托管浏览器记录应支持序列化与反序列化。"""
record = ManagedBrowserRecord(
browser_name="chromium",
browsers_path="/tmp/playwright-browsers",
install_source="auto_download",
playwright_version="1.58.0",
recorded_at="2026-04-03T10:00:00+00:00",
last_verified_at="2026-04-03T10:00:01+00:00",
)
restored_record = ManagedBrowserRecord.from_dict(record.to_dict())
assert restored_record == record

View File

@@ -0,0 +1,225 @@
"""插件依赖流水线测试。"""
from pathlib import Path
import json
import pytest
from src.plugin_runtime.dependency_pipeline import PluginDependencyPipeline
def _build_manifest(
plugin_id: str,
*,
dependencies: list[dict[str, str]] | None = None,
) -> dict[str, object]:
"""构造测试用的 Manifest v2 数据。
Args:
plugin_id: 插件 ID。
dependencies: 依赖声明列表。
Returns:
dict[str, object]: 可直接写入 ``_manifest.json`` 的字典。
"""
return {
"manifest_version": 2,
"version": "1.0.0",
"name": plugin_id,
"description": "测试插件",
"author": {
"name": "tester",
"url": "https://example.com/tester",
},
"license": "MIT",
"urls": {
"repository": f"https://example.com/{plugin_id}",
},
"host_application": {
"min_version": "1.0.0",
"max_version": "1.0.0",
},
"sdk": {
"min_version": "2.0.0",
"max_version": "2.99.99",
},
"dependencies": dependencies or [],
"capabilities": [],
"i18n": {
"default_locale": "zh-CN",
"supported_locales": ["zh-CN"],
},
"id": plugin_id,
}
def _write_plugin(
plugin_root: Path,
plugin_name: str,
plugin_id: str,
*,
dependencies: list[dict[str, str]] | None = None,
) -> Path:
"""在临时目录中写入一个测试插件。
Args:
plugin_root: 插件根目录。
plugin_name: 插件目录名。
plugin_id: 插件 ID。
dependencies: Python 依赖声明列表。
Returns:
Path: 插件目录路径。
"""
plugin_dir = plugin_root / plugin_name
plugin_dir.mkdir(parents=True)
(plugin_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8")
(plugin_dir / "_manifest.json").write_text(
json.dumps(_build_manifest(plugin_id, dependencies=dependencies)),
encoding="utf-8",
)
return plugin_dir
def test_build_plan_blocks_plugin_conflicting_with_host_requirement(tmp_path: Path) -> None:
"""与主程序依赖冲突的插件应被阻止加载。"""
plugin_root = tmp_path / "plugins"
_write_plugin(
plugin_root,
"conflict_plugin",
"test.conflict-plugin",
dependencies=[
{
"type": "python_package",
"name": "numpy",
"version_spec": "<1.0.0",
}
],
)
pipeline = PluginDependencyPipeline(project_root=Path.cwd())
plan = pipeline.build_plan([plugin_root])
assert "test.conflict-plugin" in plan.blocked_plugin_reasons
assert "主程序" in plan.blocked_plugin_reasons["test.conflict-plugin"]
assert plan.install_requirements == ()
def test_build_plan_blocks_plugins_with_conflicting_python_dependencies(tmp_path: Path) -> None:
"""插件之间出现 Python 包版本冲突时应同时阻止双方加载。"""
plugin_root = tmp_path / "plugins"
_write_plugin(
plugin_root,
"plugin_a",
"test.plugin-a",
dependencies=[
{
"type": "python_package",
"name": "demo-package",
"version_spec": "<2.0.0",
}
],
)
_write_plugin(
plugin_root,
"plugin_b",
"test.plugin-b",
dependencies=[
{
"type": "python_package",
"name": "demo-package",
"version_spec": ">=3.0.0",
}
],
)
pipeline = PluginDependencyPipeline(project_root=Path.cwd())
plan = pipeline.build_plan([plugin_root])
assert "test.plugin-a" in plan.blocked_plugin_reasons
assert "test.plugin-b" in plan.blocked_plugin_reasons
assert "test.plugin-b" in plan.blocked_plugin_reasons["test.plugin-a"]
assert "test.plugin-a" in plan.blocked_plugin_reasons["test.plugin-b"]
def test_build_plan_collects_install_requirements_for_missing_packages(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""未安装但无冲突的依赖应进入自动安装计划。"""
plugin_root = tmp_path / "plugins"
_write_plugin(
plugin_root,
"plugin_a",
"test.plugin-a",
dependencies=[
{
"type": "python_package",
"name": "demo-package",
"version_spec": ">=1.0.0,<2.0.0",
}
],
)
pipeline = PluginDependencyPipeline(project_root=Path.cwd())
monkeypatch.setattr(
pipeline._manifest_validator,
"get_installed_package_version",
lambda package_name: None if package_name == "demo-package" else "1.0.0",
)
plan = pipeline.build_plan([plugin_root])
assert plan.blocked_plugin_reasons == {}
assert len(plan.install_requirements) == 1
assert plan.install_requirements[0].package_name == "demo-package"
assert plan.install_requirements[0].plugin_ids == ("test.plugin-a",)
assert plan.install_requirements[0].requirement_text == "demo-package>=1.0.0,<2.0.0"
@pytest.mark.asyncio
async def test_execute_blocks_plugins_when_auto_install_fails(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""自动安装失败时,相关插件应被阻止加载。"""
plugin_root = tmp_path / "plugins"
_write_plugin(
plugin_root,
"plugin_a",
"test.plugin-a",
dependencies=[
{
"type": "python_package",
"name": "demo-package",
"version_spec": ">=1.0.0,<2.0.0",
}
],
)
pipeline = PluginDependencyPipeline(project_root=Path.cwd())
monkeypatch.setattr(
pipeline._manifest_validator,
"get_installed_package_version",
lambda package_name: None if package_name == "demo-package" else "1.0.0",
)
async def fake_install(_requirements) -> tuple[bool, str]:
"""模拟依赖安装失败。"""
return False, "network error"
monkeypatch.setattr(pipeline, "_install_requirements", fake_install)
result = await pipeline.execute([plugin_root])
assert result.environment_changed is False
assert "test.plugin-a" in result.blocked_plugin_reasons
assert "自动安装 Python 依赖失败" in result.blocked_plugin_reasons["test.plugin-a"]

View File

@@ -3,6 +3,8 @@
验证协议层、传输层、RPC 通信链路的正确性。
"""
# pyright: reportArgumentType=false, reportAttributeAccessIssue=false, reportCallIssue=false, reportIndexIssue=false, reportMissingImports=false, reportOptionalMemberAccess=false
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence
@@ -2891,7 +2893,7 @@ class TestIntegration:
assert instances[0].stopped is True
@pytest.mark.asyncio
async def test_handle_plugin_source_changes_only_reload_matching_supervisor(self, monkeypatch, tmp_path):
async def test_handle_plugin_source_changes_restarts_supervisors_after_dependency_sync(self, monkeypatch, tmp_path):
from src.config.file_watcher import FileChange
from src.plugin_runtime import integration as integration_module
import json
@@ -2915,7 +2917,6 @@ class TestIntegration:
def __init__(self, plugin_dirs, registered_plugins):
self._plugin_dirs = plugin_dirs
self._registered_plugins = registered_plugins
self.reload_reasons = []
self.config_updates = []
def get_loaded_plugin_ids(self):
@@ -2924,9 +2925,6 @@ class TestIntegration:
def get_loaded_plugin_versions(self):
return {plugin_id: "1.0.0" for plugin_id in self._registered_plugins}
async def reload_plugins(self, plugin_ids=None, reason="manual", external_available_plugins=None):
self.reload_reasons.append((plugin_ids, reason, external_available_plugins or {}))
async def notify_plugin_config_updated(self, plugin_id, config_data, config_version=""):
self.config_updates.append((plugin_id, config_data, config_version))
return True
@@ -2935,27 +2933,37 @@ class TestIntegration:
manager._started = True
manager._builtin_supervisor = FakeSupervisor([builtin_root], {"test.alpha": object()})
manager._third_party_supervisor = FakeSupervisor([thirdparty_root], {"test.beta": object()})
dependency_sync_calls = []
restart_calls = []
async def fake_sync(plugin_dirs: Sequence[Path]) -> Any:
"""记录依赖同步调用。"""
dependency_sync_calls.append(list(plugin_dirs))
return integration_module.DependencySyncState(
blocked_changed_plugin_ids={"test.beta"},
environment_changed=False,
)
async def fake_restart(reason: str) -> bool:
"""记录 Supervisor 重启调用。"""
restart_calls.append(reason)
return True
monkeypatch.setattr(manager, "_sync_plugin_dependencies", fake_sync)
monkeypatch.setattr(manager, "_restart_supervisors", fake_restart)
changes = [
FileChange(change_type=1, path=beta_dir / "plugin.py"),
]
refresh_calls = []
def fake_refresh() -> None:
refresh_calls.append(True)
manager._refresh_plugin_config_watch_subscriptions = fake_refresh
await manager._handle_plugin_source_changes(changes)
assert manager._builtin_supervisor.reload_reasons == []
assert manager._third_party_supervisor.reload_reasons == [
(["test.beta"], "file_watcher", {"test.alpha": "1.0.0"})
]
assert dependency_sync_calls == [[builtin_root, thirdparty_root]]
assert restart_calls == ["file_watcher_blocklist_changed"]
assert manager._builtin_supervisor.config_updates == []
assert manager._third_party_supervisor.config_updates == []
assert refresh_calls == [True]
@pytest.mark.asyncio
async def test_reload_plugins_globally_warns_and_skips_cross_supervisor_dependents(self, monkeypatch):

View File

@@ -0,0 +1,96 @@
"""插件运行时浏览器渲染能力测试。"""
from typing import Optional
import pytest
from src.plugin_runtime.integration import PluginRuntimeManager
from src.plugin_runtime.host.supervisor import PluginSupervisor
from src.services.html_render_service import HtmlRenderRequest, HtmlRenderResult
class _FakeRenderService:
"""用于替代真实浏览器渲染服务的测试桩。"""
def __init__(self) -> None:
"""初始化测试桩。"""
self.last_request: Optional[HtmlRenderRequest] = None
async def render_html_to_png(self, request: HtmlRenderRequest) -> HtmlRenderResult:
"""记录请求并返回固定的渲染结果。
Args:
request: 当前渲染请求。
Returns:
HtmlRenderResult: 固定的测试渲染结果。
"""
self.last_request = request
return HtmlRenderResult(
image_base64="ZmFrZS1pbWFnZQ==",
mime_type="image/png",
width=640,
height=480,
render_ms=12,
)
def test_render_capability_is_registered() -> None:
"""Host 注册能力时应包含 render.html2png。"""
manager = PluginRuntimeManager()
supervisor = PluginSupervisor(plugin_dirs=[])
manager._register_capability_impls(supervisor)
assert "render.html2png" in supervisor.capability_service.list_capabilities()
@pytest.mark.asyncio
async def test_render_capability_forwards_request(monkeypatch: pytest.MonkeyPatch) -> None:
"""render.html2png 应将请求透传给浏览器渲染服务。"""
from src.plugin_runtime.capabilities import render as render_capability_module
fake_service = _FakeRenderService()
monkeypatch.setattr(render_capability_module, "get_html_render_service", lambda: fake_service)
manager = PluginRuntimeManager()
result = await manager._cap_render_html2png(
"demo.plugin",
"render.html2png",
{
"html": "<body><div id='card'>hello</div></body>",
"selector": "#card",
"viewport": {"width": 1024, "height": 768},
"device_scale_factor": 1.5,
"full_page": False,
"omit_background": True,
"wait_until": "networkidle",
"wait_for_selector": "#card",
"wait_for_timeout_ms": 150,
"timeout_ms": 3000,
"allow_network": True,
},
)
assert result == {
"success": True,
"result": {
"image_base64": "ZmFrZS1pbWFnZQ==",
"mime_type": "image/png",
"width": 640,
"height": 480,
"render_ms": 12,
},
}
assert fake_service.last_request is not None
assert fake_service.last_request.selector == "#card"
assert fake_service.last_request.viewport_width == 1024
assert fake_service.last_request.viewport_height == 768
assert fake_service.last_request.device_scale_factor == 1.5
assert fake_service.last_request.omit_background is True
assert fake_service.last_request.wait_until == "networkidle"
assert fake_service.last_request.allow_network is True