重大重构:引擎模块拆分 + P0插件实现 + 55个Bug修复

核心变更:
- engine.py(1781行)拆分为8个独立模块: lifecycle/security/deps/
  datastore/pl_injector/watcher/signature/manager
- 新增plugin-bridge: 事件总线 + 服务注册 + RPC通信
- 新增i18n: 国际化/多语言翻译支持
- 新增plugin-storage: 插件键值/文件存储
- 新增ws-api: WebSocket实时通信(pub/sub + 自定义处理器)
- nodejs-adapter统一为Plugin ABC模式

Bug修复:
- 修复load_all()中store_dir未定义崩溃
- 修复DependencyResolver入度计算(拓扑排序)
- 修复PermissionError隐藏内置异常
- 修复CORS中间件头部未附加到响应
- 修复IntegrityChecker跳过__pycache__目录
- 修复版本号不一致(v2.0.0→v1.2.0)
- 修复测试文件的Logger导入/路径/私有方法调用
- 修复context.py缺少typing导入
- 修复config.py STORE_DIR默认路径(./mods→./store)

测试覆盖: 14→91个测试, 全部通过
This commit is contained in:
Falck
2026-05-12 11:40:06 +08:00
parent 3a096f59a9
commit bce27db4ac
57 changed files with 3669 additions and 2367 deletions

View File

@@ -26,7 +26,7 @@ def temp_data_dir():
@pytest.fixture
def mock_config(temp_data_dir, temp_store_dir):
def mock_config(temp_data_dir):
from oss.config.config import _global_config
original_config = _global_config
_global_config = None

View File

@@ -12,24 +12,20 @@ from oss.logger.logger import Logger
def test_cors_fix():
config = Config()
# 验证 CORS 配置默认值
cors_origins = config.get("CORS_ALLOWED_ORIGINS")
assert "http://localhost:3000" in cors_origins
assert "http://127.0.0.1:3000" in cors_origins
# 验证环境变量覆盖 CORS 配置(环境变量值为字符串)
os.environ["CORS_ALLOWED_ORIGINS"] = '["http://localhost:8080"]'
config = Config()
cors_origins = config.get("CORS_ALLOWED_ORIGINS")
# 环境变量覆盖时列表类型保持为字符串Config 不做 JSON 解析)
assert cors_origins == '["http://localhost:8080"]'
del os.environ["CORS_ALLOWED_ORIGINS"]
def test_logger_functionality():
# Logger 不接受参数,使用无参构造
logger = Logger()
assert logger is not None
logger.info("测试日志消息")
logger.info("Logger", "test log message")

83
oss/tests/test_i18n.py Normal file
View File

@@ -0,0 +1,83 @@
"""Tests for i18n plugin"""
import os
import sys
import tempfile
import json
import pytest
from pathlib import Path
PLUGIN_DIR = Path(__file__).parent.parent / "store" / "NebulaShell" / "i18n"
sys.path.insert(0, str(PLUGIN_DIR))
import importlib.util
spec = importlib.util.spec_from_file_location("i18n_main", str(PLUGIN_DIR / "main.py"))
main_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(main_module)
I18n = main_module.I18n
class TestI18n:
def test_default_language(self):
i18n = I18n()
assert i18n.get_language() == "zh-CN"
def test_set_language_valid(self):
i18n = I18n()
assert i18n.set_language("en-US") == True
assert i18n.get_language() == "en-US"
def test_set_language_invalid(self):
i18n = I18n()
assert i18n.set_language("fr-FR") == False
assert i18n.get_language() == "zh-CN"
def test_supported_languages(self):
i18n = I18n()
langs = i18n.get_supported_languages()
assert "zh-CN" in langs
assert "en-US" in langs
assert "ja-JP" in langs
def test_translate_fallback_to_key(self):
i18n = I18n()
result = i18n.translate("nonexistent.key")
assert result == "nonexistent.key"
def test_register_and_translate(self):
i18n = I18n()
i18n.register_translations("zh-CN", "test", {"greeting": "你好"})
assert i18n.translate("greeting", "test") == "你好"
def test_translate_with_format(self):
i18n = I18n()
i18n.register_translations("zh-CN", "test", {"welcome": "欢迎 {name}"})
result = i18n.translate("welcome", "test", name="张三")
assert result == "欢迎 张三"
def test_load_domain(self):
i18n = I18n()
i18n.load_domain("custom", {"key": "val"})
assert i18n.translate("key", "custom") == "val"
def test_t_alias(self):
i18n = I18n()
assert i18n.t("missing") == "missing"
def test_get_info(self):
i18n = I18n()
info = i18n.get_info()
assert "language" in info
assert "supported" in info
assert "domains" in info
def test_lifecycle(self):
i18n = I18n()
i18n.init()
i18n.start()
i18n.stop()
assert i18n.get_language() == "zh-CN"
if __name__ == '__main__':
pytest.main([__file__, '-v'])

View File

@@ -0,0 +1,190 @@
"""End-to-end integration tests for NebulaShell plugin system"""
import os
import sys
import tempfile
import json
import shutil
import pytest
from pathlib import Path
def _create_dummy_plugin(store_dir: str, name: str, dependencies: list = None, extra: str = ""):
plugin_dir = Path(store_dir) / "NebulaShell" / name
plugin_dir.mkdir(parents=True, exist_ok=True)
manifest = {
"metadata": {"name": name, "version": "1.0.0", "description": f"{name} plugin", "author": "test"},
"config": {"enabled": True, "args": {}},
"dependencies": dependencies or [],
"permissions": ["*"],
}
(plugin_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
main_code = f"""class {name.capitalize().replace('-', '')}:
name = "{name}"
version = "1.0.0"
description = "{name} plugin"
def init(self, deps=None):
pass
def start(self):
pass
def stop(self):
pass
{extra}
def New():
return {name.capitalize().replace('-', '')}()
"""
(plugin_dir / "main.py").write_text(main_code, encoding="utf-8")
class TestIntegration:
@pytest.fixture
def temp_store(self):
tmp = tempfile.mkdtemp()
store = Path(tmp) / "store"
store.mkdir()
(store / "NebulaShell").mkdir()
yield str(store)
shutil.rmtree(tmp)
def test_plugin_manager_create(self):
from oss.core.manager import PluginManager
pm = PluginManager()
assert pm is not None
assert pm.plugins == {}
def test_load_single_plugin(self, temp_store):
_create_dummy_plugin(temp_store, "hello-world")
from oss.core.manager import PluginManager
pm = PluginManager()
pm.load(Path(temp_store) / "NebulaShell" / "hello-world")
assert "hello-world" in pm.plugins
def test_load_plugins_with_dependencies(self, temp_store):
_create_dummy_plugin(temp_store, "base")
_create_dummy_plugin(temp_store, "dependent", dependencies=["base"])
from oss.core.manager import PluginManager
pm = PluginManager()
pm.load(Path(temp_store) / "NebulaShell" / "base")
pm.load(Path(temp_store) / "NebulaShell" / "dependent")
pm._sort_by_dependencies()
assert "base" in pm.plugins
assert "dependent" in pm.plugins
def test_init_and_start_all(self, temp_store):
_create_dummy_plugin(temp_store, "test-me", extra="""
_started = False
def is_started(self):
return self._started
def start(self):
self._started = True
""")
from oss.core.manager import PluginManager
pm = PluginManager()
pm.load(Path(temp_store) / "NebulaShell" / "test-me")
pm.init_and_start_all()
instance = pm.plugins["test-me"]["instance"]
assert instance.is_started() is True
def test_load_all_from_dir(self, temp_store):
_create_dummy_plugin(temp_store, "alpha")
_create_dummy_plugin(temp_store, "beta")
from oss.core.manager import PluginManager
from oss.config import init_config
init_config()
pm = PluginManager()
pm._load_plugins_from_dir(Path(temp_store))
assert "alpha" in pm.plugins
assert "beta" in pm.plugins
def test_stop_all(self, temp_store):
_create_dummy_plugin(temp_store, "will-stop", extra="""
_stopped = False
def is_stopped(self):
return self._stopped
def stop(self):
self._stopped = True
""")
from oss.core.manager import PluginManager
pm = PluginManager()
pm.load(Path(temp_store) / "NebulaShell" / "will-stop")
pm.stop_all()
instance = pm.plugins["will-stop"]["instance"]
assert instance.is_stopped() is True
def test_plugin_manager_status(self, temp_store):
_create_dummy_plugin(temp_store, "status-test")
from oss.core.manager import PluginManager
pm = PluginManager()
pm.load(Path(temp_store) / "NebulaShell" / "status-test")
status = pm.get_status()
assert status["plugins"]["total"] == 1
def test_dependency_resolver(self):
from oss.core.deps import DependencyResolver
dr = DependencyResolver()
dr.add_dependency("a", ["b"])
dr.add_dependency("b", ["c"])
dr.add_dependency("c", [])
order = dr.resolve()
assert order.index("c") < order.index("b") < order.index("a")
def test_plugin_info(self):
from oss.core.manager import PluginInfo
info = PluginInfo()
info.name = "test"
assert info.name == "test"
def test_plugin_proxy_permission(self):
from oss.core.manager import PluginInfo
from oss.core.security import PluginProxy, PluginPermissionError
proxy = PluginProxy("caller", object(), ["allowed"], {"allowed": {"instance": object()}})
assert proxy.get_plugin("allowed") is not None
with pytest.raises(PluginPermissionError):
proxy.get_plugin("not-allowed")
def test_data_store_basic(self):
from oss.core.datastore import DataStore
import tempfile
ds = DataStore()
orig = ds._base_dir
tmp = Path(tempfile.mkdtemp())
ds._base_dir = tmp / "data"
ds._base_dir.mkdir(parents=True, exist_ok=True)
assert ds.save("test-plugin", "key", {"value": 42}) is True
loaded = ds.load("test-plugin", "key")
assert loaded == {"value": 42}
ds.delete("test-plugin", "key")
assert ds.load("test-plugin", "key") is None
shutil.rmtree(tmp, ignore_errors=True)
def test_get_status_summary(self, temp_store):
_create_dummy_plugin(temp_store, "stat-p")
from oss.core.manager import PluginManager
pm = PluginManager()
pm.load(Path(temp_store) / "NebulaShell" / "stat-p")
s = pm.get_status()
assert isinstance(s, dict)
assert "plugins" in s
def test_capability_registry(self):
from oss.core.manager import CapabilityRegistry
cr = CapabilityRegistry()
cr.register_provider("http", "a", object())
assert cr.has_capability("http") is True
assert cr.get_provider("http") is not None
def test_get_ordered_plugins(self, temp_store):
_create_dummy_plugin(temp_store, "first")
_create_dummy_plugin(temp_store, "second")
from oss.core.manager import PluginManager
pm = PluginManager()
pm.load(Path(temp_store) / "NebulaShell" / "first")
pm.load(Path(temp_store) / "NebulaShell" / "second")
ordered = pm._get_ordered_plugins()
assert "first" in ordered
assert "second" in ordered
if __name__ == '__main__':
pytest.main([__file__, '-v'])

View File

@@ -14,26 +14,25 @@ class TestLogger:
def test_logger_warn(self):
logger = Logger()
logger.warn("Test warning")
# 不抛出异常即通过
logger.warn("Logger", "Test warning")
assert True
def test_logger_debug(self):
logger = Logger()
logger.debug("Test debug")
# 不抛出异常即通过
logger.debug("Logger", "Test debug")
assert True
def test_logger_warn_with_tag(self):
logger = Logger()
logger.warn("Test warning", tag="TEST")
# 不抛出异常即通过
logger.warn("TEST", "Test warning")
assert True
def test_logger_debug_with_tag(self):
logger = Logger()
logger.debug("Test debug", tag="TEST")
# 不抛出异常即通过
logger.debug("TEST", "Test debug")
assert True
def test_get_log_format_json(self):
# Logger 类没有 _get_log_format 方法,测试 Log 类的基本功能
assert Log is not None
def test_logger_json_format(self):
@@ -43,7 +42,6 @@ class TestLogger:
def test_logger_output(self):
log_capture = StringIO()
# 测试 Log 类的输出
import sys
old_stdout = sys.stdout
sys.stdout = log_capture

View File

@@ -14,46 +14,32 @@ import importlib.util
spec = importlib.util.spec_from_file_location("nodejs_adapter_main", os.path.join(PLUGIN_DIR, "main.py"))
main_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(main_module)
NodeJSAdapter = main_module.NodeJSAdapter
NodeJSAdapterPlugin = main_module.NodeJSAdapterPlugin
@pytest.fixture
def adapter():
return NodeJSAdapter()
@pytest.fixture
def temp_plugin_dir():
temp_dir = tempfile.mkdtemp()
pkg_dir = os.path.join(temp_dir, 'pkg')
os.makedirs(pkg_dir)
yield temp_dir
shutil.rmtree(temp_dir)
def plugin():
return NodeJSAdapterPlugin()
class TestNodeJSAdapter:
def test_adapter_name(self, adapter):
assert adapter.name == "nodejs-adapter"
assert adapter.version == "1.0.0"
assert "Node.js" in adapter.description
def test_plugin_name(self, plugin):
assert plugin.name == "nodejs-adapter"
assert plugin.version == "1.0.0"
def test_get_capabilities(self, adapter):
versions = adapter.check_versions()
def test_check_versions(self, plugin):
versions = plugin.check_versions()
assert isinstance(versions, dict)
def test_init_hook(self):
start = main_module.start
context = {}
result = start(context)
assert result['status'] == 'inactive'
def test_lifecycle(self, plugin):
plugin.init()
plugin.start()
plugin.stop()
# no exception = pass
def test_stop_hook(self):
init = main_module.init
get_info = main_module.get_info
context = {}
init(context)
info = get_info(context)
assert isinstance(info, dict)
def test_get_adapter(self, plugin):
adapter = plugin.get_adapter()
assert adapter is not None
if __name__ == '__main__':

View File

@@ -0,0 +1,116 @@
"""Tests for plugin-bridge: event bus, service registry"""
import os
import sys
import pytest
from pathlib import Path
PLUGIN_DIR = Path(__file__).parent.parent / "store" / "NebulaShell" / "plugin-bridge"
sys.path.insert(0, str(PLUGIN_DIR))
import importlib.util
spec = importlib.util.spec_from_file_location("plugin_bridge_main", str(PLUGIN_DIR / "main.py"))
main_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(main_module)
Bridge = main_module.Bridge
class TestEventBus:
def test_on_and_emit(self):
b = Bridge()
results = []
b.on("test.event", lambda *a, **kw: results.append((a, kw)))
b.emit("test.event", "hello", x=1)
assert len(results) == 1
assert results[0] == (("hello",), {"x": 1})
def test_multiple_handlers(self):
b = Bridge()
r1, r2 = [], []
b.on("evt", lambda: r1.append(1))
b.on("evt", lambda: r2.append(2))
b.emit("evt")
assert r1 == [1]
assert r2 == [2]
def test_off(self):
b = Bridge()
results = []
handler = lambda: results.append(1)
b.on("evt", handler)
b.emit("evt")
assert results == [1]
b.off("evt", "unknown")
b.emit("evt")
assert results == [1]
def test_no_listeners(self):
b = Bridge()
result = b.emit("nonexistent")
assert result == []
def test_has_listeners(self):
b = Bridge()
assert not b.has_listeners("evt")
b.on("evt", lambda: None)
assert b.has_listeners("evt")
def test_emit_async(self):
import time
b = Bridge()
results = []
def slow():
time.sleep(0.05)
results.append("done")
b.on("async", slow)
b.emit_async("async")
assert len(results) == 0
time.sleep(0.1)
assert results == ["done"]
def test_clear(self):
b = Bridge()
b.on("evt", lambda: None)
assert b.has_listeners("evt")
b.event_bus.clear()
assert not b.has_listeners("evt")
class TestServiceRegistry:
def test_register_and_get(self):
b = Bridge()
svc = {"name": "myservice"}
b.provide("myservice", svc)
assert b.use("myservice") is svc
def test_has_service(self):
b = Bridge()
assert not b.has_service("x")
b.provide("x", object())
assert b.has_service("x")
def test_list_services(self):
b = Bridge()
b.provide("a", object())
b.provide("b", object())
svcs = b.list_services()
assert "a" in svcs
assert "b" in svcs
def test_get_info(self):
b = Bridge()
info = b.get_info()
assert "services" in info
assert "event_listeners" in info
class TestLifecycle:
def test_init_start_stop(self):
b = Bridge()
b.init()
b.start()
b.stop()
if __name__ == '__main__':
pytest.main([__file__, '-v'])

View File

@@ -0,0 +1,88 @@
"""Tests for plugin-storage plugin"""
import os
import sys
import tempfile
import json
import pytest
from pathlib import Path
PLUGIN_DIR = Path(__file__).parent.parent / "store" / "NebulaShell" / "plugin-storage"
sys.path.insert(0, str(PLUGIN_DIR))
import importlib.util
spec = importlib.util.spec_from_file_location("storage_main", str(PLUGIN_DIR / "main.py"))
main_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(main_module)
PluginStorage = main_module.PluginStorage
class TestPluginStorage:
@pytest.fixture
def storage(self, tmp_path):
s = PluginStorage()
s._base_dir = tmp_path / "plugin-storage"
s._base_dir.mkdir(parents=True, exist_ok=True)
return s
def test_set_and_get(self, storage):
storage.set("test-plugin", "name", "hello")
assert storage.get("test-plugin", "name") == "hello"
def test_get_default(self, storage):
assert storage.get("test-plugin", "missing", "default") == "default"
def test_get_nonexistent(self, storage):
assert storage.get("test-plugin", "missing") is None
def test_delete(self, storage):
storage.set("test-plugin", "key", "val")
assert storage.get("test-plugin", "key") == "val"
storage.delete("test-plugin", "key")
assert storage.get("test-plugin", "key") is None
def test_list_keys(self, storage):
storage.set("test-plugin", "a", 1)
storage.set("test-plugin", "b", 2)
keys = storage.list_keys("test-plugin")
assert "a" in keys
assert "b" in keys
def test_clear(self, storage):
storage.set("test-plugin", "x", 1)
storage.clear("test-plugin")
assert storage.get("test-plugin", "x") is None
def test_raw_storage(self, storage):
storage.set_raw("test-plugin", "data.bin", b"hello world")
assert storage.get_raw("test-plugin", "data.bin") == b"hello world"
def test_delete_raw(self, storage):
storage.set_raw("test-plugin", "tmp.bin", b"123")
assert storage.get_raw("test-plugin", "tmp.bin") is not None
storage.delete_raw("test-plugin", "tmp.bin")
assert storage.get_raw("test-plugin", "tmp.bin") is None
def test_storage_size(self, storage):
storage.set("test-plugin", "a", "hello")
size = storage.get_storage_size("test-plugin")
assert size > 0
def test_get_info(self, storage):
info = storage.get_info()
assert "base_dir" in info
assert "plugins" in info
def test_lifecycle(self, storage):
storage.init()
storage.start()
storage.stop()
def test_json_types(self, storage):
data = {"nested": [1, 2, 3], "flag": True, "val": None}
storage.set("test-plugin", "complex", data)
assert storage.get("test-plugin", "complex") == data
if __name__ == '__main__':
pytest.main([__file__, '-v'])

148
oss/tests/test_ws_api.py Normal file
View File

@@ -0,0 +1,148 @@
"""Tests for ws-api WebSocket plugin"""
import os
import sys
import json
import time
import threading
import pytest
from pathlib import Path
PLUGIN_DIR = Path(__file__).parent.parent / "store" / "NebulaShell" / "ws-api"
sys.path.insert(0, str(PLUGIN_DIR))
import importlib.util
spec = importlib.util.spec_from_file_location("ws_api_main", str(PLUGIN_DIR / "main.py"))
main_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(main_module)
WsApi = main_module.WsApi
class TestWsApi:
def test_lifecycle(self):
api = WsApi()
api.init()
api.start()
assert api._running is True
api.stop()
assert api._running is False
def test_get_info(self):
api = WsApi()
info = api.get_info()
assert "host" in info
assert "port" in info
assert "running" in info
assert "websockets_available" in info
def test_register_handler(self):
api = WsApi()
results = []
api.register_handler("custom", lambda data, ctx: results.append(data))
assert "custom" in api._handlers
def test_default_host_port(self):
api = WsApi()
assert api._host == "127.0.0.1"
assert api._port == 8081
class TestWsApiDispatch:
@pytest.mark.asyncio
async def test_ping_pong(self):
api = WsApi()
class FakeWs:
def __init__(self):
self.sent = []
self.remote_address = ("127.0.0.1", 12345)
async def send(self, msg):
self.sent.append(json.loads(msg))
ws = FakeWs()
await api._dispatch(ws, '{"type":"ping"}', "test")
assert len(ws.sent) == 1
assert ws.sent[0] == {"type": "pong"}
@pytest.mark.asyncio
async def test_invalid_json(self):
api = WsApi()
class FakeWs:
def __init__(self):
self.sent = []
self.remote_address = ("127.0.0.1", 12345)
async def send(self, msg):
self.sent.append(json.loads(msg))
ws = FakeWs()
await api._dispatch(ws, "not json", "test")
assert len(ws.sent) == 1
assert ws.sent[0]["type"] == "error"
@pytest.mark.asyncio
async def test_subscribe(self):
api = WsApi()
class FakeWs:
def __init__(self):
self.sent = []
self.remote_address = ("127.0.0.1", 12345)
async def send(self, msg):
self.sent.append(json.loads(msg))
ws = FakeWs()
await api._dispatch(ws, '{"type":"subscribe","topic":"news"}', "test")
assert "news" in api._connections
assert len(api._connections["news"]) == 1
@pytest.mark.asyncio
async def test_unsubscribe(self):
api = WsApi()
api._connections["test-topic"] = {"addr1"}
class FakeWs:
def __init__(self):
self.sent = []
self.remote_address = ("127.0.0.1", 12345)
async def send(self, msg):
self.sent.append(json.loads(msg))
ws = FakeWs()
await api._dispatch(ws, '{"type":"unsubscribe","topic":"test-topic"}', "addr1")
assert "test-topic" not in api._connections or len(api._connections["test-topic"]) == 0
@pytest.mark.asyncio
async def test_custom_handler(self):
api = WsApi()
results = []
def handler(data, ctx):
results.append((data, ctx))
return {"processed": True}
api.register_handler("my_action", handler)
class FakeWs:
def __init__(self):
self.sent = []
self.remote_address = ("127.0.0.1", 12345)
async def send(self, msg):
self.sent.append(json.loads(msg))
ws = FakeWs()
await api._dispatch(ws, '{"type":"my_action","value":42}', "test")
assert len(results) == 1
assert results[0][0]["value"] == 42
assert len(ws.sent) == 1
assert ws.sent[0]["type"] == "my_action_response"
assert ws.sent[0]["data"]["processed"] is True
if __name__ == '__main__':
pytest.main([__file__, '-v'])