refactor(runtime): activate managed resources on demand (#6334)

This commit is contained in:
InfinityPacer
2026-08-16 16:44:45 +08:00
committed by GitHub
parent 7e851dbfa7
commit b8b59ae20a
27 changed files with 3068 additions and 116 deletions
+1 -1
View File
@@ -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 (
+63 -2
View File
@@ -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="默认不允许访问本机或私网地址"):
+1 -1
View File
@@ -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(
+99
View File
@@ -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.displaystop 只停止已配置 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"),
]
+31 -2
View File
@@ -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 淘汰任务并消费其异常"""
+339
View File
@@ -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()
+62
View File
@@ -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",
)