- 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.
226 lines
6.4 KiB
Python
226 lines
6.4 KiB
Python
"""插件依赖流水线测试。"""
|
|
|
|
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"]
|