Files
helper/tests/test_framework.py

365 lines
17 KiB
Python

"""不连接微信的框架行为测试;发送结果由内存后端记录。"""
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
import shutil
import sys
import tempfile
import threading
import time
import unittest
import uuid
from unittest.mock import Mock, patch
from wxbot_framework.backend import CAPABILITIES
from wxbot_framework.config import ConfigStore
from wxbot_framework.locking import InstanceLock
from wxbot_framework.plugins import PluginManager
from wxbot_framework.runtime import BotApplication
from wxbot_framework.sdk import Message, PluginContext, ServiceFacade
ROOT = Path(__file__).resolve().parent.parent
SCRATCH = ROOT / ".runtime/tests"
def wait_for(condition, seconds=3):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
if condition():
return
time.sleep(0.01)
raise AssertionError("等待异步结果超时")
class MemoryBackend:
def __init__(self, fail_start=False):
self.session_id = uuid.uuid4().hex
self.pid = 123
self.last_error = None
self.sent = []
self.fail_start = fail_start
self.started = False
self.stops = 0
def start(self, emit):
if self.fail_start:
raise RuntimeError("模拟微信尚未启动")
self.emit = emit
self.started = True
return True
def send_text(self, chat_id, text):
self.sent.append((chat_id, text))
return True
def stop(self):
self.stops += 1
self.started = False
return {"dll_unloaded": True}
class FrameworkTests(unittest.TestCase):
def setUp(self):
self.previous_logging = logging.root.manager.disable
logging.disable(logging.CRITICAL)
SCRATCH.mkdir(parents=True, exist_ok=True)
self.temporary = tempfile.TemporaryDirectory(dir=SCRATCH)
self.root = Path(self.temporary.name).resolve()
self.assertTrue(self.root.is_relative_to(SCRATCH.resolve()))
shutil.copytree(ROOT / "plugins", self.root / "plugins")
self.path = self.root / "bot_config.json"
self.config = json.loads((ROOT / "bot_config.json").read_text(encoding="utf-8"))
self.config.update(console=False, reconnect_seconds=0.1, text_send_delay={"enabled": False})
# 框架测试不调用外部大模型;玩法单独用假客户端验证。
self.config["plugins"]["idiom_chain"]["enabled"] = False
self.config["plugins"]["member_identity"]["enabled"] = False
self.config["plugins"]["chat_summary"]["enabled"] = False
self.config["plugins"]["chat_records"]["enabled"] = False
self.config["plugins"]["personality"]["enabled"] = False
self.config["plugins"]["mention_chat"]["enabled"] = False
self.config["members_config_file"] = None
self.write_config()
self.store = ConfigStore(self.path)
self.backend = MemoryBackend()
self.service = ServiceFacade(text_delay=self.config["text_send_delay"])
self.service.bind(self.backend)
self.manager = PluginManager(self.root / "plugins", self.store, self.service, CAPABILITIES)
def tearDown(self):
self.manager.shutdown()
self.service.close()
# 递归清理只针对本测试创建、已核对位于工作区的临时目录。
self.assertTrue(self.root.resolve().is_relative_to(SCRATCH.resolve()))
self.temporary.cleanup()
logging.disable(self.previous_logging)
def write_config(self):
self.path.write_text(json.dumps(self.config, ensure_ascii=False), encoding="utf-8")
def create_plugin(self, name, source, *, manifest=None):
directory = self.root / "plugins" / name
directory.mkdir(exist_ok=True)
(directory / "__init__.py").write_text(source, encoding="utf-8")
if manifest:
(directory / "plugin.json").write_text(json.dumps(manifest), encoding="utf-8")
return directory / "__init__.py"
def message(self, text, sequence=1):
return Message.from_native(11046, {"msg": text, "from_wxid": "filehelper", "room_wxid": "",
"native_sender": "self_test", "native_receiver": "filehelper",
"sequence": sequence}, self.backend.session_id)
def test_new_and_legacy_plugins_receive_unicode_snapshot(self):
self.manager.reload()
source = {"msg": "echo 中文 🌸\n第二行", "from_wxid": "filehelper"}
message = Message.from_native(11046, source, self.backend.session_id)
source["msg"] = "已被上游修改"
self.manager.dispatch(message)
self.manager.dispatch(self.message("test", 2))
wait_for(lambda: len(self.backend.sent) == 2)
self.assertCountEqual(self.backend.sent, [("filehelper", "中文 🌸\n第二行"), ("filehelper", "filehelper")])
def test_plugin_failure_does_not_stop_other_plugins_or_next_message(self):
self.create_plugin("broken", "class Plugin:\n def on_message(self,m,c):\n raise SystemExit('模拟插件错误')\n")
self.manager.reload()
self.manager.dispatch(self.message("test"))
self.manager.dispatch(self.message("echo 好的", 2))
wait_for(lambda: len(self.backend.sent) == 2 and self.manager.records["broken"].errors == 2)
self.assertTrue(self.manager.records["broken"].thread.is_alive())
self.assertIn(("filehelper", "好的"), self.backend.sent)
def test_disabled_and_unsupported_plugins_are_not_imported(self):
source = "raise RuntimeError('此插件不应被导入')\n"
self.create_plugin("disabled", source)
self.create_plugin("groups", source, manifest={"api": "v1", "requires": ["group.at"]})
self.config["plugins"]["disabled"] = {"enabled": False}
self.write_config()
self.manager.reload()
self.assertNotIn("disabled", self.manager.records)
self.assertNotIn("groups", self.manager.records)
groups = next(row for row in self.manager.status() if row["name"] == "groups")
self.assertEqual(groups["missing_capabilities"], ["group.at"])
def test_enable_disable_persist_and_keep_other_plugins_running(self):
self.manager.reload()
self.manager.disable("echo")
saved = self.store.load()["plugins"]["echo"]
self.assertFalse(saved["enabled"])
self.assertEqual(saved["prefix"], "echo ")
self.manager.dispatch(self.message("echo 不应回复"))
self.manager.dispatch(self.message("test", 2))
wait_for(lambda: self.manager.records["id_responder"].handled == 2)
self.assertEqual(self.backend.sent, [("filehelper", "filehelper")])
self.manager.enable("echo")
self.assertTrue(self.store.load()["plugins"]["echo"]["enabled"])
self.manager.dispatch(self.message("echo 恢复了", 3))
wait_for(lambda: ("filehelper", "恢复了") in self.backend.sent)
def test_reload_uses_fresh_source_and_failed_reload_keeps_previous(self):
source = "class Plugin:\n def on_message(self,m,c):\n c.reply(m,'v1')\n"
path = self.create_plugin("reloadable", source)
self.manager.reload()
self.manager.dispatch(self.message("one"))
wait_for(lambda: ("filehelper", "v1") in self.backend.sent)
previous_module = self.manager.records["reloadable"].module_name
stat = path.stat()
path.write_text(source.replace("v1", "v2"), encoding="utf-8")
os.utime(path, ns=(stat.st_atime_ns, stat.st_mtime_ns))
self.manager.reload("reloadable")
self.assertNotIn(previous_module, sys.modules)
self.manager.dispatch(self.message("two", 2))
wait_for(lambda: ("filehelper", "v2") in self.backend.sent)
current = self.manager.records["reloadable"]
path.write_text("class Plugin(:\n", encoding="utf-8")
self.manager.reload("reloadable")
self.assertIs(self.manager.records["reloadable"], current)
self.assertTrue(current.active)
self.manager.dispatch(self.message("three", 3))
wait_for(lambda: self.backend.sent.count(("filehelper", "v2")) == 2)
def test_unload_releases_plugin_resources(self):
marker = self.root / "lifecycle.txt"
source = ("class Plugin:\n"
" def on_load(self,c):\n self.file=open(c.config['marker'],'w',encoding='utf-8')\n"
" def on_message(self,m,c):\n pass\n"
" def on_unload(self,c):\n self.file.write('released');self.file.close()\n")
self.create_plugin("resource", source)
self.config["plugins"]["resource"] = {"enabled": True, "marker": str(marker)}
self.write_config()
self.manager.reload()
self.manager.disable("resource")
self.assertEqual(marker.read_text(encoding="utf-8"), "released")
def test_busy_reload_does_not_replace_active_callback(self):
source = ("import threading\nclass Plugin:\n"
" def on_load(self,c):\n self.entered=threading.Event();self.release=threading.Event()\n"
" def on_message(self,m,c):\n self.entered.set();self.release.wait(4)\n")
self.create_plugin("busy", source)
self.manager.reload()
original = self.manager.records["busy"]
self.manager.dispatch(self.message("work"))
self.assertTrue(original.instance.entered.wait(1))
try:
with self.assertRaises(RuntimeError):
self.manager.load_one("busy")
self.assertIs(self.manager.records["busy"], original)
self.assertTrue(original.active)
finally:
original.instance.release.set()
def test_old_session_reply_is_rejected_after_reconnect(self):
old = self.message("old")
self.service.close()
replacement = MemoryBackend()
self.service.bind(replacement)
context = PluginContext("test", {}, self.service, CAPABILITIES)
with self.assertRaises(RuntimeError):
context.reply(old, "不应发送")
self.assertEqual(replacement.sent, [])
def test_runtime_waits_reconnects_and_stops_cleanly(self):
created = []
def factory():
backend = MemoryBackend(fail_start=not created)
created.append(backend)
return backend
app = BotApplication(self.root, self.store, backend_factory=factory)
thread = threading.Thread(target=app.run)
thread.start()
try:
wait_for(lambda: len(created) >= 2 and app._connection_ready)
created[1].last_error = RuntimeError("模拟断线")
wait_for(lambda: len(created) >= 3 and app._connection_ready)
created[2].emit(Message.from_native(11046, {"msg": "test", "from_wxid": "filehelper"}, created[2].session_id))
wait_for(lambda: created[2].sent == [("filehelper", "filehelper")])
app.commands.put("quit")
thread.join(3)
self.assertFalse(thread.is_alive())
self.assertTrue(all(backend.stops == 1 for backend in created))
self.assertFalse(app.service.connected)
finally:
app.stop_event.set()
thread.join(3)
def test_single_instance_lock_is_released(self):
path = self.root / "bot.lock"
with InstanceLock(path):
with self.assertRaises(RuntimeError):
InstanceLock(path).acquire()
with InstanceLock(path):
pass
def test_plugins_reply_to_friend_and_group_conversations(self):
from wxbot_framework.text import TextRouter
router = TextRouter()
router.convert("wxid_self", "filehelper", "init")
self.manager.reload()
for sender, text in (("wxid_friend", "echo 好友"),
("12345@chatroom", "wxid_member:\necho 群聊"),
("12345@chatroom", "wxid_member:\ntest")):
data = router.convert(sender, "wxid_self", text)
self.manager.dispatch(Message.from_native(11046, data, self.backend.session_id))
wait_for(lambda: len(self.backend.sent) == 3)
self.assertCountEqual(self.backend.sent, [("wxid_friend", "好友"),
("12345@chatroom", "群聊"),
("12345@chatroom", "12345@chatroom")])
def test_runtime_allowlist_dispatches_only_selected_chat(self):
self.config["backend"] = "native_text"
self.config["chat_allowlist"] = ["filehelper"]
self.write_config()
app = BotApplication(self.root, self.store, backend_factory=lambda: self.backend)
app.service.bind(self.backend)
app.plugins.reload()
try:
app._emit(Message(text="test", chat_id="wxid_other", session_id=self.backend.session_id,
data={"msg": "test", "from_wxid": "wxid_other"}))
app._emit(self.message("echo 允许"))
wait_for(lambda: app.plugins.records["id_responder"].handled == 1 and
app.plugins.records["echo"].handled == 1)
self.assertEqual(self.backend.sent, [("filehelper", "允许")])
finally:
app.plugins.shutdown()
def test_new_group_is_logged_before_allowlist_without_body_or_member(self):
self.config["chat_allowlist"] = ["filehelper"]
self.write_config()
app = BotApplication(self.root, self.store, backend_factory=lambda: self.backend)
app.members = Mock()
room = "87654321@chatroom"
sender = "wxid_example_member"
body = "本条群正文不应出现在日志中"
# 同时覆盖标准消息和仅有会话 ID、缺少群标记的消息,避免误当私聊泄露正文。
logging.disable(logging.NOTSET)
with patch.object(app.plugins, "dispatch") as dispatch, self.assertLogs("Bot", "INFO") as captured:
for is_group in (True, False):
app._emit(Message(body, room, sender, is_group=is_group))
self.assertEqual(len(captured.records), 2)
for record in captured.records:
self.assertIn(room, record.getMessage())
self.assertNotIn(body, record.getMessage())
self.assertNotIn(sender, record.getMessage())
app.members.record.assert_not_called()
dispatch.assert_not_called()
def test_private_log_keeps_sender_and_unicode_but_escapes_line_breaks(self):
app = BotApplication(self.root, self.store, backend_factory=lambda: self.backend)
sender = "wxid_example_sender"
body = "你好 🌸\r\n第二行\t100%"
# 文件传输助手的会话 ID 与实际作者不同,必须显示作者。
message = Message.from_native(11046, {"msg": body, "chat_id": "filehelper",
"sender_id": sender, "dispatch_to_plugins": False}, "logging")
logging.disable(logging.NOTSET)
with self.assertLogs("Bot", "INFO") as captured:
app._emit(message)
self.assertEqual(len(captured.records), 1)
rendered = captured.records[0].getMessage()
self.assertIn(sender, rendered)
self.assertIn("你好 🌸", rendered)
self.assertNotIn("\n", rendered)
self.assertNotIn("\r", rendered)
self.assertEqual(json.loads(rendered.split("内容=", 1)[1]), body)
@unittest.skipUnless(sys.platform == "win32", "本机原生服务检查")
def test_native_test_messages_do_not_bypass_group_log_privacy(self):
native = str(ROOT / "native")
if native not in sys.path:
sys.path.insert(0, native)
from text_service import NativeTextService
from filehelper_service import ReceivedTextMessage
service = NativeTextService(pid=123)
account, sender, room = "wxid_example_self", "wxid_example_member", "87654321@chatroom"
service.router.set_account(account)
service._sender_wechat_name = Mock(return_value=None)
app = BotApplication(self.root, self.store, backend_factory=lambda: self.backend)
markers = ("WXBOT_AT_CHAT_PROBE", "WXBOT_GROUP_TEST", "echo WXBOT_GROUP_TEST")
logging.disable(logging.NOTSET)
with self.assertLogs(level="INFO") as captured:
for body in markers:
raw = ReceivedTextMessage(type=1, sender=room.encode(), receiver=account.encode(),
text=(sender + ":\n" + body).encode())
app._emit(Message.from_native(11046, service._map_message(raw), "logging"))
self.assertEqual(len(captured.records), len(markers))
rendered = "\n".join(captured.output)
self.assertIn(room, rendered)
self.assertNotIn(sender, rendered)
for body in markers:
self.assertNotIn(body, rendered)
service._owns_bridge = True
service._check_account = Mock()
service._run = Mock(return_value={"success": True})
with self.assertLogs("NativeText", "INFO") as sent_logs:
self.assertTrue(service.send_text(room, "WXBOT_GROUP_SEND_TEST"))
self.assertIn(room, "\n".join(sent_logs.output))
self.assertNotIn("WXBOT_GROUP_SEND_TEST", "\n".join(sent_logs.output))
if __name__ == "__main__":
unittest.main()