Files
AstrBot/tests_v4/test_bootstrap.py
T
whatevertogo 9c5ecf0a13 feat: Enhance plugin capability support and legacy compatibility
- Introduced LoadedCapability class to manage plugin capabilities.
- Updated load_plugin function to discover and load capabilities alongside handlers.
- Enhanced Peer class to handle remote provided capabilities during initialization.
- Added tests for capability registration and invocation in legacy plugins.
- Improved MessageChain and message component handling in legacy plugins.
- Added comprehensive tests for legacy plugin integration and compatibility.
- Updated protocol messages to include provided capabilities in initialization.
- Enhanced top-level module imports to include capability-related functions.
2026-03-13 06:16:57 +08:00

822 lines
27 KiB
Python

"""
Tests for runtime/bootstrap.py - Bootstrap and runtime classes.
"""
from __future__ import annotations
import asyncio
import sys
import tempfile
from io import StringIO
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import yaml
from astrbot_sdk.context import CancelToken
from astrbot_sdk.errors import AstrBotError
from astrbot_sdk.protocol.descriptors import (
CapabilityDescriptor,
CommandTrigger,
HandlerDescriptor,
)
from astrbot_sdk.protocol.messages import (
InitializeMessage,
InitializeOutput,
InvokeMessage,
PeerInfo,
)
from astrbot_sdk.runtime.bootstrap import (
PluginWorkerRuntime,
SupervisorRuntime,
WorkerSession,
_install_signal_handlers,
_prepare_stdio_transport,
_wait_for_shutdown,
)
from astrbot_sdk.runtime.capability_router import CapabilityRouter
from astrbot_sdk.runtime.loader import PluginSpec
from astrbot_sdk.runtime.peer import Peer
from tests_v4.helpers import FakeEnvManager, MemoryTransport, make_transport_pair
async def start_test_core_peer(transport: MemoryTransport) -> Peer:
"""Provide an initialize responder so transport-pair startup tests do not deadlock."""
core = Peer(
transport=transport,
peer_info=PeerInfo(name="test-core", role="core", version="v4"),
)
core.set_initialize_handler(
lambda _message: asyncio.sleep(
0,
result=InitializeOutput(
peer=PeerInfo(name="test-core", role="core", version="v4"),
capabilities=[],
metadata={},
),
)
)
await core.start()
return core
class TestInstallSignalHandlers:
"""Tests for _install_signal_handlers function."""
@pytest.mark.asyncio
async def test_installs_handlers(self):
"""_install_signal_handlers should install signal handlers."""
stop_event = asyncio.Event()
_install_signal_handlers(stop_event)
# Just verify it doesn't raise on platforms that support it
@pytest.mark.asyncio
async def test_handles_not_implemented(self):
"""_install_signal_handlers should handle NotImplementedError."""
stop_event = asyncio.Event()
with patch.object(
asyncio.get_running_loop(),
"add_signal_handler",
side_effect=NotImplementedError,
):
_install_signal_handlers(stop_event)
class TestPrepareStdioTransport:
"""Tests for _prepare_stdio_transport function."""
def test_with_both_streams(self):
"""_prepare_stdio_transport should use provided streams."""
stdin = StringIO()
stdout = StringIO()
in_stream, out_stream, original = _prepare_stdio_transport(stdin, stdout)
assert in_stream is stdin
assert out_stream is stdout
assert original is None
def test_without_streams(self):
"""_prepare_stdio_transport should use sys.stdin/stdout."""
# 保存原始值
original_stdin = sys.stdin
original_stdout = sys.stdout
try:
in_stream, out_stream, original = _prepare_stdio_transport(None, None)
# in_stream 应该是原始的 sys.stdin
assert in_stream is original_stdin
# out_stream 应该是原始的 sys.stdout(在修改前)
assert out_stream is original_stdout
# original 也应该是原始的 sys.stdout
assert original is original_stdout
# 函数会修改 sys.stdout 为 sys.stderr
assert sys.stdout is sys.stderr
finally:
# 恢复
sys.stdout = original_stdout
def test_redirects_stdout(self):
"""_prepare_stdio_transport should redirect sys.stdout to stderr."""
original_stdout = sys.stdout
_prepare_stdio_transport(None, None)
assert sys.stdout is sys.stderr
# Restore
sys.stdout = original_stdout
class TestWaitForShutdown:
"""Tests for _wait_for_shutdown function."""
@pytest.mark.asyncio
async def test_waits_for_stop_event(self):
"""_wait_for_shutdown should wait for stop_event."""
peer = MagicMock()
# wait_closed 应该返回一个永不完成的协程
async def never_complete():
await asyncio.sleep(3600)
peer.wait_closed = MagicMock(return_value=never_complete())
stop_event = asyncio.Event()
async def set_event():
await asyncio.sleep(0.05)
stop_event.set()
asyncio.create_task(set_event())
await _wait_for_shutdown(peer, stop_event)
assert stop_event.is_set()
@pytest.mark.asyncio
async def test_waits_for_peer_closed(self):
"""_wait_for_shutdown should wait for peer.wait_closed()."""
peer = MagicMock()
# wait_closed 应该返回一个会完成的协程
async def complete_soon():
await asyncio.sleep(0.05)
peer.wait_closed = MagicMock(return_value=complete_soon())
stop_event = asyncio.Event()
await _wait_for_shutdown(peer, stop_event)
peer.wait_closed.assert_called_once()
class TestWorkerSessionInit:
"""Tests for WorkerSession initialization."""
def test_init(self):
"""WorkerSession should store all parameters."""
plugin = PluginSpec(
name="test",
plugin_dir=Path("/tmp"),
manifest_path=Path("/tmp/plugin.yaml"),
requirements_path=Path("/tmp/requirements.txt"),
python_version="3.12",
manifest_data={},
)
router = CapabilityRouter()
env_manager = FakeEnvManager()
session = WorkerSession(
plugin=plugin,
repo_root=Path("/repo"),
env_manager=env_manager,
capability_router=router,
)
assert session.plugin == plugin
assert session.capability_router == router
assert session.peer is None
assert session.handlers == []
class TestWorkerSessionMethods:
"""Tests for WorkerSession methods."""
@pytest.mark.asyncio
async def test_invoke_handler_without_peer_raises(self):
"""invoke_handler should raise if peer is None."""
plugin = PluginSpec(
name="test",
plugin_dir=Path("/tmp"),
manifest_path=Path("/tmp/plugin.yaml"),
requirements_path=Path("/tmp/requirements.txt"),
python_version="3.12",
manifest_data={},
)
session = WorkerSession(
plugin=plugin,
repo_root=Path("/tmp"),
env_manager=FakeEnvManager(),
capability_router=CapabilityRouter(),
)
with pytest.raises(RuntimeError, match="not running"):
await session.invoke_handler("handler.id", {}, request_id="req-1")
@pytest.mark.asyncio
async def test_cancel_without_peer_does_nothing(self):
"""cancel should do nothing if peer is None."""
plugin = PluginSpec(
name="test",
plugin_dir=Path("/tmp"),
manifest_path=Path("/tmp/plugin.yaml"),
requirements_path=Path("/tmp/requirements.txt"),
python_version="3.12",
manifest_data={},
)
session = WorkerSession(
plugin=plugin,
repo_root=Path("/tmp"),
env_manager=FakeEnvManager(),
capability_router=CapabilityRouter(),
)
# Should not raise
await session.cancel("req-1")
@pytest.mark.asyncio
async def test_handle_initialize(self):
"""_handle_initialize should return InitializeOutput."""
plugin = PluginSpec(
name="test_plugin",
plugin_dir=Path("/tmp"),
manifest_path=Path("/tmp/plugin.yaml"),
requirements_path=Path("/tmp/requirements.txt"),
python_version="3.12",
manifest_data={},
)
router = CapabilityRouter()
session = WorkerSession(
plugin=plugin,
repo_root=Path("/tmp"),
env_manager=FakeEnvManager(),
capability_router=router,
)
message = InitializeMessage(
id="init-1",
protocol_version="1.0",
peer=PeerInfo(name="test", role="plugin"),
)
output = await session._handle_initialize(message)
assert output.peer.name == "astrbot-supervisor"
assert output.peer.role == "core"
assert len(output.capabilities) > 0
@pytest.mark.asyncio
async def test_handle_capability_invoke(self):
"""_handle_capability_invoke should route to capability_router."""
plugin = PluginSpec(
name="test",
plugin_dir=Path("/tmp"),
manifest_path=Path("/tmp/plugin.yaml"),
requirements_path=Path("/tmp/requirements.txt"),
python_version="3.12",
manifest_data={},
)
router = CapabilityRouter()
session = WorkerSession(
plugin=plugin,
repo_root=Path("/tmp"),
env_manager=FakeEnvManager(),
capability_router=router,
)
message = InvokeMessage(
id="invoke-1",
capability="llm.chat",
input={"prompt": "hello"},
)
token = CancelToken()
result = await session._handle_capability_invoke(message, token)
assert result["text"] == "Echo: hello"
@pytest.mark.asyncio
async def test_register_plugin_capability_routes_through_worker_session(self):
"""SupervisorRuntime should expose plugin-provided capabilities via router."""
transport = MemoryTransport()
with tempfile.TemporaryDirectory() as temp_dir:
runtime = SupervisorRuntime(
transport=transport,
plugins_dir=Path(temp_dir),
)
session = MagicMock()
session.invoke_capability = AsyncMock(return_value={"echo": "hi"})
descriptor = CapabilityDescriptor(
name="demo.echo",
description="Echo text",
input_schema={
"type": "object",
"properties": {"text": {"type": "string"}},
},
output_schema={
"type": "object",
"properties": {"echo": {"type": "string"}},
},
)
runtime._register_plugin_capability(descriptor, session, "demo_plugin")
result = await runtime.capability_router.execute(
"demo.echo",
{"text": "hi"},
stream=False,
cancel_token=CancelToken(),
request_id="req-1",
)
assert result == {"echo": "hi"}
session.invoke_capability.assert_awaited_once_with(
"demo.echo",
{"text": "hi"},
request_id="req-1",
)
class TestSupervisorRuntimeInit:
"""Tests for SupervisorRuntime initialization."""
def test_init(self):
"""SupervisorRuntime should initialize correctly."""
transport = MemoryTransport()
with tempfile.TemporaryDirectory() as temp_dir:
runtime = SupervisorRuntime(
transport=transport,
plugins_dir=Path(temp_dir),
)
assert runtime.transport is transport
assert runtime.worker_sessions == {}
assert runtime.handler_to_worker == {}
assert runtime.capability_to_worker == {}
assert runtime.active_requests == {}
assert runtime.loaded_plugins == []
assert isinstance(runtime.capability_router, CapabilityRouter)
def test_registers_internal_capabilities(self):
"""SupervisorRuntime should register internal capabilities."""
transport = MemoryTransport()
with tempfile.TemporaryDirectory() as temp_dir:
runtime = SupervisorRuntime(
transport=transport,
plugins_dir=Path(temp_dir),
)
# handler.invoke should be registered (but not exposed)
assert "handler.invoke" in runtime.capability_router._registrations
# Should not be in descriptors (exposed=False)
names = [d.name for d in runtime.capability_router.descriptors()]
assert "handler.invoke" not in names
class TestSupervisorRuntimeMethods:
"""Tests for SupervisorRuntime methods."""
@pytest.mark.asyncio
async def test_start_with_empty_plugins_dir(self):
"""SupervisorRuntime.start should work with empty plugins dir."""
left, right = make_transport_pair()
core = await start_test_core_peer(left)
with tempfile.TemporaryDirectory() as temp_dir:
runtime = SupervisorRuntime(
transport=right,
plugins_dir=Path(temp_dir),
env_manager=FakeEnvManager(),
)
try:
await runtime.start()
await core.wait_until_remote_initialized()
assert runtime.loaded_plugins == []
assert runtime.skipped_plugins == {}
finally:
await runtime.stop()
await core.stop()
@pytest.mark.asyncio
async def test_route_handler_invoke_missing_handler(self):
"""_route_handler_invoke should raise for missing handler."""
transport = MemoryTransport()
with tempfile.TemporaryDirectory() as temp_dir:
runtime = SupervisorRuntime(
transport=transport,
plugins_dir=Path(temp_dir),
)
with pytest.raises(AstrBotError, match="handler not found"):
await runtime._route_handler_invoke(
"req-1",
{"handler_id": "missing.handler", "event": {}},
CancelToken(),
)
@pytest.mark.asyncio
async def test_handle_worker_closed_removes_session(self):
"""_handle_worker_closed should remove session and handlers."""
left, right = make_transport_pair()
with tempfile.TemporaryDirectory() as temp_dir:
runtime = SupervisorRuntime(
transport=right,
plugins_dir=Path(temp_dir),
env_manager=FakeEnvManager(),
)
# Add fake session
mock_session = MagicMock()
mock_session.handlers = [
HandlerDescriptor(
id="test.handler", trigger=CommandTrigger(command="test")
)
]
runtime.worker_sessions["test_plugin"] = mock_session
runtime.handler_to_worker["test.handler"] = mock_session
runtime._handler_sources["test.handler"] = "test_plugin"
runtime.loaded_plugins.append("test_plugin")
runtime._handle_worker_closed("test_plugin")
assert "test_plugin" not in runtime.worker_sessions
assert "test.handler" not in runtime.handler_to_worker
assert "test.handler" not in runtime._handler_sources
assert "test_plugin" not in runtime.loaded_plugins
@pytest.mark.asyncio
async def test_handle_worker_closed_removes_active_requests(self):
"""_handle_worker_closed should drop in-flight requests owned by the worker."""
transport = MemoryTransport()
with tempfile.TemporaryDirectory() as temp_dir:
runtime = SupervisorRuntime(
transport=transport,
plugins_dir=Path(temp_dir),
)
mock_session = MagicMock()
mock_session.handlers = [
HandlerDescriptor(
id="test.handler", trigger=CommandTrigger(command="test")
)
]
runtime.worker_sessions["test_plugin"] = mock_session
runtime.handler_to_worker["test.handler"] = mock_session
runtime._handler_sources["test.handler"] = "test_plugin"
runtime.loaded_plugins.append("test_plugin")
runtime.active_requests["req-1"] = mock_session
runtime._handle_worker_closed("test_plugin")
assert "req-1" not in runtime.active_requests
@pytest.mark.asyncio
async def test_handle_worker_closed_unknown_plugin(self):
"""_handle_worker_closed should handle unknown plugin."""
transport = MemoryTransport()
with tempfile.TemporaryDirectory() as temp_dir:
runtime = SupervisorRuntime(
transport=transport,
plugins_dir=Path(temp_dir),
)
# Should not raise for unknown plugin
runtime._handle_worker_closed("unknown_plugin")
class TestPluginWorkerRuntimeInit:
"""Tests for PluginWorkerRuntime initialization."""
def test_init_with_valid_plugin(self):
"""PluginWorkerRuntime should initialize with valid plugin."""
with tempfile.TemporaryDirectory() as temp_dir:
plugin_dir = Path(temp_dir)
manifest_path = plugin_dir / "plugin.yaml"
requirements_path = plugin_dir / "requirements.txt"
manifest_path.write_text(
yaml.dump(
{
"name": "test_plugin",
"runtime": {"python": "3.12"},
"components": [],
}
),
encoding="utf-8",
)
requirements_path.write_text("", encoding="utf-8")
transport = MemoryTransport()
# This should work if the plugin is valid
runtime = PluginWorkerRuntime(plugin_dir=plugin_dir, transport=transport)
assert runtime.plugin.name == "test_plugin"
assert runtime.peer is not None
assert runtime.dispatcher is not None
class TestPluginWorkerRuntimeMethods:
"""Tests for PluginWorkerRuntime methods."""
@pytest.mark.asyncio
async def test_handle_invoke_wrong_capability(self):
"""_handle_invoke should raise for wrong capability."""
with tempfile.TemporaryDirectory() as temp_dir:
plugin_dir = Path(temp_dir)
manifest_path = plugin_dir / "plugin.yaml"
requirements_path = plugin_dir / "requirements.txt"
manifest_path.write_text(
yaml.dump(
{
"name": "test_plugin",
"runtime": {"python": "3.12"},
"components": [],
}
),
encoding="utf-8",
)
requirements_path.write_text("", encoding="utf-8")
transport = MemoryTransport()
runtime = PluginWorkerRuntime(plugin_dir=plugin_dir, transport=transport)
message = InvokeMessage(
id="invoke-1",
capability="wrong.capability",
input={},
)
token = CancelToken()
with pytest.raises(AstrBotError, match="未找到能力"):
await runtime._handle_invoke(message, token)
@pytest.mark.asyncio
async def test_handle_invoke_plugin_capability(self):
"""_handle_invoke should dispatch plugin-provided capabilities."""
with tempfile.TemporaryDirectory() as temp_dir:
plugin_dir = Path(temp_dir)
manifest_path = plugin_dir / "plugin.yaml"
requirements_path = plugin_dir / "requirements.txt"
module_dir = plugin_dir / "demo_plugin"
module_dir.mkdir()
(module_dir / "__init__.py").write_text("", encoding="utf-8")
(module_dir / "component.py").write_text(
"""
from astrbot_sdk import Star, provide_capability
class DemoComponent(Star):
@provide_capability(
"demo.echo",
description="Echo text",
input_schema={"type": "object", "properties": {"text": {"type": "string"}}},
output_schema={"type": "object", "properties": {"echo": {"type": "string"}}},
)
async def echo(self, payload):
return {"echo": payload["text"]}
""".strip(),
encoding="utf-8",
)
manifest_path.write_text(
yaml.dump(
{
"name": "test_plugin",
"runtime": {"python": "3.12"},
"components": [
{"class": "demo_plugin.component:DemoComponent"}
],
}
),
encoding="utf-8",
)
requirements_path.write_text("", encoding="utf-8")
if str(plugin_dir) not in sys.path:
sys.path.insert(0, str(plugin_dir))
try:
transport = MemoryTransport()
runtime = PluginWorkerRuntime(
plugin_dir=plugin_dir,
transport=transport,
)
message = InvokeMessage(
id="invoke-cap",
capability="demo.echo",
input={"text": "hello"},
)
result = await runtime._handle_invoke(message, CancelToken())
assert result == {"echo": "hello"}
finally:
if str(plugin_dir) in sys.path:
sys.path.remove(str(plugin_dir))
@pytest.mark.asyncio
async def test_run_lifecycle_sync_hook(self):
"""_run_lifecycle should call sync hooks."""
with tempfile.TemporaryDirectory() as temp_dir:
plugin_dir = Path(temp_dir)
manifest_path = plugin_dir / "plugin.yaml"
requirements_path = plugin_dir / "requirements.txt"
manifest_path.write_text(
yaml.dump(
{
"name": "test_plugin",
"runtime": {"python": "3.12"},
"components": [],
}
),
encoding="utf-8",
)
requirements_path.write_text("", encoding="utf-8")
transport = MemoryTransport()
runtime = PluginWorkerRuntime(plugin_dir=plugin_dir, transport=transport)
# Add mock instance with sync hook
called = []
class MockInstance:
def on_start(self, ctx):
called.append("on_start")
runtime.loaded_plugin.instances.append(MockInstance())
await runtime._run_lifecycle("on_start")
assert "on_start" in called
@pytest.mark.asyncio
async def test_run_lifecycle_async_hook(self):
"""_run_lifecycle should call async hooks."""
with tempfile.TemporaryDirectory() as temp_dir:
plugin_dir = Path(temp_dir)
manifest_path = plugin_dir / "plugin.yaml"
requirements_path = plugin_dir / "requirements.txt"
manifest_path.write_text(
yaml.dump(
{
"name": "test_plugin",
"runtime": {"python": "3.12"},
"components": [],
}
),
encoding="utf-8",
)
requirements_path.write_text("", encoding="utf-8")
transport = MemoryTransport()
runtime = PluginWorkerRuntime(plugin_dir=plugin_dir, transport=transport)
# Add mock instance with async hook
called = []
class MockInstance:
async def on_stop(self, ctx):
called.append("on_stop")
runtime.loaded_plugin.instances.append(MockInstance())
await runtime._run_lifecycle("on_stop")
assert "on_stop" in called
@pytest.mark.asyncio
async def test_run_lifecycle_missing_method(self):
"""_run_lifecycle should skip missing methods."""
with tempfile.TemporaryDirectory() as temp_dir:
plugin_dir = Path(temp_dir)
manifest_path = plugin_dir / "plugin.yaml"
requirements_path = plugin_dir / "requirements.txt"
manifest_path.write_text(
yaml.dump(
{
"name": "test_plugin",
"runtime": {"python": "3.12"},
"components": [],
}
),
encoding="utf-8",
)
requirements_path.write_text("", encoding="utf-8")
transport = MemoryTransport()
runtime = PluginWorkerRuntime(plugin_dir=plugin_dir, transport=transport)
# Add mock instance without the method
class MockInstance:
pass
runtime.loaded_plugin.instances.append(MockInstance())
# Should not raise
await runtime._run_lifecycle("on_start")
class TestIntegrationWithTransportPair:
"""Integration tests using transport pairs."""
@pytest.mark.asyncio
async def test_supervisor_responds_to_initialize(self):
"""SupervisorRuntime should respond to initialize messages."""
left, right = make_transport_pair()
core = await start_test_core_peer(left)
with tempfile.TemporaryDirectory() as temp_dir:
runtime = SupervisorRuntime(
transport=right,
plugins_dir=Path(temp_dir),
env_manager=FakeEnvManager(),
)
try:
await runtime.start()
await core.wait_until_remote_initialized()
assert core.remote_peer is not None
assert core.remote_peer.name == "astrbot-supervisor"
assert core.remote_metadata["plugins"] == []
assert core.remote_metadata["skipped_plugins"] == {}
finally:
await runtime.stop()
await core.stop()
@pytest.mark.asyncio
async def test_worker_session_lifecycle(self):
"""WorkerSession should start and stop cleanly."""
with tempfile.TemporaryDirectory() as temp_dir:
# Create a minimal plugin
plugin_dir = Path(temp_dir) / "plugins" / "test_plugin"
plugin_dir.mkdir(parents=True)
manifest_path = plugin_dir / "plugin.yaml"
requirements_path = plugin_dir / "requirements.txt"
manifest_path.write_text(
yaml.dump(
{
"name": "test_plugin",
"runtime": {
"python": f"{sys.version_info.major}.{sys.version_info.minor}"
},
"components": [],
}
),
encoding="utf-8",
)
requirements_path.write_text("", encoding="utf-8")
plugin = PluginSpec(
name="test_plugin",
plugin_dir=plugin_dir,
manifest_path=manifest_path,
requirements_path=requirements_path,
python_version=f"{sys.version_info.major}.{sys.version_info.minor}",
manifest_data={"name": "test_plugin"},
)
left, right = make_transport_pair()
session = WorkerSession(
plugin=plugin,
repo_root=Path(temp_dir),
env_manager=FakeEnvManager(),
capability_router=CapabilityRouter(),
)
# Note: Full start would require subprocess, skip for unit test
# Just verify the session can be created and stopped
await session.stop()