重大重构:引擎模块拆分 + 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:
@@ -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
|
||||
|
||||
@@ -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
83
oss/tests/test_i18n.py
Normal 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'])
|
||||
190
oss/tests/test_integration.py
Normal file
190
oss/tests/test_integration.py
Normal 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'])
|
||||
@@ -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
|
||||
|
||||
@@ -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__':
|
||||
|
||||
116
oss/tests/test_plugin_bridge.py
Normal file
116
oss/tests/test_plugin_bridge.py
Normal 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'])
|
||||
88
oss/tests/test_plugin_storage.py
Normal file
88
oss/tests/test_plugin_storage.py
Normal 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
148
oss/tests/test_ws_api.py
Normal 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'])
|
||||
Reference in New Issue
Block a user