mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
remove deprecated API modules and example scripts; update core modules (agents, providers, tools, platform adapters, utils), dashboard package and components, and unit tests.
1996 lines
73 KiB
Python
1996 lines
73 KiB
Python
"""Tests for AstrBotCoreLifecycle."""
|
|
|
|
import asyncio
|
|
import os
|
|
from contextlib import ExitStack
|
|
from types import SimpleNamespace
|
|
from typing import Any, cast
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle, LifecycleState
|
|
from astrbot.core.log import LogBroker
|
|
from astrbot.core.provider.manager import ProviderManager
|
|
from astrbot.core.star.context import Context
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_log_broker():
|
|
"""Create a mock log broker."""
|
|
log_broker = MagicMock(spec=LogBroker)
|
|
return log_broker
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_db():
|
|
"""Create a mock database."""
|
|
db = MagicMock()
|
|
db.initialize = AsyncMock()
|
|
return db
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_astrbot_config():
|
|
"""Create a mock AstrBot config."""
|
|
config = MagicMock()
|
|
config.get = MagicMock(return_value="")
|
|
config.__getitem__ = MagicMock(return_value={})
|
|
config.copy = MagicMock(return_value={})
|
|
return config
|
|
|
|
|
|
def build_initialize_test_mocks():
|
|
"""Create common mocks for lifecycle initialization tests."""
|
|
mocks = {}
|
|
|
|
mocks["html_renderer"] = MagicMock()
|
|
mocks["html_renderer"].initialize = AsyncMock()
|
|
|
|
mocks["umop_config_router"] = MagicMock()
|
|
mocks["umop_config_router"].initialize = AsyncMock()
|
|
|
|
mocks["astrbot_config_mgr"] = MagicMock()
|
|
mocks["astrbot_config_mgr"].default_conf = {}
|
|
mocks["astrbot_config_mgr"].confs = {}
|
|
|
|
mocks["persona_mgr"] = MagicMock()
|
|
mocks["persona_mgr"].initialize = AsyncMock()
|
|
|
|
mocks["provider_manager"] = MagicMock()
|
|
mocks["provider_manager"].initialize = AsyncMock()
|
|
|
|
mocks["platform_manager"] = MagicMock()
|
|
mocks["platform_manager"].initialize = AsyncMock()
|
|
|
|
mocks["conversation_manager"] = MagicMock()
|
|
mocks["platform_message_history_manager"] = MagicMock()
|
|
|
|
mocks["kb_manager"] = MagicMock()
|
|
mocks["kb_manager"].initialize = AsyncMock()
|
|
|
|
mocks["cron_manager"] = MagicMock()
|
|
|
|
mocks["star_context"] = MagicMock()
|
|
mocks["star_context"].registered_web_apis = []
|
|
mocks["star_context"]._register_tasks = []
|
|
|
|
def reset_runtime_registrations() -> None:
|
|
mocks["star_context"].registered_web_apis.clear()
|
|
mocks["star_context"]._register_tasks.clear()
|
|
|
|
mocks["star_context"].reset_runtime_registrations = MagicMock(
|
|
side_effect=reset_runtime_registrations
|
|
)
|
|
|
|
mocks["plugin_manager"] = MagicMock()
|
|
mocks["plugin_manager"].reload = AsyncMock()
|
|
|
|
mocks["astrbot_updator"] = MagicMock()
|
|
mocks["astrbot_updator_cls"] = MagicMock(return_value=mocks["astrbot_updator"])
|
|
|
|
mocks["event_bus"] = MagicMock()
|
|
mocks["event_bus_cls"] = MagicMock(return_value=mocks["event_bus"])
|
|
|
|
mocks["migra"] = AsyncMock()
|
|
|
|
async def update_llm_metadata():
|
|
return None
|
|
|
|
mocks["update_llm_metadata"] = update_llm_metadata
|
|
|
|
def discard_task(coro, *args, **kwargs):
|
|
del args, kwargs
|
|
coro.close()
|
|
return MagicMock()
|
|
|
|
mocks["create_task"] = MagicMock(side_effect=discard_task)
|
|
mocks["init_subagent_orchestrator"] = AsyncMock()
|
|
|
|
return mocks
|
|
|
|
|
|
def patch_initialize_test_mocks(mock_astrbot_config, mocks):
|
|
"""Patch shared lifecycle initialization dependencies."""
|
|
stack = ExitStack()
|
|
stack.enter_context(
|
|
patch("astrbot.core.core_lifecycle.astrbot_config", mock_astrbot_config)
|
|
)
|
|
stack.enter_context(
|
|
patch("astrbot.core.core_lifecycle.html_renderer", mocks["html_renderer"])
|
|
)
|
|
stack.enter_context(
|
|
patch(
|
|
"astrbot.core.core_lifecycle.UmopConfigRouter",
|
|
return_value=mocks["umop_config_router"],
|
|
)
|
|
)
|
|
stack.enter_context(
|
|
patch(
|
|
"astrbot.core.core_lifecycle.AstrBotConfigManager",
|
|
return_value=mocks["astrbot_config_mgr"],
|
|
)
|
|
)
|
|
stack.enter_context(
|
|
patch(
|
|
"astrbot.core.core_lifecycle.PersonaManager",
|
|
return_value=mocks["persona_mgr"],
|
|
)
|
|
)
|
|
stack.enter_context(
|
|
patch(
|
|
"astrbot.core.core_lifecycle.ProviderManager",
|
|
return_value=mocks["provider_manager"],
|
|
)
|
|
)
|
|
stack.enter_context(
|
|
patch(
|
|
"astrbot.core.core_lifecycle.PlatformManager",
|
|
return_value=mocks["platform_manager"],
|
|
)
|
|
)
|
|
stack.enter_context(
|
|
patch(
|
|
"astrbot.core.core_lifecycle.ConversationManager",
|
|
return_value=mocks["conversation_manager"],
|
|
)
|
|
)
|
|
stack.enter_context(
|
|
patch(
|
|
"astrbot.core.core_lifecycle.PlatformMessageHistoryManager",
|
|
return_value=mocks["platform_message_history_manager"],
|
|
)
|
|
)
|
|
stack.enter_context(
|
|
patch(
|
|
"astrbot.core.core_lifecycle.KnowledgeBaseManager",
|
|
return_value=mocks["kb_manager"],
|
|
)
|
|
)
|
|
stack.enter_context(
|
|
patch(
|
|
"astrbot.core.core_lifecycle.CronJobManager",
|
|
return_value=mocks["cron_manager"],
|
|
)
|
|
)
|
|
stack.enter_context(
|
|
patch("astrbot.core.core_lifecycle.Context", return_value=mocks["star_context"])
|
|
)
|
|
stack.enter_context(
|
|
patch(
|
|
"astrbot.core.core_lifecycle.PluginManager",
|
|
return_value=mocks["plugin_manager"],
|
|
)
|
|
)
|
|
stack.enter_context(
|
|
patch(
|
|
"astrbot.core.core_lifecycle.AstrBotUpdator",
|
|
mocks["astrbot_updator_cls"],
|
|
)
|
|
)
|
|
stack.enter_context(
|
|
patch("astrbot.core.core_lifecycle.EventBus", mocks["event_bus_cls"])
|
|
)
|
|
stack.enter_context(patch("astrbot.core.core_lifecycle.migra", mocks["migra"]))
|
|
stack.enter_context(
|
|
patch(
|
|
"astrbot.core.core_lifecycle.update_llm_metadata",
|
|
mocks["update_llm_metadata"],
|
|
)
|
|
)
|
|
stack.enter_context(
|
|
patch("astrbot.core.core_lifecycle.asyncio.create_task", mocks["create_task"])
|
|
)
|
|
stack.enter_context(
|
|
patch.object(
|
|
AstrBotCoreLifecycle,
|
|
"_init_or_reload_subagent_orchestrator",
|
|
mocks["init_subagent_orchestrator"],
|
|
)
|
|
)
|
|
return stack
|
|
|
|
|
|
async def build_inflight_runtime_bootstrap_lifecycle(
|
|
mock_log_broker, mock_db, mock_astrbot_config
|
|
):
|
|
"""Create a lifecycle with an in-flight runtime bootstrap task."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
mocks = build_initialize_test_mocks()
|
|
blocker = asyncio.Event()
|
|
|
|
async def blocking_reload():
|
|
await blocker.wait()
|
|
|
|
mocks["plugin_manager"].reload.side_effect = blocking_reload
|
|
|
|
lifecycle.temp_dir_cleaner = None
|
|
lifecycle.cron_manager = None
|
|
assert lifecycle.plugin_manager is not None
|
|
assert lifecycle.provider_manager is not None
|
|
assert lifecycle.platform_manager is not None
|
|
assert lifecycle.kb_manager is not None
|
|
lifecycle.plugin_manager.context = MagicMock()
|
|
lifecycle.plugin_manager.context.get_all_stars = MagicMock(return_value=[])
|
|
|
|
|
|
await asyncio.sleep(0)
|
|
|
|
return lifecycle
|
|
|
|
|
|
class DummyAwaitable:
|
|
def __init__(self, name: str) -> None:
|
|
self.name = name
|
|
|
|
def __await__(self):
|
|
if False:
|
|
yield None
|
|
return None
|
|
|
|
|
|
def build_provider_manager_for_tests() -> ProviderManager:
|
|
acm = MagicMock()
|
|
acm.confs = {
|
|
"default": {
|
|
"provider": [],
|
|
"provider_settings": {},
|
|
"provider_stt_settings": {},
|
|
"provider_tts_settings": {},
|
|
"provider_sources": [],
|
|
}
|
|
}
|
|
persona_mgr = MagicMock()
|
|
persona_mgr.default_persona = "default"
|
|
return ProviderManager(acm, MagicMock(), persona_mgr)
|
|
|
|
|
|
def build_context_for_tests() -> Context:
|
|
return Context(
|
|
asyncio.Queue(),
|
|
MagicMock(),
|
|
MagicMock(),
|
|
MagicMock(),
|
|
MagicMock(platform_insts=[]),
|
|
MagicMock(),
|
|
MagicMock(),
|
|
MagicMock(),
|
|
MagicMock(),
|
|
MagicMock(),
|
|
MagicMock(),
|
|
MagicMock(),
|
|
)
|
|
|
|
|
|
def mark_runtime_failed(
|
|
lifecycle: AstrBotCoreLifecycle,
|
|
error: BaseException,
|
|
) -> None:
|
|
failed_state = getattr(LifecycleState, "RUNTIME_FAILED", None)
|
|
if failed_state is not None:
|
|
lifecycle._set_lifecycle_state(failed_state)
|
|
else:
|
|
lifecycle._set_lifecycle_state(LifecycleState.CORE_READY)
|
|
cast(Any, lifecycle).lifecycle_state = SimpleNamespace(value="runtime_failed")
|
|
cast(Any, lifecycle).runtime_failed = True
|
|
lifecycle.runtime_ready_event.clear()
|
|
lifecycle.runtime_failed_event.set()
|
|
lifecycle.runtime_bootstrap_error = error
|
|
|
|
|
|
class TestAstrBotCoreLifecycleInit:
|
|
"""Tests for AstrBotCoreLifecycle initialization."""
|
|
|
|
def test_init(self, mock_log_broker, mock_db):
|
|
"""Test AstrBotCoreLifecycle initialization."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
|
|
assert lifecycle.log_broker == mock_log_broker
|
|
assert lifecycle.db == mock_db
|
|
assert lifecycle.lifecycle_state == LifecycleState.CREATED
|
|
assert lifecycle.subagent_orchestrator is None
|
|
assert lifecycle.cron_manager is None
|
|
assert lifecycle.temp_dir_cleaner is None
|
|
|
|
def test_init_with_proxy(
|
|
self,
|
|
mock_log_broker,
|
|
mock_db,
|
|
mock_astrbot_config,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
"""Test initialization with proxy settings."""
|
|
mock_astrbot_config.get = MagicMock(
|
|
side_effect=lambda key, default="": {
|
|
"http_proxy": "http://proxy.example.com:8080",
|
|
"no_proxy": ["localhost", "127.0.0.1"],
|
|
}.get(key, default)
|
|
)
|
|
monkeypatch.delenv("http_proxy", raising=False)
|
|
monkeypatch.delenv("https_proxy", raising=False)
|
|
monkeypatch.delenv("no_proxy", raising=False)
|
|
|
|
with patch("astrbot.core.core_lifecycle.astrbot_config", mock_astrbot_config):
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
|
|
assert lifecycle.log_broker == mock_log_broker
|
|
assert lifecycle.db == mock_db
|
|
# Verify proxy environment variables are set
|
|
assert os.environ.get("http_proxy") == "http://proxy.example.com:8080"
|
|
assert os.environ.get("https_proxy") == "http://proxy.example.com:8080"
|
|
assert "localhost" in os.environ.get("no_proxy", "")
|
|
assert "127.0.0.1" in os.environ.get("no_proxy", "")
|
|
|
|
def test_init_clears_proxy(
|
|
self,
|
|
mock_log_broker,
|
|
mock_db,
|
|
mock_astrbot_config,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
"""Test initialization clears proxy settings when configured."""
|
|
mock_astrbot_config.get = MagicMock(return_value="")
|
|
# Set proxy in environment to test clearing
|
|
monkeypatch.setenv("http_proxy", "http://old-proxy:8080")
|
|
monkeypatch.setenv("https_proxy", "http://old-proxy:8080")
|
|
|
|
with patch("astrbot.core.core_lifecycle.astrbot_config", mock_astrbot_config):
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
|
|
assert lifecycle.log_broker == mock_log_broker
|
|
# Verify proxy environment variables are cleared
|
|
assert "http_proxy" not in os.environ
|
|
assert "https_proxy" not in os.environ
|
|
|
|
def test_set_lifecycle_state_updates_events_and_derived_flags(
|
|
self,
|
|
mock_log_broker,
|
|
mock_db,
|
|
):
|
|
"""Test lifecycle state drives events and compatibility properties."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
|
|
|
|
class TestProviderManagerCleanup:
|
|
"""Tests for ProviderManager cleanup safety."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_terminate_clears_loaded_instances_and_registrations(self):
|
|
"""Test terminate removes stale loaded providers so retry starts cleanly."""
|
|
provider_manager = build_provider_manager_for_tests()
|
|
|
|
chat_provider = MagicMock()
|
|
chat_provider.terminate = AsyncMock()
|
|
stt_provider = MagicMock()
|
|
stt_provider.terminate = AsyncMock()
|
|
tts_provider = MagicMock()
|
|
tts_provider.terminate = AsyncMock()
|
|
embedding_provider = MagicMock()
|
|
embedding_provider.terminate = AsyncMock()
|
|
rerank_provider = MagicMock()
|
|
rerank_provider.terminate = AsyncMock()
|
|
|
|
provider_manager.provider_insts = [chat_provider]
|
|
provider_manager.stt_provider_insts = [stt_provider]
|
|
provider_manager.tts_provider_insts = [tts_provider]
|
|
provider_manager.embedding_provider_insts = [embedding_provider]
|
|
provider_manager.rerank_provider_insts = [rerank_provider]
|
|
provider_manager.inst_map = {
|
|
"chat": chat_provider,
|
|
"stt": stt_provider,
|
|
"tts": tts_provider,
|
|
"embedding": embedding_provider,
|
|
"rerank": rerank_provider,
|
|
}
|
|
provider_manager.curr_provider_inst = chat_provider
|
|
provider_manager.curr_stt_provider_inst = stt_provider
|
|
provider_manager.curr_tts_provider_inst = tts_provider
|
|
|
|
await provider_manager.terminate()
|
|
|
|
chat_provider.terminate.assert_awaited_once()
|
|
stt_provider.terminate.assert_awaited_once()
|
|
tts_provider.terminate.assert_awaited_once()
|
|
embedding_provider.terminate.assert_awaited_once()
|
|
rerank_provider.terminate.assert_awaited_once()
|
|
|
|
assert provider_manager.provider_insts == []
|
|
assert provider_manager.stt_provider_insts == []
|
|
assert provider_manager.tts_provider_insts == []
|
|
assert provider_manager.embedding_provider_insts == []
|
|
assert provider_manager.rerank_provider_insts == []
|
|
assert provider_manager.inst_map == {}
|
|
assert provider_manager.curr_provider_inst is None
|
|
assert provider_manager.curr_stt_provider_inst is None
|
|
assert provider_manager.curr_tts_provider_inst is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_terminate_continues_when_individual_provider_terminate_fails(self):
|
|
"""Test terminate keeps cleaning remaining providers and MCP servers after one provider fails."""
|
|
provider_manager = build_provider_manager_for_tests()
|
|
|
|
failing_provider = MagicMock()
|
|
failing_provider.meta.return_value.id = "failing"
|
|
failing_provider.terminate = AsyncMock(side_effect=RuntimeError("terminate failed"))
|
|
|
|
healthy_provider = MagicMock()
|
|
healthy_provider.meta.return_value.id = "healthy"
|
|
healthy_provider.terminate = AsyncMock()
|
|
|
|
provider_manager.provider_insts = [failing_provider, healthy_provider]
|
|
provider_manager.inst_map = {
|
|
"failing": failing_provider,
|
|
"healthy": healthy_provider,
|
|
}
|
|
|
|
with patch("astrbot.core.provider.manager.logger") as mock_logger:
|
|
await provider_manager.terminate()
|
|
|
|
failing_provider.terminate.assert_awaited_once()
|
|
healthy_provider.terminate.assert_awaited_once()
|
|
|
|
mock_logger.error.assert_called()
|
|
|
|
|
|
class TestContextRuntimeRegistrations:
|
|
"""Tests for runtime registration containers on Context."""
|
|
|
|
def test_runtime_registrations_are_isolated_and_resettable(self):
|
|
"""Test Context runtime registration containers do not leak and can reset."""
|
|
first_context = build_context_for_tests()
|
|
second_context = build_context_for_tests()
|
|
stale_task = DummyAwaitable("stale-task")
|
|
|
|
first_context.register_web_api(
|
|
"/stale",
|
|
MagicMock(),
|
|
["GET"],
|
|
"stale route",
|
|
)
|
|
first_context.register_task(stale_task, "stale task")
|
|
|
|
assert second_context.registered_web_apis == []
|
|
assert second_context._register_tasks == []
|
|
|
|
cast(Any, first_context).reset_runtime_registrations()
|
|
|
|
assert first_context.registered_web_apis == []
|
|
assert first_context._register_tasks == []
|
|
|
|
|
|
class TestAstrBotCoreLifecycleStop:
|
|
"""Tests for AstrBotCoreLifecycle.stop method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_without_initialize(self, mock_log_broker, mock_db):
|
|
"""Test stop without initialize should not raise errors."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
|
|
# Should not raise
|
|
await lifecycle.stop()
|
|
assert lifecycle.lifecycle_state == LifecycleState.CREATED
|
|
assert lifecycle.runtime_ready is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_in_core_ready_state_tolerates_missing_runtime_components(
|
|
self, mock_log_broker, mock_db
|
|
):
|
|
"""Test stop works before runtime bootstrap completes."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
|
|
lifecycle.curr_tasks = []
|
|
lifecycle.dashboard_shutdown_event = asyncio.Event()
|
|
lifecycle._set_lifecycle_state(LifecycleState.CORE_READY)
|
|
|
|
await lifecycle.stop()
|
|
|
|
|
|
class TestAstrBotCoreLifecycleTaskWrapper:
|
|
"""Tests for AstrBotCoreLifecycle._task_wrapper method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_task_wrapper_normal_completion(self, mock_log_broker, mock_db):
|
|
"""Test task wrapper with normal completion."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
|
|
async def normal_task():
|
|
pass
|
|
|
|
task = asyncio.create_task(normal_task(), name="test_task")
|
|
|
|
# Should not raise
|
|
await lifecycle._task_wrapper(task)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_task_wrapper_with_exception(self, mock_log_broker, mock_db):
|
|
"""Test task wrapper with exception."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
|
|
async def failing_task():
|
|
raise ValueError("Test error")
|
|
|
|
task = asyncio.create_task(failing_task(), name="test_task")
|
|
|
|
with patch("astrbot.core.core_lifecycle.logger") as mock_logger:
|
|
await lifecycle._task_wrapper(task)
|
|
|
|
# Verify error was logged
|
|
mock_logger.error.assert_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_task_wrapper_with_cancelled_error(self, mock_log_broker, mock_db):
|
|
"""Test task wrapper with CancelledError."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
|
|
async def cancelled_task():
|
|
raise asyncio.CancelledError()
|
|
|
|
task = asyncio.create_task(cancelled_task(), name="test_task")
|
|
|
|
# Should not raise and should not log
|
|
with patch("astrbot.core.core_lifecycle.logger") as mock_logger:
|
|
await lifecycle._task_wrapper(task)
|
|
|
|
# CancelledError should be handled silently
|
|
assert not any(
|
|
"error" in str(call).lower()
|
|
for call in mock_logger.error.call_args_list
|
|
)
|
|
|
|
|
|
class TestAstrBotCoreLifecycleLoadPlatform:
|
|
"""Tests for AstrBotCoreLifecycle.load_platform method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_load_platform(self, mock_log_broker, mock_db):
|
|
"""Test load_platform method."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
|
|
# Set up mock platform manager
|
|
mock_platform_manager = MagicMock()
|
|
|
|
mock_inst1 = MagicMock()
|
|
mock_inst1.meta = MagicMock()
|
|
mock_inst1.meta.return_value.id = "inst1"
|
|
mock_inst1.meta.return_value.name = "Instance1"
|
|
mock_inst1.run = AsyncMock()
|
|
|
|
mock_inst2 = MagicMock()
|
|
mock_inst2.meta = MagicMock()
|
|
mock_inst2.meta.return_value.id = "inst2"
|
|
mock_inst2.meta.return_value.name = "Instance2"
|
|
mock_inst2.run = AsyncMock()
|
|
|
|
mock_platform_manager.get_insts = MagicMock(
|
|
return_value=[mock_inst1, mock_inst2]
|
|
)
|
|
lifecycle.platform_manager = mock_platform_manager
|
|
|
|
# Call load_platform
|
|
tasks = lifecycle.load_platform()
|
|
|
|
# Verify tasks were created
|
|
assert len(tasks) == 2
|
|
|
|
# Verify task names
|
|
assert any("inst1" in task.get_name() for task in tasks)
|
|
assert any("inst2" in task.get_name() for task in tasks)
|
|
|
|
|
|
class TestAstrBotCoreLifecycleErrorHandling:
|
|
"""Tests for AstrBotCoreLifecycle error handling."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_subagent_orchestrator_error_is_logged(
|
|
self, mock_log_broker, mock_db, mock_astrbot_config
|
|
):
|
|
"""Test that subagent orchestrator init errors are logged."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
lifecycle.provider_manager = MagicMock()
|
|
lifecycle.provider_manager.llm_tools = MagicMock()
|
|
lifecycle.persona_mgr = MagicMock()
|
|
lifecycle.astrbot_config = mock_astrbot_config
|
|
lifecycle.astrbot_config.get = MagicMock(return_value={})
|
|
|
|
mock_subagent = MagicMock()
|
|
mock_subagent.reload_from_config = AsyncMock(
|
|
side_effect=Exception("Orchestrator init failed")
|
|
)
|
|
|
|
with (
|
|
patch(
|
|
"astrbot.core.core_lifecycle.SubAgentOrchestrator",
|
|
return_value=mock_subagent,
|
|
) as mock_subagent_cls,
|
|
patch("astrbot.core.core_lifecycle.logger") as mock_logger,
|
|
):
|
|
await lifecycle._init_or_reload_subagent_orchestrator()
|
|
|
|
mock_subagent_cls.assert_called_once_with(
|
|
lifecycle.provider_manager.llm_tools,
|
|
lifecycle.persona_mgr,
|
|
)
|
|
mock_subagent.reload_from_config.assert_awaited_once_with({})
|
|
assert mock_logger.error.called
|
|
assert any(
|
|
"Subagent orchestrator init failed" in str(call)
|
|
for call in mock_logger.error.call_args_list
|
|
)
|
|
|
|
|
|
class TestAstrBotCoreLifecycleInitialize:
|
|
"""Tests for AstrBotCoreLifecycle.initialize method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_initialize_core_stops_before_runtime_bootstrap(
|
|
self, mock_log_broker, mock_db, mock_astrbot_config
|
|
):
|
|
"""Test that initialize_core sets up only the fast core phase."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
mocks = build_initialize_test_mocks()
|
|
|
|
with patch_initialize_test_mocks(mock_astrbot_config, mocks):
|
|
await lifecycle.initialize_core()
|
|
|
|
mock_db.initialize.assert_awaited_once()
|
|
mocks["html_renderer"].initialize.assert_awaited_once()
|
|
mocks["umop_config_router"].initialize.assert_awaited_once()
|
|
mocks["persona_mgr"].initialize.assert_awaited_once()
|
|
mocks["init_subagent_orchestrator"].assert_awaited_once()
|
|
|
|
mocks["plugin_manager"].reload.assert_not_called()
|
|
mocks["provider_manager"].initialize.assert_not_called()
|
|
mocks["kb_manager"].initialize.assert_not_called()
|
|
mocks["platform_manager"].initialize.assert_not_called()
|
|
mocks["event_bus_cls"].assert_not_called()
|
|
mocks["create_task"].assert_not_called()
|
|
mocks["astrbot_updator_cls"].assert_called_once_with()
|
|
|
|
assert lifecycle.astrbot_updator == mocks["astrbot_updator"]
|
|
assert isinstance(lifecycle.dashboard_shutdown_event, asyncio.Event)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bootstrap_runtime_requires_initialize_core(
|
|
self, mock_log_broker, mock_db
|
|
):
|
|
"""Test that bootstrap_runtime requires the core phase first."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
|
|
with pytest.raises(RuntimeError, match="initialize_core"):
|
|
await lifecycle.bootstrap_runtime()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bootstrap_runtime_initializes_deferred_components_in_order(
|
|
self, mock_log_broker, mock_db, mock_astrbot_config
|
|
):
|
|
"""Test that bootstrap_runtime completes deferred runtime setup in order."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
mocks = build_initialize_test_mocks()
|
|
call_order = []
|
|
pipeline_scheduler_mapping = {"default": MagicMock()}
|
|
|
|
async def load_pipeline_scheduler():
|
|
call_order.append("pipeline_scheduler")
|
|
return pipeline_scheduler_mapping
|
|
|
|
async def record_async(name):
|
|
call_order.append(name)
|
|
|
|
def record_create_task(coro):
|
|
call_order.append("metadata_task")
|
|
coro.close()
|
|
return MagicMock()
|
|
|
|
async def plugin_reload():
|
|
await record_async("plugin_reload")
|
|
|
|
async def provider_init():
|
|
await record_async("provider_init")
|
|
|
|
async def kb_init():
|
|
await record_async("kb_init")
|
|
|
|
async def platform_init():
|
|
await record_async("platform_init")
|
|
|
|
mocks["plugin_manager"].reload.side_effect = plugin_reload
|
|
mocks["provider_manager"].initialize.side_effect = provider_init
|
|
mocks["kb_manager"].initialize.side_effect = kb_init
|
|
mocks["platform_manager"].initialize.side_effect = platform_init
|
|
mocks["event_bus_cls"].side_effect = lambda *args, **kwargs: (
|
|
call_order.append("event_bus") or mocks["event_bus"]
|
|
)
|
|
mocks["create_task"].side_effect = record_create_task
|
|
|
|
with patch_initialize_test_mocks(mock_astrbot_config, mocks):
|
|
await lifecycle.initialize_core()
|
|
|
|
|
|
await lifecycle.bootstrap_runtime()
|
|
|
|
assert call_order == [
|
|
"plugin_reload",
|
|
"provider_init",
|
|
"kb_init",
|
|
"pipeline_scheduler",
|
|
"event_bus",
|
|
"platform_init",
|
|
"metadata_task",
|
|
]
|
|
assert lifecycle.pipeline_scheduler_mapping == pipeline_scheduler_mapping
|
|
assert lifecycle.event_bus == mocks["event_bus"]
|
|
assert lifecycle.lifecycle_state == LifecycleState.RUNTIME_READY
|
|
assert lifecycle.core_initialized is True
|
|
assert lifecycle.runtime_ready is True
|
|
assert lifecycle.runtime_ready_event.is_set() is True
|
|
assert lifecycle.runtime_failed_event.is_set() is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bootstrap_runtime_sets_runtime_failed_signal_on_error(
|
|
self, mock_log_broker, mock_db, mock_astrbot_config
|
|
):
|
|
"""Test that bootstrap_runtime signals failure so start can stop waiting."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
mocks = build_initialize_test_mocks()
|
|
error = RuntimeError("runtime bootstrap failed")
|
|
mocks["plugin_manager"].reload.side_effect = error
|
|
|
|
with patch_initialize_test_mocks(mock_astrbot_config, mocks):
|
|
await lifecycle.initialize_core()
|
|
|
|
with pytest.raises(RuntimeError, match="runtime bootstrap failed"):
|
|
await lifecycle.bootstrap_runtime()
|
|
|
|
assert lifecycle.runtime_ready is False
|
|
assert lifecycle.runtime_ready_event.is_set() is False
|
|
assert lifecycle.runtime_failed_event.is_set() is True
|
|
assert lifecycle.runtime_bootstrap_error is error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bootstrap_failure_sets_failed_state_and_cleans_partial_runtime_artifacts(
|
|
self, mock_log_broker, mock_db, mock_astrbot_config
|
|
):
|
|
"""Test bootstrap failures leave an explicit failed state without stale runtime artifacts."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
mocks = build_initialize_test_mocks()
|
|
error = RuntimeError("platform init failed after partial bootstrap")
|
|
pipeline_scheduler_mapping = {"default": MagicMock()}
|
|
provider_manager = mocks["provider_manager"]
|
|
platform_manager = mocks["platform_manager"]
|
|
kb_manager = mocks["kb_manager"]
|
|
plugin_manager = mocks["plugin_manager"]
|
|
plugin_manager.cleanup_loaded_plugins = AsyncMock()
|
|
mocks["provider_manager"].terminate = AsyncMock()
|
|
mocks["platform_manager"].terminate = AsyncMock()
|
|
mocks["kb_manager"].terminate = AsyncMock()
|
|
mocks["platform_manager"].initialize.side_effect = error
|
|
|
|
with patch_initialize_test_mocks(mock_astrbot_config, mocks):
|
|
await lifecycle.initialize_core()
|
|
|
|
|
|
with pytest.raises(
|
|
RuntimeError,
|
|
match="platform init failed after partial bootstrap",
|
|
):
|
|
await lifecycle.bootstrap_runtime()
|
|
|
|
assert lifecycle.lifecycle_state.value == "runtime_failed"
|
|
assert getattr(lifecycle, "runtime_failed", False) is True
|
|
assert lifecycle.runtime_ready is False
|
|
assert lifecycle.runtime_ready_event.is_set() is False
|
|
assert lifecycle.runtime_failed_event.is_set() is True
|
|
assert lifecycle.runtime_bootstrap_error is error
|
|
assert lifecycle.event_bus is None
|
|
assert lifecycle.pipeline_scheduler_mapping == {}
|
|
assert lifecycle.start_time == 0
|
|
plugin_manager.cleanup_loaded_plugins.assert_awaited_once()
|
|
provider_manager.terminate.assert_awaited_once()
|
|
platform_manager.terminate.assert_awaited_once()
|
|
kb_manager.terminate.assert_awaited_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bootstrap_failure_allows_safe_retry_after_cleanup(
|
|
self, mock_log_broker, mock_db, mock_astrbot_config
|
|
):
|
|
"""Test a failed runtime bootstrap can retry cleanly on the same lifecycle."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
mocks = build_initialize_test_mocks()
|
|
pipeline_scheduler_mapping = {"default": MagicMock()}
|
|
attempts = 0
|
|
provider_manager = mocks["provider_manager"]
|
|
platform_manager = mocks["platform_manager"]
|
|
kb_manager = mocks["kb_manager"]
|
|
|
|
mocks["provider_manager"].terminate = AsyncMock()
|
|
mocks["platform_manager"].terminate = AsyncMock()
|
|
mocks["kb_manager"].terminate = AsyncMock()
|
|
|
|
async def platform_initialize() -> None:
|
|
nonlocal attempts
|
|
attempts += 1
|
|
if attempts == 1:
|
|
raise RuntimeError("retryable runtime bootstrap failure")
|
|
|
|
mocks["platform_manager"].initialize.side_effect = platform_initialize
|
|
|
|
with patch_initialize_test_mocks(mock_astrbot_config, mocks):
|
|
await lifecycle.initialize_core()
|
|
|
|
|
|
with pytest.raises(
|
|
RuntimeError,
|
|
match="retryable runtime bootstrap failure",
|
|
):
|
|
await lifecycle.bootstrap_runtime()
|
|
|
|
await lifecycle.bootstrap_runtime()
|
|
|
|
assert attempts == 2
|
|
assert lifecycle.lifecycle_state == LifecycleState.RUNTIME_READY
|
|
assert getattr(lifecycle, "runtime_failed", False) is False
|
|
assert lifecycle.runtime_ready is True
|
|
assert lifecycle.runtime_failed_event.is_set() is False
|
|
assert lifecycle.runtime_bootstrap_error is None
|
|
assert lifecycle.event_bus == mocks["event_bus"]
|
|
assert lifecycle.pipeline_scheduler_mapping == pipeline_scheduler_mapping
|
|
provider_manager.terminate.assert_awaited_once()
|
|
platform_manager.terminate.assert_awaited_once()
|
|
kb_manager.terminate.assert_awaited_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bootstrap_failure_clears_context_runtime_registrations_before_retry(
|
|
self, mock_log_broker, mock_db, mock_astrbot_config
|
|
):
|
|
"""Test failed bootstrap clears stale runtime registrations before retry succeeds."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
mocks = build_initialize_test_mocks()
|
|
attempts = 0
|
|
task_names: list[str] = []
|
|
|
|
async def plugin_reload() -> None:
|
|
nonlocal attempts
|
|
attempts += 1
|
|
assert lifecycle.star_context is not None
|
|
dummy_task = DummyAwaitable(f"task-{attempts}")
|
|
task_names.append(dummy_task.name)
|
|
lifecycle.star_context.registered_web_apis.append(
|
|
(
|
|
f"/attempt-{attempts}",
|
|
MagicMock(),
|
|
["GET"],
|
|
f"route-{attempts}",
|
|
)
|
|
)
|
|
lifecycle.star_context._register_tasks.append(dummy_task)
|
|
if attempts == 1:
|
|
raise RuntimeError("plugin registration left stale runtime state")
|
|
|
|
mocks["plugin_manager"].reload.side_effect = plugin_reload
|
|
|
|
with patch_initialize_test_mocks(mock_astrbot_config, mocks):
|
|
await lifecycle.initialize_core()
|
|
|
|
|
|
with pytest.raises(
|
|
RuntimeError,
|
|
match="plugin registration left stale runtime state",
|
|
):
|
|
await lifecycle.bootstrap_runtime()
|
|
|
|
assert lifecycle.star_context is not None
|
|
assert lifecycle.star_context.registered_web_apis == []
|
|
assert lifecycle.star_context._register_tasks == []
|
|
|
|
await lifecycle.bootstrap_runtime()
|
|
|
|
assert lifecycle.star_context is not None
|
|
assert len(lifecycle.star_context.registered_web_apis) == 1
|
|
route, _, methods, desc = lifecycle.star_context.registered_web_apis[0]
|
|
assert route == "/attempt-2"
|
|
assert methods == ["GET"]
|
|
assert desc == "route-2"
|
|
assert [
|
|
cast(DummyAwaitable, task).name
|
|
for task in lifecycle.star_context._register_tasks
|
|
] == [task_names[-1]]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_initialize_sets_up_all_components(
|
|
self, mock_log_broker, mock_db, mock_astrbot_config
|
|
):
|
|
"""Test that initialize sets up all required components in correct order."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
|
|
# Mock all the dependencies
|
|
mock_db.initialize = AsyncMock()
|
|
mock_html_renderer = MagicMock()
|
|
mock_html_renderer.initialize = AsyncMock()
|
|
|
|
mock_umop_config_router = MagicMock()
|
|
mock_umop_config_router.initialize = AsyncMock()
|
|
|
|
mock_astrbot_config_mgr = MagicMock()
|
|
mock_astrbot_config_mgr.default_conf = {}
|
|
mock_astrbot_config_mgr.confs = {}
|
|
|
|
mock_persona_mgr = MagicMock()
|
|
mock_persona_mgr.initialize = AsyncMock()
|
|
|
|
mock_provider_manager = MagicMock()
|
|
mock_provider_manager.initialize = AsyncMock()
|
|
|
|
mock_platform_manager = MagicMock()
|
|
mock_platform_manager.initialize = AsyncMock()
|
|
|
|
mock_conversation_manager = MagicMock()
|
|
|
|
mock_platform_message_history_manager = MagicMock()
|
|
|
|
mock_kb_manager = MagicMock()
|
|
mock_kb_manager.initialize = AsyncMock()
|
|
|
|
mock_cron_manager = MagicMock()
|
|
|
|
mock_star_context = MagicMock()
|
|
mock_star_context._register_tasks = []
|
|
|
|
mock_plugin_manager = MagicMock()
|
|
mock_plugin_manager.reload = AsyncMock()
|
|
|
|
mock_pipeline_scheduler = MagicMock()
|
|
mock_pipeline_scheduler.initialize = AsyncMock()
|
|
|
|
mock_astrbot_updator = MagicMock()
|
|
|
|
mock_event_bus = MagicMock()
|
|
|
|
with (
|
|
patch("astrbot.core.core_lifecycle.astrbot_config", mock_astrbot_config),
|
|
patch("astrbot.core.core_lifecycle.html_renderer", mock_html_renderer),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.UmopConfigRouter",
|
|
return_value=mock_umop_config_router,
|
|
),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.AstrBotConfigManager",
|
|
return_value=mock_astrbot_config_mgr,
|
|
),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.PersonaManager",
|
|
return_value=mock_persona_mgr,
|
|
),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.ProviderManager",
|
|
return_value=mock_provider_manager,
|
|
),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.PlatformManager",
|
|
return_value=mock_platform_manager,
|
|
),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.ConversationManager",
|
|
return_value=mock_conversation_manager,
|
|
),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.PlatformMessageHistoryManager",
|
|
return_value=mock_platform_message_history_manager,
|
|
),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.KnowledgeBaseManager",
|
|
return_value=mock_kb_manager,
|
|
),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.CronJobManager",
|
|
return_value=mock_cron_manager,
|
|
),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.Context", return_value=mock_star_context
|
|
),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.PluginManager",
|
|
return_value=mock_plugin_manager,
|
|
),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.PipelineScheduler",
|
|
return_value=mock_pipeline_scheduler,
|
|
),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.AstrBotUpdator",
|
|
return_value=mock_astrbot_updator,
|
|
),
|
|
patch("astrbot.core.core_lifecycle.EventBus", return_value=mock_event_bus),
|
|
patch("astrbot.core.core_lifecycle.migra", new_callable=AsyncMock),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.update_llm_metadata",
|
|
new_callable=AsyncMock,
|
|
),
|
|
):
|
|
await lifecycle.initialize()
|
|
|
|
# Verify database initialized
|
|
mock_db.initialize.assert_awaited_once()
|
|
|
|
# Verify html renderer initialized
|
|
mock_html_renderer.initialize.assert_awaited_once()
|
|
|
|
# Verify UMOP config router initialized
|
|
mock_umop_config_router.initialize.assert_awaited_once()
|
|
|
|
# Verify persona manager initialized
|
|
mock_persona_mgr.initialize.assert_awaited_once()
|
|
|
|
# Verify provider manager initialized
|
|
mock_provider_manager.initialize.assert_awaited_once()
|
|
|
|
# Verify platform manager initialized
|
|
mock_platform_manager.initialize.assert_awaited_once()
|
|
|
|
# Verify plugin manager reloaded
|
|
mock_plugin_manager.reload.assert_awaited_once()
|
|
|
|
# Verify knowledge base manager initialized
|
|
mock_kb_manager.initialize.assert_awaited_once()
|
|
|
|
# Verify pipeline scheduler loaded
|
|
assert lifecycle.pipeline_scheduler_mapping is not None
|
|
assert lifecycle.lifecycle_state == LifecycleState.RUNTIME_READY
|
|
assert lifecycle.core_initialized is True
|
|
assert lifecycle.runtime_ready is True
|
|
|
|
|
|
class TestAstrBotCoreLifecycleLoad:
|
|
"""Tests for AstrBotCoreLifecycle._load method."""
|
|
|
|
def test_load_requires_runtime_ready_state(self, mock_log_broker, mock_db):
|
|
"""Test that _load rejects half-bootstrapped lifecycle state."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
lifecycle.event_bus = MagicMock()
|
|
lifecycle.event_bus.dispatch = AsyncMock()
|
|
lifecycle.star_context = MagicMock()
|
|
lifecycle.star_context._register_tasks = []
|
|
lifecycle._set_lifecycle_state(LifecycleState.CORE_READY)
|
|
|
|
with patch("astrbot.core.core_lifecycle.asyncio.create_task"):
|
|
with pytest.raises(RuntimeError, match="RUNTIME_READY"):
|
|
lifecycle._load()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_initialize_handles_migration_failure(
|
|
self, mock_log_broker, mock_db, mock_astrbot_config
|
|
):
|
|
"""Test that initialize handles migration failures gracefully."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
|
|
mock_db.initialize = AsyncMock()
|
|
|
|
mock_html_renderer = MagicMock()
|
|
mock_html_renderer.initialize = AsyncMock()
|
|
|
|
mock_umop_config_router = MagicMock()
|
|
mock_umop_config_router.initialize = AsyncMock()
|
|
|
|
mock_astrbot_config_mgr = MagicMock()
|
|
mock_astrbot_config_mgr.default_conf = {}
|
|
mock_astrbot_config_mgr.confs = {}
|
|
|
|
# Mock components that need to be created for initialize to continue
|
|
with (
|
|
patch("astrbot.core.core_lifecycle.astrbot_config", mock_astrbot_config),
|
|
patch("astrbot.core.core_lifecycle.html_renderer", mock_html_renderer),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.UmopConfigRouter",
|
|
return_value=mock_umop_config_router,
|
|
),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.AstrBotConfigManager",
|
|
return_value=mock_astrbot_config_mgr,
|
|
),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.PersonaManager",
|
|
return_value=MagicMock(initialize=AsyncMock()),
|
|
),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.ProviderManager",
|
|
return_value=MagicMock(initialize=AsyncMock()),
|
|
),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.PlatformManager",
|
|
return_value=MagicMock(initialize=AsyncMock()),
|
|
),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.ConversationManager",
|
|
return_value=MagicMock(),
|
|
),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.PlatformMessageHistoryManager",
|
|
return_value=MagicMock(),
|
|
),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.KnowledgeBaseManager",
|
|
return_value=MagicMock(initialize=AsyncMock()),
|
|
),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.CronJobManager",
|
|
return_value=MagicMock(),
|
|
),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.Context",
|
|
return_value=MagicMock(_register_tasks=[]),
|
|
),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.PluginManager",
|
|
return_value=MagicMock(reload=AsyncMock()),
|
|
),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.PipelineScheduler",
|
|
return_value=MagicMock(initialize=AsyncMock()),
|
|
),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.AstrBotUpdator",
|
|
return_value=MagicMock(),
|
|
),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.EventBus",
|
|
return_value=MagicMock(),
|
|
),
|
|
patch(
|
|
"astrbot.core.core_lifecycle.migra",
|
|
AsyncMock(side_effect=Exception("Migration failed")),
|
|
),
|
|
patch("astrbot.core.core_lifecycle.logger") as mock_logger,
|
|
patch(
|
|
"astrbot.core.core_lifecycle.update_llm_metadata",
|
|
new_callable=AsyncMock,
|
|
),
|
|
):
|
|
# Should not raise, just log the error
|
|
await lifecycle.initialize()
|
|
|
|
# Verify migration error was logged
|
|
mock_logger.error.assert_called()
|
|
|
|
|
|
class TestAstrBotCoreLifecycleStart:
|
|
"""Tests for AstrBotCoreLifecycle.start method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_waits_for_runtime_ready_before_loading(
|
|
self, mock_log_broker, mock_db
|
|
):
|
|
"""Test that start waits for deferred runtime bootstrap before loading."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
lifecycle._set_lifecycle_state(LifecycleState.CORE_READY)
|
|
lifecycle.curr_tasks = []
|
|
|
|
async def wait_for_runtime_ready() -> None:
|
|
await lifecycle.runtime_ready_event.wait()
|
|
|
|
lifecycle.runtime_bootstrap_task = asyncio.create_task(wait_for_runtime_ready())
|
|
|
|
with (
|
|
patch.object(lifecycle, "_load") as mock_load,
|
|
patch(
|
|
"astrbot.core.core_lifecycle.star_handlers_registry"
|
|
) as mock_registry,
|
|
patch("astrbot.core.core_lifecycle.logger"),
|
|
):
|
|
mock_registry.get_handlers_by_event_type = MagicMock(return_value=[])
|
|
start_task = asyncio.create_task(lifecycle.start())
|
|
|
|
await asyncio.sleep(0)
|
|
mock_load.assert_not_called()
|
|
|
|
lifecycle._set_lifecycle_state(LifecycleState.RUNTIME_READY)
|
|
lifecycle.runtime_ready_event.set()
|
|
|
|
await asyncio.wait_for(start_task, timeout=0.1)
|
|
await asyncio.wait_for(lifecycle.runtime_bootstrap_task, timeout=0.1)
|
|
|
|
mock_load.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_runtime_failed_returns_cleanly(self, mock_log_broker, mock_db):
|
|
"""Test that start exits when deferred runtime bootstrap reports failure."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
lifecycle._set_lifecycle_state(LifecycleState.CORE_READY)
|
|
lifecycle.curr_tasks = []
|
|
error = RuntimeError("bootstrap exploded with detailed reason")
|
|
|
|
async def wait_for_runtime_failure() -> None:
|
|
await lifecycle.runtime_failed_event.wait()
|
|
|
|
lifecycle.runtime_bootstrap_task = asyncio.create_task(
|
|
wait_for_runtime_failure()
|
|
)
|
|
|
|
with (
|
|
patch.object(lifecycle, "_load") as mock_load,
|
|
patch(
|
|
"astrbot.core.core_lifecycle.star_handlers_registry"
|
|
) as mock_registry,
|
|
patch("astrbot.core.core_lifecycle.logger") as mock_logger,
|
|
):
|
|
mock_registry.get_handlers_by_event_type = MagicMock(return_value=[])
|
|
start_task = asyncio.create_task(lifecycle.start())
|
|
|
|
await asyncio.sleep(0)
|
|
mock_load.assert_not_called()
|
|
|
|
lifecycle.runtime_bootstrap_error = error
|
|
lifecycle.runtime_failed_event.set()
|
|
|
|
await asyncio.wait_for(start_task, timeout=0.1)
|
|
await asyncio.wait_for(lifecycle.runtime_bootstrap_task, timeout=0.1)
|
|
|
|
mock_load.assert_not_called()
|
|
assert any(
|
|
"bootstrap exploded with detailed reason" in str(call)
|
|
for call in mock_logger.error.call_args_list
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_runtime_failed_state_returns_cleanly_without_bootstrap_task(
|
|
self, mock_log_broker, mock_db
|
|
):
|
|
"""Test explicit runtime failure state wins over missing bootstrap task errors."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
lifecycle.curr_tasks = []
|
|
error = RuntimeError("bootstrap failed before retry")
|
|
mark_runtime_failed(lifecycle, error)
|
|
lifecycle.runtime_bootstrap_task = None
|
|
|
|
with (
|
|
patch.object(lifecycle, "_load") as mock_load,
|
|
patch("astrbot.core.core_lifecycle.logger") as mock_logger,
|
|
):
|
|
await asyncio.wait_for(lifecycle.start(), timeout=0.1)
|
|
|
|
mock_load.assert_not_called()
|
|
assert any(
|
|
"bootstrap failed before retry" in str(call)
|
|
for call in mock_logger.error.call_args_list
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_consumes_prefailed_runtime_bootstrap_task(
|
|
self, mock_log_broker, mock_db
|
|
):
|
|
"""Test start consumes a fast-failed bootstrap task before returning."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
lifecycle.curr_tasks = []
|
|
error = RuntimeError("bootstrap failed immediately")
|
|
|
|
async def fail_fast() -> None:
|
|
raise error
|
|
|
|
lifecycle.runtime_bootstrap_task = asyncio.create_task(fail_fast())
|
|
await asyncio.sleep(0)
|
|
lifecycle.runtime_bootstrap_error = error
|
|
lifecycle._set_lifecycle_state(LifecycleState.RUNTIME_FAILED)
|
|
|
|
with (
|
|
patch.object(lifecycle, "_load") as mock_load,
|
|
patch("astrbot.core.core_lifecycle.logger") as mock_logger,
|
|
):
|
|
await asyncio.wait_for(lifecycle.start(), timeout=0.1)
|
|
|
|
mock_load.assert_not_called()
|
|
assert lifecycle.runtime_bootstrap_task is not None
|
|
assert getattr(lifecycle.runtime_bootstrap_task, "_log_traceback", False) is False
|
|
assert any(
|
|
"bootstrap failed immediately" in str(call)
|
|
for call in mock_logger.error.call_args_list
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_raises_when_runtime_bootstrap_task_missing(
|
|
self, mock_log_broker, mock_db
|
|
):
|
|
"""Test that start fails fast when no runtime bootstrap task was scheduled."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
lifecycle._set_lifecycle_state(LifecycleState.CORE_READY)
|
|
lifecycle.curr_tasks = []
|
|
|
|
with patch("astrbot.core.core_lifecycle.logger"):
|
|
with pytest.raises(RuntimeError, match="runtime bootstrap task"):
|
|
await asyncio.wait_for(lifecycle.start(), timeout=0.1)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_returns_cleanly_when_stop_interrupts_runtime_wait(
|
|
self, mock_log_broker, mock_db, mock_astrbot_config
|
|
):
|
|
"""Test stop wakes start callers blocked on deferred runtime bootstrap."""
|
|
lifecycle = await build_inflight_runtime_bootstrap_lifecycle(
|
|
mock_log_broker,
|
|
mock_db,
|
|
mock_astrbot_config,
|
|
)
|
|
|
|
with (
|
|
patch.object(lifecycle, "_load") as mock_load,
|
|
patch(
|
|
"astrbot.core.core_lifecycle.star_handlers_registry"
|
|
) as mock_registry,
|
|
patch("astrbot.core.core_lifecycle.logger") as mock_logger,
|
|
):
|
|
mock_registry.get_handlers_by_event_type = MagicMock(return_value=[])
|
|
start_task = asyncio.create_task(lifecycle.start())
|
|
|
|
await asyncio.sleep(0)
|
|
mock_load.assert_not_called()
|
|
|
|
await lifecycle.stop()
|
|
await asyncio.wait_for(start_task, timeout=0.1)
|
|
|
|
mock_load.assert_not_called()
|
|
mock_logger.error.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_returns_cleanly_when_restart_interrupts_runtime_wait(
|
|
self, mock_log_broker, mock_db, mock_astrbot_config
|
|
):
|
|
"""Test restart wakes start callers blocked on deferred runtime bootstrap."""
|
|
lifecycle = await build_inflight_runtime_bootstrap_lifecycle(
|
|
mock_log_broker,
|
|
mock_db,
|
|
mock_astrbot_config,
|
|
)
|
|
|
|
with (
|
|
patch.object(lifecycle, "_load") as mock_load,
|
|
patch(
|
|
"astrbot.core.core_lifecycle.star_handlers_registry"
|
|
) as mock_registry,
|
|
patch("astrbot.core.core_lifecycle.threading.Thread") as mock_thread,
|
|
patch("astrbot.core.core_lifecycle.logger") as mock_logger,
|
|
):
|
|
mock_registry.get_handlers_by_event_type = MagicMock(return_value=[])
|
|
start_task = asyncio.create_task(lifecycle.start())
|
|
|
|
await asyncio.sleep(0)
|
|
mock_load.assert_not_called()
|
|
|
|
await lifecycle.restart()
|
|
await asyncio.wait_for(start_task, timeout=0.1)
|
|
|
|
mock_thread.assert_called_once()
|
|
mock_thread.return_value.start.assert_called_once()
|
|
mock_load.assert_not_called()
|
|
mock_logger.error.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_loads_event_bus_and_runs(self, mock_log_broker, mock_db):
|
|
"""Test that start loads event bus and runs tasks."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
|
|
# Set up minimal state
|
|
lifecycle.event_bus = MagicMock()
|
|
lifecycle.event_bus.dispatch = AsyncMock()
|
|
|
|
lifecycle.cron_manager = None
|
|
|
|
lifecycle.temp_dir_cleaner = None
|
|
|
|
lifecycle.star_context = MagicMock()
|
|
lifecycle.star_context._register_tasks = []
|
|
|
|
lifecycle.plugin_manager = MagicMock()
|
|
lifecycle.plugin_manager.context = MagicMock()
|
|
lifecycle.plugin_manager.context.get_all_stars = MagicMock(return_value=[])
|
|
|
|
lifecycle.provider_manager = MagicMock()
|
|
lifecycle.provider_manager.terminate = AsyncMock()
|
|
|
|
lifecycle.platform_manager = MagicMock()
|
|
lifecycle.platform_manager.terminate = AsyncMock()
|
|
|
|
lifecycle.kb_manager = MagicMock()
|
|
lifecycle.kb_manager.terminate = AsyncMock()
|
|
|
|
lifecycle.dashboard_shutdown_event = asyncio.Event()
|
|
|
|
lifecycle.curr_tasks = []
|
|
lifecycle._set_lifecycle_state(LifecycleState.RUNTIME_READY)
|
|
|
|
with (
|
|
patch(
|
|
"astrbot.core.core_lifecycle.star_handlers_registry"
|
|
) as mock_registry,
|
|
patch("astrbot.core.core_lifecycle.logger"),
|
|
):
|
|
mock_registry.get_handlers_by_event_type = MagicMock(return_value=[])
|
|
await lifecycle.start()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_calls_on_astrbot_loaded_hook(self, mock_log_broker, mock_db):
|
|
"""Test that start calls the OnAstrBotLoadedEvent handlers."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
|
|
# Set up minimal state
|
|
lifecycle.event_bus = MagicMock()
|
|
lifecycle.event_bus.dispatch = AsyncMock()
|
|
|
|
lifecycle.cron_manager = None
|
|
lifecycle.temp_dir_cleaner = None
|
|
|
|
lifecycle.star_context = MagicMock()
|
|
lifecycle.star_context._register_tasks = []
|
|
|
|
lifecycle.plugin_manager = MagicMock()
|
|
lifecycle.plugin_manager.context = MagicMock()
|
|
lifecycle.plugin_manager.context.get_all_stars = MagicMock(return_value=[])
|
|
|
|
lifecycle.provider_manager = MagicMock()
|
|
lifecycle.provider_manager.terminate = AsyncMock()
|
|
|
|
lifecycle.platform_manager = MagicMock()
|
|
lifecycle.platform_manager.terminate = AsyncMock()
|
|
|
|
lifecycle.kb_manager = MagicMock()
|
|
lifecycle.kb_manager.terminate = AsyncMock()
|
|
|
|
lifecycle.dashboard_shutdown_event = asyncio.Event()
|
|
|
|
lifecycle.curr_tasks = []
|
|
lifecycle._set_lifecycle_state(LifecycleState.RUNTIME_READY)
|
|
|
|
# Create a mock handler
|
|
mock_handler = MagicMock()
|
|
mock_handler.handler = AsyncMock()
|
|
mock_handler.handler_module_path = "test_module"
|
|
mock_handler.handler_name = "test_handler"
|
|
|
|
with (
|
|
patch(
|
|
"astrbot.core.core_lifecycle.star_handlers_registry"
|
|
) as mock_registry,
|
|
patch(
|
|
"astrbot.core.core_lifecycle.star_map",
|
|
{"test_module": MagicMock(name="Test Handler")},
|
|
),
|
|
patch("astrbot.core.core_lifecycle.logger"),
|
|
):
|
|
mock_registry.get_handlers_by_event_type = MagicMock(
|
|
return_value=[mock_handler]
|
|
)
|
|
await lifecycle.start()
|
|
|
|
# Verify handler was called
|
|
mock_handler.handler.assert_awaited_once()
|
|
|
|
|
|
class TestAstrBotCoreLifecycleStopAdditional:
|
|
"""Additional tests for AstrBotCoreLifecycle.stop method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_cancels_all_tasks(self, mock_log_broker, mock_db):
|
|
"""Test that stop cancels all current tasks."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
|
|
lifecycle.temp_dir_cleaner = None
|
|
lifecycle.cron_manager = None
|
|
|
|
lifecycle.plugin_manager = MagicMock()
|
|
lifecycle.plugin_manager.context = MagicMock()
|
|
lifecycle.plugin_manager.context.get_all_stars = MagicMock(return_value=[])
|
|
|
|
lifecycle.provider_manager = MagicMock()
|
|
lifecycle.provider_manager.terminate = AsyncMock()
|
|
|
|
lifecycle.platform_manager = MagicMock()
|
|
lifecycle.platform_manager.terminate = AsyncMock()
|
|
|
|
lifecycle.kb_manager = MagicMock()
|
|
lifecycle.kb_manager.terminate = AsyncMock()
|
|
|
|
lifecycle.dashboard_shutdown_event = asyncio.Event()
|
|
lifecycle.event_bus = MagicMock()
|
|
lifecycle.pipeline_scheduler_mapping = {"default": MagicMock()}
|
|
lifecycle.start_time = 123
|
|
lifecycle._set_lifecycle_state(LifecycleState.RUNTIME_READY)
|
|
|
|
# Create mock tasks
|
|
mock_task1 = MagicMock(spec=asyncio.Task)
|
|
mock_task1.cancel = MagicMock()
|
|
mock_task1.get_name = MagicMock(return_value="task1")
|
|
|
|
mock_task2 = MagicMock(spec=asyncio.Task)
|
|
mock_task2.cancel = MagicMock()
|
|
mock_task2.get_name = MagicMock(return_value="task2")
|
|
|
|
lifecycle.curr_tasks = [mock_task1, mock_task2]
|
|
|
|
await lifecycle.stop()
|
|
|
|
# Verify tasks were cancelled
|
|
mock_task1.cancel.assert_called_once()
|
|
mock_task2.cancel.assert_called_once()
|
|
assert lifecycle.curr_tasks == []
|
|
assert lifecycle.event_bus is None
|
|
assert lifecycle.pipeline_scheduler_mapping == {}
|
|
assert lifecycle.start_time == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_terminates_all_managers(self, mock_log_broker, mock_db):
|
|
"""Test that stop terminates all managers in correct order."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
|
|
lifecycle.temp_dir_cleaner = None
|
|
lifecycle.cron_manager = None
|
|
|
|
lifecycle.plugin_manager = MagicMock()
|
|
lifecycle.plugin_manager.context = MagicMock()
|
|
lifecycle.plugin_manager.context.get_all_stars = MagicMock(return_value=[])
|
|
|
|
lifecycle.provider_manager = MagicMock()
|
|
lifecycle.provider_manager.terminate = AsyncMock()
|
|
|
|
lifecycle.platform_manager = MagicMock()
|
|
lifecycle.platform_manager.terminate = AsyncMock()
|
|
|
|
lifecycle.kb_manager = MagicMock()
|
|
lifecycle.kb_manager.terminate = AsyncMock()
|
|
|
|
lifecycle.dashboard_shutdown_event = asyncio.Event()
|
|
lifecycle.event_bus = MagicMock()
|
|
lifecycle.pipeline_scheduler_mapping = {"default": MagicMock()}
|
|
lifecycle.start_time = 123
|
|
lifecycle._set_lifecycle_state(LifecycleState.RUNTIME_READY)
|
|
|
|
lifecycle.curr_tasks = []
|
|
|
|
await lifecycle.stop()
|
|
|
|
# Verify all managers were terminated
|
|
lifecycle.provider_manager.terminate.assert_awaited_once()
|
|
lifecycle.platform_manager.terminate.assert_awaited_once()
|
|
lifecycle.kb_manager.terminate.assert_awaited_once()
|
|
assert lifecycle.event_bus is None
|
|
assert lifecycle.pipeline_scheduler_mapping == {}
|
|
assert lifecycle.curr_tasks == []
|
|
assert lifecycle.start_time == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_handles_plugin_termination_error(
|
|
self, mock_log_broker, mock_db
|
|
):
|
|
"""Test that stop handles plugin termination errors gracefully."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
|
|
lifecycle.temp_dir_cleaner = None
|
|
lifecycle.cron_manager = None
|
|
|
|
# Create a mock plugin that raises exception on termination
|
|
mock_plugin = MagicMock()
|
|
mock_plugin.name = "test_plugin"
|
|
|
|
lifecycle.plugin_manager = MagicMock()
|
|
lifecycle.plugin_manager.context = MagicMock()
|
|
lifecycle.plugin_manager.context.get_all_stars = MagicMock(
|
|
return_value=[mock_plugin]
|
|
)
|
|
lifecycle.plugin_manager._terminate_plugin = AsyncMock(
|
|
side_effect=Exception("Plugin termination failed")
|
|
)
|
|
|
|
lifecycle.provider_manager = MagicMock()
|
|
lifecycle.provider_manager.terminate = AsyncMock()
|
|
|
|
lifecycle.platform_manager = MagicMock()
|
|
lifecycle.platform_manager.terminate = AsyncMock()
|
|
|
|
lifecycle.kb_manager = MagicMock()
|
|
lifecycle.kb_manager.terminate = AsyncMock()
|
|
|
|
lifecycle.dashboard_shutdown_event = asyncio.Event()
|
|
lifecycle._set_lifecycle_state(LifecycleState.RUNTIME_READY)
|
|
|
|
lifecycle.curr_tasks = []
|
|
|
|
with patch("astrbot.core.core_lifecycle.logger") as mock_logger:
|
|
# Should not raise
|
|
await lifecycle.stop()
|
|
|
|
# Verify warning was logged about plugin termination failure
|
|
mock_logger.warning.assert_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_cancels_inflight_runtime_bootstrap_without_failure_state(
|
|
self, mock_log_broker, mock_db, mock_astrbot_config
|
|
):
|
|
"""Test stop clears cancelled runtime bootstrap failure markers."""
|
|
lifecycle = await build_inflight_runtime_bootstrap_lifecycle(
|
|
mock_log_broker,
|
|
mock_db,
|
|
mock_astrbot_config,
|
|
)
|
|
|
|
await lifecycle.stop()
|
|
|
|
assert lifecycle.runtime_bootstrap_task is None
|
|
assert lifecycle.runtime_failed_event.is_set() is False
|
|
assert lifecycle.runtime_bootstrap_error is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_waits_for_bootstrap_cleanup_before_manager_termination(
|
|
self, mock_log_broker, mock_db, mock_astrbot_config
|
|
):
|
|
"""Test stop awaits cancelled bootstrap cleanup before manager teardown."""
|
|
lifecycle = await build_inflight_runtime_bootstrap_lifecycle(
|
|
mock_log_broker,
|
|
mock_db,
|
|
mock_astrbot_config,
|
|
)
|
|
cleanup_finished = asyncio.Event()
|
|
|
|
async def cleanup_partial_runtime_bootstrap() -> None:
|
|
cleanup_finished.set()
|
|
|
|
async def terminate_provider() -> None:
|
|
assert cleanup_finished.is_set()
|
|
|
|
lifecycle._cleanup_partial_runtime_bootstrap = AsyncMock(
|
|
side_effect=cleanup_partial_runtime_bootstrap,
|
|
)
|
|
assert lifecycle.provider_manager is not None
|
|
lifecycle.provider_manager.terminate = AsyncMock(side_effect=terminate_provider)
|
|
|
|
await lifecycle.stop()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_clears_runtime_request_ready_before_manager_termination(
|
|
self, mock_log_broker, mock_db
|
|
):
|
|
"""Test stop blocks new request traffic before tearing managers down."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
lifecycle.temp_dir_cleaner = None
|
|
lifecycle.cron_manager = None
|
|
lifecycle.plugin_manager = MagicMock()
|
|
lifecycle.plugin_manager.context = MagicMock()
|
|
lifecycle.plugin_manager.context.get_all_stars = MagicMock(return_value=[])
|
|
lifecycle.platform_manager = MagicMock()
|
|
lifecycle.platform_manager.terminate = AsyncMock()
|
|
lifecycle.kb_manager = MagicMock()
|
|
lifecycle.kb_manager.terminate = AsyncMock()
|
|
lifecycle.dashboard_shutdown_event = asyncio.Event()
|
|
|
|
lifecycle.provider_manager = MagicMock()
|
|
lifecycle.provider_manager.terminate = AsyncMock(side_effect=terminate_provider)
|
|
|
|
await lifecycle.stop()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_awaits_metadata_update_task_before_manager_termination(
|
|
self, mock_log_broker, mock_db
|
|
):
|
|
"""Test stop waits for metadata task cancellation before teardown."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
lifecycle.temp_dir_cleaner = None
|
|
lifecycle.cron_manager = None
|
|
lifecycle.plugin_manager = MagicMock()
|
|
lifecycle.plugin_manager.context = MagicMock()
|
|
lifecycle.plugin_manager.context.get_all_stars = MagicMock(return_value=[])
|
|
lifecycle.platform_manager = MagicMock()
|
|
lifecycle.platform_manager.terminate = AsyncMock()
|
|
lifecycle.kb_manager = MagicMock()
|
|
lifecycle.kb_manager.terminate = AsyncMock()
|
|
lifecycle.dashboard_shutdown_event = asyncio.Event()
|
|
|
|
cleanup_finished = asyncio.Event()
|
|
|
|
async def metadata_update() -> None:
|
|
try:
|
|
await asyncio.Event().wait()
|
|
except asyncio.CancelledError:
|
|
cleanup_finished.set()
|
|
raise
|
|
|
|
async def terminate_provider() -> None:
|
|
assert cleanup_finished.is_set()
|
|
|
|
lifecycle.provider_manager = MagicMock()
|
|
lifecycle.provider_manager.terminate = AsyncMock(side_effect=terminate_provider)
|
|
|
|
await asyncio.sleep(0)
|
|
|
|
await lifecycle.stop()
|
|
|
|
|
|
class TestAstrBotCoreLifecycleRestart:
|
|
"""Tests for AstrBotCoreLifecycle.restart method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restart_in_core_ready_state_tolerates_missing_runtime_components(
|
|
self, mock_log_broker, mock_db
|
|
):
|
|
"""Test restart works before deferred runtime bootstrap completes."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
lifecycle.astrbot_updator = MagicMock()
|
|
lifecycle.dashboard_shutdown_event = asyncio.Event()
|
|
lifecycle._set_lifecycle_state(LifecycleState.CORE_READY)
|
|
|
|
with patch("astrbot.core.core_lifecycle.threading.Thread") as mock_thread:
|
|
await lifecycle.restart()
|
|
|
|
mock_thread.assert_called_once()
|
|
mock_thread.return_value.start.assert_called_once()
|
|
assert lifecycle.lifecycle_state == LifecycleState.CREATED
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restart_terminates_managers_and_starts_thread(
|
|
self, mock_log_broker, mock_db
|
|
):
|
|
"""Test that restart terminates managers and starts reboot thread."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
|
|
lifecycle.provider_manager = MagicMock()
|
|
lifecycle.provider_manager.terminate = AsyncMock()
|
|
|
|
lifecycle.platform_manager = MagicMock()
|
|
lifecycle.platform_manager.terminate = AsyncMock()
|
|
|
|
lifecycle.kb_manager = MagicMock()
|
|
lifecycle.kb_manager.terminate = AsyncMock()
|
|
|
|
lifecycle.dashboard_shutdown_event = asyncio.Event()
|
|
|
|
lifecycle.astrbot_updator = MagicMock()
|
|
lifecycle.event_bus = MagicMock()
|
|
lifecycle.pipeline_scheduler_mapping = {"default": MagicMock()}
|
|
lifecycle.start_time = 123
|
|
lifecycle._set_lifecycle_state(LifecycleState.RUNTIME_READY)
|
|
|
|
mock_task1 = MagicMock(spec=asyncio.Task)
|
|
mock_task1.cancel = MagicMock()
|
|
mock_task1.get_name = MagicMock(return_value="task1")
|
|
|
|
mock_task2 = MagicMock(spec=asyncio.Task)
|
|
mock_task2.cancel = MagicMock()
|
|
mock_task2.get_name = MagicMock(return_value="task2")
|
|
|
|
lifecycle.curr_tasks = [mock_task1, mock_task2]
|
|
|
|
with patch("astrbot.core.core_lifecycle.threading.Thread") as mock_thread:
|
|
await lifecycle.restart()
|
|
|
|
# Verify managers were terminated
|
|
lifecycle.provider_manager.terminate.assert_awaited_once()
|
|
lifecycle.platform_manager.terminate.assert_awaited_once()
|
|
lifecycle.kb_manager.terminate.assert_awaited_once()
|
|
mock_task1.cancel.assert_called_once()
|
|
mock_task2.cancel.assert_called_once()
|
|
|
|
# Verify thread was started
|
|
mock_thread.assert_called_once()
|
|
mock_thread.return_value.start.assert_called_once()
|
|
assert lifecycle.curr_tasks == []
|
|
assert lifecycle.event_bus is None
|
|
assert lifecycle.pipeline_scheduler_mapping == {}
|
|
assert lifecycle.start_time == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restart_cancels_inflight_runtime_bootstrap_without_failure_state(
|
|
self, mock_log_broker, mock_db, mock_astrbot_config
|
|
):
|
|
"""Test restart clears cancelled runtime bootstrap failure markers."""
|
|
lifecycle = await build_inflight_runtime_bootstrap_lifecycle(
|
|
mock_log_broker,
|
|
mock_db,
|
|
mock_astrbot_config,
|
|
)
|
|
|
|
with patch("astrbot.core.core_lifecycle.threading.Thread") as mock_thread:
|
|
await lifecycle.restart()
|
|
|
|
mock_thread.assert_called_once()
|
|
mock_thread.return_value.start.assert_called_once()
|
|
assert lifecycle.runtime_bootstrap_task is None
|
|
assert lifecycle.runtime_failed_event.is_set() is False
|
|
assert lifecycle.runtime_bootstrap_error is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restart_waits_for_bootstrap_cleanup_before_manager_termination(
|
|
self, mock_log_broker, mock_db, mock_astrbot_config
|
|
):
|
|
"""Test restart awaits cancelled bootstrap cleanup before manager teardown."""
|
|
lifecycle = await build_inflight_runtime_bootstrap_lifecycle(
|
|
mock_log_broker,
|
|
mock_db,
|
|
mock_astrbot_config,
|
|
)
|
|
cleanup_finished = asyncio.Event()
|
|
|
|
async def cleanup_partial_runtime_bootstrap() -> None:
|
|
cleanup_finished.set()
|
|
|
|
async def terminate_provider() -> None:
|
|
assert cleanup_finished.is_set()
|
|
|
|
lifecycle._cleanup_partial_runtime_bootstrap = AsyncMock(
|
|
side_effect=cleanup_partial_runtime_bootstrap,
|
|
)
|
|
assert lifecycle.provider_manager is not None
|
|
lifecycle.provider_manager.terminate = AsyncMock(side_effect=terminate_provider)
|
|
|
|
with patch("astrbot.core.core_lifecycle.threading.Thread") as mock_thread:
|
|
await lifecycle.restart()
|
|
|
|
mock_thread.assert_called_once()
|
|
mock_thread.return_value.start.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restart_clears_runtime_request_ready_before_manager_termination(
|
|
self, mock_log_broker, mock_db
|
|
):
|
|
"""Test restart blocks new request traffic before tearing managers down."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
lifecycle.platform_manager = MagicMock()
|
|
lifecycle.platform_manager.terminate = AsyncMock()
|
|
lifecycle.kb_manager = MagicMock()
|
|
lifecycle.kb_manager.terminate = AsyncMock()
|
|
lifecycle.dashboard_shutdown_event = asyncio.Event()
|
|
lifecycle.astrbot_updator = MagicMock()
|
|
|
|
lifecycle.provider_manager = MagicMock()
|
|
lifecycle.provider_manager.terminate = AsyncMock(side_effect=terminate_provider)
|
|
|
|
with patch("astrbot.core.core_lifecycle.threading.Thread") as mock_thread:
|
|
await lifecycle.restart()
|
|
|
|
mock_thread.assert_called_once()
|
|
mock_thread.return_value.start.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restart_awaits_metadata_update_task_before_manager_termination(
|
|
self, mock_log_broker, mock_db
|
|
):
|
|
"""Test restart waits for metadata task cancellation before teardown."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
lifecycle.platform_manager = MagicMock()
|
|
lifecycle.platform_manager.terminate = AsyncMock()
|
|
lifecycle.kb_manager = MagicMock()
|
|
lifecycle.kb_manager.terminate = AsyncMock()
|
|
lifecycle.dashboard_shutdown_event = asyncio.Event()
|
|
lifecycle.astrbot_updator = MagicMock()
|
|
|
|
cleanup_finished = asyncio.Event()
|
|
|
|
async def metadata_update() -> None:
|
|
try:
|
|
await asyncio.Event().wait()
|
|
except asyncio.CancelledError:
|
|
cleanup_finished.set()
|
|
raise
|
|
|
|
async def terminate_provider() -> None:
|
|
assert cleanup_finished.is_set()
|
|
|
|
lifecycle.provider_manager = MagicMock()
|
|
lifecycle.provider_manager.terminate = AsyncMock(side_effect=terminate_provider)
|
|
|
|
await asyncio.sleep(0)
|
|
|
|
with patch("astrbot.core.core_lifecycle.threading.Thread") as mock_thread:
|
|
await lifecycle.restart()
|
|
|
|
mock_thread.assert_called_once()
|
|
mock_thread.return_value.start.assert_called_once()
|
|
|
|
|
|
class TestAstrBotCoreLifecycleLoadPipelineScheduler:
|
|
"""Tests for AstrBotCoreLifecycle.load_pipeline_scheduler method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_load_pipeline_scheduler_creates_schedulers(
|
|
self, mock_log_broker, mock_db, mock_astrbot_config
|
|
):
|
|
"""Test that load_pipeline_scheduler creates schedulers for each config."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
|
|
mock_astrbot_config_mgr = MagicMock()
|
|
mock_astrbot_config_mgr.confs = {
|
|
"config1": MagicMock(),
|
|
"config2": MagicMock(),
|
|
}
|
|
|
|
mock_plugin_manager = MagicMock()
|
|
|
|
mock_scheduler1 = MagicMock()
|
|
mock_scheduler1.initialize = AsyncMock()
|
|
|
|
mock_scheduler2 = MagicMock()
|
|
mock_scheduler2.initialize = AsyncMock()
|
|
|
|
with (
|
|
patch(
|
|
"astrbot.core.core_lifecycle.PipelineScheduler"
|
|
) as mock_scheduler_cls,
|
|
patch("astrbot.core.core_lifecycle.PipelineContext"),
|
|
):
|
|
# Configure mock to return different schedulers
|
|
mock_scheduler_cls.side_effect = [mock_scheduler1, mock_scheduler2]
|
|
|
|
lifecycle.astrbot_config_mgr = mock_astrbot_config_mgr
|
|
lifecycle.plugin_manager = mock_plugin_manager
|
|
lifecycle._set_lifecycle_state(LifecycleState.CORE_READY)
|
|
|
|
result = await lifecycle.load_pipeline_scheduler()
|
|
|
|
# Verify schedulers were created for each config
|
|
assert len(result) == 2
|
|
assert "config1" in result
|
|
assert "config2" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reload_pipeline_scheduler_updates_existing(
|
|
self, mock_log_broker, mock_db, mock_astrbot_config
|
|
):
|
|
"""Test that reload_pipeline_scheduler updates existing scheduler."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
|
|
mock_astrbot_config_mgr = MagicMock()
|
|
mock_astrbot_config_mgr.confs = {
|
|
"config1": MagicMock(),
|
|
}
|
|
|
|
mock_plugin_manager = MagicMock()
|
|
|
|
mock_new_scheduler = MagicMock()
|
|
mock_new_scheduler.initialize = AsyncMock()
|
|
|
|
lifecycle.astrbot_config_mgr = mock_astrbot_config_mgr
|
|
lifecycle.plugin_manager = mock_plugin_manager
|
|
lifecycle.pipeline_scheduler_mapping = {}
|
|
|
|
with (
|
|
patch(
|
|
"astrbot.core.core_lifecycle.PipelineScheduler"
|
|
) as mock_scheduler_cls,
|
|
patch("astrbot.core.core_lifecycle.PipelineContext"),
|
|
):
|
|
mock_scheduler_cls.return_value = mock_new_scheduler
|
|
|
|
lifecycle._set_lifecycle_state(LifecycleState.CORE_READY)
|
|
await lifecycle.reload_pipeline_scheduler("config1")
|
|
|
|
# Verify scheduler was added to mapping
|
|
assert "config1" in lifecycle.pipeline_scheduler_mapping
|
|
mock_new_scheduler.initialize.assert_awaited_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reload_pipeline_scheduler_raises_for_missing_config(
|
|
self, mock_log_broker, mock_db
|
|
):
|
|
"""Test that reload_pipeline_scheduler raises error for missing config."""
|
|
lifecycle = AstrBotCoreLifecycle(mock_log_broker, mock_db)
|
|
|
|
mock_astrbot_config_mgr = MagicMock()
|
|
mock_astrbot_config_mgr.confs = {}
|
|
|
|
lifecycle.astrbot_config_mgr = mock_astrbot_config_mgr
|
|
lifecycle._set_lifecycle_state(LifecycleState.CORE_READY)
|
|
|
|
with pytest.raises(ValueError, match=r"配置文件 .* 不存在"):
|
|
await lifecycle.reload_pipeline_scheduler("nonexistent")
|