mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 00:16:57 +08:00
refactor(runtime): activate managed resources on demand (#6334)
This commit is contained in:
@@ -108,13 +108,13 @@ async def test_agent_initialization_failure_does_not_stop_module_startup(
|
||||
monkeypatch.setattr(modules_initializer, "init_agent", agent_initializer.init_agent)
|
||||
|
||||
for name in (
|
||||
"DisplayHelper",
|
||||
"DohHelper",
|
||||
"SitesHelper",
|
||||
"ResourceHelper",
|
||||
"ModuleManager",
|
||||
):
|
||||
monkeypatch.setattr(modules_initializer, name, MagicMock())
|
||||
monkeypatch.setattr(modules_initializer, "init_managed_resources", MagicMock())
|
||||
monkeypatch.setattr(modules_initializer, "user_auth", MagicMock())
|
||||
monkeypatch.setattr(modules_initializer.EventManager, "start", MagicMock())
|
||||
for name in (
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import asyncio
|
||||
import sys
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from types import ModuleType
|
||||
from typing import Optional
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.agent.tools.impl.browse_webpage import BrowserAction, BrowseWebpageTool
|
||||
from app.adapters.network.browser import BrowserSessionHelper, PlaywrightHelper
|
||||
from app.adapters.network.browser import (
|
||||
BrowserSessionHelper,
|
||||
PlaywrightHelper,
|
||||
launch_browser_context,
|
||||
launch_browser_context_async,
|
||||
)
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
@@ -224,6 +232,59 @@ def test_legacy_browser_type_constructor_is_accepted():
|
||||
assert source == "<html>ok</html>"
|
||||
|
||||
|
||||
def test_sync_browser_facade_activates_display_only_for_headed_mode(monkeypatch):
|
||||
"""同步启动仅在明确有界面模式获取 host.display,参数原样交给浏览器。"""
|
||||
provider = ModuleType("cloakbrowser")
|
||||
launch_context = MagicMock(return_value=object())
|
||||
provider.launch_context = launch_context
|
||||
monkeypatch.setitem(sys.modules, "cloakbrowser", provider)
|
||||
activate = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"app.adapters.network.browser.acquire_managed_resource",
|
||||
activate,
|
||||
)
|
||||
|
||||
headless_context = launch_browser_context(headless=True, locale="zh-CN")
|
||||
headed_context = launch_browser_context(headless=False, locale="zh-CN")
|
||||
|
||||
assert headless_context is launch_context.return_value
|
||||
assert headed_context is launch_context.return_value
|
||||
activate.assert_called_once_with(
|
||||
"host.display",
|
||||
reason="headed_browser_launch",
|
||||
retry=True,
|
||||
)
|
||||
assert launch_context.call_args_list == [
|
||||
call(headless=True, locale="zh-CN"),
|
||||
call(headless=False, locale="zh-CN"),
|
||||
]
|
||||
|
||||
|
||||
def test_async_browser_facade_waits_for_display_before_provider(monkeypatch):
|
||||
"""异步有界面启动必须等待显示资源完成激活后再创建浏览器上下文。"""
|
||||
events: list[str] = []
|
||||
provider = ModuleType("cloakbrowser")
|
||||
|
||||
async def provider_launch(**_kwargs):
|
||||
events.append("provider")
|
||||
return object()
|
||||
|
||||
provider.launch_context_async = provider_launch
|
||||
monkeypatch.setitem(sys.modules, "cloakbrowser", provider)
|
||||
|
||||
async def activate(*_args, **_kwargs):
|
||||
events.append("display")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.adapters.network.browser.acquire_managed_resource_async",
|
||||
AsyncMock(side_effect=activate),
|
||||
)
|
||||
|
||||
asyncio.run(launch_browser_context_async(headless=False, timezone="Asia/Shanghai"))
|
||||
|
||||
assert events == ["display", "provider"]
|
||||
|
||||
|
||||
def test_browser_session_helper_blocks_private_network_by_default():
|
||||
"""默认应阻止 Agent 浏览器访问本机或私网地址。"""
|
||||
with pytest.raises(ValueError, match="默认不允许访问本机或私网地址"):
|
||||
|
||||
@@ -142,7 +142,7 @@ def test_init_modules_does_not_clear_package_tool_cache(monkeypatch):
|
||||
raise AssertionError("init_modules must not clear package tool cache directly")
|
||||
|
||||
monkeypatch.setattr(modules_initializer, "clear_package_tool_cache", fail_if_called)
|
||||
monkeypatch.setattr(modules_initializer, "DisplayHelper", lambda: None)
|
||||
monkeypatch.setattr(modules_initializer, "init_managed_resources", lambda: None)
|
||||
monkeypatch.setattr(modules_initializer, "DohHelper", lambda: None)
|
||||
monkeypatch.setattr(modules_initializer, "SitesHelper", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""虚拟显示托管资源与旧 API 的兼容测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from types import ModuleType
|
||||
|
||||
from app.adapters.system.display import DisplayHelper
|
||||
from app.adapters.system.display.resource import VirtualDisplayResource
|
||||
from app.foundation.singleton import Singleton
|
||||
|
||||
|
||||
def test_virtual_display_skips_host_process_outside_docker(monkeypatch) -> None:
|
||||
"""非容器环境启动资源时不得创建虚拟显示进程。"""
|
||||
monkeypatch.setattr(
|
||||
"app.adapters.system.display.resource.SystemUtils.is_docker",
|
||||
lambda: False,
|
||||
)
|
||||
resource = VirtualDisplayResource()
|
||||
|
||||
resource.start()
|
||||
resource.stop()
|
||||
|
||||
assert resource.display is None
|
||||
|
||||
|
||||
def test_virtual_display_starts_and_stops_owned_process(monkeypatch) -> None:
|
||||
"""容器环境只停止当前资源实际拥有的显示进程。"""
|
||||
events: list[object] = []
|
||||
|
||||
class FakeDisplay:
|
||||
"""记录 pyvirtualdisplay 的构造与生命周期。"""
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
events.append(("create", kwargs))
|
||||
|
||||
def start(self) -> None:
|
||||
events.append("start")
|
||||
|
||||
def stop(self) -> None:
|
||||
events.append("stop")
|
||||
|
||||
pyvirtualdisplay = ModuleType("pyvirtualdisplay")
|
||||
pyvirtualdisplay.Display = FakeDisplay
|
||||
monkeypatch.setitem(sys.modules, "pyvirtualdisplay", pyvirtualdisplay)
|
||||
monkeypatch.setattr(
|
||||
"app.adapters.system.display.resource.SystemUtils.is_docker",
|
||||
lambda: True,
|
||||
)
|
||||
monkeypatch.setenv("DISPLAY", ":99")
|
||||
resource = VirtualDisplayResource()
|
||||
|
||||
resource.start()
|
||||
resource.stop()
|
||||
resource.stop()
|
||||
|
||||
assert events == [
|
||||
(
|
||||
"create",
|
||||
{
|
||||
"visible": False,
|
||||
"size": (1024, 768),
|
||||
"extra_args": [":99"],
|
||||
},
|
||||
),
|
||||
"start",
|
||||
"stop",
|
||||
]
|
||||
assert resource.display is None
|
||||
|
||||
|
||||
def test_display_helper_keeps_legacy_constructor_and_stop_contract(monkeypatch) -> None:
|
||||
"""旧构造入口显式激活 host.display,stop 只停止已配置 Runtime。"""
|
||||
events: list[tuple[str, str]] = []
|
||||
singleton_key = (DisplayHelper, (), frozenset())
|
||||
previous = Singleton._instances.pop(singleton_key, None)
|
||||
monkeypatch.setattr(
|
||||
"app.adapters.system.display.acquire_managed_resource",
|
||||
lambda capability_id, *, reason, retry: events.append(
|
||||
("activate", capability_id)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.adapters.system.display.stop_managed_resource",
|
||||
lambda capability_id, *, reason: events.append(("stop", capability_id)),
|
||||
)
|
||||
try:
|
||||
helper = DisplayHelper()
|
||||
assert DisplayHelper() is helper
|
||||
helper.stop()
|
||||
finally:
|
||||
Singleton._instances.pop(singleton_key, None)
|
||||
if previous is not None:
|
||||
Singleton._instances[singleton_key] = previous
|
||||
|
||||
assert events == [
|
||||
("activate", "host.display"),
|
||||
("stop", "host.display"),
|
||||
]
|
||||
@@ -0,0 +1,382 @@
|
||||
"""旧插件资源导入扫描与加载前准备合同测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.runtime.compat import resource_imports
|
||||
from app.runtime.compat.resource_imports import (
|
||||
PluginResourceImportScanError,
|
||||
RESOURCE_IMPORT_RULES,
|
||||
scan_plugin_resource_imports,
|
||||
)
|
||||
from app.runtime.extensions import plugin_manager as plugin_manager_module
|
||||
from app.runtime.extensions.plugin_manager import PluginManager
|
||||
from app.startup import plugins_initializer
|
||||
|
||||
|
||||
_HEADED_CLOAKBROWSER_ENTRYPOINTS = (
|
||||
"launch",
|
||||
"launch_async",
|
||||
"launch_context",
|
||||
"launch_context_async",
|
||||
"launch_persistent_context",
|
||||
"launch_persistent_context_async",
|
||||
)
|
||||
|
||||
|
||||
def _write_plugin(root: Path, plugin_id: str, source: str) -> Path:
|
||||
"""写入一个仅用于 AST 扫描的最小插件源码目录。"""
|
||||
plugin_dir = root / plugin_id.lower()
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "__init__.py").write_text(source, encoding="utf-8")
|
||||
return plugin_dir
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"source",
|
||||
(
|
||||
"import cloakbrowser\n",
|
||||
"from cloakbrowser import *\n",
|
||||
"from cloakbrowser.browser import launch_context\n",
|
||||
"__import__('cloakbrowser')\n",
|
||||
"import importlib\nimportlib.import_module('cloakbrowser.browser')\n",
|
||||
"import importlib as loader\nloader.import_module('cloakbrowser')\n",
|
||||
"from importlib import import_module as load\nload('cloakbrowser')\n",
|
||||
),
|
||||
)
|
||||
def test_cloakbrowser_import_shapes_require_display(
|
||||
tmp_path: Path,
|
||||
source: str,
|
||||
) -> None:
|
||||
"""静态、星号、子模块及常量动态导入均准备虚拟显示。"""
|
||||
plugin_dir = _write_plugin(tmp_path, "SamplePlugin", source)
|
||||
|
||||
assert scan_plugin_resource_imports("SamplePlugin", plugin_dir) == ("host.display",)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"entrypoint",
|
||||
_HEADED_CLOAKBROWSER_ENTRYPOINTS,
|
||||
)
|
||||
def test_all_headed_capable_cloakbrowser_entrypoints_require_display(
|
||||
tmp_path: Path,
|
||||
entrypoint: str,
|
||||
) -> None:
|
||||
"""CloakBrowser 六类允许 headed 模式的入口共用同一资源规则。"""
|
||||
plugin_dir = _write_plugin(
|
||||
tmp_path,
|
||||
"HeadedPlugin",
|
||||
f"from cloakbrowser import {entrypoint}\n",
|
||||
)
|
||||
|
||||
assert scan_plugin_resource_imports("HeadedPlugin", plugin_dir) == ("host.display",)
|
||||
assert RESOURCE_IMPORT_RULES[0].headed_entrypoints == (
|
||||
_HEADED_CLOAKBROWSER_ENTRYPOINTS
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("plugin_id", "source"),
|
||||
(
|
||||
("DynamicWechat", "from cloakbrowser import launch_context_async\n"),
|
||||
("ContractCheck", "from cloakbrowser import launch_context\n"),
|
||||
("InvitesSignin", "from cloakbrowser import launch_context\n"),
|
||||
(
|
||||
"WeatherWidget",
|
||||
"__import__('cloakbrowser')\nfrom cloakbrowser import launch_context\n",
|
||||
),
|
||||
(
|
||||
"P115StrmHelper",
|
||||
"from cloakbrowser import launch_context as _cloak_launch_context\n",
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_current_direct_cloakbrowser_plugin_shapes_require_display(
|
||||
tmp_path: Path,
|
||||
plugin_id: str,
|
||||
source: str,
|
||||
) -> None:
|
||||
"""当前五种直接 CloakBrowser 插件导入形态均命中 host.display。"""
|
||||
plugin_dir = _write_plugin(tmp_path, plugin_id, source)
|
||||
|
||||
assert scan_plugin_resource_imports(plugin_id, plugin_dir) == ("host.display",)
|
||||
|
||||
|
||||
def test_sdk_browser_import_does_not_require_legacy_resource(tmp_path: Path) -> None:
|
||||
"""宿主 SDK 浏览器门面自行按 headless 参数协调资源,不应被保守扫描。"""
|
||||
plugin_dir = _write_plugin(
|
||||
tmp_path,
|
||||
"SdkPlugin",
|
||||
"from app.sdk.browser import launch_browser_context_async\n",
|
||||
)
|
||||
|
||||
assert scan_plugin_resource_imports("SdkPlugin", plugin_dir) == ()
|
||||
|
||||
|
||||
def test_scanner_reuses_successful_file_result(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""未变化源码在热加载扫描时复用按文件状态缓存的结果。"""
|
||||
plugin_dir = _write_plugin(tmp_path, "CachedPlugin", "import cloakbrowser\n")
|
||||
parse_calls = 0
|
||||
original_parse = resource_imports.ast.parse
|
||||
|
||||
def count_parse(*args, **kwargs):
|
||||
nonlocal parse_calls
|
||||
parse_calls += 1
|
||||
return original_parse(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(resource_imports.ast, "parse", count_parse)
|
||||
|
||||
assert scan_plugin_resource_imports("CachedPlugin", plugin_dir) == ("host.display",)
|
||||
assert scan_plugin_resource_imports("CachedPlugin", plugin_dir) == ("host.display",)
|
||||
assert parse_calls == 1
|
||||
|
||||
|
||||
def test_scanner_invalidates_cache_when_source_changes(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""文件大小或修改时间变化后重新解析,不沿用旧能力集合。"""
|
||||
plugin_dir = _write_plugin(
|
||||
tmp_path,
|
||||
"ChangedPlugin",
|
||||
"from app.sdk.browser import launch_browser_context\n",
|
||||
)
|
||||
source_path = plugin_dir / "__init__.py"
|
||||
parse_calls = 0
|
||||
original_parse = resource_imports.ast.parse
|
||||
|
||||
def count_parse(*args, **kwargs):
|
||||
nonlocal parse_calls
|
||||
parse_calls += 1
|
||||
return original_parse(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(resource_imports.ast, "parse", count_parse)
|
||||
|
||||
assert scan_plugin_resource_imports("ChangedPlugin", plugin_dir) == ()
|
||||
source_path.write_text(
|
||||
"from cloakbrowser.browser import launch_persistent_context_async\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert scan_plugin_resource_imports("ChangedPlugin", plugin_dir) == (
|
||||
"host.display",
|
||||
)
|
||||
assert parse_calls == 2
|
||||
|
||||
|
||||
def test_scanner_invalidates_equal_size_source_with_preserved_mtime(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""等长热更新即使保留 mtime,也不能复用替换前的导入结论。"""
|
||||
plugin_dir = _write_plugin(tmp_path, "ReplacedPlugin", "import cloakbrowser\n")
|
||||
source_path = plugin_dir / "__init__.py"
|
||||
original_stat = source_path.stat()
|
||||
parse_calls = 0
|
||||
original_parse = resource_imports.ast.parse
|
||||
|
||||
def count_parse(*args, **kwargs):
|
||||
nonlocal parse_calls
|
||||
parse_calls += 1
|
||||
return original_parse(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(resource_imports.ast, "parse", count_parse)
|
||||
|
||||
assert scan_plugin_resource_imports("ReplacedPlugin", plugin_dir) == (
|
||||
"host.display",
|
||||
)
|
||||
source_path.write_text("import cloakbrowsex\n", encoding="utf-8")
|
||||
os.utime(
|
||||
source_path,
|
||||
ns=(original_stat.st_atime_ns, original_stat.st_mtime_ns),
|
||||
)
|
||||
|
||||
assert scan_plugin_resource_imports("ReplacedPlugin", plugin_dir) == ()
|
||||
assert parse_calls == 2
|
||||
|
||||
|
||||
def test_scanner_conservatively_prepares_resources_for_invalid_source(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""未被导入的残留语法文件不得阻断插件,但必须准备全部资源。"""
|
||||
plugin_dir = _write_plugin(
|
||||
tmp_path,
|
||||
"BrokenPlugin",
|
||||
"from app.sdk.browser import launch_browser_context\n",
|
||||
)
|
||||
(plugin_dir / "unused.py").write_text(
|
||||
"from cloakbrowser import (\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert scan_plugin_resource_imports("BrokenPlugin", plugin_dir) == ("host.display",)
|
||||
|
||||
|
||||
def test_scanner_conservatively_prepares_resources_for_read_error(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""源码读取失败时按全部资源准备,不能降级为空资源集合。"""
|
||||
plugin_dir = _write_plugin(
|
||||
tmp_path,
|
||||
"UnreadablePlugin",
|
||||
"from app.sdk.browser import launch_browser_context\n",
|
||||
)
|
||||
|
||||
original_open = resource_imports.tokenize.open
|
||||
|
||||
def guarded_open(path: Path):
|
||||
if Path(path).parent == plugin_dir:
|
||||
raise OSError("fixture read failure")
|
||||
return original_open(path)
|
||||
|
||||
monkeypatch.setattr(resource_imports.tokenize, "open", guarded_open)
|
||||
|
||||
assert scan_plugin_resource_imports("UnreadablePlugin", plugin_dir) == (
|
||||
"host.display",
|
||||
)
|
||||
|
||||
|
||||
def test_scanner_conservatively_prepares_resources_for_walk_error(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""目录遍历失败时准备全部资源,后续导入仍由 Python loader 判断。"""
|
||||
plugin_dir = _write_plugin(tmp_path, "WalkErrorPlugin", "plugin_name = 'ok'\n")
|
||||
|
||||
def fail_walk(_path: Path, _pattern: str):
|
||||
raise OSError("fixture walk failure")
|
||||
|
||||
monkeypatch.setattr(Path, "rglob", fail_walk)
|
||||
|
||||
assert scan_plugin_resource_imports("WalkErrorPlugin", plugin_dir) == (
|
||||
"host.display",
|
||||
)
|
||||
|
||||
|
||||
def test_scanner_honors_python_source_encoding_cookie(tmp_path: Path) -> None:
|
||||
"""合法的非 UTF-8 Python 源码按 PEP 263 声明解析。"""
|
||||
plugin_dir = tmp_path / "encodedplugin"
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / "__init__.py").write_bytes(
|
||||
"# -*- coding: latin-1 -*-\n# café\nimport cloakbrowser\n".encode("latin-1")
|
||||
)
|
||||
|
||||
assert scan_plugin_resource_imports("EncodedPlugin", plugin_dir) == (
|
||||
"host.display",
|
||||
)
|
||||
|
||||
|
||||
def _fake_plugin_module(module_name: str) -> ModuleType:
|
||||
"""构造满足 PluginManager 类发现合同的内存模块。"""
|
||||
module = ModuleType(module_name)
|
||||
plugin_type = type(
|
||||
module_name.rsplit(".", maxsplit=1)[-1].title(),
|
||||
(),
|
||||
{
|
||||
"init_plugin": lambda self, _config: None,
|
||||
"plugin_name": "Fixture",
|
||||
},
|
||||
)
|
||||
setattr(module, plugin_type.__name__, plugin_type)
|
||||
return module
|
||||
|
||||
|
||||
def test_plugin_preparer_runs_before_import_in_non_debug_and_isolates_failures(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""扫描或资源失败只阻止对应插件,后续插件仍按准备后导入的顺序加载。"""
|
||||
plugins_root = tmp_path / "app" / "plugins"
|
||||
for plugin_id in ("scanfailed", "resourcefailed", "healthy"):
|
||||
_write_plugin(plugins_root, plugin_id, "plugin_name = 'Fixture'\n")
|
||||
|
||||
events: list[tuple[str, str]] = []
|
||||
|
||||
def prepare(*, plugin_id: str, plugin_dir: Path) -> None:
|
||||
assert plugin_dir.name == plugin_id
|
||||
events.append(("prepare", plugin_id))
|
||||
if plugin_id == "scanfailed":
|
||||
raise PluginResourceImportScanError("fixture scan failure")
|
||||
if plugin_id == "resourcefailed":
|
||||
raise RuntimeError("fixture resource activation failure")
|
||||
|
||||
def import_plugin(module_name: str) -> ModuleType:
|
||||
plugin_id = module_name.rsplit(".", maxsplit=1)[-1]
|
||||
assert events[-1] == ("prepare", plugin_id)
|
||||
events.append(("import", plugin_id))
|
||||
return _fake_plugin_module(module_name)
|
||||
|
||||
monkeypatch.setattr(
|
||||
plugin_manager_module,
|
||||
"settings",
|
||||
SimpleNamespace(ROOT_PATH=tmp_path, DEBUG=False),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugin_manager_module,
|
||||
"_legacy_plugin_import_preparer",
|
||||
prepare,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugin_manager_module,
|
||||
"_legacy_import_scanner",
|
||||
lambda **_kwargs: None,
|
||||
)
|
||||
monkeypatch.setattr(importlib, "import_module", import_plugin)
|
||||
|
||||
plugins = PluginManager._load_selective_plugins(
|
||||
None,
|
||||
["ScanFailed", "ResourceFailed", "Healthy"],
|
||||
lambda plugin_type: hasattr(plugin_type, "init_plugin"),
|
||||
)
|
||||
|
||||
assert [plugin.__name__ for plugin in plugins] == ["Healthy"]
|
||||
assert ("prepare", "scanfailed") in events
|
||||
assert ("prepare", "resourcefailed") in events
|
||||
assert ("prepare", "healthy") in events
|
||||
assert ("import", "scanfailed") not in events
|
||||
assert ("import", "resourcefailed") not in events
|
||||
assert ("import", "healthy") in events
|
||||
|
||||
|
||||
def test_startup_preparer_activates_scanner_results_generically(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""组合根逐项激活扫描结果,并使用稳定的旧插件导入原因。"""
|
||||
events: list[tuple[str, str, str]] = []
|
||||
plugin_dir = _write_plugin(tmp_path, "LegacyPlugin", "import cloakbrowser\n")
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"scan_plugin_resource_imports",
|
||||
lambda plugin_id, path: (
|
||||
events.append(("scan", plugin_id, path.name))
|
||||
or ("host.display", "fixture.resource")
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"acquire_managed_resource",
|
||||
lambda capability_id, *, reason: events.append(
|
||||
("acquire", capability_id, reason)
|
||||
),
|
||||
)
|
||||
|
||||
plugins_initializer._prepare_legacy_plugin_import(
|
||||
plugin_id="LegacyPlugin",
|
||||
plugin_dir=plugin_dir,
|
||||
)
|
||||
|
||||
assert events == [
|
||||
("scan", "LegacyPlugin", "legacyplugin"),
|
||||
("acquire", "host.display", "legacy_plugin_import"),
|
||||
("acquire", "fixture.resource", "legacy_plugin_import"),
|
||||
]
|
||||
@@ -366,7 +366,6 @@ def _patch_module_shutdown_dependencies(monkeypatch) -> dict:
|
||||
for name, method_name in (
|
||||
("ModuleManager", "shutdown"),
|
||||
("EventManager", "stop"),
|
||||
("DisplayHelper", "stop"),
|
||||
("DohHelper", "shutdown"),
|
||||
("ThreadHelper", "shutdown"),
|
||||
("RedisHelper", "close"),
|
||||
@@ -381,11 +380,24 @@ def _patch_module_shutdown_dependencies(monkeypatch) -> dict:
|
||||
key = name.removesuffix("Helper").removesuffix("Manager").lower()
|
||||
dependencies[key] = getattr(instance, method_name)
|
||||
|
||||
for name in ("stop_message", "stop_frontend", "clear_temp"):
|
||||
for name in (
|
||||
"close_browser_sessions",
|
||||
"stop_message",
|
||||
"stop_frontend",
|
||||
"clear_temp",
|
||||
):
|
||||
dependency = MagicMock()
|
||||
monkeypatch.setattr(modules_initializer, name, dependency)
|
||||
dependencies[name] = dependency
|
||||
|
||||
stop_managed_resources = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
modules_initializer,
|
||||
"stop_managed_resources",
|
||||
stop_managed_resources,
|
||||
)
|
||||
dependencies["stop_managed_resources"] = stop_managed_resources
|
||||
|
||||
async_redis = MagicMock()
|
||||
async_redis.close = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
@@ -400,6 +412,23 @@ def _patch_module_shutdown_dependencies(monkeypatch) -> dict:
|
||||
return dependencies
|
||||
|
||||
|
||||
def test_browser_sessions_close_before_managed_resources(monkeypatch) -> None:
|
||||
"""显示等宿主资源必须晚于浏览器会话释放,避免存活上下文失去依赖。"""
|
||||
calls: list[str] = []
|
||||
monkeypatch.setattr(modules_initializer, "stop_agent", AsyncMock())
|
||||
dependencies = _patch_module_shutdown_dependencies(monkeypatch)
|
||||
dependencies["close_browser_sessions"].side_effect = lambda: calls.append("browser")
|
||||
|
||||
async def stop_resources() -> None:
|
||||
calls.append("resources")
|
||||
|
||||
dependencies["stop_managed_resources"].side_effect = stop_resources
|
||||
|
||||
asyncio.run(modules_initializer.stop_modules())
|
||||
|
||||
assert calls == ["browser", "resources"]
|
||||
|
||||
|
||||
def test_shared_http_close_waits_for_real_lru_eviction(monkeypatch):
|
||||
"""最终 HTTP 关闭必须等待真实 LRU 淘汰任务并消费其异常"""
|
||||
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
"""Managed Resource 与 Capability Runtime 的集成合同测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.runtime.capabilities.errors import (
|
||||
CapabilityOperationError,
|
||||
CapabilityRuntimeClosedError,
|
||||
)
|
||||
from app.runtime.capabilities.runtime import CapabilityRuntime
|
||||
from app.runtime.extensions.managed_resource_adapter import (
|
||||
AsyncManagedResourceAdapter,
|
||||
SyncManagedResourceAdapter,
|
||||
build_managed_resource_registry,
|
||||
)
|
||||
from app.runtime import managed_resources as managed_resource_facade
|
||||
from app.runtime.managed_resources import (
|
||||
MANAGED_RESOURCE_ASYNC_KIND,
|
||||
MANAGED_RESOURCE_SYNC_KIND,
|
||||
acquire_managed_resource,
|
||||
acquire_managed_resource_async,
|
||||
configure_managed_resource_runtime,
|
||||
managed_resource_observations,
|
||||
managed_resource_snapshot,
|
||||
shutdown_managed_resource_runtime,
|
||||
)
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parents[1]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_managed_resource_facade(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""每个用例使用独立 Runtime,避免不可逆关闭态泄漏到后续测试。"""
|
||||
monkeypatch.setattr(
|
||||
managed_resource_facade,
|
||||
"_managed_resource_runtime",
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _write_manifest(
|
||||
root: Path, *, capability_id: str, kind: str, entrypoint: str
|
||||
) -> None:
|
||||
"""写入一个最小 on-first-use 托管资源声明。"""
|
||||
resource_dir = root / capability_id.replace(".", "_")
|
||||
resource_dir.mkdir(parents=True)
|
||||
(resource_dir / "capability.toml").write_text(
|
||||
"\n".join(
|
||||
(
|
||||
"schema_version = 1",
|
||||
f'id = "{capability_id}"',
|
||||
f'kind = "{kind}"',
|
||||
f'entrypoint = "{entrypoint}"',
|
||||
"depends_on = []",
|
||||
"",
|
||||
"[metadata]",
|
||||
f'name = "{capability_id}"',
|
||||
"",
|
||||
"[activation]",
|
||||
'policy = "on_first_use"',
|
||||
"watch = []",
|
||||
"",
|
||||
)
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _runtime(root: Path) -> CapabilityRuntime:
|
||||
"""构造同时支持同步与异步资源的测试 Runtime。"""
|
||||
registry = build_managed_resource_registry((root,))
|
||||
return CapabilityRuntime(
|
||||
registry,
|
||||
adapters={
|
||||
MANAGED_RESOURCE_SYNC_KIND: SyncManagedResourceAdapter(),
|
||||
MANAGED_RESOURCE_ASYNC_KIND: AsyncManagedResourceAdapter(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_sync_managed_resource_is_single_flight(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""并发首用只能发布一个同步资源实例。"""
|
||||
module_name = "fixture_sync_managed_resource"
|
||||
module = ModuleType(module_name)
|
||||
|
||||
class SyncResource:
|
||||
"""记录同步资源的创建、启动和停止次数。"""
|
||||
|
||||
instances: list["SyncResource"] = []
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.started = 0
|
||||
self.stopped = 0
|
||||
type(self).instances.append(self)
|
||||
|
||||
def start(self) -> None:
|
||||
self.started += 1
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stopped += 1
|
||||
|
||||
module.SyncResource = SyncResource
|
||||
monkeypatch.setitem(sys.modules, module_name, module)
|
||||
_write_manifest(
|
||||
tmp_path,
|
||||
capability_id="fixture.sync",
|
||||
kind=MANAGED_RESOURCE_SYNC_KIND,
|
||||
entrypoint=f"{module_name}:SyncResource",
|
||||
)
|
||||
runtime = _runtime(tmp_path)
|
||||
configure_managed_resource_runtime(runtime)
|
||||
|
||||
barrier = threading.Barrier(8)
|
||||
|
||||
def activate() -> SyncResource:
|
||||
barrier.wait(timeout=2)
|
||||
return acquire_managed_resource("fixture.sync", reason="test")
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
resources = list(executor.map(lambda _index: activate(), range(8)))
|
||||
|
||||
assert len({id(resource) for resource in resources}) == 1
|
||||
assert len(SyncResource.instances) == 1
|
||||
assert SyncResource.instances[0].started == 1
|
||||
assert managed_resource_snapshot("fixture.sync").generation == 1
|
||||
assert [
|
||||
observation.outcome
|
||||
for observation in managed_resource_observations("fixture.sync")
|
||||
if observation.operation == "activate"
|
||||
] == ["started", "succeeded"]
|
||||
|
||||
asyncio.run(shutdown_managed_resource_runtime(reason="test_shutdown"))
|
||||
|
||||
assert SyncResource.instances[0].stopped == 1
|
||||
with pytest.raises(CapabilityRuntimeClosedError):
|
||||
acquire_managed_resource("fixture.sync", reason="after_shutdown")
|
||||
|
||||
|
||||
def test_async_managed_resource_uses_async_adapter(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""异步资源通过异步 Runtime 入口启动并关闭。"""
|
||||
module_name = "fixture_async_managed_resource"
|
||||
module = ModuleType(module_name)
|
||||
|
||||
class AsyncResource:
|
||||
"""记录异步资源生命周期调用。"""
|
||||
|
||||
instances: list["AsyncResource"] = []
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.events: list[str] = []
|
||||
type(self).instances.append(self)
|
||||
|
||||
async def start(self) -> None:
|
||||
self.events.append("start")
|
||||
|
||||
async def stop(self) -> None:
|
||||
self.events.append("stop")
|
||||
|
||||
module.AsyncResource = AsyncResource
|
||||
monkeypatch.setitem(sys.modules, module_name, module)
|
||||
_write_manifest(
|
||||
tmp_path,
|
||||
capability_id="fixture.async",
|
||||
kind=MANAGED_RESOURCE_ASYNC_KIND,
|
||||
entrypoint=f"{module_name}:AsyncResource",
|
||||
)
|
||||
runtime = _runtime(tmp_path)
|
||||
configure_managed_resource_runtime(runtime)
|
||||
|
||||
async def exercise() -> AsyncResource:
|
||||
resource = await acquire_managed_resource_async(
|
||||
"fixture.async",
|
||||
reason="test",
|
||||
)
|
||||
await shutdown_managed_resource_runtime(reason="test_shutdown")
|
||||
return resource
|
||||
|
||||
resource = asyncio.run(exercise())
|
||||
|
||||
assert resource.events == ["start", "stop"]
|
||||
assert AsyncResource.instances == [resource]
|
||||
|
||||
|
||||
def test_failed_start_is_cleaned_before_explicit_retry(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""启动失败的候选必须先清理,显式 retry 才能发布下一代资源。"""
|
||||
module_name = "fixture_retry_managed_resource"
|
||||
module = ModuleType(module_name)
|
||||
|
||||
class RetryResource:
|
||||
"""首个候选启动失败,后续候选正常启动。"""
|
||||
|
||||
instances: list["RetryResource"] = []
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.events: list[str] = []
|
||||
self.fail_start = not type(self).instances
|
||||
type(self).instances.append(self)
|
||||
|
||||
def start(self) -> None:
|
||||
self.events.append("start")
|
||||
if self.fail_start:
|
||||
raise RuntimeError("start failed")
|
||||
|
||||
def stop(self) -> None:
|
||||
self.events.append("stop")
|
||||
|
||||
module.RetryResource = RetryResource
|
||||
monkeypatch.setitem(sys.modules, module_name, module)
|
||||
_write_manifest(
|
||||
tmp_path,
|
||||
capability_id="fixture.retry",
|
||||
kind=MANAGED_RESOURCE_SYNC_KIND,
|
||||
entrypoint=f"{module_name}:RetryResource",
|
||||
)
|
||||
configure_managed_resource_runtime(_runtime(tmp_path))
|
||||
|
||||
with pytest.raises(CapabilityOperationError, match="start failed"):
|
||||
acquire_managed_resource(
|
||||
"fixture.retry",
|
||||
reason="first_use",
|
||||
retry=False,
|
||||
)
|
||||
|
||||
resource = acquire_managed_resource(
|
||||
"fixture.retry",
|
||||
reason="retry",
|
||||
retry=True,
|
||||
)
|
||||
|
||||
assert RetryResource.instances[0].events == ["start", "stop"]
|
||||
assert resource is RetryResource.instances[1]
|
||||
assert resource.events == ["start"]
|
||||
|
||||
asyncio.run(shutdown_managed_resource_runtime(reason="test_shutdown"))
|
||||
|
||||
assert resource.events == ["start", "stop"]
|
||||
|
||||
|
||||
def test_shutdown_does_not_materialize_unused_resource(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""关闭未激活 Runtime 时不得构造资源或调用 start。"""
|
||||
module_name = "fixture_unused_managed_resource"
|
||||
module = ModuleType(module_name)
|
||||
|
||||
class UnusedResource:
|
||||
"""任何实例化都表示关闭路径发生反向激活。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
raise AssertionError("unused resource must not be materialized")
|
||||
|
||||
def start(self) -> None:
|
||||
raise AssertionError("unused resource must not start")
|
||||
|
||||
def stop(self) -> None:
|
||||
raise AssertionError("unused resource must not stop")
|
||||
|
||||
module.UnusedResource = UnusedResource
|
||||
monkeypatch.setitem(sys.modules, module_name, module)
|
||||
_write_manifest(
|
||||
tmp_path,
|
||||
capability_id="fixture.unused",
|
||||
kind=MANAGED_RESOURCE_SYNC_KIND,
|
||||
entrypoint=f"{module_name}:UnusedResource",
|
||||
)
|
||||
configure_managed_resource_runtime(_runtime(tmp_path))
|
||||
|
||||
asyncio.run(shutdown_managed_resource_runtime(reason="test_shutdown"))
|
||||
|
||||
|
||||
def test_startup_initializer_discovers_manifest_without_importing_resource() -> None:
|
||||
"""启动装配只能读取声明,不得提前导入或构造虚拟显示实现。"""
|
||||
script = """
|
||||
import asyncio
|
||||
import sys
|
||||
from app.startup.managed_resources_initializer import (
|
||||
init_managed_resources,
|
||||
stop_managed_resources,
|
||||
)
|
||||
|
||||
runtime = init_managed_resources()
|
||||
assert runtime.get_running("host.display") is None
|
||||
assert "app.adapters.system.display.resource" not in sys.modules
|
||||
assert "pyvirtualdisplay" not in sys.modules
|
||||
asyncio.run(stop_managed_resources())
|
||||
assert "app.adapters.system.display.resource" not in sys.modules
|
||||
assert "pyvirtualdisplay" not in sys.modules
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_startup_shutdown_without_init_does_not_build_registry(monkeypatch) -> None:
|
||||
"""未执行启动装配时,关闭入口不得通过发现声明反向初始化 Runtime。"""
|
||||
from app.startup import managed_resources_initializer
|
||||
|
||||
build_registry = MagicMock(side_effect=AssertionError("must not discover"))
|
||||
monkeypatch.setattr(
|
||||
managed_resources_initializer,
|
||||
"_managed_resource_runtime",
|
||||
None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
managed_resources_initializer,
|
||||
"build_managed_resource_registry",
|
||||
build_registry,
|
||||
)
|
||||
|
||||
asyncio.run(managed_resources_initializer.stop_managed_resources())
|
||||
|
||||
build_registry.assert_not_called()
|
||||
@@ -1,4 +1,8 @@
|
||||
import importlib
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from app.sdk.cache import Cache, cached
|
||||
from app.sdk.config import settings
|
||||
@@ -12,6 +16,9 @@ from app.sdk.utilities import StringUtils as UtilityStringUtils
|
||||
from app.sdk.utilities import decrypt, encrypt
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parents[1]
|
||||
|
||||
|
||||
def test_sdk_exports_canonical_plugin_interfaces():
|
||||
"""SDK 应复用 canonical 对象,不复制实现或制造第二套单例。"""
|
||||
from app.domain.context import MediaInfo as CanonicalMediaInfo
|
||||
@@ -68,3 +75,58 @@ def test_legacy_common_crypto_aliases_round_trip():
|
||||
passphrase = b"0123456789abcdef"
|
||||
|
||||
assert legacy_decrypt(legacy_encrypt(message, passphrase), passphrase) == message
|
||||
|
||||
|
||||
def test_browser_sdk_import_is_provider_free():
|
||||
"""仅导入浏览器 SDK 不得加载浏览器或虚拟显示实现。"""
|
||||
script = """
|
||||
import sys
|
||||
import app.sdk.browser
|
||||
|
||||
for name in (
|
||||
"cloakbrowser",
|
||||
"pyvirtualdisplay",
|
||||
"app.adapters.network.browser",
|
||||
"app.adapters.system.display.resource",
|
||||
):
|
||||
assert name not in sys.modules, name
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_browser_sdk_delegates_sync_and_async_launch(monkeypatch):
|
||||
"""SDK 只转发浏览器参数,不复制宿主生命周期实现。"""
|
||||
from app.sdk import browser as browser_sdk
|
||||
from app.adapters.network import browser as browser_adapter
|
||||
|
||||
sync_context = object()
|
||||
async_context = object()
|
||||
sync_launch = MagicMock(return_value=sync_context)
|
||||
async_launch = AsyncMock(return_value=async_context)
|
||||
monkeypatch.setattr(browser_adapter, "launch_browser_context", sync_launch)
|
||||
monkeypatch.setattr(browser_adapter, "launch_browser_context_async", async_launch)
|
||||
|
||||
assert browser_sdk.launch_browser_context(headless=False, locale="zh-CN") is sync_context
|
||||
|
||||
async def run_async():
|
||||
return await browser_sdk.launch_browser_context_async(
|
||||
headless=True,
|
||||
timezone="Asia/Shanghai",
|
||||
)
|
||||
|
||||
import asyncio
|
||||
|
||||
assert asyncio.run(run_async()) is async_context
|
||||
sync_launch.assert_called_once_with(headless=False, locale="zh-CN")
|
||||
async_launch.assert_awaited_once_with(
|
||||
headless=True,
|
||||
timezone="Asia/Shanghai",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user