Tighten external legacy plugin compatibility smoke tests

This commit is contained in:
whatevertogo
2026-03-13 16:24:28 +08:00
parent d83e2551b7
commit 701384398e
7 changed files with 164 additions and 12 deletions

View File

@@ -26,6 +26,7 @@
- 2026-03-13: Real legacy `Star` plugins may call compat context helpers on `self` or `self.context` during `__init__()` and `initialize()`, including `self.put_kv_data(...)`, `self.get_kv_data(...)`, and `self.context.get_config()`. Keep proxy methods on `LegacyStar`, expose a safe `LegacyContext.get_config()`, and bind the shared legacy context before lifecycle hooks run.
- 2026-03-13: On Windows, `.gitignore` matching is case-insensitive. A broad entry like `astrBot/` will also ignore `src-new/astrbot/...`, which can silently hide real compat alias packages from `git status`. Keep that ignore anchored as `/astrBot/` if it is only meant for a root-level scratch checkout.
- 2026-03-13: The reference checkout under `astrBot/astrbot/api` exposes a broader old plugin-facing package surface than the current `src-new/astrbot` alias package. When improving package-name compatibility, compare against those public `api/*` modules as a set instead of only patching the one import path hit by the latest migrated plugin.
- 2026-03-13: Some real legacy plugins call `asyncio.create_task()` during object construction. Calling `load_plugin()` outside a running event loop can therefore produce false-negative compat failures even though the real worker path is fine. External compat smoke tests should load plugins under an active loop, and "real compatibility" claims should preferably exercise `SupervisorRuntime` + worker + a real handler invocation instead of import-only checks.
# 开发命令

View File

@@ -26,6 +26,7 @@
- 2026-03-13: Real legacy `Star` plugins may call compat context helpers on `self` or `self.context` during `__init__()` and `initialize()`, including `self.put_kv_data(...)`, `self.get_kv_data(...)`, and `self.context.get_config()`. Keep proxy methods on `LegacyStar`, expose a safe `LegacyContext.get_config()`, and bind the shared legacy context before lifecycle hooks run.
- 2026-03-13: On Windows, `.gitignore` matching is case-insensitive. A broad entry like `astrBot/` will also ignore `src-new/astrbot/...`, which can silently hide real compat alias packages from `git status`. Keep that ignore anchored as `/astrBot/` if it is only meant for a root-level scratch checkout.
- 2026-03-13: The reference checkout under `astrBot/astrbot/api` exposes a broader old plugin-facing package surface than the current `src-new/astrbot` alias package. When improving package-name compatibility, compare against those public `api/*` modules as a set instead of only patching the one import path hit by the latest migrated plugin.
- 2026-03-13: Some real legacy plugins call `asyncio.create_task()` during object construction. Calling `load_plugin()` outside a running event loop can therefore produce false-negative compat failures even though the real worker path is fine. External compat smoke tests should load plugins under an active loop, and "real compatibility" claims should preferably exercise `SupervisorRuntime` + worker + a real handler invocation instead of import-only checks.
# 开发命令

View File

@@ -3,7 +3,8 @@
from loguru import logger
from astrbot_sdk._shared_preferences import sp
from astrbot_sdk.api.basic import AstrBotConfig
from . import utils
__all__ = ["logger", "sp", "utils"]
__all__ = ["AstrBotConfig", "logger", "sp", "utils"]

View File

@@ -0,0 +1,3 @@
"""旧版 ``astrbot.core.message`` 导入路径兼容入口。"""
from .components import * # noqa: F403

View File

@@ -0,0 +1,3 @@
"""旧版 ``astrbot.core.message.components`` 导入路径兼容入口。"""
from astrbot_sdk.api.message.components import * # noqa: F403

View File

@@ -240,6 +240,20 @@ class TestAstrbotImportAlias:
assert callable(SessionWaiter.trigger)
assert callable(session_waiter)
def test_legacy_astrbot_core_root_exports(self):
"""astrbot.core root should expose common legacy root names."""
from astrbot.core import AstrBotConfig, logger, sp
from astrbot.core.message.components import At, Image, Node, Nodes, Plain
assert AstrBotConfig is not None
assert logger is not None
assert sp is not None
assert Plain(text="ok").text == "ok"
assert Image(file="https://example.com/test.png").file
assert At(qq="1").user_id == "1"
assert Node(uin="1", content=[]).sender_id == "1"
assert Nodes(nodes=[]).nodes == []
def test_legacy_astrbot_event_filter_module_exports(self):
"""astrbot.api.event.filter should be importable from the old module path."""
from astrbot.api.event.filter import EventMessageType, command, llm_tool

View File

@@ -2,10 +2,17 @@
默认不跑;手动验证真实外部插件时,设置
``ASTRBOT_EXTERNAL_PLUGIN_REPO=https://...`` 即可启用。
如果还希望验证真实 handler 调用,而不是仅验证可加载,可以额外设置:
- ``ASTRBOT_EXTERNAL_PLUGIN_COMMAND=<command>``
- ``ASTRBOT_EXTERNAL_PLUGIN_EVENT_TEXT=<text>`` (可选,默认等于 command)
- ``ASTRBOT_EXTERNAL_PLUGIN_EXPECT_TEXT=<substring>`` (可选)
"""
from __future__ import annotations
import asyncio
import os
import subprocess
import tempfile
@@ -14,12 +21,35 @@ from pathlib import Path
import pytest
from astrbot_sdk.protocol.messages import InitializeOutput, PeerInfo
from astrbot_sdk.runtime.bootstrap import SupervisorRuntime
from astrbot_sdk.runtime.loader import (
PluginEnvironmentManager,
load_plugin_spec,
)
from astrbot_sdk.runtime.peer import Peer
from tests_v4.helpers import make_transport_pair
EXTERNAL_PLUGIN_REPO_ENV = "ASTRBOT_EXTERNAL_PLUGIN_REPO"
EXTERNAL_PLUGIN_COMMAND_ENV = "ASTRBOT_EXTERNAL_PLUGIN_COMMAND"
EXTERNAL_PLUGIN_EVENT_TEXT_ENV = "ASTRBOT_EXTERNAL_PLUGIN_EVENT_TEXT"
EXTERNAL_PLUGIN_EXPECT_TEXT_ENV = "ASTRBOT_EXTERNAL_PLUGIN_EXPECT_TEXT"
def _clone_external_plugin(
*,
project_root: Path,
repo_url: str,
clone_dir: Path,
) -> None:
subprocess.run(
["git", "clone", "--depth", "1", repo_url, str(clone_dir)],
check=True,
cwd=project_root,
capture_output=True,
text=True,
)
@pytest.mark.skipif(
@@ -33,12 +63,10 @@ def test_external_plugin_load_smoke():
with tempfile.TemporaryDirectory(prefix="astrbot-external-plugin-") as temp_dir:
clone_dir = Path(temp_dir) / "plugin"
subprocess.run(
["git", "clone", "--depth", "1", repo_url, str(clone_dir)],
check=True,
cwd=project_root,
capture_output=True,
text=True,
_clone_external_plugin(
project_root=project_root,
repo_url=repo_url,
clone_dir=clone_dir,
)
spec = load_plugin_spec(clone_dir)
@@ -46,6 +74,7 @@ def test_external_plugin_load_smoke():
python_path = manager.prepare_environment(spec)
script = textwrap.dedent(
f"""
import asyncio
import sys
from pathlib import Path
@@ -55,11 +84,14 @@ def test_external_plugin_load_smoke():
from astrbot_sdk.runtime.loader import load_plugin, load_plugin_spec
spec = load_plugin_spec(plugin_dir)
loaded = load_plugin(spec)
print("PLUGIN", loaded.plugin.name)
print("HANDLERS", len(loaded.handlers))
print("CAPS", len(loaded.capabilities))
async def main():
spec = load_plugin_spec(plugin_dir)
loaded = load_plugin(spec)
print("PLUGIN", loaded.plugin.name)
print("HANDLERS", len(loaded.handlers))
print("CAPS", len(loaded.capabilities))
asyncio.run(main())
"""
)
env = os.environ.copy()
@@ -78,3 +110,100 @@ def test_external_plugin_load_smoke():
)
assert "HANDLERS" in result.stdout
assert "PLUGIN" in result.stdout
@pytest.mark.skipif(
not (
os.getenv(EXTERNAL_PLUGIN_REPO_ENV) and os.getenv(EXTERNAL_PLUGIN_COMMAND_ENV)
),
reason=(
f"set {EXTERNAL_PLUGIN_REPO_ENV} and {EXTERNAL_PLUGIN_COMMAND_ENV} "
"to enable external plugin runtime command smoke tests"
),
)
@pytest.mark.asyncio
async def test_external_plugin_runtime_command_smoke():
"""按需拉起真实 supervisor/worker 链路并执行一个外部插件命令。"""
repo_url = os.environ[EXTERNAL_PLUGIN_REPO_ENV]
command_name = os.environ[EXTERNAL_PLUGIN_COMMAND_ENV]
event_text = os.getenv(EXTERNAL_PLUGIN_EVENT_TEXT_ENV) or command_name
expected_text = os.getenv(EXTERNAL_PLUGIN_EXPECT_TEXT_ENV)
project_root = Path(__file__).resolve().parent.parent
with tempfile.TemporaryDirectory(prefix="astrbot-external-runtime-") as temp_dir:
plugins_root = Path(temp_dir) / "plugins"
plugin_root = plugins_root / "external_plugin"
_clone_external_plugin(
project_root=project_root,
repo_url=repo_url,
clone_dir=plugin_root,
)
left, right = make_transport_pair()
core = Peer(
transport=left,
peer_info=PeerInfo(name="outer-core", role="core", version="v4"),
)
core.set_initialize_handler(
lambda _message: asyncio.sleep(
0,
result=InitializeOutput(
peer=PeerInfo(name="outer-core", role="core", version="v4"),
capabilities=[],
metadata={},
),
)
)
runtime = SupervisorRuntime(
transport=right,
plugins_dir=plugins_root,
env_manager=PluginEnvironmentManager(project_root),
)
await core.start()
try:
await runtime.start()
await core.wait_until_remote_initialized()
handler = next(
(
item
for item in core.remote_handlers
if getattr(item.trigger, "command", None) == command_name
),
None,
)
assert handler is not None, (
f"command handler not found: {command_name}; "
f"available={[getattr(item.trigger, 'command', None) for item in core.remote_handlers]}"
)
await core.invoke(
"handler.invoke",
{
"handler_id": handler.id,
"event": {
"text": event_text,
"session_id": "external-smoke-session",
"user_id": "user-1",
"platform": "test",
},
},
request_id="external-runtime-command",
)
sent_messages = list(runtime.capability_router.sent_messages)
assert sent_messages, (
"external plugin command completed but did not emit any platform "
"message; this usually means the command path was not really exercised"
)
if expected_text is not None:
assert any(
expected_text in item.get("text", "")
for item in sent_messages
if "text" in item
), sent_messages
finally:
await runtime.stop()
await core.stop()