diff --git a/pyproject.toml b/pyproject.toml index 571ef8a..5140bfb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,3 +83,7 @@ openspace = [ "local_server/config.json", "local_server/README.md", ] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] diff --git a/tests/cloud/test_client.py b/tests/cloud/test_client.py new file mode 100644 index 0000000..c0ed2f0 --- /dev/null +++ b/tests/cloud/test_client.py @@ -0,0 +1,56 @@ +import importlib.util +import io +import zipfile +from pathlib import Path + +import pytest + + +def _load_module(): + module_path = Path(__file__).resolve().parents[2] / "openspace" / "cloud" / "client.py" + spec = importlib.util.spec_from_file_location("openspace_cloud_client_test", module_path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +_client = _load_module() +CloudError = _client.CloudError +OpenSpaceClient = _client.OpenSpaceClient + + +def _zip_bytes(files: dict[str, str]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, mode="w") as zf: + for name, content in files.items(): + zf.writestr(name, content) + return buffer.getvalue() + + +def test_extract_zip_skips_path_traversal_entries(tmp_path: Path): + zip_data = _zip_bytes( + { + "SKILL.md": "name: demo", + "../escape.txt": "nope", + "/absolute.txt": "nope", + "nested/file.txt": "ok", + } + ) + + extracted = OpenSpaceClient._extract_zip(zip_data, tmp_path) + + assert extracted == ["SKILL.md", "nested/file.txt"] + assert (tmp_path / "SKILL.md").read_text(encoding="utf-8") == "name: demo" + assert (tmp_path / "nested" / "file.txt").read_text(encoding="utf-8") == "ok" + + +def test_validate_origin_parents_enforces_fixed_origin(): + with pytest.raises(CloudError, match="exactly 1 parent_skill_id"): + OpenSpaceClient._validate_origin_parents("fixed", []) + + OpenSpaceClient._validate_origin_parents("fixed", ["parent-1"]) + + +def test_unified_diff_returns_none_when_snapshots_match(): + assert OpenSpaceClient._unified_diff({"a.txt": "same\n"}, {"a.txt": "same\n"}) is None diff --git a/tests/config/test_utils.py b/tests/config/test_utils.py new file mode 100644 index 0000000..10c63a8 --- /dev/null +++ b/tests/config/test_utils.py @@ -0,0 +1,42 @@ +import importlib.util +from pathlib import Path + + +def _load_module(): + module_path = Path(__file__).resolve().parents[2] / "openspace" / "config" / "utils.py" + spec = importlib.util.spec_from_file_location("openspace_config_utils_test", module_path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +_utils = _load_module() +get_config_value = _utils.get_config_value +load_json_file = _utils.load_json_file +save_json_file = _utils.save_json_file + + +def test_pytest_scaffold_uses_tests_directory(pytestconfig): + assert pytestconfig.getini("testpaths") == ["tests"] + + +def test_get_config_value_supports_dict_and_object(): + config_dict = {"value": 42} + + class ConfigObject: + value = 42 + + assert get_config_value(config_dict, "value") == 42 + assert get_config_value(ConfigObject(), "value") == 42 + assert get_config_value(config_dict, "missing", "fallback") == "fallback" + + +def test_save_and_load_json_round_trip(tmp_path: Path): + payload = {"name": "openspace", "nested": {"enabled": True}} + target = tmp_path / "nested" / "config.json" + + save_json_file(payload, target) + + assert target.exists() + assert load_json_file(target) == payload diff --git a/tests/host_detection/test_nanobot.py b/tests/host_detection/test_nanobot.py new file mode 100644 index 0000000..16daebe --- /dev/null +++ b/tests/host_detection/test_nanobot.py @@ -0,0 +1,85 @@ +import importlib.util +from pathlib import Path + + +def _load_module(): + module_path = Path(__file__).resolve().parents[2] / "openspace" / "host_detection" / "nanobot.py" + spec = importlib.util.spec_from_file_location("openspace_nanobot_test", module_path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +_nanobot = _load_module() +match_provider = _nanobot.match_provider + + +def test_match_provider_prefers_forced_provider(): + providers = { + "openai": {"apiKey": "oa-key", "apiBase": "https://openai.example/v1"}, + "minimax": {"apiKey": "mini-key", "apiBase": "https://minimax.example/v1"}, + } + + result = match_provider(providers, model="anything", forced_provider="minimax") + + assert result == { + "api_key": "mini-key", + "api_base": "https://minimax.example/v1", + } + + +def test_read_nanobot_mcp_env_returns_openspace_env(tmp_path: Path, monkeypatch): + config_path = tmp_path / "config.json" + config_path.write_text( + """ + { + "tools": { + "mcpServers": { + "openspace": { + "env": { + "OPENSPACE_MODEL": "openrouter/anthropic/claude" + } + } + } + } + } + """.strip(), + encoding="utf-8", + ) + monkeypatch.setattr(_nanobot, "NANOBOT_CONFIG_PATH", config_path) + + assert _nanobot.read_nanobot_mcp_env() == {"OPENSPACE_MODEL": "openrouter/anthropic/claude"} + + +def test_try_read_nanobot_config_extracts_model_and_provider(tmp_path: Path, monkeypatch): + config_path = tmp_path / "config.json" + config_path.write_text( + """ + { + "providers": { + "minimax": { + "apiKey": "mini-key", + "apiBase": "https://minimax.example/v1" + } + }, + "agents": { + "defaults": { + "model": "minimax/text-01", + "provider": "minimax" + } + } + } + """.strip(), + encoding="utf-8", + ) + monkeypatch.setattr(_nanobot, "NANOBOT_CONFIG_PATH", config_path) + + result = _nanobot.try_read_nanobot_config("") + + assert result == { + "api_key": "mini-key", + "api_base": "https://minimax.example/v1", + "_model": "minimax/text-01", + "_forced_provider": "minimax", + }