Files
nanobot/tests/channels/test_channel_setup.py
T
chengyongruandGitHub 462a0dfb0f refactor(channels): make built-in channels self-contained (#4908)
* refactor(channels): own setup and instance contracts

* refactor(channels): isolate management contracts

* refactor(channels): normalize activation contracts

* fix(channels): enforce management contracts

* refactor(channels): finish setup ownership migration

* fix(channels): harden management contracts

* fix(channels): enforce lazy loading and runtime ownership

* fix(feishu): make multi-instance startup idempotent

* fix(webui): render channel setup contracts cleanly

* fix(feishu): stop websocket clients cleanly

* fix(channels): enforce persistence and activation gates

* fix(channels): preserve global feature action scope

* fix(channels): apply defaults for single plugins

* fix(channels): enforce management contract boundaries

* refactor(feishu): remove identity helper indirection

* fix(channels): preserve management setup contracts

* refactor(channels): generalize instance settings UI

* refactor(channels): package channel plugins with web UI metadata

* refactor(channels): make built-ins self-contained packages

* test(channels): colocate tests with channel packages

* fix(dingtalk): use official brand icon

* feat(channels): colocate webui translations

* docs(channels): clarify plugin ownership

* test(exec): remove output wait race

* refactor(channels): unify plugin descriptors

* fix(channels): enforce descriptor-owned contracts

* refactor(channels): finish package-owned plugin setup

* refactor(channels): use repository-owned packages only

* fix(channels): self-describe dependencies and runtime state

* fix(channels): warn about legacy entry points
2026-07-19 23:30:49 +08:00

284 lines
9.6 KiB
Python

import ast
import json
import re
import subprocess
import sys
from pathlib import Path
import pytest
import nanobot.channels._setup as channel_setup_module
import nanobot.channels.registry as registry_module
from nanobot.channels._setup import channel_setup_spec
from nanobot.channels.plugin import ChannelPlugin, load_channel_package
from nanobot.channels.registry import channel_default_enabled, discover_plugins
EXPECTED_CHANNELS = {
"dingtalk",
"discord",
"email",
"feishu",
"matrix",
"mattermost",
"mochat",
"msteams",
"napcat",
"qq",
"signal",
"slack",
"telegram",
"websocket",
"wecom",
"weixin",
"whatsapp",
}
def test_channel_setup_spec_derives_route_and_secret_metadata() -> None:
slack = channel_setup_spec("slack")
assert slack is not None
assert slack.secrets == {"appToken", "botToken"}
assert slack.route_field_types == {
"appToken": "secret",
"botToken": "secret",
"groupPolicy": ("enum", {"mention", "open", "allowlist"}),
}
assert slack.simple_required_fields == ("appToken", "botToken")
assert slack.fields["groupPolicy"].default == "mention"
group_policy = next(
field
for field in slack.to_public_dict("slack")["fields"]
if field["field"] == "groupPolicy"
)
assert group_policy["default_value"] == "mention"
def test_matrix_setup_requires_one_complete_login_method() -> None:
matrix = channel_setup_spec("matrix")
assert matrix is not None
base = {
"homeserver": "https://matrix.example",
"userId": "@nanobot:matrix.example",
}
assert matrix.is_configured(base | {"password": "secret"})
assert matrix.is_configured(base | {"accessToken": "token", "deviceId": "DEVICE"})
assert not matrix.is_configured(base | {"accessToken": "token"})
def test_channel_setup_spec_separates_writable_and_snapshot_fields() -> None:
matrix = channel_setup_spec("matrix")
discord = channel_setup_spec("discord")
assert matrix is not None
assert discord is not None
assert "allowFrom" not in matrix.route_field_types
assert "allowFrom" in matrix.snapshot_fields
assert "allowFrom" in discord.route_field_types
assert "allowFrom" not in discord.snapshot_fields
def test_webui_forms_have_writable_mattermost_and_whatsapp_contracts() -> None:
mattermost = channel_setup_spec("mattermost")
whatsapp = channel_setup_spec("whatsapp")
assert mattermost is not None
assert whatsapp is not None
assert mattermost.route_field_types["serverUrl"] == "string"
assert mattermost.route_field_types["token"] == "secret"
assert whatsapp.route_field_types["allowFrom"] == "list"
assert whatsapp.route_field_types["groupPolicy"] == (
"enum",
{"mention", "open"},
)
def test_every_channel_is_a_self_contained_package() -> None:
channel_dir = Path(channel_setup_module.__file__).parent
package_names = {path.parent.name for path in channel_dir.glob("*/manifest.py")}
assert not hasattr(channel_setup_module, "CHANNEL_SETUP_SPECS")
assert package_names == EXPECTED_CHANNELS
assert set(discover_plugins()) == EXPECTED_CHANNELS
for name in EXPECTED_CHANNELS:
package_dir = channel_dir / name
assert (package_dir / "__init__.py").is_file()
assert (package_dir / "manifest.py").is_file()
assert (package_dir / "runtime.py").is_file()
assert not (channel_dir / f"{name}.py").exists()
plugin = load_channel_package(name)
assert plugin is not None
assert plugin.name == name
assert plugin.runtime.startswith(f"nanobot.channels.{name}.runtime:")
assert plugin.setup is channel_setup_spec(name)
if plugin.webui is not None:
assert (package_dir / plugin.webui).is_file()
def test_channel_locales_cover_authoritative_setup_contracts() -> None:
channel_dir = Path(channel_setup_module.__file__).parent
for name in EXPECTED_CHANNELS:
plugin = load_channel_package(name)
assert plugin is not None
if plugin.webui is None or plugin.setup is None:
continue
english = json.loads(
(channel_dir / name / "webui" / "locales" / "en.json").read_text(encoding="utf-8")
)
setup_messages = english["setup"]
field_messages = setup_messages.get("fields", {})
for field_name, field in plugin.setup.fields.items():
if not field.writable:
continue
message_key = re.sub(r"[^A-Za-z0-9_-]+", "_", field_name)
assert message_key in field_messages, f"{name} field {field_name} has no locale copy"
if plugin.setup.official_url:
assert setup_messages.get("officialLabel"), f"{name} has no localized official label"
def test_channel_manifests_only_import_contract_modules() -> None:
channel_dir = Path(channel_setup_module.__file__).parent
allowed_imports = {
"nanobot.channels._manifest",
"nanobot.channels.contracts",
"nanobot.channels.plugin",
}
for name in EXPECTED_CHANNELS:
manifest_path = channel_dir / name / "manifest.py"
tree = ast.parse(manifest_path.read_text(encoding="utf-8"))
imports: set[str] = set()
for node in tree.body:
if isinstance(node, ast.Import):
imports.update(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
imports.add(node.module)
allowed_channel_imports = {
module
for module in imports
if module.startswith(f"nanobot.channels.{name}.")
and not module.endswith(".runtime")
}
unexpected = imports - allowed_imports - allowed_channel_imports
assert not unexpected, f"{name} imports runtime dependencies: {unexpected}"
def test_runtime_classes_do_not_declare_persisted_management_hooks() -> None:
channel_dir = Path(channel_setup_module.__file__).parent
management_hooks = {
"feature_instances",
"instance_specs",
"runtime_name",
"supports_multiple_instances",
"update_instance_config",
}
for name in EXPECTED_CHANNELS:
tree = ast.parse((channel_dir / name / "runtime.py").read_text(encoding="utf-8"))
declared = {
item.name
for node in tree.body
if isinstance(node, ast.ClassDef)
for item in node.body
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))
}
assert declared.isdisjoint(management_hooks), f"{name} runtime owns {declared & management_hooks}"
def test_feishu_package_manifest_owns_runtime_and_webui_metadata() -> None:
plugin = load_channel_package("feishu")
assert plugin is not None
assert plugin.runtime == "nanobot.channels.feishu.runtime:FeishuChannel"
assert plugin.dependencies == ("lark-oapi>=1.5.0,<2.0.0",)
assert plugin.connector == "nanobot.channels.feishu.connect:FeishuConnectStore"
assert plugin.management.multi_instance is True
assert plugin.webui == "webui/index.tsx"
def test_weixin_package_manifest_owns_runtime_and_webui_metadata() -> None:
plugin = load_channel_package("weixin")
assert plugin is not None
assert plugin.runtime == "nanobot.channels.weixin.runtime:WeixinChannel"
assert plugin.dependencies == ("qrcode[pil]>=8.0", "pycryptodome>=3.20.0")
assert plugin.connector == "nanobot.channels.weixin.connect:WeixinConnectStore"
assert plugin.webui == "webui/index.tsx"
def test_package_manifests_do_not_import_runtimes() -> None:
code = f"""
import sys
from nanobot.channels.plugin import load_channel_package
for name in {sorted(EXPECTED_CHANNELS)!r}:
plugin = load_channel_package(name)
assert plugin is not None
assert f"nanobot.channels.{{name}}.runtime" not in sys.modules
"""
result = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
def test_channel_plugin_normalizes_webui_entry() -> None:
plugin = ChannelPlugin(
name="demo",
display_name="Demo",
runtime="example.demo.runtime:DemoChannel",
webui="webui\\index.tsx",
)
assert plugin.webui == "webui/index.tsx"
def test_channel_plugin_name_must_match_package_identifier() -> None:
with pytest.raises(ValueError, match="letters, digits, or underscores"):
ChannelPlugin(
name="google-chat",
display_name="Google Chat",
runtime="example.google_chat.runtime:GoogleChatChannel",
)
def test_channel_plugin_rejects_invalid_runtime_import_path() -> None:
with pytest.raises(ValueError, match="absolute import path"):
ChannelPlugin(
name="demo",
display_name="Demo",
runtime="../runtime:DemoChannel",
)
def test_channel_default_enabled_uses_package_manifest(monkeypatch) -> None:
plugin = ChannelPlugin(
name="demo",
display_name="Demo",
runtime="example.demo.runtime:DemoChannel",
default_enabled=True,
)
monkeypatch.setattr(
registry_module,
"load_channel_plugin",
lambda name: plugin if name == "demo" else (_ for _ in ()).throw(ImportError()),
)
assert channel_default_enabled("demo") is True
assert channel_default_enabled("missing") is False
def test_websocket_manifest_declares_the_only_default_enabled_channel() -> None:
enabled = {
name
for name in EXPECTED_CHANNELS
if (plugin := load_channel_package(name)) is not None and plugin.default_enabled
}
assert enabled == {"websocket"}