chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# This file makes tests a package so test modules can import from each other
|
||||
@@ -0,0 +1,132 @@
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
SERVER_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(SERVER_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(SERVER_ROOT))
|
||||
SERVER_SRC = SERVER_ROOT / "src"
|
||||
if str(SERVER_SRC) not in sys.path:
|
||||
sys.path.insert(0, str(SERVER_SRC))
|
||||
|
||||
# Ensure telemetry is disabled during test collection and execution to avoid
|
||||
# any background network or thread startup that could slow or block pytest.
|
||||
os.environ.setdefault("DISABLE_TELEMETRY", "true")
|
||||
os.environ.setdefault("UNITY_MCP_DISABLE_TELEMETRY", "true")
|
||||
os.environ.setdefault("MCP_DISABLE_TELEMETRY", "true")
|
||||
|
||||
# NOTE: These tests are integration tests for the MCP server Python code.
|
||||
# They test tools, resources, and utilities without requiring Unity to be running.
|
||||
# Tests can now import directly from the parent package since they're inside src/
|
||||
# To run: cd Server && uv run pytest tests/integration/ -v
|
||||
|
||||
# Stub telemetry modules to avoid file I/O during import of tools package
|
||||
telemetry = types.ModuleType("telemetry")
|
||||
|
||||
|
||||
def _noop(*args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class MilestoneType:
|
||||
pass
|
||||
|
||||
|
||||
telemetry.record_resource_usage = _noop
|
||||
telemetry.record_tool_usage = _noop
|
||||
telemetry.record_milestone = _noop
|
||||
telemetry.MilestoneType = MilestoneType
|
||||
telemetry.get_package_version = lambda: "0.0.0"
|
||||
sys.modules.setdefault("telemetry", telemetry)
|
||||
|
||||
telemetry_decorator = types.ModuleType("telemetry_decorator")
|
||||
|
||||
|
||||
def _noop_decorator(*_dargs, **_dkwargs):
|
||||
def _wrap(fn):
|
||||
return fn
|
||||
|
||||
return _wrap
|
||||
|
||||
|
||||
telemetry_decorator.telemetry_tool = _noop_decorator
|
||||
telemetry_decorator.telemetry_resource = _noop_decorator
|
||||
sys.modules.setdefault("telemetry_decorator", telemetry_decorator)
|
||||
|
||||
# Stub fastmcp module (not mcp.server.fastmcp)
|
||||
fastmcp = types.ModuleType("fastmcp")
|
||||
|
||||
|
||||
class _DummyFastMCP:
|
||||
pass
|
||||
|
||||
|
||||
class _DummyContext:
|
||||
pass
|
||||
|
||||
|
||||
class _DummyMiddleware:
|
||||
"""Base middleware class stub."""
|
||||
pass
|
||||
|
||||
|
||||
class _DummyMiddlewareContext:
|
||||
"""Middleware context stub."""
|
||||
pass
|
||||
|
||||
|
||||
class _DummyToolResult:
|
||||
"""Stub for fastmcp.server.server.ToolResult"""
|
||||
def __init__(self, content=None, is_error=False):
|
||||
self.content = content or []
|
||||
self.is_error = is_error
|
||||
|
||||
|
||||
fastmcp.FastMCP = _DummyFastMCP
|
||||
fastmcp.Context = _DummyContext
|
||||
sys.modules.setdefault("fastmcp", fastmcp)
|
||||
|
||||
# Stub fastmcp.server, fastmcp.server.middleware, fastmcp.server.server submodules
|
||||
fastmcp_server = types.ModuleType("fastmcp.server")
|
||||
fastmcp_server_middleware = types.ModuleType("fastmcp.server.middleware")
|
||||
fastmcp_server_middleware.Middleware = _DummyMiddleware
|
||||
fastmcp_server_middleware.MiddlewareContext = _DummyMiddlewareContext
|
||||
fastmcp_server_server = types.ModuleType("fastmcp.server.server")
|
||||
fastmcp_server_server.ToolResult = _DummyToolResult
|
||||
fastmcp.server = fastmcp_server
|
||||
fastmcp_server.middleware = fastmcp_server_middleware
|
||||
fastmcp_server.server = fastmcp_server_server
|
||||
sys.modules.setdefault("fastmcp.server", fastmcp_server)
|
||||
sys.modules.setdefault("fastmcp.server.middleware", fastmcp_server_middleware)
|
||||
sys.modules.setdefault("fastmcp.server.server", fastmcp_server_server)
|
||||
|
||||
# Stub mcp.types for TextContent, ImageContent, ToolAnnotations
|
||||
_mcp_types = sys.modules.get("mcp.types")
|
||||
if _mcp_types is None:
|
||||
_mcp_mod = sys.modules.setdefault("mcp", types.ModuleType("mcp"))
|
||||
_mcp_types = types.ModuleType("mcp.types")
|
||||
|
||||
class _TextContent:
|
||||
def __init__(self, **kwargs):
|
||||
for k, v in kwargs.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
class _ImageContent:
|
||||
def __init__(self, **kwargs):
|
||||
for k, v in kwargs.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
class _ToolAnnotations:
|
||||
def __init__(self, **kwargs):
|
||||
for k, v in kwargs.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
_mcp_types.TextContent = _TextContent
|
||||
_mcp_types.ImageContent = _ImageContent
|
||||
_mcp_types.ToolAnnotations = _ToolAnnotations
|
||||
_mcp_mod.types = _mcp_types
|
||||
sys.modules["mcp.types"] = _mcp_types
|
||||
|
||||
# Note: starlette is now a proper dependency (via mcp package), so we don't stub it anymore.
|
||||
# The real starlette package will be imported when needed.
|
||||
@@ -0,0 +1,456 @@
|
||||
"""Tests for ApiKeyService: validation, caching, retries, and singleton lifecycle."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from services.api_key_service import ApiKeyService, ValidationResult
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_singleton():
|
||||
"""Reset the ApiKeyService singleton between tests."""
|
||||
ApiKeyService._instance = None
|
||||
yield
|
||||
ApiKeyService._instance = None
|
||||
|
||||
|
||||
def _make_service(
|
||||
validation_url="https://auth.example.com/validate",
|
||||
cache_ttl=300.0,
|
||||
service_token_header=None,
|
||||
service_token=None,
|
||||
):
|
||||
return ApiKeyService(
|
||||
validation_url=validation_url,
|
||||
cache_ttl=cache_ttl,
|
||||
service_token_header=service_token_header,
|
||||
service_token=service_token,
|
||||
)
|
||||
|
||||
|
||||
def _mock_response(status_code=200, json_data=None):
|
||||
resp = MagicMock(spec=httpx.Response)
|
||||
resp.status_code = status_code
|
||||
resp.json.return_value = json_data or {}
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Singleton lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSingletonLifecycle:
|
||||
def test_get_instance_before_init_raises(self):
|
||||
with pytest.raises(RuntimeError, match="not initialized"):
|
||||
ApiKeyService.get_instance()
|
||||
|
||||
def test_is_initialized_false_before_init(self):
|
||||
assert ApiKeyService.is_initialized() is False
|
||||
|
||||
def test_is_initialized_true_after_init(self):
|
||||
_make_service()
|
||||
assert ApiKeyService.is_initialized() is True
|
||||
|
||||
def test_get_instance_returns_service(self):
|
||||
svc = _make_service()
|
||||
assert ApiKeyService.get_instance() is svc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Basic validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBasicValidation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_key(self):
|
||||
svc = _make_service()
|
||||
mock_resp = _mock_response(
|
||||
200, {"valid": True, "user_id": "user-1", "metadata": {"plan": "pro"}})
|
||||
|
||||
with patch("httpx.AsyncClient") as MockClient:
|
||||
instance = AsyncMock()
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
instance.post = AsyncMock(return_value=mock_resp)
|
||||
MockClient.return_value = instance
|
||||
|
||||
result = await svc.validate("test-valid-key-12345678")
|
||||
|
||||
assert result.valid is True
|
||||
assert result.user_id == "user-1"
|
||||
assert result.metadata == {"plan": "pro"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_key_200_body(self):
|
||||
svc = _make_service()
|
||||
mock_resp = _mock_response(
|
||||
200, {"valid": False, "error": "Key revoked"})
|
||||
|
||||
with patch("httpx.AsyncClient") as MockClient:
|
||||
instance = AsyncMock()
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
instance.post = AsyncMock(return_value=mock_resp)
|
||||
MockClient.return_value = instance
|
||||
|
||||
result = await svc.validate("test-invalid-key-1234")
|
||||
|
||||
assert result.valid is False
|
||||
assert result.error == "Key revoked"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_key_401_status(self):
|
||||
svc = _make_service()
|
||||
mock_resp = _mock_response(401)
|
||||
|
||||
with patch("httpx.AsyncClient") as MockClient:
|
||||
instance = AsyncMock()
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
instance.post = AsyncMock(return_value=mock_resp)
|
||||
MockClient.return_value = instance
|
||||
|
||||
result = await svc.validate("test-bad-key-12345678")
|
||||
|
||||
assert result.valid is False
|
||||
assert "Invalid API key" in result.error
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_key_fast_path(self):
|
||||
svc = _make_service()
|
||||
|
||||
with patch("httpx.AsyncClient") as MockClient:
|
||||
result = await svc.validate("")
|
||||
|
||||
assert result.valid is False
|
||||
assert "required" in result.error.lower()
|
||||
# No HTTP call should have been made
|
||||
MockClient.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Caching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCaching:
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_hit_valid_key(self):
|
||||
svc = _make_service(cache_ttl=300.0)
|
||||
mock_resp = _mock_response(200, {"valid": True, "user_id": "u1"})
|
||||
call_count = 0
|
||||
|
||||
async def counting_post(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return mock_resp
|
||||
|
||||
with patch("httpx.AsyncClient") as MockClient:
|
||||
instance = AsyncMock()
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
instance.post = counting_post
|
||||
MockClient.return_value = instance
|
||||
|
||||
r1 = await svc.validate("test-cached-valid-key1")
|
||||
r2 = await svc.validate("test-cached-valid-key1")
|
||||
|
||||
assert r1.valid is True
|
||||
assert r2.valid is True
|
||||
assert r2.user_id == "u1"
|
||||
assert call_count == 1 # Only one HTTP call
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_hit_invalid_key(self):
|
||||
svc = _make_service(cache_ttl=300.0)
|
||||
mock_resp = _mock_response(200, {"valid": False, "error": "bad"})
|
||||
call_count = 0
|
||||
|
||||
async def counting_post(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return mock_resp
|
||||
|
||||
with patch("httpx.AsyncClient") as MockClient:
|
||||
instance = AsyncMock()
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
instance.post = counting_post
|
||||
MockClient.return_value = instance
|
||||
|
||||
r1 = await svc.validate("test-cached-bad-key12")
|
||||
r2 = await svc.validate("test-cached-bad-key12")
|
||||
|
||||
assert r1.valid is False
|
||||
assert r2.valid is False
|
||||
assert call_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_expiry(self):
|
||||
svc = _make_service(cache_ttl=1.0) # 1 second TTL
|
||||
mock_resp = _mock_response(200, {"valid": True, "user_id": "u1"})
|
||||
call_count = 0
|
||||
|
||||
async def counting_post(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return mock_resp
|
||||
|
||||
with patch("httpx.AsyncClient") as MockClient:
|
||||
instance = AsyncMock()
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
instance.post = counting_post
|
||||
MockClient.return_value = instance
|
||||
|
||||
await svc.validate("test-expiry-key-12345")
|
||||
assert call_count == 1
|
||||
|
||||
# Manually expire the cache entry by manipulating the stored tuple
|
||||
async with svc._cache_lock:
|
||||
key = "test-expiry-key-12345"
|
||||
valid, user_id, metadata, _expires = svc._cache[key]
|
||||
svc._cache[key] = (valid, user_id, metadata, time.time() - 1)
|
||||
|
||||
await svc.validate("test-expiry-key-12345")
|
||||
assert call_count == 2 # Had to re-validate
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalidate_cache(self):
|
||||
svc = _make_service(cache_ttl=300.0)
|
||||
mock_resp = _mock_response(200, {"valid": True, "user_id": "u1"})
|
||||
call_count = 0
|
||||
|
||||
async def counting_post(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return mock_resp
|
||||
|
||||
with patch("httpx.AsyncClient") as MockClient:
|
||||
instance = AsyncMock()
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
instance.post = counting_post
|
||||
MockClient.return_value = instance
|
||||
|
||||
await svc.validate("test-invalidate-key12")
|
||||
assert call_count == 1
|
||||
|
||||
await svc.invalidate_cache("test-invalidate-key12")
|
||||
|
||||
await svc.validate("test-invalidate-key12")
|
||||
assert call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_cache(self):
|
||||
svc = _make_service(cache_ttl=300.0)
|
||||
mock_resp = _mock_response(200, {"valid": True, "user_id": "u1"})
|
||||
call_count = 0
|
||||
|
||||
async def counting_post(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return mock_resp
|
||||
|
||||
with patch("httpx.AsyncClient") as MockClient:
|
||||
instance = AsyncMock()
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
instance.post = counting_post
|
||||
MockClient.return_value = instance
|
||||
|
||||
await svc.validate("test-clear-key1-12345")
|
||||
await svc.validate("test-clear-key2-12345")
|
||||
assert call_count == 2
|
||||
|
||||
await svc.clear_cache()
|
||||
|
||||
await svc.validate("test-clear-key1-12345")
|
||||
await svc.validate("test-clear-key2-12345")
|
||||
assert call_count == 4 # Both had to re-validate
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transient failures & retries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTransientFailures:
|
||||
@pytest.mark.asyncio
|
||||
async def test_5xx_not_cached(self):
|
||||
svc = _make_service(cache_ttl=300.0)
|
||||
mock_500 = _mock_response(500)
|
||||
mock_ok = _mock_response(200, {"valid": True, "user_id": "u1"})
|
||||
responses = [mock_500, mock_500, mock_ok] # Extra for retry
|
||||
call_idx = 0
|
||||
|
||||
async def sequential_post(*args, **kwargs):
|
||||
nonlocal call_idx
|
||||
resp = responses[min(call_idx, len(responses) - 1)]
|
||||
call_idx += 1
|
||||
return resp
|
||||
|
||||
with patch("httpx.AsyncClient") as MockClient:
|
||||
instance = AsyncMock()
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
instance.post = sequential_post
|
||||
MockClient.return_value = instance
|
||||
|
||||
# First call: 500 -> not cached
|
||||
r1 = await svc.validate("test-5xx-test-key1234")
|
||||
assert r1.valid is False
|
||||
assert r1.cacheable is False
|
||||
|
||||
# Second call should hit HTTP again (not cached)
|
||||
r2 = await svc.validate("test-5xx-test-key1234")
|
||||
# Second call also gets 500 from our mock sequence
|
||||
assert r2.valid is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_then_retry_succeeds(self):
|
||||
svc = _make_service()
|
||||
mock_ok = _mock_response(200, {"valid": True, "user_id": "u1"})
|
||||
attempt = 0
|
||||
|
||||
async def timeout_then_ok(*args, **kwargs):
|
||||
nonlocal attempt
|
||||
attempt += 1
|
||||
if attempt == 1:
|
||||
raise httpx.TimeoutException("timed out")
|
||||
return mock_ok
|
||||
|
||||
with patch("httpx.AsyncClient") as MockClient:
|
||||
instance = AsyncMock()
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
instance.post = timeout_then_ok
|
||||
MockClient.return_value = instance
|
||||
|
||||
result = await svc.validate("test-timeout-retry-ok")
|
||||
|
||||
assert result.valid is True
|
||||
assert result.user_id == "u1"
|
||||
assert attempt == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_exhausts_retries(self):
|
||||
svc = _make_service()
|
||||
|
||||
async def always_timeout(*args, **kwargs):
|
||||
raise httpx.TimeoutException("timed out")
|
||||
|
||||
with patch("httpx.AsyncClient") as MockClient:
|
||||
instance = AsyncMock()
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
instance.post = always_timeout
|
||||
MockClient.return_value = instance
|
||||
|
||||
result = await svc.validate("test-timeout-exhaust1")
|
||||
|
||||
assert result.valid is False
|
||||
assert "timeout" in result.error.lower()
|
||||
assert result.cacheable is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_error_then_retry_succeeds(self):
|
||||
svc = _make_service()
|
||||
mock_ok = _mock_response(200, {"valid": True, "user_id": "u1"})
|
||||
attempt = 0
|
||||
|
||||
async def error_then_ok(*args, **kwargs):
|
||||
nonlocal attempt
|
||||
attempt += 1
|
||||
if attempt == 1:
|
||||
raise httpx.ConnectError("connection refused")
|
||||
return mock_ok
|
||||
|
||||
with patch("httpx.AsyncClient") as MockClient:
|
||||
instance = AsyncMock()
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
instance.post = error_then_ok
|
||||
MockClient.return_value = instance
|
||||
|
||||
result = await svc.validate("test-reqerr-retry-ok1")
|
||||
|
||||
assert result.valid is True
|
||||
assert attempt == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_error_exhausts_retries(self):
|
||||
svc = _make_service()
|
||||
|
||||
async def always_error(*args, **kwargs):
|
||||
raise httpx.ConnectError("connection refused")
|
||||
|
||||
with patch("httpx.AsyncClient") as MockClient:
|
||||
instance = AsyncMock()
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
instance.post = always_error
|
||||
MockClient.return_value = instance
|
||||
|
||||
result = await svc.validate("test-reqerr-exhaust1")
|
||||
|
||||
assert result.valid is False
|
||||
assert "unavailable" in result.error.lower()
|
||||
assert result.cacheable is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unexpected_exception(self):
|
||||
svc = _make_service()
|
||||
|
||||
async def unexpected(*args, **kwargs):
|
||||
raise ValueError("something unexpected")
|
||||
|
||||
with patch("httpx.AsyncClient") as MockClient:
|
||||
instance = AsyncMock()
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
instance.post = unexpected
|
||||
MockClient.return_value = instance
|
||||
|
||||
result = await svc.validate("test-unexpected-err12")
|
||||
|
||||
assert result.valid is False
|
||||
assert result.cacheable is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service token
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestServiceToken:
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_token_sent_in_headers(self):
|
||||
svc = _make_service(
|
||||
service_token_header="X-Service-Token",
|
||||
service_token="test-svc-token-123",
|
||||
)
|
||||
mock_resp = _mock_response(200, {"valid": True, "user_id": "u1"})
|
||||
captured_headers = {}
|
||||
|
||||
async def capture_post(url, *, json=None, headers=None):
|
||||
captured_headers.update(headers or {})
|
||||
return mock_resp
|
||||
|
||||
with patch("httpx.AsyncClient") as MockClient:
|
||||
instance = AsyncMock()
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
instance.post = capture_post
|
||||
MockClient.return_value = instance
|
||||
|
||||
await svc.validate("test-svctoken-key1234")
|
||||
|
||||
assert captured_headers.get("X-Service-Token") == "test-svc-token-123"
|
||||
assert captured_headers.get("Content-Type") == "application/json"
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Tests for auth configuration validation and startup routes."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.config import config
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_config(monkeypatch):
|
||||
"""Prevent main() side effects on the global config from leaking to other tests."""
|
||||
monkeypatch.setattr(config, "http_remote_hosted", config.http_remote_hosted)
|
||||
monkeypatch.setattr(config, "api_key_validation_url", config.api_key_validation_url)
|
||||
monkeypatch.setattr(config, "api_key_login_url", config.api_key_login_url)
|
||||
monkeypatch.setattr(config, "api_key_cache_ttl", config.api_key_cache_ttl)
|
||||
monkeypatch.setattr(config, "api_key_service_token_header", config.api_key_service_token_header)
|
||||
monkeypatch.setattr(config, "api_key_service_token", config.api_key_service_token)
|
||||
|
||||
|
||||
class TestStartupConfigValidation:
|
||||
def test_remote_hosted_flag_without_validation_url_exits(self, monkeypatch):
|
||||
"""--http-remote-hosted without --api-key-validation-url should SystemExit(1)."""
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
"main",
|
||||
"--transport", "http",
|
||||
"--http-remote-hosted",
|
||||
# Deliberately omit --api-key-validation-url
|
||||
],
|
||||
)
|
||||
monkeypatch.delenv("UNITY_MCP_API_KEY_VALIDATION_URL", raising=False)
|
||||
monkeypatch.delenv("UNITY_MCP_HTTP_REMOTE_HOSTED", raising=False)
|
||||
|
||||
from main import main
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
def test_remote_hosted_env_var_without_validation_url_exits(self, monkeypatch):
|
||||
"""UNITY_MCP_HTTP_REMOTE_HOSTED=true without validation URL should SystemExit(1)."""
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
"main",
|
||||
"--transport", "http",
|
||||
# No --http-remote-hosted flag
|
||||
],
|
||||
)
|
||||
monkeypatch.setenv("UNITY_MCP_HTTP_REMOTE_HOSTED", "true")
|
||||
monkeypatch.delenv("UNITY_MCP_API_KEY_VALIDATION_URL", raising=False)
|
||||
|
||||
from main import main
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
|
||||
class TestLoginUrlEndpoint:
|
||||
"""Test the /api/auth/login-url route handler logic.
|
||||
|
||||
These tests replicate the handler inline to avoid full MCP server construction.
|
||||
The logic mirrors main.py's auth_login_url route exactly.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def _auth_login_url(_request):
|
||||
"""Replicate the route handler from main.py."""
|
||||
if not config.api_key_login_url:
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": "API key management not configured. Contact your server administrator.",
|
||||
},
|
||||
status_code=404,
|
||||
)
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"login_url": config.api_key_login_url,
|
||||
})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_url_returns_url_when_configured(self, monkeypatch):
|
||||
monkeypatch.setattr(config, "api_key_login_url",
|
||||
"https://app.example.com/keys")
|
||||
|
||||
response = await self._auth_login_url(MagicMock(spec=Request))
|
||||
|
||||
assert response.status_code == 200
|
||||
body = json.loads(response.body.decode())
|
||||
assert body["success"] is True
|
||||
assert body["login_url"] == "https://app.example.com/keys"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_url_returns_404_when_not_configured(self, monkeypatch):
|
||||
monkeypatch.setattr(config, "api_key_login_url", None)
|
||||
|
||||
response = await self._auth_login_url(MagicMock(spec=Request))
|
||||
|
||||
assert response.status_code == 404
|
||||
body = json.loads(response.body.decode())
|
||||
assert body["success"] is False
|
||||
assert "not configured" in body["error"]
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Connection resilience when a domain reload leaves the Unity socket half-open."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from core.config import config
|
||||
import transport.legacy.unity_connection as uc
|
||||
from transport.legacy.unity_connection import UnityConnection
|
||||
|
||||
|
||||
def _start_silent_bridge():
|
||||
"""Accept and handshake, then never answer (a wedged half-open bridge)."""
|
||||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
srv.bind(("127.0.0.1", 0))
|
||||
srv.listen(8)
|
||||
port = srv.getsockname()[1]
|
||||
accepted: list[socket.socket] = []
|
||||
stop = threading.Event()
|
||||
|
||||
def serve():
|
||||
srv.settimeout(0.5)
|
||||
while not stop.is_set():
|
||||
try:
|
||||
client, _ = srv.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
accepted.append(client)
|
||||
try:
|
||||
client.sendall(b"WELCOME UNITY-MCP 1 FRAMING=1\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
threading.Thread(target=serve, daemon=True).start()
|
||||
return srv, port, stop, accepted
|
||||
|
||||
|
||||
def _write_reloading_status(tmp_path):
|
||||
status_dir = Path(tmp_path) / ".unity-mcp"
|
||||
status_dir.mkdir(parents=True, exist_ok=True)
|
||||
(status_dir / "unity-mcp-status-deadbeef.json").write_text(
|
||||
json.dumps({"reloading": True, "reason": "reloading", "project_path": "x"})
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def silent_bridge(monkeypatch, tmp_path):
|
||||
srv, port, stop, accepted = _start_silent_bridge()
|
||||
monkeypatch.setattr(uc.Path, "home", lambda: Path(tmp_path))
|
||||
monkeypatch.setattr(uc.stdio_port_registry, "get_port", lambda instance_id=None: port)
|
||||
monkeypatch.setattr(uc.stdio_port_registry, "get_instance", lambda instance_id: None)
|
||||
monkeypatch.setattr(config, "connection_timeout", 1.0)
|
||||
monkeypatch.setattr(config, "command_total_timeout", 2.0, raising=False)
|
||||
try:
|
||||
yield port
|
||||
finally:
|
||||
stop.set()
|
||||
try:
|
||||
srv.close()
|
||||
except OSError:
|
||||
pass
|
||||
for client in accepted:
|
||||
try:
|
||||
client.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def test_send_command_against_wedged_socket_is_bounded(silent_bridge, monkeypatch):
|
||||
"""A connection_timeout longer than the total budget must not let a single blocking
|
||||
recv overrun the ceiling: the deadline caps each recv, so the call still stops near
|
||||
command_total_timeout rather than connection_timeout."""
|
||||
monkeypatch.setattr(config, "connection_timeout", 5.0)
|
||||
conn = UnityConnection(port=silent_bridge, instance_id="Repro@deadbeef")
|
||||
start = time.monotonic()
|
||||
with pytest.raises(TimeoutError, match="exceeded total deadline"):
|
||||
conn.send_command("get_editor_state", {})
|
||||
assert time.monotonic() - start < 4.0
|
||||
|
||||
|
||||
def test_send_command_with_retry_is_bounded_by_deadline(silent_bridge, tmp_path, monkeypatch):
|
||||
"""The reload-wait loop honours command_total_timeout, not just max_wait_s."""
|
||||
conn = UnityConnection(port=silent_bridge, instance_id="Repro@deadbeef")
|
||||
monkeypatch.setattr(uc, "get_unity_connection", lambda instance_id=None: conn)
|
||||
_write_reloading_status(tmp_path)
|
||||
|
||||
start = time.monotonic()
|
||||
resp = uc.send_command_with_retry("get_editor_state", {}, instance_id="Repro@deadbeef")
|
||||
assert time.monotonic() - start < 6.0
|
||||
assert uc._extract_response_reason(resp) == "reloading"
|
||||
|
||||
|
||||
def test_reload_signal_drops_socket_for_fresh_reconnect(silent_bridge, tmp_path):
|
||||
"""A 'reloading' status drops the socket so the next command reconnects."""
|
||||
conn = UnityConnection(port=silent_bridge, instance_id="Repro@deadbeef")
|
||||
assert conn.connect() is True
|
||||
assert conn.sock is not None
|
||||
_write_reloading_status(tmp_path)
|
||||
|
||||
response = conn.send_command("get_editor_state", {})
|
||||
assert conn.sock is None
|
||||
assert uc._extract_response_reason(response) == "reloading"
|
||||
@@ -0,0 +1,30 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_debug_request_context_includes_server_diagnostics(monkeypatch):
|
||||
# Import inside test so stubs in conftest are applied.
|
||||
import services.tools.debug_request_context as mod
|
||||
|
||||
class DummyCtx:
|
||||
# minimal surface for debug_request_context
|
||||
request_context = None
|
||||
session_id = None
|
||||
client_id = None
|
||||
|
||||
async def get_state(self, _k):
|
||||
return None
|
||||
|
||||
# Ensure get_package_version is stable for assertion
|
||||
monkeypatch.setattr(mod, "get_package_version", lambda: "9.9.9-test")
|
||||
|
||||
res = await mod.debug_request_context(DummyCtx())
|
||||
assert res.get("success") is True
|
||||
data = res.get("data") or {}
|
||||
server = data.get("server") or {}
|
||||
assert server.get("version") == "9.9.9-test"
|
||||
assert "cwd" in server
|
||||
assert "argv" in server
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"""
|
||||
Integration test for domain reload resilience.
|
||||
|
||||
Tests that the MCP server can handle rapid-fire requests during Unity domain reloads
|
||||
by waiting for the plugin to reconnect instead of failing immediately.
|
||||
"""
|
||||
import asyncio
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from datetime import datetime
|
||||
|
||||
from .test_helpers import DummyContext
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_hub_waits_for_reconnection_during_reload():
|
||||
"""Test that PluginHub._resolve_session_id waits for plugin reconnection."""
|
||||
# Import after conftest stubs are set up
|
||||
from transport.plugin_hub import PluginHub
|
||||
from transport.plugin_registry import PluginRegistry, PluginSession
|
||||
|
||||
# Create a mock registry
|
||||
mock_registry = AsyncMock(spec=PluginRegistry)
|
||||
|
||||
# Simulate plugin reconnection sequence:
|
||||
# First 2 calls: no sessions (plugin disconnected)
|
||||
# Third call: session appears (plugin reconnected)
|
||||
call_count = [0]
|
||||
|
||||
async def mock_list_sessions(**kwargs):
|
||||
call_count[0] += 1
|
||||
if call_count[0] <= 2:
|
||||
# Plugin not yet reconnected
|
||||
return {}
|
||||
else:
|
||||
# Plugin reconnected
|
||||
now = datetime.now()
|
||||
session = PluginSession(
|
||||
session_id="test-session-123",
|
||||
project_name="TestProject",
|
||||
project_hash="abc123",
|
||||
unity_version="2022.3.0f1",
|
||||
registered_at=now,
|
||||
connected_at=now
|
||||
)
|
||||
return {"test-session-123": session}
|
||||
|
||||
mock_registry.list_sessions = mock_list_sessions
|
||||
|
||||
# Configure PluginHub with our mock while preserving the original state
|
||||
original_registry = PluginHub._registry
|
||||
original_lock = PluginHub._lock
|
||||
PluginHub._registry = mock_registry
|
||||
PluginHub._lock = asyncio.Lock()
|
||||
|
||||
try:
|
||||
# Call _resolve_session_id when no session is available
|
||||
# It should wait and retry until the session appears
|
||||
session_id = await PluginHub._resolve_session_id(unity_instance=None)
|
||||
|
||||
# Should have retried and eventually found the session
|
||||
assert session_id == "test-session-123"
|
||||
assert call_count[0] >= 3 # Should have tried at least 3 times
|
||||
|
||||
finally:
|
||||
# Clean up: restore original PluginHub state
|
||||
PluginHub._registry = original_registry
|
||||
PluginHub._lock = original_lock
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_hub_fails_after_timeout():
|
||||
"""Test that PluginHub._resolve_session_id eventually times out if plugin never reconnects."""
|
||||
from transport.plugin_hub import PluginHub
|
||||
from transport.plugin_registry import PluginRegistry
|
||||
|
||||
# Create a mock registry that never returns sessions
|
||||
mock_registry = AsyncMock(spec=PluginRegistry)
|
||||
|
||||
async def mock_list_sessions(**kwargs):
|
||||
return {} # Never returns sessions
|
||||
|
||||
mock_registry.list_sessions = mock_list_sessions
|
||||
|
||||
# Configure PluginHub with our mock while preserving the original state
|
||||
original_registry = PluginHub._registry
|
||||
original_lock = PluginHub._lock
|
||||
PluginHub._registry = mock_registry
|
||||
PluginHub._lock = asyncio.Lock()
|
||||
|
||||
# Temporarily override config for a short timeout
|
||||
with patch('transport.plugin_hub.config') as mock_config:
|
||||
mock_config.reload_max_retries = 3 # Only 3 retries
|
||||
mock_config.reload_retry_ms = 10 # 10ms between retries
|
||||
|
||||
try:
|
||||
# Should raise RuntimeError after timeout
|
||||
with pytest.raises(RuntimeError, match="No Unity plugins are currently connected"):
|
||||
await PluginHub._resolve_session_id(unity_instance=None)
|
||||
finally:
|
||||
# Clean up: restore original PluginHub state
|
||||
PluginHub._registry = original_registry
|
||||
PluginHub._lock = original_lock
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_hub_no_wait_when_retry_disabled(monkeypatch):
|
||||
"""retry_on_reload=False should skip reconnect wait loops."""
|
||||
from transport.plugin_hub import PluginHub, NoUnitySessionError
|
||||
from transport.plugin_registry import PluginRegistry
|
||||
|
||||
mock_registry = AsyncMock(spec=PluginRegistry)
|
||||
mock_registry.get_session_id_by_hash = AsyncMock(return_value=None)
|
||||
mock_registry.list_sessions = AsyncMock(return_value={})
|
||||
|
||||
original_registry = PluginHub._registry
|
||||
original_lock = PluginHub._lock
|
||||
PluginHub._registry = mock_registry
|
||||
PluginHub._lock = asyncio.Lock()
|
||||
|
||||
monkeypatch.setenv("UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "20.0")
|
||||
|
||||
try:
|
||||
with pytest.raises(NoUnitySessionError):
|
||||
await PluginHub._resolve_session_id(
|
||||
unity_instance="hash-missing",
|
||||
retry_on_reload=False,
|
||||
)
|
||||
|
||||
assert mock_registry.get_session_id_by_hash.await_count == 1
|
||||
assert mock_registry.list_sessions.await_count == 1
|
||||
finally:
|
||||
PluginHub._registry = original_registry
|
||||
PluginHub._lock = original_lock
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_command_for_instance_fails_fast_on_stale_when_retry_disabled(monkeypatch):
|
||||
"""Stale HTTP session should not send command when retry_on_reload is disabled."""
|
||||
from transport.plugin_hub import PluginHub
|
||||
|
||||
resolve_mock = AsyncMock(return_value="sess-stale")
|
||||
ensure_mock = AsyncMock(return_value=False)
|
||||
send_mock = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(PluginHub, "_resolve_session_id", resolve_mock)
|
||||
monkeypatch.setattr(PluginHub, "_ensure_live_connection", ensure_mock)
|
||||
monkeypatch.setattr(PluginHub, "send_command", send_mock)
|
||||
|
||||
result = await PluginHub.send_command_for_instance(
|
||||
unity_instance="Project@hash-stale",
|
||||
command_type="manage_script",
|
||||
params={"action": "edit"},
|
||||
retry_on_reload=False,
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["hint"] == "retry"
|
||||
assert result.get("data", {}).get("reason") == "stale_connection"
|
||||
assert resolve_mock.await_count == 1
|
||||
_, resolve_kwargs = resolve_mock.await_args
|
||||
assert resolve_kwargs.get("retry_on_reload") is False
|
||||
send_mock.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_console_during_simulated_reload(monkeypatch):
|
||||
"""
|
||||
Simulate the stress test: create script (triggers reload) + rapid read_console calls.
|
||||
|
||||
This test simulates what happens when:
|
||||
1. A script is created (triggering domain reload)
|
||||
2. Multiple read_console calls are made immediately
|
||||
3. The plugin disconnects and reconnects during those calls
|
||||
"""
|
||||
# Setup tools
|
||||
from services.tools.read_console import read_console
|
||||
|
||||
call_count = [0]
|
||||
|
||||
async def fake_send_command(*args, **kwargs):
|
||||
"""Simulate successful command execution."""
|
||||
call_count[0] += 1
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Retrieved {call_count[0]} log entries.",
|
||||
"data": ["<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Auto-discovered 10 tools"]
|
||||
}
|
||||
|
||||
# Patch the async_send_command_with_retry directly
|
||||
import services.tools.read_console
|
||||
monkeypatch.setattr(
|
||||
services.tools.read_console,
|
||||
"async_send_command_with_retry",
|
||||
fake_send_command
|
||||
)
|
||||
|
||||
# Run multiple read_console calls rapidly (simulating the stress test)
|
||||
results = []
|
||||
for i in range(5):
|
||||
result = await read_console(
|
||||
ctx=DummyContext(),
|
||||
action="get",
|
||||
types=["all"],
|
||||
count=50,
|
||||
format="plain",
|
||||
include_stacktrace=False
|
||||
)
|
||||
results.append(result)
|
||||
|
||||
# All calls should succeed
|
||||
assert len(results) == 5
|
||||
for i, result in enumerate(results):
|
||||
assert result["success"] is True, f"Call {i+1} failed with result: {result}"
|
||||
assert "data" in result
|
||||
|
||||
# At least 5 calls should have been made
|
||||
assert call_count[0] == 5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_hub_respects_unity_instance_preference():
|
||||
"""Test that _resolve_session_id prefers a specific Unity instance if requested."""
|
||||
from transport.plugin_hub import PluginHub, InstanceSelectionRequiredError
|
||||
from transport.plugin_registry import PluginRegistry, PluginSession
|
||||
|
||||
# Create a mock registry with two sessions
|
||||
mock_registry = AsyncMock(spec=PluginRegistry)
|
||||
|
||||
now = datetime.now()
|
||||
session1 = PluginSession(
|
||||
session_id="session-1",
|
||||
project_name="Project1",
|
||||
project_hash="hash1",
|
||||
unity_version="2022.3.0f1",
|
||||
registered_at=now,
|
||||
connected_at=now
|
||||
)
|
||||
session2 = PluginSession(
|
||||
session_id="session-2",
|
||||
project_name="Project2",
|
||||
project_hash="hash2",
|
||||
unity_version="2022.3.0f1",
|
||||
registered_at=now,
|
||||
connected_at=now
|
||||
)
|
||||
|
||||
async def mock_list_sessions(**kwargs):
|
||||
return {
|
||||
"session-1": session1,
|
||||
"session-2": session2
|
||||
}
|
||||
|
||||
async def mock_get_session_id_by_hash(project_hash, user_id=None):
|
||||
if project_hash == "hash2":
|
||||
return "session-2"
|
||||
return None
|
||||
|
||||
mock_registry.list_sessions = mock_list_sessions
|
||||
mock_registry.get_session_id_by_hash = mock_get_session_id_by_hash
|
||||
|
||||
# Configure PluginHub with our mock while preserving the original state
|
||||
original_registry = PluginHub._registry
|
||||
original_lock = PluginHub._lock
|
||||
PluginHub._registry = mock_registry
|
||||
PluginHub._lock = asyncio.Lock()
|
||||
|
||||
try:
|
||||
# Request specific Unity instance
|
||||
session_id = await PluginHub._resolve_session_id(unity_instance="hash2")
|
||||
|
||||
# Should return the requested instance
|
||||
assert session_id == "session-2"
|
||||
|
||||
# Request default (no specific instance)
|
||||
with pytest.raises(InstanceSelectionRequiredError, match="Multiple Unity instances"):
|
||||
await PluginHub._resolve_session_id(unity_instance=None)
|
||||
|
||||
finally:
|
||||
# Clean up: restore original PluginHub state
|
||||
PluginHub._registry = original_registry
|
||||
PluginHub._lock = original_lock
|
||||
@@ -0,0 +1,138 @@
|
||||
import pytest
|
||||
|
||||
from .test_helpers import DummyContext, DummyMCP, setup_script_tools
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normalizes_lsp_and_index_ranges(monkeypatch):
|
||||
tools = setup_script_tools()
|
||||
apply = tools["apply_text_edits"]
|
||||
calls = []
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
calls.append(params)
|
||||
return {"success": True}
|
||||
|
||||
# Patch the send_command_with_retry function at the module level where it's imported
|
||||
import transport.legacy.unity_connection
|
||||
monkeypatch.setattr(
|
||||
transport.legacy.unity_connection,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
# No need to patch tools.manage_script; it calls unity_connection.send_command_with_retry
|
||||
|
||||
# LSP-style
|
||||
edits = [{
|
||||
"range": {"start": {"line": 10, "character": 2}, "end": {"line": 10, "character": 2}},
|
||||
"newText": "// lsp\n"
|
||||
}]
|
||||
await apply(
|
||||
DummyContext(),
|
||||
uri="mcpforunity://path/Assets/Scripts/F.cs",
|
||||
edits=edits,
|
||||
precondition_sha256="x",
|
||||
)
|
||||
p = calls[-1]
|
||||
e = p["edits"][0]
|
||||
assert e["startLine"] == 11 and e["startCol"] == 3
|
||||
|
||||
# Index pair
|
||||
calls.clear()
|
||||
edits = [{"range": [0, 0], "text": "// idx\n"}]
|
||||
# fake read to provide contents length
|
||||
|
||||
async def fake_read(cmd, params, **kwargs):
|
||||
if params.get("action") == "read":
|
||||
return {"success": True, "data": {"contents": "hello\n"}}
|
||||
calls.append(params)
|
||||
return {"success": True}
|
||||
|
||||
# Override unity_connection for this read normalization case
|
||||
monkeypatch.setattr(
|
||||
transport.legacy.unity_connection,
|
||||
"async_send_command_with_retry",
|
||||
fake_read,
|
||||
)
|
||||
await apply(
|
||||
DummyContext(),
|
||||
uri="mcpforunity://path/Assets/Scripts/F.cs",
|
||||
edits=edits,
|
||||
precondition_sha256="x",
|
||||
)
|
||||
# last call is apply_text_edits
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_noop_evidence_shape(monkeypatch):
|
||||
tools = setup_script_tools()
|
||||
apply = tools["apply_text_edits"]
|
||||
# Route response from Unity indicating no-op
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"no_op": True, "evidence": {"reason": "identical_content"}},
|
||||
}
|
||||
# Patch the send_command_with_retry function at the module level where it's imported
|
||||
import transport.legacy.unity_connection
|
||||
monkeypatch.setattr(
|
||||
transport.legacy.unity_connection,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
# No need to patch tools.manage_script; it calls unity_connection.send_command_with_retry
|
||||
|
||||
resp = await apply(
|
||||
DummyContext(),
|
||||
uri="mcpforunity://path/Assets/Scripts/F.cs",
|
||||
edits=[
|
||||
{"startLine": 1, "startCol": 1, "endLine": 1, "endCol": 1, "newText": ""}
|
||||
],
|
||||
precondition_sha256="x",
|
||||
)
|
||||
assert resp["success"] is True
|
||||
assert resp.get("data", {}).get("no_op") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_atomic_multi_span_and_relaxed(monkeypatch):
|
||||
tools_text = setup_script_tools()
|
||||
apply_text = tools_text["apply_text_edits"]
|
||||
# Fake send for read and write; verify atomic applyMode and validate=relaxed passes through
|
||||
sent = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
if params.get("action") == "read":
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"contents": "public class C{\nvoid M(){ int x=2; }\n}\n"},
|
||||
}
|
||||
sent.setdefault("calls", []).append(params)
|
||||
return {"success": True}
|
||||
|
||||
# Patch the send_command_with_retry function at the module level where it's imported
|
||||
import transport.legacy.unity_connection
|
||||
monkeypatch.setattr(
|
||||
transport.legacy.unity_connection,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
edits = [
|
||||
{"startLine": 2, "startCol": 14, "endLine": 2, "endCol": 15, "newText": "3"},
|
||||
{"startLine": 3, "startCol": 2, "endLine": 3,
|
||||
"endCol": 2, "newText": "// tail\n"}
|
||||
]
|
||||
resp = await apply_text(
|
||||
DummyContext(),
|
||||
uri="mcpforunity://path/Assets/Scripts/C.cs",
|
||||
edits=edits,
|
||||
precondition_sha256="sha",
|
||||
options={"validate": "relaxed", "applyMode": "atomic"},
|
||||
)
|
||||
assert resp["success"] is True
|
||||
# Last manage_script call should include options with applyMode atomic and validate relaxed
|
||||
last = sent["calls"][-1]
|
||||
assert last.get("options", {}).get("applyMode") == "atomic"
|
||||
assert last.get("options", {}).get("validate") == "relaxed"
|
||||
@@ -0,0 +1,66 @@
|
||||
import pytest
|
||||
|
||||
from .test_helpers import DummyContext, setup_script_tools
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_zero_based_normalized_warning(monkeypatch):
|
||||
tools = setup_script_tools()
|
||||
apply_edits = tools["apply_text_edits"]
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
# Simulate Unity path returning minimal success
|
||||
return {"success": True}
|
||||
|
||||
import transport.legacy.unity_connection
|
||||
monkeypatch.setattr(
|
||||
transport.legacy.unity_connection,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
# Explicit fields given as 0-based (invalid); SDK should normalize and warn
|
||||
edits = [{"startLine": 0, "startCol": 0,
|
||||
"endLine": 0, "endCol": 0, "newText": "//x"}]
|
||||
resp = await apply_edits(
|
||||
DummyContext(),
|
||||
uri="mcpforunity://path/Assets/Scripts/F.cs",
|
||||
edits=edits,
|
||||
precondition_sha256="sha",
|
||||
)
|
||||
|
||||
assert resp["success"] is True
|
||||
data = resp.get("data", {})
|
||||
assert "normalizedEdits" in data
|
||||
assert any(
|
||||
w == "zero_based_explicit_fields_normalized" for w in data.get("warnings", []))
|
||||
ne = data["normalizedEdits"][0]
|
||||
assert ne["startLine"] == 1 and ne["startCol"] == 1 and ne["endLine"] == 1 and ne["endCol"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strict_zero_based_error(monkeypatch):
|
||||
tools = setup_script_tools()
|
||||
apply_edits = tools["apply_text_edits"]
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
return {"success": True}
|
||||
|
||||
import transport.legacy.unity_connection
|
||||
monkeypatch.setattr(
|
||||
transport.legacy.unity_connection,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
edits = [{"startLine": 0, "startCol": 0,
|
||||
"endLine": 0, "endCol": 0, "newText": "//x"}]
|
||||
resp = await apply_edits(
|
||||
DummyContext(),
|
||||
uri="mcpforunity://path/Assets/Scripts/F.cs",
|
||||
edits=edits,
|
||||
precondition_sha256="sha",
|
||||
strict=True,
|
||||
)
|
||||
assert resp["success"] is False
|
||||
assert resp.get("code") == "zero_based_explicit_fields"
|
||||
@@ -0,0 +1,59 @@
|
||||
import pytest
|
||||
|
||||
from services.registry import get_registered_resources
|
||||
|
||||
from .test_helpers import DummyContext
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_editor_state_v2_is_registered_and_has_contract_fields(monkeypatch):
|
||||
"""
|
||||
Canonical editor state resource should be `mcpforunity://editor/state` and conform to v2 contract fields.
|
||||
"""
|
||||
# Import module to ensure it registers its decorator without disturbing global registry state.
|
||||
import services.resources.editor_state # noqa: F401
|
||||
|
||||
resources = get_registered_resources()
|
||||
|
||||
state_res = next(
|
||||
(r for r in resources if r.get("uri") == "mcpforunity://editor/state"),
|
||||
None,
|
||||
)
|
||||
assert state_res is not None, (
|
||||
"Expected canonical editor state resource `mcpforunity://editor/state` to be registered. "
|
||||
"This is required so clients can poll readiness/staleness and avoid tool loops."
|
||||
)
|
||||
|
||||
async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs):
|
||||
# Minimal stub payload for v2 resource tests. The server layer should enrich with staleness/advice.
|
||||
assert command_type == "get_editor_state"
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"schema_version": "unity-mcp/editor_state@2",
|
||||
"observed_at_unix_ms": 1730000000000,
|
||||
"sequence": 1,
|
||||
"compilation": {"is_compiling": False, "is_domain_reload_pending": False},
|
||||
"tests": {"is_running": False},
|
||||
},
|
||||
}
|
||||
|
||||
# Patch transport so the resource can be invoked without Unity running.
|
||||
import transport.unity_transport as unity_transport
|
||||
monkeypatch.setattr(unity_transport, "send_with_unity_instance", fake_send_with_unity_instance)
|
||||
|
||||
result = await state_res["func"](DummyContext())
|
||||
payload = result.model_dump() if hasattr(result, "model_dump") else result
|
||||
assert isinstance(payload, dict)
|
||||
|
||||
# Contract assertions (top-level)
|
||||
assert payload.get("success") is True
|
||||
data = payload.get("data")
|
||||
assert isinstance(data, dict)
|
||||
assert data.get("schema_version") == "unity-mcp/editor_state@2"
|
||||
assert "observed_at_unix_ms" in data
|
||||
assert "sequence" in data
|
||||
assert "advice" in data
|
||||
assert "staleness" in data
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_external_changes_scanner_marks_dirty_and_clears(tmp_path, monkeypatch):
|
||||
# Ensure the scanner is active for this unit-style test (not gated by PYTEST_CURRENT_TEST).
|
||||
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
||||
|
||||
from services.state.external_changes_scanner import ExternalChangesScanner
|
||||
|
||||
# Create a minimal Unity-like layout
|
||||
root = tmp_path / "Project"
|
||||
(root / "Assets").mkdir(parents=True)
|
||||
(root / "ProjectSettings").mkdir(parents=True)
|
||||
(root / "Packages").mkdir(parents=True)
|
||||
|
||||
inst = "Test@deadbeef"
|
||||
s = ExternalChangesScanner(scan_interval_ms=0, max_entries=10000)
|
||||
s.set_project_root(inst, str(root))
|
||||
|
||||
# Create a file before baseline so the initial scan establishes a stable reference point.
|
||||
p = root / "Assets" / "x.txt"
|
||||
p.write_text("hi")
|
||||
|
||||
# Baseline scan: should not be dirty.
|
||||
first = s.update_and_get(inst)
|
||||
assert first["external_changes_dirty"] is False
|
||||
|
||||
# Touch the file and scan again: should become dirty.
|
||||
now = time.time()
|
||||
os.utime(p, (now + 10.0, now + 10.0))
|
||||
|
||||
second = s.update_and_get(inst)
|
||||
assert second["external_changes_dirty"] is True
|
||||
assert isinstance(second["external_changes_last_seen_unix_ms"], int)
|
||||
assert isinstance(second["dirty_since_unix_ms"], int)
|
||||
|
||||
# Clear and confirm dirty flag resets.
|
||||
s.clear_dirty(inst)
|
||||
third = s.update_and_get(inst)
|
||||
assert third["external_changes_dirty"] is False
|
||||
assert isinstance(third["last_cleared_unix_ms"], int)
|
||||
|
||||
|
||||
def test_external_changes_scanner_includes_file_dependency_roots(tmp_path, monkeypatch):
|
||||
# Ensure the scanner is active for this unit-style test (not gated by PYTEST_CURRENT_TEST).
|
||||
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
||||
|
||||
from services.state.external_changes_scanner import ExternalChangesScanner
|
||||
|
||||
# Unity project root
|
||||
root = tmp_path / "Project"
|
||||
(root / "Assets").mkdir(parents=True)
|
||||
(root / "ProjectSettings").mkdir(parents=True)
|
||||
(root / "Packages").mkdir(parents=True)
|
||||
|
||||
# External local package root (outside project root)
|
||||
pkg = tmp_path / "ExternalPkg"
|
||||
(pkg / "Editor").mkdir(parents=True)
|
||||
target = pkg / "Editor" / "Some.cs"
|
||||
target.write_text("// v1")
|
||||
|
||||
# manifest.json referencing file: dependency
|
||||
manifest = root / "Packages" / "manifest.json"
|
||||
manifest.write_text(
|
||||
'{\n "dependencies": {\n "com.example.pkg": "file:../../ExternalPkg"\n }\n}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
inst = "Test@deadbeef"
|
||||
s = ExternalChangesScanner(scan_interval_ms=0, max_entries=10000)
|
||||
s.set_project_root(inst, str(root))
|
||||
|
||||
# Baseline scan captures current mtimes across project + external pkg
|
||||
baseline = s.update_and_get(inst)
|
||||
assert baseline["external_changes_dirty"] is False
|
||||
|
||||
# Touch external package file and scan again -> should mark dirty
|
||||
now = time.time()
|
||||
os.utime(target, (now + 10.0, now + 10.0))
|
||||
|
||||
changed = s.update_and_get(inst)
|
||||
assert changed["external_changes_dirty"] is True
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
Tests for the find_gameobjects tool.
|
||||
|
||||
This tool provides paginated GameObject search, returning instance IDs only.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from .test_helpers import DummyContext
|
||||
import services.tools.find_gameobjects as find_go_mod
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_gameobjects_basic_search(monkeypatch):
|
||||
"""Test basic search returns instance IDs."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured["params"] = params
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"instanceIDs": [12345, 67890],
|
||||
"pageSize": 25,
|
||||
"cursor": 0,
|
||||
"totalCount": 2,
|
||||
"hasMore": False,
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
find_go_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await find_go_mod.find_gameobjects(
|
||||
ctx=DummyContext(),
|
||||
search_term="Player",
|
||||
search_method="by_name",
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["cmd"] == "find_gameobjects"
|
||||
assert captured["params"]["searchTerm"] == "Player"
|
||||
assert captured["params"]["searchMethod"] == "by_name"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_gameobjects_by_component(monkeypatch):
|
||||
"""Test search by component type."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"instanceIDs": [111, 222, 333],
|
||||
"pageSize": 25,
|
||||
"cursor": 0,
|
||||
"totalCount": 3,
|
||||
"hasMore": False,
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
find_go_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await find_go_mod.find_gameobjects(
|
||||
ctx=DummyContext(),
|
||||
search_term="Camera",
|
||||
search_method="by_component",
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["params"]["searchTerm"] == "Camera"
|
||||
assert captured["params"]["searchMethod"] == "by_component"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_gameobjects_pagination_params(monkeypatch):
|
||||
"""Test pagination parameters are passed correctly."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"instanceIDs": [444, 555],
|
||||
"pageSize": 10,
|
||||
"cursor": 20,
|
||||
"totalCount": 50,
|
||||
"hasMore": True,
|
||||
"nextCursor": "30",
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
find_go_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await find_go_mod.find_gameobjects(
|
||||
ctx=DummyContext(),
|
||||
search_term="Enemy",
|
||||
search_method="by_tag",
|
||||
page_size="10",
|
||||
cursor="20",
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
p = captured["params"]
|
||||
assert p["pageSize"] == 10
|
||||
assert p["cursor"] == 20
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_gameobjects_boolean_coercion(monkeypatch):
|
||||
"""Test boolean string coercion for include_inactive."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {"instanceIDs": []}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
find_go_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await find_go_mod.find_gameobjects(
|
||||
ctx=DummyContext(),
|
||||
search_term="HiddenObject",
|
||||
search_method="by_name",
|
||||
include_inactive="true",
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
p = captured["params"]
|
||||
assert p["includeInactive"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_gameobjects_by_layer(monkeypatch):
|
||||
"""Test search by layer."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {"instanceIDs": [999]}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
find_go_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await find_go_mod.find_gameobjects(
|
||||
ctx=DummyContext(),
|
||||
search_term="UI",
|
||||
search_method="by_layer",
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["params"]["searchMethod"] == "by_layer"
|
||||
assert captured["params"]["searchTerm"] == "UI"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_gameobjects_by_path(monkeypatch):
|
||||
"""Test search by hierarchy path."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {"instanceIDs": [777]}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
find_go_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await find_go_mod.find_gameobjects(
|
||||
ctx=DummyContext(),
|
||||
search_term="Canvas/Panel/Button",
|
||||
search_method="by_path",
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["params"]["searchMethod"] == "by_path"
|
||||
assert captured["params"]["searchTerm"] == "Canvas/Panel/Button"
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"""
|
||||
Tests for the GameObject resources.
|
||||
|
||||
Resources:
|
||||
- mcpforunity://scene/gameobject/{instance_id}
|
||||
- mcpforunity://scene/gameobject/{instance_id}/components
|
||||
- mcpforunity://scene/gameobject/{instance_id}/component/{component_name}
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from .test_helpers import DummyContext
|
||||
import services.resources.gameobject as gameobject_res_mod
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_gameobject_data(monkeypatch):
|
||||
"""Test reading a single GameObject resource."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured["params"] = params
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"instanceID": 12345,
|
||||
"name": "Player",
|
||||
"tag": "Player",
|
||||
"layer": 0,
|
||||
"activeSelf": True,
|
||||
"activeInHierarchy": True,
|
||||
"isStatic": False,
|
||||
"path": "/Player",
|
||||
"componentTypes": ["Transform", "PlayerController", "Rigidbody"],
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
gameobject_res_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await gameobject_res_mod.get_gameobject(
|
||||
ctx=DummyContext(),
|
||||
instance_id="12345",
|
||||
)
|
||||
|
||||
assert resp.success is True
|
||||
assert captured["params"]["instanceID"] == 12345
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_gameobject_components(monkeypatch):
|
||||
"""Test reading all components for a GameObject."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured["params"] = params
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"cursor": 0,
|
||||
"pageSize": 25,
|
||||
"next_cursor": None,
|
||||
"truncated": False,
|
||||
"total": 3,
|
||||
"items": [
|
||||
{"typeName": "UnityEngine.Transform", "instanceID": 1, "enabled": True},
|
||||
{"typeName": "UnityEngine.MeshRenderer", "instanceID": 2, "enabled": True},
|
||||
{"typeName": "UnityEngine.BoxCollider", "instanceID": 3, "enabled": True},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
gameobject_res_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await gameobject_res_mod.get_gameobject_components(
|
||||
ctx=DummyContext(),
|
||||
instance_id="12345",
|
||||
)
|
||||
|
||||
assert resp.success is True
|
||||
assert captured["params"]["instanceID"] == 12345
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_gameobject_components_pagination(monkeypatch):
|
||||
"""Test pagination parameters for components resource."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"cursor": 10,
|
||||
"pageSize": 5,
|
||||
"next_cursor": "15",
|
||||
"truncated": True,
|
||||
"total": 20,
|
||||
"items": [],
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
gameobject_res_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await gameobject_res_mod.get_gameobject_components(
|
||||
ctx=DummyContext(),
|
||||
instance_id="12345",
|
||||
page_size=5,
|
||||
cursor=10,
|
||||
)
|
||||
|
||||
assert resp.success is True
|
||||
p = captured["params"]
|
||||
assert p["pageSize"] == 5
|
||||
assert p["cursor"] == 10
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_gameobject_components_include_properties(monkeypatch):
|
||||
"""Test include_properties flag for components resource."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"typeName": "UnityEngine.Rigidbody",
|
||||
"instanceID": 123,
|
||||
"mass": 1.0,
|
||||
"drag": 0.0,
|
||||
"useGravity": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
gameobject_res_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await gameobject_res_mod.get_gameobject_components(
|
||||
ctx=DummyContext(),
|
||||
instance_id="12345",
|
||||
include_properties=True,
|
||||
)
|
||||
|
||||
assert resp.success is True
|
||||
assert captured["params"]["includeProperties"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_gameobject_component_single(monkeypatch):
|
||||
"""Test reading a single component by name."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured["params"] = params
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"typeName": "UnityEngine.Rigidbody",
|
||||
"instanceID": 67890,
|
||||
"mass": 5.0,
|
||||
"drag": 0.1,
|
||||
"angularDrag": 0.05,
|
||||
"useGravity": True,
|
||||
"isKinematic": False,
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
gameobject_res_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await gameobject_res_mod.get_gameobject_component(
|
||||
ctx=DummyContext(),
|
||||
instance_id="12345",
|
||||
component_name="Rigidbody",
|
||||
)
|
||||
|
||||
assert resp.success is True
|
||||
p = captured["params"]
|
||||
assert p["instanceID"] == 12345
|
||||
assert p["componentName"] == "Rigidbody"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_gameobject_component_not_found(monkeypatch):
|
||||
"""Test error when component is not found."""
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "GameObject '12345' does not have a 'NonExistent' component.",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
gameobject_res_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await gameobject_res_mod.get_gameobject_component(
|
||||
ctx=DummyContext(),
|
||||
instance_id="12345",
|
||||
component_name="NonExistent",
|
||||
)
|
||||
|
||||
assert resp.success is False
|
||||
assert "NonExistent" in (resp.message or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_gameobject_not_found(monkeypatch):
|
||||
"""Test error when GameObject is not found."""
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "GameObject with instanceID '99999' not found.",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
gameobject_res_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await gameobject_res_mod.get_gameobject(
|
||||
ctx=DummyContext(),
|
||||
instance_id="99999",
|
||||
)
|
||||
|
||||
assert resp.success is False
|
||||
assert "99999" in (resp.message or "")
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import pytest
|
||||
|
||||
from .test_helpers import DummyContext, setup_script_tools
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sha_param_shape_and_routing(monkeypatch):
|
||||
tools = setup_script_tools()
|
||||
get_sha = tools["get_sha"]
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {"sha256": "abc", "lengthBytes": 1, "lastModifiedUtc": "2020-01-01T00:00:00Z", "uri": "mcpforunity://path/Assets/Scripts/A.cs", "path": "Assets/Scripts/A.cs"}}
|
||||
|
||||
# Patch the send_command_with_retry function at the module level where it's imported
|
||||
import transport.legacy.unity_connection
|
||||
monkeypatch.setattr(
|
||||
transport.legacy.unity_connection,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
|
||||
|
||||
resp = await get_sha(DummyContext(), uri="mcpforunity://path/Assets/Scripts/A.cs")
|
||||
assert captured["cmd"] == "manage_script"
|
||||
assert captured["params"]["action"] == "get_sha"
|
||||
assert captured["params"]["name"] == "A"
|
||||
assert captured["params"]["path"].endswith("Assets/Scripts")
|
||||
assert resp["success"] is True
|
||||
assert resp["data"] == {"sha256": "abc", "lengthBytes": 1}
|
||||
@@ -0,0 +1,88 @@
|
||||
class _DummyMeta(dict):
|
||||
def __getattr__(self, item):
|
||||
try:
|
||||
return self[item]
|
||||
except KeyError as exc:
|
||||
raise AttributeError(item) from exc
|
||||
|
||||
model_extra = property(lambda self: self)
|
||||
|
||||
def model_dump(self, exclude_none=True):
|
||||
if not exclude_none:
|
||||
return dict(self)
|
||||
return {k: v for k, v in self.items() if v is not None}
|
||||
|
||||
|
||||
class DummyContext:
|
||||
"""Mock context object for testing"""
|
||||
|
||||
def __init__(self, **meta):
|
||||
import uuid
|
||||
self.log_info = []
|
||||
self.log_warning = []
|
||||
self.log_error = []
|
||||
self._meta = _DummyMeta(meta)
|
||||
# Give each context a unique session_id to avoid state leakage between tests
|
||||
self.session_id = str(uuid.uuid4())
|
||||
# Add state storage to mimic FastMCP context state
|
||||
self._state = {}
|
||||
|
||||
class _RequestContext:
|
||||
def __init__(self, meta):
|
||||
self.meta = meta
|
||||
|
||||
self.request_context = _RequestContext(self._meta)
|
||||
|
||||
async def info(self, message):
|
||||
self.log_info.append(message)
|
||||
|
||||
async def warning(self, message):
|
||||
self.log_warning.append(message)
|
||||
|
||||
# Some code paths call warn(); treat it as an alias of warning()
|
||||
async def warn(self, message):
|
||||
await self.warning(message)
|
||||
|
||||
async def error(self, message):
|
||||
self.log_error.append(message)
|
||||
|
||||
async def set_state(self, key, value):
|
||||
"""Set state value (mimics FastMCP context.set_state)"""
|
||||
self._state[key] = value
|
||||
|
||||
async def get_state(self, key, default=None):
|
||||
"""Get state value (mimics FastMCP context.get_state)"""
|
||||
return self._state.get(key, default)
|
||||
|
||||
|
||||
class DummyMCP:
|
||||
"""Mock MCP server for testing tool registration patterns."""
|
||||
|
||||
def __init__(self):
|
||||
self.tools = {}
|
||||
|
||||
def tool(self, *args, **kwargs):
|
||||
def deco(fn):
|
||||
self.tools[fn.__name__] = fn
|
||||
return fn
|
||||
return deco
|
||||
|
||||
|
||||
def setup_script_tools():
|
||||
"""
|
||||
Setup script-related tools for testing.
|
||||
|
||||
Returns a dict mapping tool names to their async handler functions.
|
||||
Useful for testing parameter serialization and SDK behavior.
|
||||
"""
|
||||
mcp = DummyMCP()
|
||||
# Import tools to trigger decorator-based registration
|
||||
import services.tools.manage_script
|
||||
from services.registry import get_registered_tools
|
||||
|
||||
for tool_info in get_registered_tools():
|
||||
name = tool_info['name']
|
||||
if any(k in name for k in ['script', 'apply_text', 'create_script',
|
||||
'delete_script', 'validate_script', 'get_sha']):
|
||||
mcp.tools[name] = tool_info['func']
|
||||
return mcp.tools
|
||||
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
Test the improved anchor matching logic.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
import services.tools.script_apply_edits as script_apply_edits_module
|
||||
|
||||
|
||||
def test_improved_anchor_matching():
|
||||
"""Test that our improved anchor matching finds the right closing brace."""
|
||||
|
||||
test_code = '''using UnityEngine;
|
||||
|
||||
public class TestClass : MonoBehaviour
|
||||
{
|
||||
void Start()
|
||||
{
|
||||
Debug.Log("test");
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
// Update logic
|
||||
}
|
||||
}'''
|
||||
|
||||
# Test the problematic anchor pattern
|
||||
anchor_pattern = r"\s*}\s*$"
|
||||
flags = re.MULTILINE
|
||||
|
||||
# Test our improved function
|
||||
best_match = script_apply_edits_module._find_best_anchor_match(
|
||||
anchor_pattern, test_code, flags, prefer_last=True
|
||||
)
|
||||
|
||||
assert best_match is not None, "anchor pattern not found"
|
||||
match_pos = best_match.start()
|
||||
line_num = test_code[:match_pos].count('\n') + 1
|
||||
total_lines = test_code.count('\n') + 1
|
||||
assert line_num >= total_lines - \
|
||||
2, f"expected match near end (>= {total_lines-2}), got line {line_num}"
|
||||
|
||||
|
||||
def test_old_vs_new_matching():
|
||||
"""Compare old vs new matching behavior."""
|
||||
|
||||
test_code = '''using UnityEngine;
|
||||
|
||||
public class TestClass : MonoBehaviour
|
||||
{
|
||||
void Start()
|
||||
{
|
||||
Debug.Log("test");
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (condition)
|
||||
{
|
||||
DoSomething();
|
||||
}
|
||||
}
|
||||
|
||||
void LateUpdate()
|
||||
{
|
||||
// More logic
|
||||
}
|
||||
}'''
|
||||
|
||||
import re
|
||||
|
||||
anchor_pattern = r"\s*}\s*$"
|
||||
flags = re.MULTILINE
|
||||
|
||||
# Old behavior (first match)
|
||||
old_match = re.search(anchor_pattern, test_code, flags)
|
||||
old_line = test_code[:old_match.start()].count(
|
||||
'\n') + 1 if old_match else None
|
||||
|
||||
# New behavior (improved matching)
|
||||
new_match = script_apply_edits_module._find_best_anchor_match(
|
||||
anchor_pattern, test_code, flags, prefer_last=True
|
||||
)
|
||||
new_line = test_code[:new_match.start()].count(
|
||||
'\n') + 1 if new_match else None
|
||||
|
||||
assert old_line is not None and new_line is not None, "failed to locate anchors"
|
||||
assert new_line > old_line, f"improved matcher should choose a later line (old={old_line}, new={new_line})"
|
||||
total_lines = test_code.count('\n') + 1
|
||||
assert new_line >= total_lines - \
|
||||
2, f"expected class-end match near end (>= {total_lines-2}), got {new_line}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_edits_with_improved_matching():
|
||||
"""Test that _apply_edits_locally uses improved matching."""
|
||||
|
||||
original_code = '''using UnityEngine;
|
||||
|
||||
public class TestClass : MonoBehaviour
|
||||
{
|
||||
public string message = "Hello World";
|
||||
|
||||
void Start()
|
||||
{
|
||||
Debug.Log(message);
|
||||
}
|
||||
}'''
|
||||
|
||||
# Test anchor_insert with the problematic pattern
|
||||
edits = [{
|
||||
"op": "anchor_insert",
|
||||
"anchor": r"\s*}\s*$", # This should now find the class end
|
||||
"position": "before",
|
||||
"text": "\n public void NewMethod() { Debug.Log(\"Added at class end\"); }\n"
|
||||
}]
|
||||
|
||||
result = await script_apply_edits_module._apply_edits_locally(
|
||||
original_code, edits)
|
||||
lines = result.split('\n')
|
||||
try:
|
||||
idx = next(i for i, line in enumerate(lines) if "NewMethod" in line)
|
||||
except StopIteration:
|
||||
assert False, "NewMethod not found in result"
|
||||
total_lines = len(lines)
|
||||
assert idx >= total_lines - \
|
||||
5, f"method inserted too early (idx={idx}, total_lines={total_lines})"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Testing improved anchor matching...")
|
||||
print("="*60)
|
||||
|
||||
success1 = test_improved_anchor_matching()
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("Comparing old vs new behavior...")
|
||||
success2 = test_old_vs_new_matching()
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("Testing _apply_edits_locally with improved matching...")
|
||||
success3 = test_apply_edits_with_improved_matching()
|
||||
|
||||
print("\n" + "="*60)
|
||||
if success1 and success2 and success3:
|
||||
print("🎉 ALL TESTS PASSED! Improved anchor matching is working!")
|
||||
else:
|
||||
print("💥 Some tests failed. Need more work on anchor matching.")
|
||||
@@ -0,0 +1,428 @@
|
||||
"""
|
||||
Tests for per-call unity_instance routing via middleware argument interception.
|
||||
|
||||
When a tool call includes unity_instance in its arguments, the middleware:
|
||||
1. Pops the key before Pydantic validation sees it
|
||||
2. Resolves it to a validated instance identifier
|
||||
3. Sets it in request-scoped state for that call only (does NOT persist to session)
|
||||
"""
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from .test_helpers import DummyContext
|
||||
from core.config import config
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DummyMiddlewareContext:
|
||||
"""Minimal MiddlewareContext stand-in with a mutable arguments dict."""
|
||||
|
||||
def __init__(self, ctx, arguments: dict | None = None):
|
||||
self.fastmcp_context = ctx
|
||||
self.message = SimpleNamespace(arguments=arguments if arguments is not None else {})
|
||||
|
||||
|
||||
def _make_middleware(monkeypatch, *, transport="stdio", plugin_hub_configured=False, sessions=None, pool_instances=None):
|
||||
"""
|
||||
Build a UnityInstanceMiddleware with patched transport dependencies.
|
||||
|
||||
sessions: dict of session_id -> SimpleNamespace(project=..., hash=...)
|
||||
pool_instances: list of SimpleNamespace(id=..., hash=...)
|
||||
"""
|
||||
plugin_hub_mod = types.ModuleType("transport.plugin_hub")
|
||||
|
||||
_sessions = sessions or {}
|
||||
_configured = plugin_hub_configured
|
||||
|
||||
class FakePluginHub:
|
||||
@classmethod
|
||||
def is_configured(cls):
|
||||
return _configured
|
||||
|
||||
@classmethod
|
||||
async def get_sessions(cls, user_id=None):
|
||||
return SimpleNamespace(sessions=_sessions)
|
||||
|
||||
@classmethod
|
||||
async def _resolve_session_id(cls, instance, user_id=None):
|
||||
return None
|
||||
|
||||
plugin_hub_mod.PluginHub = FakePluginHub
|
||||
monkeypatch.setitem(sys.modules, "transport.plugin_hub", plugin_hub_mod)
|
||||
monkeypatch.delitem(sys.modules, "transport.unity_instance_middleware", raising=False)
|
||||
|
||||
from transport.unity_instance_middleware import UnityInstanceMiddleware
|
||||
|
||||
middleware = UnityInstanceMiddleware()
|
||||
monkeypatch.setattr(config, "transport_mode", transport)
|
||||
monkeypatch.setattr(config, "http_remote_hosted", False)
|
||||
|
||||
if pool_instances is not None:
|
||||
async def fake_discover(ctx):
|
||||
return pool_instances
|
||||
monkeypatch.setattr(middleware, "_discover_instances", fake_discover)
|
||||
|
||||
return middleware
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pop behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unity_instance_is_popped_from_arguments(monkeypatch):
|
||||
"""unity_instance key must be removed from arguments before the tool function sees them."""
|
||||
instances = [SimpleNamespace(id="Proj@abc123", hash="abc123")]
|
||||
mw = _make_middleware(monkeypatch, pool_instances=instances)
|
||||
|
||||
ctx = DummyContext()
|
||||
ctx.client_id = "client-1"
|
||||
args = {"action": "get_active", "unity_instance": "abc123"}
|
||||
mw_ctx = DummyMiddlewareContext(ctx, arguments=args)
|
||||
|
||||
await mw._inject_unity_instance(mw_ctx)
|
||||
|
||||
assert "unity_instance" not in args
|
||||
assert "action" in args # other keys untouched
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_arguments_without_unity_instance_untouched(monkeypatch):
|
||||
"""When unity_instance is absent, arguments dict is left completely untouched."""
|
||||
mw = _make_middleware(monkeypatch, pool_instances=[SimpleNamespace(id="Proj@abc123", hash="abc123")])
|
||||
|
||||
ctx = DummyContext()
|
||||
ctx.client_id = "client-1"
|
||||
# Seed a persisted instance so auto-select isn't needed
|
||||
await mw.set_active_instance(ctx, "Proj@abc123")
|
||||
|
||||
args = {"action": "get_active", "name": "Test"}
|
||||
mw_ctx = DummyMiddlewareContext(ctx, arguments=args)
|
||||
|
||||
await mw._inject_unity_instance(mw_ctx)
|
||||
|
||||
assert args == {"action": "get_active", "name": "Test"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-call routing (no persistence)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inline_routes_to_specified_instance(monkeypatch):
|
||||
"""Per-call unity_instance sets request state to the resolved instance."""
|
||||
instances = [SimpleNamespace(id="Proj@abc123", hash="abc123")]
|
||||
mw = _make_middleware(monkeypatch, pool_instances=instances)
|
||||
|
||||
ctx = DummyContext()
|
||||
ctx.client_id = "client-1"
|
||||
mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "abc123"})
|
||||
|
||||
await mw._inject_unity_instance(mw_ctx)
|
||||
|
||||
assert await ctx.get_state("unity_instance") == "Proj@abc123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inline_does_not_persist_to_session(monkeypatch):
|
||||
"""Per-call unity_instance must not change the session-persisted instance."""
|
||||
instances = [
|
||||
SimpleNamespace(id="ProjA@aaa111", hash="aaa111"),
|
||||
SimpleNamespace(id="ProjB@bbb222", hash="bbb222"),
|
||||
]
|
||||
mw = _make_middleware(monkeypatch, pool_instances=instances)
|
||||
|
||||
ctx = DummyContext()
|
||||
ctx.client_id = "client-1"
|
||||
await mw.set_active_instance(ctx, "ProjA@aaa111")
|
||||
|
||||
# Call 1: inline override to ProjB
|
||||
mw_ctx1 = DummyMiddlewareContext(ctx, arguments={"unity_instance": "bbb222"})
|
||||
await mw._inject_unity_instance(mw_ctx1)
|
||||
assert await ctx.get_state("unity_instance") == "ProjB@bbb222"
|
||||
|
||||
# Call 2: no inline — must revert to session-persisted ProjA
|
||||
mw_ctx2 = DummyMiddlewareContext(ctx, arguments={})
|
||||
await mw._inject_unity_instance(mw_ctx2)
|
||||
assert await ctx.get_state("unity_instance") == "ProjA@aaa111"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inline_overrides_session_persisted_instance(monkeypatch):
|
||||
"""Inline unity_instance takes precedence over session-persisted instance."""
|
||||
instances = [
|
||||
SimpleNamespace(id="ProjA@aaa111", hash="aaa111"),
|
||||
SimpleNamespace(id="ProjB@bbb222", hash="bbb222"),
|
||||
]
|
||||
mw = _make_middleware(monkeypatch, pool_instances=instances)
|
||||
|
||||
ctx = DummyContext()
|
||||
ctx.client_id = "client-1"
|
||||
await mw.set_active_instance(ctx, "ProjA@aaa111")
|
||||
|
||||
mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "ProjB@bbb222"})
|
||||
await mw._inject_unity_instance(mw_ctx)
|
||||
|
||||
assert await ctx.get_state("unity_instance") == "ProjB@bbb222"
|
||||
# Session still pinned to ProjA
|
||||
assert await mw.get_active_instance(ctx) == "ProjA@aaa111"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Port number resolution (stdio)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_port_number_resolves_to_name_hash_stdio(monkeypatch):
|
||||
"""Bare port number resolves to the matching Name@hash in stdio mode."""
|
||||
instances = [
|
||||
SimpleNamespace(id="Proj@abc123", hash="abc123", port=6401),
|
||||
SimpleNamespace(id="Other@def456", hash="def456", port=6402),
|
||||
]
|
||||
mw = _make_middleware(monkeypatch, transport="stdio", pool_instances=instances)
|
||||
|
||||
ctx = DummyContext()
|
||||
ctx.client_id = "client-1"
|
||||
mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "6401"})
|
||||
|
||||
await mw._inject_unity_instance(mw_ctx)
|
||||
|
||||
assert await ctx.get_state("unity_instance") == "Proj@abc123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_port_number_not_found_raises(monkeypatch):
|
||||
"""Port number with no matching instance raises ValueError."""
|
||||
instances = [SimpleNamespace(id="Proj@abc123", hash="abc123", port=6401)]
|
||||
mw = _make_middleware(monkeypatch, transport="stdio", pool_instances=instances)
|
||||
|
||||
ctx = DummyContext()
|
||||
ctx.client_id = "client-1"
|
||||
mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "9999"})
|
||||
|
||||
with pytest.raises(ValueError, match="No Unity instance found on port 9999"):
|
||||
await mw._inject_unity_instance(mw_ctx)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_port_number_errors_in_http_mode(monkeypatch):
|
||||
"""Bare port number raises ValueError in HTTP transport mode."""
|
||||
mw = _make_middleware(monkeypatch, transport="http")
|
||||
|
||||
ctx = DummyContext()
|
||||
ctx.client_id = "client-1"
|
||||
mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "6401"})
|
||||
|
||||
with pytest.raises(ValueError, match="not supported in HTTP transport mode"):
|
||||
await mw._inject_unity_instance(mw_ctx)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Name@hash and hash prefix resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_name_at_hash_resolves_exactly(monkeypatch):
|
||||
"""Full Name@hash resolves directly without discovery."""
|
||||
instances = [SimpleNamespace(id="Proj@abc123", hash="abc123")]
|
||||
mw = _make_middleware(monkeypatch, pool_instances=instances)
|
||||
|
||||
ctx = DummyContext()
|
||||
ctx.client_id = "client-1"
|
||||
mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "Proj@abc123"})
|
||||
|
||||
await mw._inject_unity_instance(mw_ctx)
|
||||
|
||||
assert await ctx.get_state("unity_instance") == "Proj@abc123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_name_at_hash_raises(monkeypatch):
|
||||
"""Unknown Name@hash raises ValueError."""
|
||||
instances = [SimpleNamespace(id="Proj@abc123", hash="abc123")]
|
||||
mw = _make_middleware(monkeypatch, pool_instances=instances)
|
||||
|
||||
ctx = DummyContext()
|
||||
ctx.client_id = "client-1"
|
||||
mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "Ghost@deadbeef"})
|
||||
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
await mw._inject_unity_instance(mw_ctx)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hash_prefix_resolves_unique(monkeypatch):
|
||||
"""Unique hash prefix resolves to the full Name@hash."""
|
||||
instances = [SimpleNamespace(id="Proj@abc123", hash="abc123")]
|
||||
mw = _make_middleware(monkeypatch, pool_instances=instances)
|
||||
|
||||
ctx = DummyContext()
|
||||
ctx.client_id = "client-1"
|
||||
mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "abc"})
|
||||
|
||||
await mw._inject_unity_instance(mw_ctx)
|
||||
|
||||
assert await ctx.get_state("unity_instance") == "Proj@abc123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ambiguous_hash_prefix_raises(monkeypatch):
|
||||
"""Ambiguous hash prefix raises ValueError."""
|
||||
instances = [
|
||||
SimpleNamespace(id="ProjA@abc111", hash="abc111"),
|
||||
SimpleNamespace(id="ProjB@abc222", hash="abc222"),
|
||||
]
|
||||
mw = _make_middleware(monkeypatch, pool_instances=instances)
|
||||
|
||||
ctx = DummyContext()
|
||||
ctx.client_id = "client-1"
|
||||
mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "abc"})
|
||||
|
||||
with pytest.raises(ValueError, match="ambiguous"):
|
||||
await mw._inject_unity_instance(mw_ctx)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_match_raises(monkeypatch):
|
||||
"""Hash prefix matching nothing raises ValueError."""
|
||||
instances = [SimpleNamespace(id="Proj@abc123", hash="abc123")]
|
||||
mw = _make_middleware(monkeypatch, pool_instances=instances)
|
||||
|
||||
ctx = DummyContext()
|
||||
ctx.client_id = "client-1"
|
||||
mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": "xyz"})
|
||||
|
||||
with pytest.raises(ValueError, match="No running Unity instance"):
|
||||
await mw._inject_unity_instance(mw_ctx)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_none_unity_instance_falls_through_to_session(monkeypatch):
|
||||
"""None value for unity_instance falls through to session-persisted instance."""
|
||||
mw = _make_middleware(monkeypatch)
|
||||
ctx = DummyContext()
|
||||
ctx.client_id = "client-1"
|
||||
await mw.set_active_instance(ctx, "Proj@abc123")
|
||||
|
||||
mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": None, "action": "x"})
|
||||
|
||||
await mw._inject_unity_instance(mw_ctx)
|
||||
|
||||
assert await ctx.get_state("unity_instance") == "Proj@abc123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_string_unity_instance_falls_through_to_session(monkeypatch):
|
||||
"""Empty string unity_instance falls through to session-persisted instance."""
|
||||
mw = _make_middleware(monkeypatch)
|
||||
ctx = DummyContext()
|
||||
ctx.client_id = "client-1"
|
||||
await mw.set_active_instance(ctx, "Proj@abc123")
|
||||
|
||||
mw_ctx = DummyMiddlewareContext(ctx, arguments={"unity_instance": " "})
|
||||
|
||||
await mw._inject_unity_instance(mw_ctx)
|
||||
|
||||
assert await ctx.get_state("unity_instance") == "Proj@abc123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_read_unaffected(monkeypatch):
|
||||
"""on_read_resource with no .arguments attribute routes via session state normally."""
|
||||
mw = _make_middleware(monkeypatch)
|
||||
ctx = DummyContext()
|
||||
ctx.client_id = "client-1"
|
||||
await mw.set_active_instance(ctx, "Proj@abc123")
|
||||
|
||||
# ReadResourceRequestParams has .uri not .arguments
|
||||
resource_ctx = SimpleNamespace(
|
||||
fastmcp_context=ctx,
|
||||
message=SimpleNamespace(uri="mcpforunity://scene/active"),
|
||||
)
|
||||
|
||||
await mw._inject_unity_instance(resource_ctx)
|
||||
|
||||
assert await ctx.get_state("unity_instance") == "Proj@abc123"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# set_active_instance tool: port number support
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_active_instance_port_stdio(monkeypatch):
|
||||
"""set_active_instance accepts a port number in stdio mode and resolves to Name@hash."""
|
||||
monkeypatch.setattr(config, "transport_mode", "stdio")
|
||||
monkeypatch.setattr(config, "http_remote_hosted", False)
|
||||
|
||||
from transport.unity_instance_middleware import UnityInstanceMiddleware, set_unity_instance_middleware
|
||||
mw = UnityInstanceMiddleware()
|
||||
set_unity_instance_middleware(mw)
|
||||
|
||||
pool_instance = SimpleNamespace(id="Proj@abc123", hash="abc123", port=6401)
|
||||
|
||||
class FakePool:
|
||||
def discover_all_instances(self, force_refresh=False):
|
||||
return [pool_instance]
|
||||
|
||||
import services.tools.set_active_instance as sat
|
||||
monkeypatch.setattr(sat, "get_unity_connection_pool", lambda: FakePool())
|
||||
|
||||
from services.tools.set_active_instance import set_active_instance
|
||||
|
||||
ctx = DummyContext()
|
||||
ctx.client_id = "client-1"
|
||||
|
||||
result = await set_active_instance(ctx, instance="6401")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["data"]["instance"] == "Proj@abc123"
|
||||
assert await mw.get_active_instance(ctx) == "Proj@abc123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_active_instance_port_http_errors(monkeypatch):
|
||||
"""set_active_instance rejects port numbers in HTTP mode."""
|
||||
monkeypatch.setattr(config, "transport_mode", "http")
|
||||
monkeypatch.setattr(config, "http_remote_hosted", False)
|
||||
|
||||
from services.tools.set_active_instance import set_active_instance
|
||||
|
||||
ctx = DummyContext()
|
||||
ctx.client_id = "client-1"
|
||||
|
||||
result = await set_active_instance(ctx, instance="6401")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "not supported in HTTP transport mode" in result["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# batch_execute rejects inner unity_instance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_execute_rejects_inner_unity_instance():
|
||||
"""batch_execute raises ValueError when an inner command contains unity_instance."""
|
||||
from services.tools.batch_execute import batch_execute
|
||||
|
||||
ctx = DummyContext()
|
||||
ctx.client_id = "client-1"
|
||||
ctx._state["unity_instance"] = "Proj@abc123"
|
||||
|
||||
commands = [
|
||||
{"tool": "manage_scene", "params": {"action": "get_active", "unity_instance": "6402"}},
|
||||
]
|
||||
|
||||
with pytest.raises(ValueError, match="Per-command instance routing is not supported inside batch_execute"):
|
||||
await batch_execute(ctx, commands=commands)
|
||||
@@ -0,0 +1,139 @@
|
||||
import pytest
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
|
||||
from .test_helpers import DummyContext
|
||||
from core.config import config
|
||||
|
||||
|
||||
class DummyMiddlewareContext:
|
||||
def __init__(self, ctx):
|
||||
self.fastmcp_context = ctx
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_selects_single_instance_via_pluginhub(monkeypatch):
|
||||
plugin_hub = types.ModuleType("transport.plugin_hub")
|
||||
|
||||
class PluginHub:
|
||||
@classmethod
|
||||
def is_configured(cls) -> bool:
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
async def get_sessions(cls):
|
||||
raise AssertionError("get_sessions should be stubbed in test")
|
||||
|
||||
plugin_hub.PluginHub = PluginHub
|
||||
monkeypatch.setitem(sys.modules, "transport.plugin_hub", plugin_hub)
|
||||
monkeypatch.delitem(sys.modules, "transport.unity_instance_middleware", raising=False)
|
||||
|
||||
from transport.unity_instance_middleware import UnityInstanceMiddleware, PluginHub as ImportedPluginHub
|
||||
assert ImportedPluginHub is plugin_hub.PluginHub
|
||||
|
||||
monkeypatch.setattr(config, "transport_mode", "http")
|
||||
|
||||
middleware = UnityInstanceMiddleware()
|
||||
ctx = DummyContext()
|
||||
ctx.client_id = "client-1"
|
||||
middleware_context = DummyMiddlewareContext(ctx)
|
||||
|
||||
call_count = {"sessions": 0}
|
||||
|
||||
async def fake_get_sessions():
|
||||
call_count["sessions"] += 1
|
||||
return SimpleNamespace(
|
||||
sessions={
|
||||
"session-1": SimpleNamespace(project="Ramble", hash="deadbeef"),
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(plugin_hub.PluginHub, "get_sessions", fake_get_sessions)
|
||||
|
||||
selected = await middleware._maybe_autoselect_instance(ctx)
|
||||
|
||||
assert selected == "Ramble@deadbeef"
|
||||
assert await middleware.get_active_instance(ctx) == "Ramble@deadbeef"
|
||||
assert call_count["sessions"] == 1
|
||||
|
||||
await middleware._inject_unity_instance(middleware_context)
|
||||
|
||||
assert await ctx.get_state("unity_instance") == "Ramble@deadbeef"
|
||||
assert call_count["sessions"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_selects_single_instance_via_stdio(monkeypatch):
|
||||
plugin_hub = types.ModuleType("transport.plugin_hub")
|
||||
|
||||
class PluginHub:
|
||||
@classmethod
|
||||
def is_configured(cls) -> bool:
|
||||
return False
|
||||
|
||||
plugin_hub.PluginHub = PluginHub
|
||||
monkeypatch.setitem(sys.modules, "transport.plugin_hub", plugin_hub)
|
||||
monkeypatch.delitem(sys.modules, "transport.unity_instance_middleware", raising=False)
|
||||
|
||||
from transport.unity_instance_middleware import UnityInstanceMiddleware, PluginHub as ImportedPluginHub
|
||||
assert ImportedPluginHub is plugin_hub.PluginHub
|
||||
|
||||
monkeypatch.setattr(config, "transport_mode", "stdio")
|
||||
|
||||
middleware = UnityInstanceMiddleware()
|
||||
ctx = DummyContext()
|
||||
ctx.client_id = "client-1"
|
||||
middleware_context = DummyMiddlewareContext(ctx)
|
||||
|
||||
class PoolStub:
|
||||
def discover_all_instances(self, force_refresh=False):
|
||||
assert force_refresh is True
|
||||
return [SimpleNamespace(id="UnityMCPTests@cc8756d4")]
|
||||
|
||||
unity_connection = types.ModuleType("transport.legacy.unity_connection")
|
||||
unity_connection.get_unity_connection_pool = lambda: PoolStub()
|
||||
monkeypatch.setitem(sys.modules, "transport.legacy.unity_connection", unity_connection)
|
||||
|
||||
selected = await middleware._maybe_autoselect_instance(ctx)
|
||||
|
||||
assert selected == "UnityMCPTests@cc8756d4"
|
||||
assert await middleware.get_active_instance(ctx) == "UnityMCPTests@cc8756d4"
|
||||
|
||||
await middleware._inject_unity_instance(middleware_context)
|
||||
|
||||
assert await ctx.get_state("unity_instance") == "UnityMCPTests@cc8756d4"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_select_handles_stdio_errors(monkeypatch):
|
||||
plugin_hub = types.ModuleType("transport.plugin_hub")
|
||||
|
||||
class PluginHub:
|
||||
@classmethod
|
||||
def is_configured(cls) -> bool:
|
||||
return False
|
||||
|
||||
plugin_hub.PluginHub = PluginHub
|
||||
monkeypatch.setitem(sys.modules, "transport.plugin_hub", plugin_hub)
|
||||
monkeypatch.delitem(sys.modules, "transport.unity_instance_middleware", raising=False)
|
||||
|
||||
from transport.unity_instance_middleware import UnityInstanceMiddleware, PluginHub as ImportedPluginHub
|
||||
assert ImportedPluginHub is plugin_hub.PluginHub
|
||||
|
||||
middleware = UnityInstanceMiddleware()
|
||||
ctx = DummyContext()
|
||||
ctx.client_id = "client-1"
|
||||
|
||||
class PoolStub:
|
||||
def discover_all_instances(self, force_refresh=False):
|
||||
raise ConnectionError("stdio unavailable")
|
||||
|
||||
unity_connection = types.ModuleType("transport.legacy.unity_connection")
|
||||
unity_connection.get_unity_connection_pool = lambda: PoolStub()
|
||||
monkeypatch.setitem(sys.modules, "transport.legacy.unity_connection", unity_connection)
|
||||
|
||||
selected = await middleware._maybe_autoselect_instance(ctx)
|
||||
|
||||
assert selected is None
|
||||
assert await middleware.get_active_instance(ctx) is None
|
||||
@@ -0,0 +1,446 @@
|
||||
"""
|
||||
Comprehensive test suite for Unity instance routing.
|
||||
|
||||
These tests validate that set_active_instance correctly routes subsequent
|
||||
tool calls to the intended Unity instance across ALL tool categories.
|
||||
|
||||
DESIGN: Single source of truth via middleware state:
|
||||
- set_active_instance tool stores instance per session in UnityInstanceMiddleware
|
||||
- Middleware injects instance into ctx.set_state() for each tool call
|
||||
- get_unity_instance_from_context() reads from ctx.get_state()
|
||||
- All tools (GameObject, Script, Asset, etc.) use get_unity_instance_from_context()
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, Mock, MagicMock, patch
|
||||
from fastmcp import Context
|
||||
|
||||
from core.config import config
|
||||
from transport.unity_instance_middleware import UnityInstanceMiddleware
|
||||
from services.tools import get_unity_instance_from_context
|
||||
from services.tools.set_active_instance import set_active_instance as set_active_instance_tool
|
||||
from transport.models import SessionList, SessionDetails
|
||||
|
||||
|
||||
def _make_stateful_ctx(session_id: str) -> Mock:
|
||||
"""Build a Context shim whose set/get/delete_state share one dict.
|
||||
|
||||
The middleware now defers persistence to FastMCP's session-scoped state
|
||||
store, so a useful unit-test mock must actually round-trip values rather
|
||||
than returning fresh Mocks. This helper does that minimally.
|
||||
"""
|
||||
state: dict[str, object] = {}
|
||||
ctx = Mock(spec=Context)
|
||||
ctx.session_id = session_id
|
||||
ctx.set_state = AsyncMock(side_effect=lambda k, v: state.__setitem__(k, v))
|
||||
ctx.get_state = AsyncMock(side_effect=lambda k: state.get(k))
|
||||
ctx.delete_state = AsyncMock(side_effect=lambda k: state.pop(k, None))
|
||||
return ctx
|
||||
|
||||
|
||||
class TestInstanceRoutingBasics:
|
||||
"""Test basic middleware functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_middleware_stores_and_retrieves_instance(self):
|
||||
"""Middleware should store and retrieve instance per session."""
|
||||
middleware = UnityInstanceMiddleware()
|
||||
ctx = _make_stateful_ctx("test-session-1")
|
||||
|
||||
await middleware.set_active_instance(ctx, "TestProject@abc123")
|
||||
|
||||
assert await middleware.get_active_instance(ctx) == "TestProject@abc123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_middleware_isolates_sessions(self):
|
||||
"""Two MCP sessions must not see each other's active instance.
|
||||
|
||||
Regression test for #1023: the prior implementation keyed on the
|
||||
peer-supplied client_id and collapsed multiple clients onto a shared
|
||||
record. Each ctx in this test holds its own private state dict, so
|
||||
leakage would surface as a cross-read.
|
||||
"""
|
||||
middleware = UnityInstanceMiddleware()
|
||||
|
||||
ctx1 = _make_stateful_ctx("session-1")
|
||||
ctx2 = _make_stateful_ctx("session-2")
|
||||
|
||||
await middleware.set_active_instance(ctx1, "Project1@aaa")
|
||||
await middleware.set_active_instance(ctx2, "Project2@bbb")
|
||||
|
||||
assert await middleware.get_active_instance(ctx1) == "Project1@aaa"
|
||||
assert await middleware.get_active_instance(ctx2) == "Project2@bbb"
|
||||
|
||||
|
||||
class TestInstanceRoutingIntegration:
|
||||
"""Test that instance routing works end-to-end for all tool categories."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_middleware_injects_state_into_context(self):
|
||||
"""Middleware on_call_tool should inject instance into ctx state.
|
||||
|
||||
After this PR the middleware writes two distinct keys: a persistence
|
||||
key (``mcpforunity.active_instance``) when ``set_active_instance`` is
|
||||
called, and a per-request injection key (``unity_instance``) inside
|
||||
``on_call_tool``. Tools downstream read ``unity_instance``.
|
||||
"""
|
||||
middleware = UnityInstanceMiddleware()
|
||||
|
||||
ctx = _make_stateful_ctx("test-session")
|
||||
|
||||
middleware_ctx = Mock()
|
||||
middleware_ctx.fastmcp_context = ctx
|
||||
|
||||
await middleware.set_active_instance(ctx, "TestProject@abc123")
|
||||
|
||||
async def mock_call_next(ctx):
|
||||
return {"success": True}
|
||||
|
||||
await middleware.on_call_tool(middleware_ctx, mock_call_next)
|
||||
|
||||
# The per-request injection must have happened so tools downstream
|
||||
# can read it; the persistence write is verified by the round-trip
|
||||
# test in TestInstanceRoutingBasics above.
|
||||
ctx.set_state.assert_any_call("unity_instance", "TestProject@abc123")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_unity_instance_from_context_checks_state(self):
|
||||
"""get_unity_instance_from_context must read from ctx.get_state()."""
|
||||
ctx = Mock(spec=Context)
|
||||
|
||||
# Set up state storage (only source of truth now)
|
||||
state_storage = {"unity_instance": "Project@state123"}
|
||||
ctx.get_state = AsyncMock(side_effect=lambda k: state_storage.get(k))
|
||||
|
||||
# Call and verify
|
||||
result = await get_unity_instance_from_context(ctx)
|
||||
|
||||
assert result == "Project@state123", \
|
||||
"get_unity_instance_from_context must read from ctx.get_state()!"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_unity_instance_returns_none_when_not_set(self):
|
||||
"""Should return None when no instance is set."""
|
||||
ctx = Mock(spec=Context)
|
||||
|
||||
# Empty state storage
|
||||
state_storage = {}
|
||||
ctx.get_state = AsyncMock(side_effect=lambda k: state_storage.get(k))
|
||||
|
||||
result = await get_unity_instance_from_context(ctx)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestInstanceRoutingToolCategories:
|
||||
"""Test instance routing for each tool category."""
|
||||
|
||||
def _create_mock_context_with_instance(self, instance_id: str):
|
||||
"""Helper to create a mock context with instance set via middleware."""
|
||||
ctx = Mock(spec=Context)
|
||||
ctx.session_id = "test-session"
|
||||
|
||||
# Set up state storage (only source of truth)
|
||||
state_storage = {"unity_instance": instance_id}
|
||||
ctx.get_state = AsyncMock(side_effect=lambda k: state_storage.get(k))
|
||||
ctx.set_state = AsyncMock(side_effect=lambda k,
|
||||
v: state_storage.__setitem__(k, v))
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
|
||||
class TestInstanceRoutingHTTP:
|
||||
"""Validate HTTP-specific routing helpers."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_active_instance_http_transport(self, monkeypatch):
|
||||
"""set_active_instance should enumerate PluginHub sessions under HTTP."""
|
||||
middleware = UnityInstanceMiddleware()
|
||||
ctx = Mock(spec=Context)
|
||||
ctx.session_id = "http-session"
|
||||
state_storage = {}
|
||||
ctx.set_state = AsyncMock(side_effect=lambda k,
|
||||
v: state_storage.__setitem__(k, v))
|
||||
ctx.get_state = AsyncMock(side_effect=lambda k: state_storage.get(k))
|
||||
|
||||
monkeypatch.setattr(config, "transport_mode", "http")
|
||||
fake_sessions = SessionList(
|
||||
sessions={
|
||||
"sess-1": SessionDetails(
|
||||
project="Ramble",
|
||||
hash="8e29de57",
|
||||
unity_version="6000.2.10f1",
|
||||
connected_at="2025-11-21T03:30:03.682353+00:00",
|
||||
)
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.tools.set_active_instance.PluginHub.get_sessions",
|
||||
AsyncMock(return_value=fake_sessions),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.tools.set_active_instance.get_unity_instance_middleware",
|
||||
lambda: middleware,
|
||||
)
|
||||
|
||||
result = await set_active_instance_tool(ctx, "Ramble@8e29de57")
|
||||
|
||||
assert result["success"] is True
|
||||
assert await middleware.get_active_instance(ctx) == "Ramble@8e29de57"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_active_instance_http_hash_only(self, monkeypatch):
|
||||
"""Hash-only selection should resolve via PluginHub registry."""
|
||||
middleware = UnityInstanceMiddleware()
|
||||
ctx = Mock(spec=Context)
|
||||
ctx.session_id = "http-session-2"
|
||||
state_storage = {}
|
||||
ctx.set_state = AsyncMock(side_effect=lambda k,
|
||||
v: state_storage.__setitem__(k, v))
|
||||
ctx.get_state = AsyncMock(side_effect=lambda k: state_storage.get(k))
|
||||
|
||||
monkeypatch.setattr(config, "transport_mode", "http")
|
||||
fake_sessions = SessionList(
|
||||
sessions={
|
||||
"sess-99": SessionDetails(
|
||||
project="UnityMCPTests",
|
||||
hash="cc8756d4",
|
||||
unity_version="2021.3.45f2",
|
||||
connected_at="2025-11-21T03:37:01.501022+00:00",
|
||||
)
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.tools.set_active_instance.PluginHub.get_sessions",
|
||||
AsyncMock(return_value=fake_sessions),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.tools.set_active_instance.get_unity_instance_middleware",
|
||||
lambda: middleware,
|
||||
)
|
||||
|
||||
result = await set_active_instance_tool(ctx, "UnityMCPTests@cc8756d4")
|
||||
|
||||
assert result["success"] is True
|
||||
assert await middleware.get_active_instance(ctx) == "UnityMCPTests@cc8756d4"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_active_instance_http_hash_missing(self, monkeypatch):
|
||||
"""Unknown hashes should surface a clear error."""
|
||||
middleware = UnityInstanceMiddleware()
|
||||
ctx = Mock(spec=Context)
|
||||
ctx.session_id = "http-session-3"
|
||||
|
||||
monkeypatch.setattr(config, "transport_mode", "http")
|
||||
fake_sessions = SessionList(sessions={})
|
||||
monkeypatch.setattr(
|
||||
"services.tools.set_active_instance.PluginHub.get_sessions",
|
||||
AsyncMock(return_value=fake_sessions),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.tools.set_active_instance.get_unity_instance_middleware",
|
||||
lambda: middleware,
|
||||
)
|
||||
|
||||
result = await set_active_instance_tool(ctx, "Unknown@deadbeef")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "No Unity instances" in result["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_active_instance_http_hash_ambiguous(self, monkeypatch):
|
||||
"""Ambiguous hash prefixes should mirror stdio error messaging."""
|
||||
middleware = UnityInstanceMiddleware()
|
||||
ctx = Mock(spec=Context)
|
||||
ctx.session_id = "http-session-4"
|
||||
|
||||
monkeypatch.setattr(config, "transport_mode", "http")
|
||||
fake_sessions = SessionList(
|
||||
sessions={
|
||||
"sess-a": SessionDetails(project="ProjA", hash="abc12345", unity_version="2022", connected_at="now"),
|
||||
"sess-b": SessionDetails(project="ProjB", hash="abc98765", unity_version="2022", connected_at="now"),
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.tools.set_active_instance.PluginHub.get_sessions",
|
||||
AsyncMock(return_value=fake_sessions),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.tools.set_active_instance.get_unity_instance_middleware",
|
||||
lambda: middleware,
|
||||
)
|
||||
|
||||
result = await set_active_instance_tool(ctx, "abc")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Name@hash" in result["error"]
|
||||
|
||||
|
||||
class TestInstanceRoutingRaceConditions:
|
||||
"""Test for race conditions and timing issues."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rapid_instance_switching(self):
|
||||
"""Rapidly switching instances should not cause routing errors."""
|
||||
middleware = UnityInstanceMiddleware()
|
||||
ctx = Mock(spec=Context)
|
||||
ctx.session_id = "test-session"
|
||||
|
||||
state_storage = {}
|
||||
ctx.set_state = AsyncMock(side_effect=lambda k,
|
||||
v: state_storage.__setitem__(k, v))
|
||||
ctx.get_state = AsyncMock(side_effect=lambda k: state_storage.get(k))
|
||||
|
||||
instances = ["Project1@aaa", "Project2@bbb", "Project3@ccc"]
|
||||
|
||||
for instance in instances:
|
||||
await middleware.set_active_instance(ctx, instance)
|
||||
|
||||
# Create middleware context
|
||||
middleware_ctx = Mock()
|
||||
middleware_ctx.fastmcp_context = ctx
|
||||
|
||||
async def mock_call_next(ctx):
|
||||
return {"success": True}
|
||||
|
||||
# Execute middleware
|
||||
await middleware.on_call_tool(middleware_ctx, mock_call_next)
|
||||
|
||||
# Verify correct instance is set
|
||||
assert state_storage.get("unity_instance") == instance
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_then_immediate_create_script(self):
|
||||
"""Setting instance then immediately creating script should route correctly."""
|
||||
# This reproduces the bug: set_active_instance → create_script went to wrong instance
|
||||
|
||||
middleware = UnityInstanceMiddleware()
|
||||
ctx = Mock(spec=Context)
|
||||
ctx.session_id = "test-session"
|
||||
ctx.info = Mock()
|
||||
|
||||
state_storage = {}
|
||||
ctx.set_state = AsyncMock(side_effect=lambda k,
|
||||
v: state_storage.__setitem__(k, v))
|
||||
ctx.get_state = AsyncMock(side_effect=lambda k: state_storage.get(k))
|
||||
ctx.request_context = None
|
||||
|
||||
# Set active instance
|
||||
await middleware.set_active_instance(ctx, "ramble@8e29de57")
|
||||
|
||||
# Simulate middleware intercepting create_script call
|
||||
middleware_ctx = Mock()
|
||||
middleware_ctx.fastmcp_context = ctx
|
||||
|
||||
async def mock_create_script_call(ctx):
|
||||
# This simulates what create_script does
|
||||
instance = await get_unity_instance_from_context(ctx)
|
||||
return {"success": True, "routed_to": instance}
|
||||
|
||||
# Inject state via middleware
|
||||
await middleware.on_call_tool(middleware_ctx, mock_create_script_call)
|
||||
|
||||
# Verify create_script would route to correct instance
|
||||
result = await mock_create_script_call(ctx)
|
||||
assert result["routed_to"] == "ramble@8e29de57", \
|
||||
"create_script must route to the instance set by set_active_instance"
|
||||
|
||||
|
||||
class TestInstanceRoutingSequentialOperations:
|
||||
"""Test the exact failure scenario from user report."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_four_script_creation_sequence(self):
|
||||
"""
|
||||
Reproduce the exact failure:
|
||||
1. set_active(ramble) → create_script1 → should go to ramble
|
||||
2. set_active(UnityMCPTests) → create_script2 → should go to UnityMCPTests
|
||||
3. set_active(ramble) → create_script3 → should go to ramble
|
||||
4. set_active(UnityMCPTests) → create_script4 → should go to UnityMCPTests
|
||||
|
||||
ACTUAL BEHAVIOR:
|
||||
- Script1 went to UnityMCPTests (WRONG)
|
||||
- Script2 went to ramble (WRONG)
|
||||
- Script3 went to ramble (CORRECT)
|
||||
- Script4 went to UnityMCPTests (CORRECT)
|
||||
"""
|
||||
middleware = UnityInstanceMiddleware()
|
||||
|
||||
# Track which instance each script was created in
|
||||
script_routes = {}
|
||||
|
||||
async def simulate_create_script(ctx, script_name, expected_instance):
|
||||
# Inject state via middleware
|
||||
middleware_ctx = Mock()
|
||||
middleware_ctx.fastmcp_context = ctx
|
||||
|
||||
async def mock_tool_call(middleware_ctx):
|
||||
# The middleware passes the middleware_ctx, we need the fastmcp_context
|
||||
tool_ctx = middleware_ctx.fastmcp_context
|
||||
instance = await get_unity_instance_from_context(tool_ctx)
|
||||
script_routes[script_name] = instance
|
||||
return {"success": True}
|
||||
|
||||
await middleware.on_call_tool(middleware_ctx, mock_tool_call)
|
||||
return expected_instance
|
||||
|
||||
# Session context
|
||||
ctx = Mock(spec=Context)
|
||||
ctx.session_id = "test-session"
|
||||
ctx.info = Mock()
|
||||
|
||||
state_storage = {}
|
||||
ctx.set_state = AsyncMock(side_effect=lambda k,
|
||||
v: state_storage.__setitem__(k, v))
|
||||
ctx.get_state = AsyncMock(side_effect=lambda k: state_storage.get(k))
|
||||
|
||||
# Execute sequence
|
||||
await middleware.set_active_instance(ctx, "ramble@8e29de57")
|
||||
expected1 = await simulate_create_script(ctx, "Script1", "ramble@8e29de57")
|
||||
|
||||
await middleware.set_active_instance(ctx, "UnityMCPTests@cc8756d4")
|
||||
expected2 = await simulate_create_script(ctx, "Script2", "UnityMCPTests@cc8756d4")
|
||||
|
||||
await middleware.set_active_instance(ctx, "ramble@8e29de57")
|
||||
expected3 = await simulate_create_script(ctx, "Script3", "ramble@8e29de57")
|
||||
|
||||
await middleware.set_active_instance(ctx, "UnityMCPTests@cc8756d4")
|
||||
expected4 = await simulate_create_script(ctx, "Script4", "UnityMCPTests@cc8756d4")
|
||||
|
||||
# Assertions - these will FAIL until the bug is fixed
|
||||
assert script_routes.get("Script1") == expected1, \
|
||||
f"Script1 should route to {expected1}, got {script_routes.get('Script1')}"
|
||||
assert script_routes.get("Script2") == expected2, \
|
||||
f"Script2 should route to {expected2}, got {script_routes.get('Script2')}"
|
||||
assert script_routes.get("Script3") == expected3, \
|
||||
f"Script3 should route to {expected3}, got {script_routes.get('Script3')}"
|
||||
assert script_routes.get("Script4") == expected4, \
|
||||
f"Script4 should route to {expected4}, got {script_routes.get('Script4')}"
|
||||
|
||||
|
||||
# Test regimen summary
|
||||
"""
|
||||
COMPREHENSIVE TEST REGIMEN FOR INSTANCE ROUTING
|
||||
|
||||
Prerequisites:
|
||||
- Two Unity instances running (e.g., ramble, UnityMCPTests)
|
||||
- MCP server connected to both instances
|
||||
|
||||
Test Categories:
|
||||
1. ✅ Middleware State Management (4 tests)
|
||||
2. ✅ Middleware Integration (2 tests)
|
||||
3. ✅ get_unity_instance_from_context (2 tests)
|
||||
4. ✅ Tool Category Coverage (11 categories)
|
||||
5. ✅ Race Conditions (2 tests)
|
||||
6. ✅ Sequential Operations (1 test - reproduces exact user bug)
|
||||
|
||||
Total: 21 tests
|
||||
|
||||
DESIGN:
|
||||
Single source of truth via middleware state:
|
||||
- set_active_instance stores instance per session in UnityInstanceMiddleware
|
||||
- Middleware injects instance into ctx.set_state() for each tool call
|
||||
- get_unity_instance_from_context() reads from ctx.get_state()
|
||||
- All tools use get_unity_instance_from_context()
|
||||
|
||||
This ensures consistent routing across ALL tool categories (Script, GameObject, Asset, etc.)
|
||||
"""
|
||||
@@ -0,0 +1,88 @@
|
||||
import pytest
|
||||
|
||||
from .test_helpers import DummyContext
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_gameobject_uses_session_state(monkeypatch):
|
||||
"""Test that tools use session-stored active instance via middleware"""
|
||||
|
||||
from transport.unity_instance_middleware import UnityInstanceMiddleware, set_unity_instance_middleware
|
||||
|
||||
# Arrange: Initialize middleware and set a session-scoped active instance
|
||||
middleware = UnityInstanceMiddleware()
|
||||
set_unity_instance_middleware(middleware)
|
||||
|
||||
ctx = DummyContext()
|
||||
await middleware.set_active_instance(ctx, "SessionProj@AAAA1111")
|
||||
assert await middleware.get_active_instance(ctx) == "SessionProj@AAAA1111"
|
||||
|
||||
# Simulate middleware injection into request state
|
||||
await ctx.set_state("unity_instance", "SessionProj@AAAA1111")
|
||||
|
||||
captured = {}
|
||||
|
||||
# Monkeypatch transport to capture the resolved instance_id
|
||||
async def fake_send(command_type, params, **kwargs):
|
||||
captured["command_type"] = command_type
|
||||
captured["params"] = params
|
||||
captured["instance_id"] = kwargs.get("instance_id")
|
||||
return {"success": True, "data": {}}
|
||||
|
||||
import services.tools.manage_gameobject as mg
|
||||
monkeypatch.setattr(
|
||||
"services.tools.manage_gameobject.async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
# Act: call tool - should use session state from context
|
||||
res = await mg.manage_gameobject(
|
||||
ctx,
|
||||
action="create",
|
||||
name="SessionSphere",
|
||||
primitive_type="Sphere",
|
||||
)
|
||||
|
||||
# Assert: uses session-stored instance
|
||||
assert res.get("success") is True
|
||||
assert captured.get("command_type") == "manage_gameobject"
|
||||
assert captured.get("instance_id") == "SessionProj@AAAA1111"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_gameobject_without_active_instance(monkeypatch):
|
||||
"""Test that tools work with no active instance set (uses None/default)"""
|
||||
|
||||
from transport.unity_instance_middleware import UnityInstanceMiddleware, set_unity_instance_middleware
|
||||
|
||||
# Arrange: Initialize middleware with no active instance set
|
||||
middleware = UnityInstanceMiddleware()
|
||||
set_unity_instance_middleware(middleware)
|
||||
|
||||
ctx = DummyContext()
|
||||
assert await middleware.get_active_instance(ctx) is None
|
||||
# Don't set any state in context
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_send(command_type, params, **kwargs):
|
||||
captured["instance_id"] = kwargs.get("instance_id")
|
||||
return {"success": True, "data": {}}
|
||||
|
||||
import services.tools.manage_gameobject as mg
|
||||
monkeypatch.setattr(
|
||||
"services.tools.manage_gameobject.async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
# Act: call without active instance
|
||||
res = await mg.manage_gameobject(
|
||||
ctx,
|
||||
action="create",
|
||||
name="DefaultSphere",
|
||||
primitive_type="Sphere",
|
||||
)
|
||||
|
||||
# Assert: uses None (connection pool will pick default)
|
||||
assert res.get("success") is True
|
||||
assert captured.get("instance_id") is None
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
Simple tests for JSON string parameter parsing logic.
|
||||
Tests the core JSON parsing functionality without MCP server dependencies.
|
||||
"""
|
||||
import json
|
||||
import pytest
|
||||
|
||||
|
||||
def parse_properties_json(properties):
|
||||
"""
|
||||
Test the JSON parsing logic that would be used in manage_asset.
|
||||
This simulates the core parsing functionality.
|
||||
"""
|
||||
if isinstance(properties, str):
|
||||
try:
|
||||
parsed = json.loads(properties)
|
||||
return parsed, "success"
|
||||
except json.JSONDecodeError as e:
|
||||
return properties, f"failed to parse: {e}"
|
||||
return properties, "no_parsing_needed"
|
||||
|
||||
|
||||
class TestJsonParsingLogic:
|
||||
"""Test the core JSON parsing logic."""
|
||||
|
||||
def test_valid_json_string_parsing(self):
|
||||
"""Test that valid JSON strings are correctly parsed."""
|
||||
json_string = '{"shader": "Universal Render Pipeline/Lit", "color": [0, 0, 1, 1]}'
|
||||
|
||||
result, status = parse_properties_json(json_string)
|
||||
|
||||
assert status == "success"
|
||||
assert isinstance(result, dict)
|
||||
assert result["shader"] == "Universal Render Pipeline/Lit"
|
||||
assert result["color"] == [0, 0, 1, 1]
|
||||
|
||||
def test_invalid_json_string_handling(self):
|
||||
"""Test that invalid JSON strings are handled gracefully."""
|
||||
invalid_json = '{"invalid": json, "missing": quotes}'
|
||||
|
||||
result, status = parse_properties_json(invalid_json)
|
||||
|
||||
assert "failed to parse" in status
|
||||
assert result == invalid_json # Original string returned
|
||||
|
||||
def test_dict_input_unchanged(self):
|
||||
"""Test that dict inputs are passed through unchanged."""
|
||||
original_dict = {
|
||||
"shader": "Universal Render Pipeline/Lit", "color": [0, 0, 1, 1]}
|
||||
|
||||
result, status = parse_properties_json(original_dict)
|
||||
|
||||
assert status == "no_parsing_needed"
|
||||
assert result == original_dict
|
||||
|
||||
def test_none_input_handled(self):
|
||||
"""Test that None input is handled correctly."""
|
||||
result, status = parse_properties_json(None)
|
||||
|
||||
assert status == "no_parsing_needed"
|
||||
assert result is None
|
||||
|
||||
def test_complex_json_parsing(self):
|
||||
"""Test parsing of complex JSON with nested objects and arrays."""
|
||||
complex_json = '''
|
||||
{
|
||||
"shader": "Universal Render Pipeline/Lit",
|
||||
"color": [1, 0, 0, 1],
|
||||
"float": {"name": "_Metallic", "value": 0.5},
|
||||
"texture": {"name": "_MainTex", "path": "Assets/Textures/Test.png"}
|
||||
}
|
||||
'''
|
||||
|
||||
result, status = parse_properties_json(complex_json)
|
||||
|
||||
assert status == "success"
|
||||
assert isinstance(result, dict)
|
||||
assert result["shader"] == "Universal Render Pipeline/Lit"
|
||||
assert result["color"] == [1, 0, 0, 1]
|
||||
assert result["float"]["name"] == "_Metallic"
|
||||
assert result["float"]["value"] == 0.5
|
||||
assert result["texture"]["name"] == "_MainTex"
|
||||
assert result["texture"]["path"] == "Assets/Textures/Test.png"
|
||||
|
||||
def test_empty_json_string(self):
|
||||
"""Test handling of empty JSON string."""
|
||||
empty_json = "{}"
|
||||
|
||||
result, status = parse_properties_json(empty_json)
|
||||
|
||||
assert status == "success"
|
||||
assert isinstance(result, dict)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_malformed_json_edge_cases(self):
|
||||
"""Test various malformed JSON edge cases."""
|
||||
test_cases = [
|
||||
'{"incomplete": }',
|
||||
'{"missing": "quote}',
|
||||
'{"trailing": "comma",}',
|
||||
'{"unclosed": [1, 2, 3}',
|
||||
'not json at all',
|
||||
'{"nested": {"broken": }'
|
||||
]
|
||||
|
||||
for malformed_json in test_cases:
|
||||
result, status = parse_properties_json(malformed_json)
|
||||
assert "failed to parse" in status
|
||||
assert result == malformed_json
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,69 @@
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# locate server src dynamically to avoid hardcoded layout assumptions
|
||||
ROOT = Path(__file__).resolve().parents[2] # tests/integration -> tests -> Server
|
||||
candidates = [
|
||||
ROOT / "src",
|
||||
]
|
||||
SRC = next((p for p in candidates if p.exists()), None)
|
||||
if SRC is None:
|
||||
searched = "\n".join(str(p) for p in candidates)
|
||||
pytest.skip(
|
||||
"MCP for Unity server source not found. Tried:\n" + searched,
|
||||
allow_module_level=True,
|
||||
)
|
||||
|
||||
|
||||
def test_no_print_statements_in_codebase():
|
||||
"""Ensure no stray print/sys.stdout writes remain in server source."""
|
||||
# CLI tools that intentionally print to stdout
|
||||
ALLOWED_PRINT_FILES = {
|
||||
Path("scene_generator") / "test_pipeline.py",
|
||||
}
|
||||
offenders = []
|
||||
syntax_errors = []
|
||||
for py_file in SRC.rglob("*.py"):
|
||||
# Skip virtual envs and third-party packages if they exist under SRC
|
||||
parts = set(py_file.parts)
|
||||
if ".venv" in parts or "site-packages" in parts:
|
||||
continue
|
||||
try:
|
||||
text = py_file.read_text(encoding="utf-8", errors="strict")
|
||||
except UnicodeDecodeError:
|
||||
# Be tolerant of encoding edge cases in source tree without silently dropping bytes
|
||||
text = py_file.read_text(encoding="utf-8", errors="replace")
|
||||
try:
|
||||
tree = ast.parse(text, filename=str(py_file))
|
||||
except SyntaxError:
|
||||
syntax_errors.append(py_file.relative_to(SRC))
|
||||
continue
|
||||
|
||||
class StdoutVisitor(ast.NodeVisitor):
|
||||
def __init__(self):
|
||||
self.hit = False
|
||||
|
||||
def visit_Call(self, node: ast.Call):
|
||||
# print(...)
|
||||
if isinstance(node.func, ast.Name) and node.func.id == "print":
|
||||
self.hit = True
|
||||
# sys.stdout.write(...)
|
||||
if isinstance(node.func, ast.Attribute) and node.func.attr == "write":
|
||||
val = node.func.value
|
||||
if isinstance(val, ast.Attribute) and val.attr == "stdout":
|
||||
if isinstance(val.value, ast.Name) and val.value.id == "sys":
|
||||
self.hit = True
|
||||
self.generic_visit(node)
|
||||
|
||||
v = StdoutVisitor()
|
||||
v.visit(tree)
|
||||
rel_path = py_file.relative_to(SRC)
|
||||
if v.hit and rel_path not in ALLOWED_PRINT_FILES:
|
||||
offenders.append(rel_path)
|
||||
assert not syntax_errors, "syntax errors in: " + \
|
||||
", ".join(str(e) for e in syntax_errors)
|
||||
assert not offenders, "stdout writes found in: " + \
|
||||
", ".join(str(o) for o in offenders)
|
||||
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
Tests for JSON string parameter parsing in manage_asset tool.
|
||||
"""
|
||||
import pytest
|
||||
import json
|
||||
|
||||
from .test_helpers import DummyContext
|
||||
from services.tools.manage_asset import manage_asset
|
||||
|
||||
|
||||
class TestManageAssetJsonParsing:
|
||||
"""Test JSON string parameter parsing functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_properties_json_string_parsing(self, monkeypatch):
|
||||
"""Test that JSON string properties are correctly parsed to dict."""
|
||||
# Mock context
|
||||
ctx = DummyContext()
|
||||
|
||||
# Patch Unity transport
|
||||
async def fake_async(cmd, params, **kwargs):
|
||||
return {"success": True, "message": "Asset created successfully", "data": {"path": "Assets/Test.mat"}}
|
||||
monkeypatch.setattr(
|
||||
"services.tools.manage_asset.async_send_command_with_retry", fake_async)
|
||||
|
||||
# Test with JSON string properties
|
||||
result = await manage_asset(
|
||||
ctx=ctx,
|
||||
action="create",
|
||||
path="Assets/Test.mat",
|
||||
asset_type="Material",
|
||||
properties='{"shader": "Universal Render Pipeline/Lit", "color": [0, 0, 1, 1]}'
|
||||
)
|
||||
|
||||
# Verify the result - JSON string was successfully parsed and passed to Unity
|
||||
assert result["success"] is True
|
||||
assert "Asset created successfully" in result["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_properties_invalid_json_string(self, monkeypatch):
|
||||
"""Test handling of invalid JSON string properties."""
|
||||
ctx = DummyContext()
|
||||
|
||||
async def fake_async(cmd, params, **kwargs):
|
||||
return {"success": True, "message": "Asset created successfully"}
|
||||
monkeypatch.setattr(
|
||||
"services.tools.manage_asset.async_send_command_with_retry", fake_async)
|
||||
|
||||
# Test with invalid JSON string
|
||||
result = await manage_asset(
|
||||
ctx=ctx,
|
||||
action="create",
|
||||
path="Assets/Test.mat",
|
||||
asset_type="Material",
|
||||
properties='{"invalid": json, "missing": quotes}'
|
||||
)
|
||||
|
||||
# Verify behavior: parsing fails with a clear error
|
||||
assert result.get("success") is False
|
||||
assert "properties must be" in result.get("message", "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_properties_dict_unchanged(self, monkeypatch):
|
||||
"""Test that dict properties are passed through unchanged."""
|
||||
ctx = DummyContext()
|
||||
|
||||
async def fake_async(cmd, params, **kwargs):
|
||||
return {"success": True, "message": "Asset created successfully"}
|
||||
monkeypatch.setattr(
|
||||
"services.tools.manage_asset.async_send_command_with_retry", fake_async)
|
||||
|
||||
# Test with dict properties
|
||||
properties_dict = {
|
||||
"shader": "Universal Render Pipeline/Lit", "color": [0, 0, 1, 1]}
|
||||
|
||||
result = await manage_asset(
|
||||
ctx=ctx,
|
||||
action="create",
|
||||
path="Assets/Test.mat",
|
||||
asset_type="Material",
|
||||
properties=properties_dict
|
||||
)
|
||||
|
||||
# Verify no JSON parsing was attempted (allow initial Processing log)
|
||||
assert not any("coerced properties" in msg for msg in ctx.log_info)
|
||||
assert result["success"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_properties_none_handling(self, monkeypatch):
|
||||
"""Test that None properties are handled correctly."""
|
||||
ctx = DummyContext()
|
||||
|
||||
async def fake_async(cmd, params, **kwargs):
|
||||
return {"success": True, "message": "Asset created successfully"}
|
||||
monkeypatch.setattr(
|
||||
"services.tools.manage_asset.async_send_command_with_retry", fake_async)
|
||||
|
||||
# Test with None properties
|
||||
result = await manage_asset(
|
||||
ctx=ctx,
|
||||
action="create",
|
||||
path="Assets/Test.mat",
|
||||
asset_type="Material",
|
||||
properties=None
|
||||
)
|
||||
|
||||
# Verify no JSON parsing was attempted (allow initial Processing log)
|
||||
assert not any("coerced properties" in msg for msg in ctx.log_info)
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
class TestManageGameObjectJsonParsing:
|
||||
"""Test JSON string parameter parsing for manage_gameobject tool."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_component_properties_json_string_parsing(self, monkeypatch):
|
||||
"""Test that JSON string component_properties result in successful operation."""
|
||||
from services.tools.manage_gameobject import manage_gameobject
|
||||
|
||||
ctx = DummyContext()
|
||||
|
||||
async def fake_send(_cmd, params, **_kwargs):
|
||||
return {"success": True, "message": "GameObject created successfully"}
|
||||
monkeypatch.setattr(
|
||||
"services.tools.manage_gameobject.async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
# Test with JSON string component_properties
|
||||
result = await manage_gameobject(
|
||||
ctx=ctx,
|
||||
action="create",
|
||||
name="TestObject",
|
||||
component_properties='{"MeshRenderer": {"material": "Assets/Materials/BlueMaterial.mat"}}'
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_component_properties_parsing_verification(self, monkeypatch):
|
||||
"""Test that component_properties are actually parsed to dict before sending."""
|
||||
from services.tools.manage_gameobject import manage_gameobject
|
||||
ctx = DummyContext()
|
||||
|
||||
captured_params = {}
|
||||
async def fake_send(_cmd, params, **_kwargs):
|
||||
captured_params.update(params)
|
||||
return {"success": True, "message": "GameObject created successfully"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"services.tools.manage_gameobject.async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
await manage_gameobject(
|
||||
ctx=ctx,
|
||||
action="create",
|
||||
name="TestObject",
|
||||
component_properties='{"MeshRenderer": {"material": "Assets/Materials/BlueMaterial.mat"}}'
|
||||
)
|
||||
|
||||
assert isinstance(captured_params.get("componentProperties"), dict)
|
||||
@@ -0,0 +1,29 @@
|
||||
import asyncio
|
||||
|
||||
from .test_helpers import DummyContext
|
||||
import services.tools.manage_asset as manage_asset_mod
|
||||
|
||||
|
||||
def test_manage_asset_pagination_coercion(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_async_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
manage_asset_mod, "async_send_command_with_retry", fake_async_send)
|
||||
|
||||
result = asyncio.run(
|
||||
manage_asset_mod.manage_asset(
|
||||
ctx=DummyContext(),
|
||||
action="search",
|
||||
path="Assets",
|
||||
page_size="50",
|
||||
page_number="2",
|
||||
)
|
||||
)
|
||||
|
||||
assert result == {"success": True, "data": {}}
|
||||
assert captured["params"]["pageSize"] == 50
|
||||
assert captured["params"]["pageNumber"] == 2
|
||||
@@ -0,0 +1,242 @@
|
||||
"""
|
||||
Tests for the manage_components tool.
|
||||
|
||||
This tool handles component lifecycle operations (add, remove, set_property).
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from .test_helpers import DummyContext
|
||||
import services.tools.manage_components as manage_comp_mod
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_components_add_single(monkeypatch):
|
||||
"""Test adding a single component."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured["params"] = params
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"addedComponents": [{"typeName": "UnityEngine.Rigidbody", "instanceID": 12345}]
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
manage_comp_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await manage_comp_mod.manage_components(
|
||||
ctx=DummyContext(),
|
||||
action="add",
|
||||
target="Player",
|
||||
component_type="Rigidbody",
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["cmd"] == "manage_components"
|
||||
assert captured["params"]["action"] == "add"
|
||||
assert captured["params"]["target"] == "Player"
|
||||
assert captured["params"]["componentType"] == "Rigidbody"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_components_remove(monkeypatch):
|
||||
"""Test removing a component."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {"instanceID": 12345, "name": "Player"}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
manage_comp_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await manage_comp_mod.manage_components(
|
||||
ctx=DummyContext(),
|
||||
action="remove",
|
||||
target="Player",
|
||||
component_type="Rigidbody",
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["params"]["action"] == "remove"
|
||||
assert captured["params"]["componentType"] == "Rigidbody"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_components_set_property_single(monkeypatch):
|
||||
"""Test setting a single component property."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {"instanceID": 12345}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
manage_comp_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await manage_comp_mod.manage_components(
|
||||
ctx=DummyContext(),
|
||||
action="set_property",
|
||||
target="Player",
|
||||
component_type="Rigidbody",
|
||||
property="mass",
|
||||
value=5.0,
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["params"]["action"] == "set_property"
|
||||
assert captured["params"]["property"] == "mass"
|
||||
assert captured["params"]["value"] == 5.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_components_set_property_multiple(monkeypatch):
|
||||
"""Test setting multiple component properties via properties dict."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {"instanceID": 12345}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
manage_comp_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await manage_comp_mod.manage_components(
|
||||
ctx=DummyContext(),
|
||||
action="set_property",
|
||||
target="Player",
|
||||
component_type="Rigidbody",
|
||||
properties={"mass": 5.0, "drag": 0.5},
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["params"]["action"] == "set_property"
|
||||
assert captured["params"]["properties"] == {"mass": 5.0, "drag": 0.5}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_components_set_property_json_string(monkeypatch):
|
||||
"""Test setting component properties with JSON string input."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {"instanceID": 12345}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
manage_comp_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await manage_comp_mod.manage_components(
|
||||
ctx=DummyContext(),
|
||||
action="set_property",
|
||||
target="Player",
|
||||
component_type="Rigidbody",
|
||||
properties='{"mass": 10.0}', # JSON string
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["params"]["properties"] == {"mass": 10.0}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_components_add_with_properties(monkeypatch):
|
||||
"""Test adding a component with initial properties."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"addedComponents": [{"typeName": "Rigidbody", "instanceID": 123}]},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
manage_comp_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await manage_comp_mod.manage_components(
|
||||
ctx=DummyContext(),
|
||||
action="add",
|
||||
target="Player",
|
||||
component_type="Rigidbody",
|
||||
properties={"mass": 2.0, "useGravity": False},
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["params"]["properties"] == {"mass": 2.0, "useGravity": False}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_components_search_method_passthrough(monkeypatch):
|
||||
"""Test that search_method is correctly passed through."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
manage_comp_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await manage_comp_mod.manage_components(
|
||||
ctx=DummyContext(),
|
||||
action="add",
|
||||
target="Canvas/Panel",
|
||||
component_type="Image",
|
||||
search_method="by_path",
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["params"]["searchMethod"] == "by_path"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_components_target_by_id(monkeypatch):
|
||||
"""Test targeting by instance ID."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
manage_comp_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await manage_comp_mod.manage_components(
|
||||
ctx=DummyContext(),
|
||||
action="add",
|
||||
target=12345, # Integer instance ID
|
||||
component_type="BoxCollider",
|
||||
search_method="by_id",
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["params"]["target"] == 12345
|
||||
assert captured["params"]["searchMethod"] == "by_id"
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import pytest
|
||||
|
||||
from .test_helpers import DummyContext
|
||||
import services.tools.manage_gameobject as manage_go_mod
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_gameobject_is_static_true(monkeypatch):
|
||||
"""Test that is_static=True is passed as isStatic in params."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
manage_go_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await manage_go_mod.manage_gameobject(
|
||||
ctx=DummyContext(),
|
||||
action="modify",
|
||||
target="Ground",
|
||||
is_static=True,
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["params"]["action"] == "modify"
|
||||
assert captured["params"]["target"] == "Ground"
|
||||
assert captured["params"]["isStatic"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_gameobject_is_static_false(monkeypatch):
|
||||
"""Test that is_static=False is passed as isStatic in params."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
manage_go_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await manage_go_mod.manage_gameobject(
|
||||
ctx=DummyContext(),
|
||||
action="modify",
|
||||
target="Ground",
|
||||
is_static=False,
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["params"]["isStatic"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_gameobject_is_static_string_coercion(monkeypatch):
|
||||
"""Test that string 'true' is coerced to bool for is_static."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
manage_go_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await manage_go_mod.manage_gameobject(
|
||||
ctx=DummyContext(),
|
||||
action="modify",
|
||||
target="Ground",
|
||||
is_static="true",
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["params"]["isStatic"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_gameobject_is_static_string_false_coercion(monkeypatch):
|
||||
"""Test that string 'false' is coerced to bool for is_static."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
manage_go_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await manage_go_mod.manage_gameobject(
|
||||
ctx=DummyContext(),
|
||||
action="modify",
|
||||
target="Ground",
|
||||
is_static="false",
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["params"]["isStatic"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_gameobject_is_static_omitted(monkeypatch):
|
||||
"""Test that omitting is_static does not include isStatic in params."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
manage_go_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await manage_go_mod.manage_gameobject(
|
||||
ctx=DummyContext(),
|
||||
action="modify",
|
||||
target="Ground",
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert "isStatic" not in captured["params"]
|
||||
@@ -0,0 +1,77 @@
|
||||
import pytest
|
||||
|
||||
from .test_helpers import DummyContext
|
||||
import services.tools.manage_gameobject as manage_go_mod
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_look_at_vector_target(monkeypatch):
|
||||
"""look_at action forwards look_at_target as a vector."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {"rotation": [0, 90, 0]}}
|
||||
|
||||
monkeypatch.setattr(manage_go_mod, "async_send_command_with_retry", fake_send)
|
||||
|
||||
resp = await manage_go_mod.manage_gameobject(
|
||||
ctx=DummyContext(),
|
||||
action="look_at",
|
||||
target="MainCamera",
|
||||
look_at_target=[10.0, 0.0, 5.0],
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
p = captured["params"]
|
||||
assert p["action"] == "look_at"
|
||||
assert p["target"] == "MainCamera"
|
||||
assert p["look_at_target"] == [10.0, 0.0, 5.0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_look_at_string_target(monkeypatch):
|
||||
"""look_at action forwards look_at_target as a GO name string."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {"rotation": [0, 45, 0]}}
|
||||
|
||||
monkeypatch.setattr(manage_go_mod, "async_send_command_with_retry", fake_send)
|
||||
|
||||
resp = await manage_go_mod.manage_gameobject(
|
||||
ctx=DummyContext(),
|
||||
action="look_at",
|
||||
target="MainCamera",
|
||||
look_at_target="Player",
|
||||
look_at_up=[0, 1, 0],
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
p = captured["params"]
|
||||
assert p["action"] == "look_at"
|
||||
assert p["look_at_target"] == "Player"
|
||||
assert p["look_at_up"] == [0, 1, 0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_look_at_without_target_still_sends(monkeypatch):
|
||||
"""look_at without look_at_target should still send the command (C# will error)."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": False, "message": "look_at_target is required"}
|
||||
|
||||
monkeypatch.setattr(manage_go_mod, "async_send_command_with_retry", fake_send)
|
||||
|
||||
resp = await manage_go_mod.manage_gameobject(
|
||||
ctx=DummyContext(),
|
||||
action="look_at",
|
||||
target="MainCamera",
|
||||
)
|
||||
|
||||
p = captured["params"]
|
||||
assert p["action"] == "look_at"
|
||||
assert "look_at_target" not in p
|
||||
@@ -0,0 +1,65 @@
|
||||
import pytest
|
||||
|
||||
from .test_helpers import DummyContext
|
||||
import services.tools.manage_gameobject as manage_go_mod
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_gameobject_boolean_coercion(monkeypatch):
|
||||
"""Test that string boolean values are properly coerced for valid actions."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
manage_go_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
# Test boolean coercion with "modify" action (valid action)
|
||||
resp = await manage_go_mod.manage_gameobject(
|
||||
ctx=DummyContext(),
|
||||
action="modify",
|
||||
target="Player",
|
||||
set_active="true", # String should be coerced to bool
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["params"]["action"] == "modify"
|
||||
assert captured["params"]["target"] == "Player"
|
||||
# setActive string "true" is coerced to bool True
|
||||
assert captured["params"]["setActive"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_gameobject_create_with_tag(monkeypatch):
|
||||
"""Test that create action properly passes tag parameter."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
manage_go_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await manage_go_mod.manage_gameobject(
|
||||
ctx=DummyContext(),
|
||||
action="create",
|
||||
name="TestObject",
|
||||
tag="Player",
|
||||
position=[1.0, 2.0, 3.0],
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
p = captured["params"]
|
||||
assert p["action"] == "create"
|
||||
assert p["name"] == "TestObject"
|
||||
assert p["tag"] == "Player"
|
||||
assert p["position"] == [1.0, 2.0, 3.0]
|
||||
@@ -0,0 +1,44 @@
|
||||
import pytest
|
||||
|
||||
from .test_helpers import DummyContext
|
||||
import services.tools.manage_scene as manage_scene_mod
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_scene_get_hierarchy_paging_params_pass_through(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
manage_scene_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await manage_scene_mod.manage_scene(
|
||||
ctx=DummyContext(),
|
||||
action="get_hierarchy",
|
||||
parent="Player",
|
||||
page_size="10",
|
||||
cursor="20",
|
||||
max_nodes="1000",
|
||||
max_depth="6",
|
||||
max_children_per_node="200",
|
||||
include_transform="true",
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
p = captured["params"]
|
||||
assert p["action"] == "get_hierarchy"
|
||||
assert p["parent"] == "Player"
|
||||
assert p["pageSize"] in (10, "10")
|
||||
assert p["cursor"] in (20, "20")
|
||||
assert p["maxNodes"] in (1000, "1000")
|
||||
assert p["maxDepth"] in (6, "6")
|
||||
assert p["maxChildrenPerNode"] in (200, "200")
|
||||
assert p["includeTransform"] in (True, "true")
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import pytest
|
||||
|
||||
from .test_helpers import DummyContext, setup_script_tools
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_split_uri_unity_path(monkeypatch):
|
||||
test_tools = setup_script_tools()
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs): # capture params and return success
|
||||
captured['cmd'] = cmd
|
||||
captured['params'] = params
|
||||
return {"success": True, "message": "ok"}
|
||||
|
||||
# Patch the send_command_with_retry function at the module level where it's imported
|
||||
import transport.legacy.unity_connection
|
||||
monkeypatch.setattr(
|
||||
transport.legacy.unity_connection,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
|
||||
|
||||
fn = test_tools['apply_text_edits']
|
||||
uri = "mcpforunity://path/Assets/Scripts/MyScript.cs"
|
||||
await fn(DummyContext(), uri=uri, edits=[], precondition_sha256=None)
|
||||
|
||||
assert captured['cmd'] == 'manage_script'
|
||||
assert captured['params']['name'] == 'MyScript'
|
||||
assert captured['params']['path'] == 'Assets/Scripts'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"uri, expected_name, expected_path",
|
||||
[
|
||||
("file:///Users/alex/Project/Assets/Scripts/Foo%20Bar.cs",
|
||||
"Foo Bar", "Assets/Scripts"),
|
||||
("file://localhost/Users/alex/Project/Assets/Hello.cs", "Hello", "Assets"),
|
||||
("file:///C:/Users/Alex/Proj/Assets/Scripts/Hello.cs",
|
||||
"Hello", "Assets/Scripts"),
|
||||
# outside Assets → fall back to normalized dir
|
||||
("file:///tmp/Other.cs", "Other", "tmp"),
|
||||
],
|
||||
)
|
||||
async def test_split_uri_file_urls(monkeypatch, uri, expected_name, expected_path):
|
||||
test_tools = setup_script_tools()
|
||||
captured = {}
|
||||
|
||||
async def fake_send(_cmd, params, **kwargs):
|
||||
captured['cmd'] = _cmd
|
||||
captured['params'] = params
|
||||
return {"success": True, "message": "ok"}
|
||||
|
||||
# Patch the send_command_with_retry function at the module level where it's imported
|
||||
import transport.legacy.unity_connection
|
||||
monkeypatch.setattr(
|
||||
transport.legacy.unity_connection,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
|
||||
|
||||
fn = test_tools['apply_text_edits']
|
||||
await fn(DummyContext(), uri=uri, edits=[], precondition_sha256=None)
|
||||
|
||||
assert captured['params']['name'] == expected_name
|
||||
assert captured['params']['path'] == expected_path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_split_uri_plain_path(monkeypatch):
|
||||
test_tools = setup_script_tools()
|
||||
captured = {}
|
||||
|
||||
async def fake_send(_cmd, params, **kwargs):
|
||||
captured['params'] = params
|
||||
return {"success": True, "message": "ok"}
|
||||
|
||||
# Patch the send_command_with_retry function at the module level where it's imported
|
||||
import transport.legacy.unity_connection
|
||||
monkeypatch.setattr(
|
||||
transport.legacy.unity_connection,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
|
||||
|
||||
fn = test_tools['apply_text_edits']
|
||||
await fn(
|
||||
DummyContext(),
|
||||
uri="Assets/Scripts/Thing.cs",
|
||||
edits=[],
|
||||
precondition_sha256=None,
|
||||
)
|
||||
|
||||
assert captured['params']['name'] == 'Thing'
|
||||
assert captured['params']['path'] == 'Assets/Scripts'
|
||||
@@ -0,0 +1,134 @@
|
||||
import pytest
|
||||
|
||||
from .test_helpers import DummyContext
|
||||
import services.tools.manage_scriptable_object as mod
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_scriptable_object_forwards_create_params(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_async_send(cmd, params, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {"ok": True}}
|
||||
|
||||
monkeypatch.setattr(mod, "async_send_command_with_retry", fake_async_send)
|
||||
|
||||
ctx = DummyContext()
|
||||
await ctx.set_state("unity_instance", "UnityMCPTests@dummy")
|
||||
|
||||
result = await (
|
||||
mod.manage_scriptable_object(
|
||||
ctx=ctx,
|
||||
action="create",
|
||||
type_name="My.Namespace.TestDefinition",
|
||||
folder_path="Assets/Temp/Foo",
|
||||
asset_name="Bar",
|
||||
overwrite="true",
|
||||
patches='[{"propertyPath":"displayName","op":"set","value":"Hello"}]',
|
||||
)
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert captured["cmd"] == "manage_scriptable_object"
|
||||
assert captured["params"]["action"] == "create"
|
||||
assert captured["params"]["typeName"] == "My.Namespace.TestDefinition"
|
||||
assert captured["params"]["folderPath"] == "Assets/Temp/Foo"
|
||||
assert captured["params"]["assetName"] == "Bar"
|
||||
assert captured["params"]["overwrite"] is True
|
||||
assert isinstance(captured["params"]["patches"], list)
|
||||
assert captured["params"]["patches"][0]["propertyPath"] == "displayName"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_scriptable_object_forwards_modify_params(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_async_send(cmd, params, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {"ok": True}}
|
||||
|
||||
monkeypatch.setattr(mod, "async_send_command_with_retry", fake_async_send)
|
||||
|
||||
ctx = DummyContext()
|
||||
await ctx.set_state("unity_instance", "UnityMCPTests@dummy")
|
||||
|
||||
result = await (
|
||||
mod.manage_scriptable_object(
|
||||
ctx=ctx,
|
||||
action="modify",
|
||||
target='{"guid":"abc"}',
|
||||
patches=[{"propertyPath": "materials.Array.size", "op": "array_resize", "value": 2}],
|
||||
)
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert captured["cmd"] == "manage_scriptable_object"
|
||||
assert captured["params"]["action"] == "modify"
|
||||
assert captured["params"]["target"] == {"guid": "abc"}
|
||||
assert captured["params"]["patches"][0]["op"] == "array_resize"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_scriptable_object_forwards_dry_run_param(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_async_send(cmd, params, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {"dryRun": True, "validationResults": []}}
|
||||
|
||||
monkeypatch.setattr(mod, "async_send_command_with_retry", fake_async_send)
|
||||
|
||||
ctx = DummyContext()
|
||||
await ctx.set_state("unity_instance", "UnityMCPTests@dummy")
|
||||
|
||||
result = await (
|
||||
mod.manage_scriptable_object(
|
||||
ctx=ctx,
|
||||
action="modify",
|
||||
target='{"guid":"abc123"}',
|
||||
patches=[{"propertyPath": "intValue", "op": "set", "value": 42}],
|
||||
dry_run=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert captured["cmd"] == "manage_scriptable_object"
|
||||
assert captured["params"]["action"] == "modify"
|
||||
assert captured["params"]["dryRun"] is True
|
||||
assert captured["params"]["target"] == {"guid": "abc123"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_scriptable_object_dry_run_string_coercion(monkeypatch):
|
||||
"""Test that dry_run accepts string 'true' and coerces to boolean."""
|
||||
captured = {}
|
||||
|
||||
async def fake_async_send(cmd, params, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {"dryRun": True}}
|
||||
|
||||
monkeypatch.setattr(mod, "async_send_command_with_retry", fake_async_send)
|
||||
|
||||
ctx = DummyContext()
|
||||
await ctx.set_state("unity_instance", "UnityMCPTests@dummy")
|
||||
|
||||
result = await (
|
||||
mod.manage_scriptable_object(
|
||||
ctx=ctx,
|
||||
action="modify",
|
||||
target={"guid": "xyz"},
|
||||
patches=[],
|
||||
dry_run="true", # String instead of bool
|
||||
)
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert captured["params"]["dryRun"] is True
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Integration tests for manage_texture tool."""
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
from .test_helpers import DummyContext
|
||||
import services.tools.manage_texture as manage_texture_mod
|
||||
|
||||
def run_async(coro):
|
||||
"""Simple wrapper to run a coroutine synchronously."""
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
asyncio.set_event_loop(loop)
|
||||
return loop.run_until_complete(coro)
|
||||
finally:
|
||||
loop.close()
|
||||
asyncio.set_event_loop(None)
|
||||
|
||||
async def noop_preflight(*args, **kwargs):
|
||||
return None
|
||||
|
||||
class TestManageTextureIntegration:
|
||||
"""Integration tests for texture management tool logic."""
|
||||
|
||||
def test_create_texture_with_color_array(self, monkeypatch):
|
||||
"""Test creating a texture with RGB color array (0-255)."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(func, instance, cmd, params, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured["params"] = params
|
||||
return {"success": True, "message": "Created texture"}
|
||||
|
||||
monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send)
|
||||
monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight)
|
||||
|
||||
resp = run_async(manage_texture_mod.manage_texture(
|
||||
ctx=DummyContext(),
|
||||
action="create",
|
||||
path="Assets/TestTextures/Red.png",
|
||||
width=64,
|
||||
height=64,
|
||||
fill_color=[255, 0, 0, 255]
|
||||
))
|
||||
|
||||
assert resp["success"] is True
|
||||
assert captured["params"]["fillColor"] == [255, 0, 0, 255]
|
||||
|
||||
def test_create_texture_with_normalized_color(self, monkeypatch):
|
||||
"""Test creating a texture with normalized color (0.0-1.0)."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(func, instance, cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "message": "Created texture"}
|
||||
|
||||
monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send)
|
||||
monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight)
|
||||
|
||||
resp = run_async(manage_texture_mod.manage_texture(
|
||||
ctx=DummyContext(),
|
||||
action="create",
|
||||
path="Assets/TestTextures/Blue.png",
|
||||
fill_color=[0.0, 0.0, 1.0, 1.0]
|
||||
))
|
||||
|
||||
assert resp["success"] is True
|
||||
# Should be normalized to 0-255
|
||||
assert captured["params"]["fillColor"] == [0, 0, 255, 255]
|
||||
|
||||
def test_create_sprite_with_pattern(self, monkeypatch):
|
||||
"""Test creating a sprite with checkerboard pattern."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(func, instance, cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "message": "Created sprite", "data": {"asSprite": True}}
|
||||
|
||||
monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send)
|
||||
monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight)
|
||||
|
||||
resp = run_async(manage_texture_mod.manage_texture(
|
||||
ctx=DummyContext(),
|
||||
action="create_sprite",
|
||||
path="Assets/TestTextures/Checkerboard.png",
|
||||
pattern="checkerboard",
|
||||
as_sprite={
|
||||
"pixelsPerUnit": 100.0,
|
||||
"pivot": [0.5, 0.5]
|
||||
}
|
||||
))
|
||||
|
||||
assert resp["success"] is True
|
||||
assert captured["params"]["action"] == "create_sprite"
|
||||
assert captured["params"]["pattern"] == "checkerboard"
|
||||
assert captured["params"]["spriteSettings"]["pixelsPerUnit"] == 100.0
|
||||
|
||||
def test_create_texture_with_import_settings(self, monkeypatch):
|
||||
"""Test creating a texture with import settings (conversion of snake_case to camelCase)."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(func, instance, cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "message": "Created texture"}
|
||||
|
||||
monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send)
|
||||
monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight)
|
||||
|
||||
resp = run_async(manage_texture_mod.manage_texture(
|
||||
ctx=DummyContext(),
|
||||
action="create",
|
||||
path="Assets/TestTextures/SpriteTexture.png",
|
||||
import_settings={
|
||||
"texture_type": "sprite",
|
||||
"sprite_pixels_per_unit": 100,
|
||||
"filter_mode": "point",
|
||||
"wrap_mode": "clamp"
|
||||
}
|
||||
))
|
||||
|
||||
assert resp["success"] is True
|
||||
settings = captured["params"]["importSettings"]
|
||||
assert settings["textureType"] == "Sprite"
|
||||
assert settings["spritePixelsPerUnit"] == 100
|
||||
assert settings["filterMode"] == "Point"
|
||||
assert settings["wrapMode"] == "Clamp"
|
||||
|
||||
def test_texture_modify_params(self, monkeypatch):
|
||||
"""Test texture modify parameter conversion."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(func, instance, cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "message": "Modified texture"}
|
||||
|
||||
monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send)
|
||||
monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight)
|
||||
|
||||
resp = run_async(manage_texture_mod.manage_texture(
|
||||
ctx=DummyContext(),
|
||||
action="modify",
|
||||
path="Assets/Textures/Test.png",
|
||||
set_pixels={
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 10,
|
||||
"height": 10,
|
||||
"color": [255, 0, 0, 255]
|
||||
}
|
||||
))
|
||||
|
||||
assert resp["success"] is True
|
||||
assert captured["params"]["setPixels"]["color"] == [255, 0, 0, 255]
|
||||
|
||||
def test_texture_modify_pixels_array(self, monkeypatch):
|
||||
"""Test texture modify pixel array normalization."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(func, instance, cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "message": "Modified texture"}
|
||||
|
||||
monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send)
|
||||
monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight)
|
||||
|
||||
resp = run_async(manage_texture_mod.manage_texture(
|
||||
ctx=DummyContext(),
|
||||
action="modify",
|
||||
path="Assets/Textures/Test.png",
|
||||
set_pixels={
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 2,
|
||||
"height": 2,
|
||||
"pixels": [
|
||||
[1.0, 0.0, 0.0, 1.0],
|
||||
[0.0, 1.0, 0.0, 1.0],
|
||||
[0.0, 0.0, 1.0, 1.0],
|
||||
[0.5, 0.5, 0.5, 1.0],
|
||||
]
|
||||
}
|
||||
))
|
||||
|
||||
assert resp["success"] is True
|
||||
assert captured["params"]["setPixels"]["pixels"] == [
|
||||
[255, 0, 0, 255],
|
||||
[0, 255, 0, 255],
|
||||
[0, 0, 255, 255],
|
||||
[128, 128, 128, 255],
|
||||
]
|
||||
|
||||
def test_texture_modify_pixels_invalid_length(self, monkeypatch):
|
||||
"""Test error handling for invalid pixel array length."""
|
||||
async def fake_send(*args, **kwargs):
|
||||
return {"success": True}
|
||||
|
||||
monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send)
|
||||
monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight)
|
||||
|
||||
resp = run_async(manage_texture_mod.manage_texture(
|
||||
ctx=DummyContext(),
|
||||
action="modify",
|
||||
path="Assets/Textures/Test.png",
|
||||
set_pixels={
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 2,
|
||||
"height": 2,
|
||||
"pixels": [
|
||||
[255, 0, 0, 255],
|
||||
[0, 255, 0, 255],
|
||||
[0, 0, 255, 255],
|
||||
]
|
||||
}
|
||||
))
|
||||
|
||||
assert resp["success"] is False
|
||||
assert "pixels array must have 4 entries" in resp["message"]
|
||||
|
||||
def test_texture_modify_invalid_set_pixels_type(self, monkeypatch):
|
||||
"""Test error handling for invalid set_pixels input type."""
|
||||
async def fake_send(*args, **kwargs):
|
||||
return {"success": True}
|
||||
|
||||
monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send)
|
||||
monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight)
|
||||
|
||||
resp = run_async(manage_texture_mod.manage_texture(
|
||||
ctx=DummyContext(),
|
||||
action="modify",
|
||||
path="Assets/Textures/Test.png",
|
||||
set_pixels=[]
|
||||
))
|
||||
|
||||
assert resp["success"] is False
|
||||
assert resp["message"] == "set_pixels must be a JSON object"
|
||||
|
||||
def test_texture_delete_params(self, monkeypatch):
|
||||
"""Test texture delete parameter pass-through."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(func, instance, cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "message": "Deleted texture"}
|
||||
|
||||
monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send)
|
||||
monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight)
|
||||
|
||||
resp = run_async(manage_texture_mod.manage_texture(
|
||||
ctx=DummyContext(),
|
||||
action="delete",
|
||||
path="Assets/Textures/Old.png"
|
||||
))
|
||||
|
||||
assert resp["success"] is True
|
||||
assert captured["params"]["path"] == "Assets/Textures/Old.png"
|
||||
|
||||
def test_invalid_dimensions(self, monkeypatch):
|
||||
"""Test error handling for invalid dimensions."""
|
||||
async def fake_send(func, instance, cmd, params, **kwargs):
|
||||
w = params.get("width", 0)
|
||||
if w > 4096:
|
||||
return {"success": False, "message": "Invalid dimensions: 5000x64. Must be 1-4096."}
|
||||
return {"success": True}
|
||||
|
||||
monkeypatch.setattr(manage_texture_mod, "send_with_unity_instance", fake_send)
|
||||
monkeypatch.setattr(manage_texture_mod, "preflight", noop_preflight)
|
||||
|
||||
resp = run_async(manage_texture_mod.manage_texture(
|
||||
ctx=DummyContext(),
|
||||
action="create",
|
||||
path="Assets/Invalid.png",
|
||||
width=0,
|
||||
height=64 # Non-positive dimension
|
||||
))
|
||||
|
||||
assert resp["success"] is False
|
||||
assert "positive" in resp["message"].lower()
|
||||
@@ -0,0 +1,428 @@
|
||||
"""Integration tests for manage_ui tool."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
|
||||
from .test_helpers import DummyContext
|
||||
import services.tools.manage_ui as manage_ui_mod
|
||||
|
||||
|
||||
def run_async(coro):
|
||||
"""Simple wrapper to run a coroutine synchronously."""
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
asyncio.set_event_loop(loop)
|
||||
return loop.run_until_complete(coro)
|
||||
finally:
|
||||
loop.close()
|
||||
asyncio.set_event_loop(None)
|
||||
|
||||
|
||||
SAMPLE_UXML = """<ui:UXML xmlns:ui="UnityEngine.UIElements">
|
||||
<ui:Label text="Hello World" />
|
||||
</ui:UXML>"""
|
||||
|
||||
SAMPLE_USS = """.label {
|
||||
font-size: 24px;
|
||||
color: white;
|
||||
}"""
|
||||
|
||||
|
||||
class TestManageUIPathValidation:
|
||||
"""Tests for path validation logic."""
|
||||
|
||||
def test_create_rejects_path_outside_assets(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_send(_ctx, _instance, _cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True}
|
||||
|
||||
monkeypatch.setattr(manage_ui_mod, "send_mutation", fake_send)
|
||||
|
||||
resp = run_async(manage_ui_mod.manage_ui(
|
||||
ctx=DummyContext(),
|
||||
action="create",
|
||||
path="NotAssets/UI/Test.uxml",
|
||||
contents=SAMPLE_UXML,
|
||||
))
|
||||
|
||||
assert resp["success"] is False
|
||||
assert "Assets/" in resp["message"]
|
||||
|
||||
def test_create_rejects_traversal(self, monkeypatch):
|
||||
async def fake_send(*_args, **_kwargs):
|
||||
return {"success": True}
|
||||
|
||||
monkeypatch.setattr(manage_ui_mod, "send_mutation", fake_send)
|
||||
|
||||
resp = run_async(manage_ui_mod.manage_ui(
|
||||
ctx=DummyContext(),
|
||||
action="create",
|
||||
path="Assets/../etc/passwd.uxml",
|
||||
contents=SAMPLE_UXML,
|
||||
))
|
||||
|
||||
assert resp["success"] is False
|
||||
# Path normalization resolves ".." so it either fails traversal or Assets/ check
|
||||
assert "traversal" in resp["message"] or "Assets/" in resp["message"]
|
||||
|
||||
def test_create_rejects_invalid_extension(self, monkeypatch):
|
||||
async def fake_send(*_args, **_kwargs):
|
||||
return {"success": True}
|
||||
|
||||
monkeypatch.setattr(manage_ui_mod, "send_mutation", fake_send)
|
||||
|
||||
resp = run_async(manage_ui_mod.manage_ui(
|
||||
ctx=DummyContext(),
|
||||
action="create",
|
||||
path="Assets/UI/Test.cs",
|
||||
contents="some content",
|
||||
))
|
||||
|
||||
assert resp["success"] is False
|
||||
assert ".uxml or .uss" in resp["message"]
|
||||
|
||||
def test_create_accepts_uxml_extension(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_send(_ctx, _instance, _cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "message": "Created"}
|
||||
|
||||
monkeypatch.setattr(manage_ui_mod, "send_mutation", fake_send)
|
||||
|
||||
resp = run_async(manage_ui_mod.manage_ui(
|
||||
ctx=DummyContext(),
|
||||
action="create",
|
||||
path="Assets/UI/Menu.uxml",
|
||||
contents=SAMPLE_UXML,
|
||||
))
|
||||
|
||||
assert resp["success"] is True
|
||||
assert captured["params"]["path"] == "Assets/UI/Menu.uxml"
|
||||
|
||||
def test_create_accepts_uss_extension(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_send(_ctx, _instance, _cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "message": "Created"}
|
||||
|
||||
monkeypatch.setattr(manage_ui_mod, "send_mutation", fake_send)
|
||||
|
||||
resp = run_async(manage_ui_mod.manage_ui(
|
||||
ctx=DummyContext(),
|
||||
action="create",
|
||||
path="Assets/UI/Styles.uss",
|
||||
contents=SAMPLE_USS,
|
||||
))
|
||||
|
||||
assert resp["success"] is True
|
||||
assert captured["params"]["path"] == "Assets/UI/Styles.uss"
|
||||
|
||||
|
||||
class TestManageUIContentsEncoding:
|
||||
"""Tests for base64 content encoding."""
|
||||
|
||||
def test_create_encodes_contents_as_base64(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_send(_ctx, _instance, _cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "message": "Created"}
|
||||
|
||||
monkeypatch.setattr(manage_ui_mod, "send_mutation", fake_send)
|
||||
|
||||
run_async(manage_ui_mod.manage_ui(
|
||||
ctx=DummyContext(),
|
||||
action="create",
|
||||
path="Assets/UI/Test.uxml",
|
||||
contents=SAMPLE_UXML,
|
||||
))
|
||||
|
||||
params = captured["params"]
|
||||
assert params["contentsEncoded"] is True
|
||||
decoded = base64.b64decode(params["encodedContents"]).decode("utf-8")
|
||||
assert decoded == SAMPLE_UXML
|
||||
# Raw contents should NOT be in params (only encoded)
|
||||
assert "contents" not in params
|
||||
|
||||
def test_update_encodes_contents_as_base64(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_send(_ctx, _instance, _cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "message": "Updated"}
|
||||
|
||||
monkeypatch.setattr(manage_ui_mod, "send_mutation", fake_send)
|
||||
|
||||
run_async(manage_ui_mod.manage_ui(
|
||||
ctx=DummyContext(),
|
||||
action="update",
|
||||
path="Assets/UI/Test.uss",
|
||||
contents=SAMPLE_USS,
|
||||
))
|
||||
|
||||
params = captured["params"]
|
||||
assert params["contentsEncoded"] is True
|
||||
decoded = base64.b64decode(params["encodedContents"]).decode("utf-8")
|
||||
assert decoded == SAMPLE_USS
|
||||
|
||||
|
||||
class TestManageUIActionRouting:
|
||||
"""Tests for action-based parameter routing."""
|
||||
|
||||
def test_read_uses_non_mutation_path(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_send(_func, _instance, cmd, params, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {"contents": "test"}}
|
||||
|
||||
monkeypatch.setattr(manage_ui_mod, "send_with_unity_instance", fake_send)
|
||||
|
||||
run_async(manage_ui_mod.manage_ui(
|
||||
ctx=DummyContext(),
|
||||
action="read",
|
||||
path="Assets/UI/Test.uxml",
|
||||
))
|
||||
|
||||
assert captured["cmd"] == "manage_ui"
|
||||
assert captured["params"]["action"] == "read"
|
||||
|
||||
def test_create_uses_mutation_path(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_send(_ctx, _instance, cmd, _params, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
return {"success": True, "message": "Created"}
|
||||
|
||||
monkeypatch.setattr(manage_ui_mod, "send_mutation", fake_send)
|
||||
|
||||
run_async(manage_ui_mod.manage_ui(
|
||||
ctx=DummyContext(),
|
||||
action="create",
|
||||
path="Assets/UI/Test.uxml",
|
||||
contents=SAMPLE_UXML,
|
||||
))
|
||||
|
||||
assert captured["cmd"] == "manage_ui"
|
||||
|
||||
def test_attach_ui_document_params(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_send(_ctx, _instance, _cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "message": "Attached"}
|
||||
|
||||
monkeypatch.setattr(manage_ui_mod, "send_mutation", fake_send)
|
||||
|
||||
run_async(manage_ui_mod.manage_ui(
|
||||
ctx=DummyContext(),
|
||||
action="attach_ui_document",
|
||||
target="MyCanvas",
|
||||
source_asset="Assets/UI/Menu.uxml",
|
||||
panel_settings="Assets/UI/PanelSettings.asset",
|
||||
sort_order=5,
|
||||
))
|
||||
|
||||
params = captured["params"]
|
||||
assert params["action"] == "attach_ui_document"
|
||||
assert params["target"] == "MyCanvas"
|
||||
assert params["sourceAsset"] == "Assets/UI/Menu.uxml"
|
||||
assert params["panelSettings"] == "Assets/UI/PanelSettings.asset"
|
||||
assert params["sortOrder"] == 5
|
||||
|
||||
def test_create_panel_settings_params(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_send(_ctx, _instance, _cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "message": "Created"}
|
||||
|
||||
monkeypatch.setattr(manage_ui_mod, "send_mutation", fake_send)
|
||||
|
||||
run_async(manage_ui_mod.manage_ui(
|
||||
ctx=DummyContext(),
|
||||
action="create_panel_settings",
|
||||
path="Assets/UI/MyPanel.asset",
|
||||
scale_mode="ScaleWithScreenSize",
|
||||
reference_resolution={"width": 1920, "height": 1080},
|
||||
))
|
||||
|
||||
params = captured["params"]
|
||||
assert params["action"] == "create_panel_settings"
|
||||
assert params["scaleMode"] == "ScaleWithScreenSize"
|
||||
assert params["referenceResolution"] == {"width": 1920, "height": 1080}
|
||||
|
||||
def test_get_visual_tree_params(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_send(_func, _instance, _cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {"tree": {}}}
|
||||
|
||||
monkeypatch.setattr(manage_ui_mod, "send_with_unity_instance", fake_send)
|
||||
|
||||
run_async(manage_ui_mod.manage_ui(
|
||||
ctx=DummyContext(),
|
||||
action="get_visual_tree",
|
||||
target="UIRoot",
|
||||
max_depth=5,
|
||||
))
|
||||
|
||||
params = captured["params"]
|
||||
assert params["action"] == "get_visual_tree"
|
||||
assert params["target"] == "UIRoot"
|
||||
assert params["maxDepth"] == 5
|
||||
|
||||
def test_ping_uses_non_mutation_path(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_send(_func, _instance, _cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "message": "pong"}
|
||||
|
||||
monkeypatch.setattr(manage_ui_mod, "send_with_unity_instance", fake_send)
|
||||
|
||||
resp = run_async(manage_ui_mod.manage_ui(
|
||||
ctx=DummyContext(),
|
||||
action="ping",
|
||||
))
|
||||
|
||||
assert resp["success"] is True
|
||||
assert captured["params"]["action"] == "ping"
|
||||
|
||||
|
||||
class TestManageUINoneRemoval:
|
||||
"""Tests that None values are properly excluded from params."""
|
||||
|
||||
def test_none_params_excluded(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_send(_func, _instance, _cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {}}
|
||||
|
||||
monkeypatch.setattr(manage_ui_mod, "send_with_unity_instance", fake_send)
|
||||
|
||||
run_async(manage_ui_mod.manage_ui(
|
||||
ctx=DummyContext(),
|
||||
action="read",
|
||||
path="Assets/UI/Test.uxml",
|
||||
# All other params are None
|
||||
))
|
||||
|
||||
params = captured["params"]
|
||||
assert "target" not in params
|
||||
assert "sourceAsset" not in params
|
||||
assert "panelSettings" not in params
|
||||
assert "sortOrder" not in params
|
||||
assert "scaleMode" not in params
|
||||
assert "maxDepth" not in params
|
||||
|
||||
|
||||
class TestManageUIReadResponse:
|
||||
"""Tests for read response handling."""
|
||||
|
||||
def test_read_decodes_base64_response(self, monkeypatch):
|
||||
encoded = base64.b64encode(SAMPLE_UXML.encode("utf-8")).decode("utf-8")
|
||||
|
||||
async def fake_send(*_args, **_kwargs):
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"path": "Assets/UI/Test.uxml",
|
||||
"contents": SAMPLE_UXML,
|
||||
"encodedContents": encoded,
|
||||
"contentsEncoded": True,
|
||||
}
|
||||
}
|
||||
|
||||
monkeypatch.setattr(manage_ui_mod, "send_with_unity_instance", fake_send)
|
||||
|
||||
resp = run_async(manage_ui_mod.manage_ui(
|
||||
ctx=DummyContext(),
|
||||
action="read",
|
||||
path="Assets/UI/Test.uxml",
|
||||
))
|
||||
|
||||
assert resp["success"] is True
|
||||
data = resp["data"]
|
||||
assert data["contents"] == SAMPLE_UXML
|
||||
assert "encodedContents" not in data
|
||||
assert "contentsEncoded" not in data
|
||||
|
||||
|
||||
class TestManageUIRenderUI:
|
||||
"""Tests for render_ui action."""
|
||||
|
||||
def test_render_ui_routes_params(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_send(_ctx, _instance, _cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "message": "Rendered",
|
||||
"data": {"path": "Assets/Screenshots/test.png"}}
|
||||
|
||||
monkeypatch.setattr(manage_ui_mod, "send_mutation", fake_send)
|
||||
|
||||
resp = run_async(manage_ui_mod.manage_ui(
|
||||
ctx=DummyContext(), action="render_ui",
|
||||
target="UIRoot", width=1280, height=720,
|
||||
include_image=True, max_resolution=480,
|
||||
screenshot_file_name="my-preview",
|
||||
))
|
||||
assert resp["success"] is True
|
||||
p = captured["params"]
|
||||
assert p["action"] == "render_ui"
|
||||
assert p["target"] == "UIRoot"
|
||||
assert p["width"] == 1280
|
||||
assert p["height"] == 720
|
||||
assert p["include_image"] is True
|
||||
assert p["max_resolution"] == 480
|
||||
assert p["file_name"] == "my-preview"
|
||||
|
||||
def test_render_ui_none_excluded(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_send(_ctx, _instance, _cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "message": "ok"}
|
||||
|
||||
monkeypatch.setattr(manage_ui_mod, "send_mutation", fake_send)
|
||||
run_async(manage_ui_mod.manage_ui(
|
||||
ctx=DummyContext(), action="render_ui", target="X"))
|
||||
p = captured["params"]
|
||||
for k in ("width", "height", "include_image", "max_resolution", "file_name"):
|
||||
assert k not in p
|
||||
|
||||
|
||||
class TestManageUILinkStylesheet:
|
||||
"""Tests for link_stylesheet action."""
|
||||
|
||||
def test_link_stylesheet_routes_params(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_send(_ctx, _instance, _cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "message": "Linked"}
|
||||
|
||||
monkeypatch.setattr(manage_ui_mod, "send_mutation", fake_send)
|
||||
|
||||
resp = run_async(manage_ui_mod.manage_ui(
|
||||
ctx=DummyContext(), action="link_stylesheet",
|
||||
path="Assets/UI/Menu.uxml",
|
||||
stylesheet="Assets/UI/Styles.uss",
|
||||
))
|
||||
assert resp["success"] is True
|
||||
p = captured["params"]
|
||||
assert p["action"] == "link_stylesheet"
|
||||
assert p["path"] == "Assets/UI/Menu.uxml"
|
||||
assert p["stylesheet"] == "Assets/UI/Styles.uss"
|
||||
for k in ("width", "height", "include_image"):
|
||||
assert k not in p
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Tests for UnityInstanceMiddleware auth enforcement in remote-hosted mode."""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from core.config import config
|
||||
from tests.integration.test_helpers import DummyContext
|
||||
|
||||
|
||||
class TestMiddlewareAuthEnforcement:
|
||||
@pytest.mark.asyncio
|
||||
async def test_remote_hosted_requires_user_id(self, monkeypatch):
|
||||
"""_inject_unity_instance should raise RuntimeError when remote-hosted and no user_id."""
|
||||
monkeypatch.setattr(config, "http_remote_hosted", True)
|
||||
|
||||
from transport.unity_instance_middleware import UnityInstanceMiddleware
|
||||
|
||||
middleware = UnityInstanceMiddleware()
|
||||
|
||||
# Mock _resolve_user_id to return None (no API key / failed validation)
|
||||
monkeypatch.setattr(middleware, "_resolve_user_id",
|
||||
AsyncMock(return_value=None))
|
||||
|
||||
ctx = DummyContext()
|
||||
middleware_ctx = Mock()
|
||||
middleware_ctx.fastmcp_context = ctx
|
||||
|
||||
with pytest.raises(RuntimeError, match="API key authentication required"):
|
||||
await middleware._inject_unity_instance(middleware_ctx)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sets_user_id_in_context_state(self, monkeypatch):
|
||||
"""_inject_unity_instance should set user_id in ctx state when resolved."""
|
||||
monkeypatch.setattr(config, "http_remote_hosted", True)
|
||||
|
||||
from transport.unity_instance_middleware import UnityInstanceMiddleware
|
||||
|
||||
middleware = UnityInstanceMiddleware()
|
||||
monkeypatch.setattr(middleware, "_resolve_user_id",
|
||||
AsyncMock(return_value="user-55"))
|
||||
|
||||
# We need PluginHub to be configured for the session resolution path
|
||||
# But we don't need it to actually find a session for this test
|
||||
from transport.plugin_hub import PluginHub
|
||||
from transport.plugin_registry import PluginRegistry
|
||||
|
||||
registry = PluginRegistry()
|
||||
loop = asyncio.get_running_loop()
|
||||
PluginHub.configure(registry, loop)
|
||||
|
||||
ctx = DummyContext()
|
||||
ctx.client_id = "client-1"
|
||||
middleware_ctx = Mock()
|
||||
middleware_ctx.fastmcp_context = ctx
|
||||
|
||||
# Set an active instance so the middleware doesn't try to auto-select
|
||||
await middleware.set_active_instance(ctx, "Proj@hash1")
|
||||
# Register a matching session so resolution doesn't fail
|
||||
await registry.register("s1", "Proj", "hash1", "2022", user_id="user-55")
|
||||
|
||||
await middleware._inject_unity_instance(middleware_ctx)
|
||||
|
||||
assert await ctx.get_state("user_id") == "user-55"
|
||||
|
||||
|
||||
class TestAutoSelectDisabledRemoteHosted:
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_select_returns_none_in_remote_hosted(self, monkeypatch):
|
||||
"""_maybe_autoselect_instance should return None in remote-hosted mode even with one session."""
|
||||
monkeypatch.setattr(config, "http_remote_hosted", True)
|
||||
monkeypatch.setattr(config, "transport_mode", "http")
|
||||
|
||||
# Re-import middleware to pick up the stubbed transport module
|
||||
monkeypatch.delitem(
|
||||
sys.modules, "transport.unity_instance_middleware", raising=False)
|
||||
from transport.unity_instance_middleware import UnityInstanceMiddleware, PluginHub as HubRef
|
||||
|
||||
# Configure PluginHub with one session so auto-select has something to find
|
||||
from transport.plugin_registry import PluginRegistry
|
||||
registry = PluginRegistry()
|
||||
await registry.register("s1", "Proj", "h1", "2022", user_id="userA")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
HubRef.configure(registry, loop)
|
||||
|
||||
middleware = UnityInstanceMiddleware()
|
||||
ctx = DummyContext()
|
||||
ctx.client_id = "client-1"
|
||||
|
||||
result = await middleware._maybe_autoselect_instance(ctx)
|
||||
# Remote-hosted mode should NOT auto-select (early return at the transport check)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestHttpAuthBehavior:
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_local_does_not_require_user_id(self, monkeypatch):
|
||||
"""HTTP local mode should allow requests without user_id."""
|
||||
monkeypatch.setattr(config, "http_remote_hosted", False)
|
||||
monkeypatch.setattr(config, "transport_mode", "http")
|
||||
|
||||
from transport import unity_transport
|
||||
|
||||
async def fake_send_command_for_instance(*_args, **_kwargs):
|
||||
return {"success": True, "data": {"ok": True}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
unity_transport.PluginHub,
|
||||
"send_command_for_instance",
|
||||
fake_send_command_for_instance,
|
||||
)
|
||||
|
||||
async def _unused_send_fn(*_args, **_kwargs):
|
||||
raise AssertionError("send_fn should not be used in HTTP mode")
|
||||
|
||||
result = await unity_transport.send_with_unity_instance(
|
||||
_unused_send_fn, None, "ping", {}
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["data"] == {"ok": True}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_remote_requires_user_id(self, monkeypatch):
|
||||
"""HTTP remote-hosted mode should reject requests without user_id."""
|
||||
monkeypatch.setattr(config, "http_remote_hosted", True)
|
||||
monkeypatch.setattr(config, "transport_mode", "http")
|
||||
|
||||
from transport import unity_transport
|
||||
|
||||
async def _unused_send_fn(*_args, **_kwargs):
|
||||
raise AssertionError("send_fn should not be used in HTTP mode")
|
||||
|
||||
result = await unity_transport.send_with_unity_instance(
|
||||
_unused_send_fn, None, "ping", {}
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error"] == "auth_required"
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Integration tests for multi-user session isolation in remote-hosted mode.
|
||||
|
||||
These tests compose PluginRegistry + PluginHub to verify that users
|
||||
cannot see or interact with each other's Unity instances.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.config import config
|
||||
from transport.plugin_hub import NoUnitySessionError, PluginHub
|
||||
from transport.plugin_registry import PluginRegistry
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_plugin_hub():
|
||||
old_registry = PluginHub._registry
|
||||
old_connections = PluginHub._connections.copy()
|
||||
old_pending = PluginHub._pending.copy()
|
||||
old_lock = PluginHub._lock
|
||||
old_loop = PluginHub._loop
|
||||
|
||||
yield
|
||||
|
||||
PluginHub._registry = old_registry
|
||||
PluginHub._connections = old_connections
|
||||
PluginHub._pending = old_pending
|
||||
PluginHub._lock = old_lock
|
||||
PluginHub._loop = old_loop
|
||||
|
||||
|
||||
async def _setup_two_user_registry():
|
||||
"""Set up a registry with two users, each having Unity instances.
|
||||
|
||||
Returns the configured registry. Also configures PluginHub to use it.
|
||||
"""
|
||||
registry = PluginRegistry()
|
||||
loop = asyncio.get_running_loop()
|
||||
PluginHub.configure(registry, loop)
|
||||
|
||||
await registry.register("sess-A1", "ProjectAlpha", "hashA1", "2022.3", user_id="userA")
|
||||
await registry.register("sess-B1", "ProjectBeta", "hashB1", "2022.3", user_id="userB")
|
||||
await registry.register("sess-A2", "ProjectGamma", "hashA2", "2022.3", user_id="userA")
|
||||
|
||||
return registry
|
||||
|
||||
|
||||
class TestMultiUserSessionFiltering:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sessions_filters_by_user(self):
|
||||
"""PluginHub.get_sessions(user_id=X) returns only X's sessions."""
|
||||
await _setup_two_user_registry()
|
||||
|
||||
sessions_a = await PluginHub.get_sessions(user_id="userA")
|
||||
assert len(sessions_a.sessions) == 2
|
||||
project_names = {s.project for s in sessions_a.sessions.values()}
|
||||
assert project_names == {"ProjectAlpha", "ProjectGamma"}
|
||||
|
||||
sessions_b = await PluginHub.get_sessions(user_id="userB")
|
||||
assert len(sessions_b.sessions) == 1
|
||||
assert next(iter(sessions_b.sessions.values())
|
||||
).project == "ProjectBeta"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sessions_no_filter_returns_all_in_local_mode(self):
|
||||
"""In local mode, PluginHub.get_sessions() without user_id returns everything."""
|
||||
await _setup_two_user_registry()
|
||||
|
||||
all_sessions = await PluginHub.get_sessions()
|
||||
assert len(all_sessions.sessions) == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sessions_no_filter_raises_in_remote_hosted(self, monkeypatch):
|
||||
"""In remote-hosted mode, PluginHub.get_sessions() without user_id raises."""
|
||||
monkeypatch.setattr(config, "http_remote_hosted", True)
|
||||
await _setup_two_user_registry()
|
||||
|
||||
with pytest.raises(ValueError, match="requires user_id"):
|
||||
await PluginHub.get_sessions()
|
||||
|
||||
|
||||
class TestResolveSessionIdIsolation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_session_for_own_hash(self, monkeypatch):
|
||||
"""User A can resolve their own project hash."""
|
||||
monkeypatch.setattr(config, "http_remote_hosted", True)
|
||||
await _setup_two_user_registry()
|
||||
|
||||
session_id = await PluginHub._resolve_session_id("hashA1", user_id="userA")
|
||||
assert session_id == "sess-A1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_resolve_other_users_hash(self, monkeypatch):
|
||||
"""User A cannot resolve User B's project hash."""
|
||||
monkeypatch.setattr(config, "http_remote_hosted", True)
|
||||
monkeypatch.setenv("UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "0.1")
|
||||
await _setup_two_user_registry()
|
||||
|
||||
# userA tries to resolve userB's hash -> should not find it
|
||||
with pytest.raises(NoUnitySessionError):
|
||||
await PluginHub._resolve_session_id("hashB1", user_id="userA")
|
||||
|
||||
|
||||
class TestInstanceListResourceIsolation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_unity_instances_resource_filters_by_user(self, monkeypatch):
|
||||
"""The unity_instances resource should pass user_id and return filtered results."""
|
||||
monkeypatch.setattr(config, "http_remote_hosted", True)
|
||||
monkeypatch.setattr(config, "transport_mode", "http")
|
||||
await _setup_two_user_registry()
|
||||
|
||||
from services.resources.unity_instances import unity_instances
|
||||
from tests.integration.test_helpers import DummyContext
|
||||
|
||||
ctx = DummyContext()
|
||||
await ctx.set_state("user_id", "userA")
|
||||
|
||||
result = await unity_instances(ctx)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["instance_count"] == 2
|
||||
instance_names = {i["name"] for i in result["instances"]}
|
||||
assert instance_names == {"ProjectAlpha", "ProjectGamma"}
|
||||
assert "ProjectBeta" not in instance_names
|
||||
|
||||
|
||||
class TestSetActiveInstanceIsolation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_active_instance_only_sees_own_sessions(self, monkeypatch):
|
||||
"""set_active_instance should only offer sessions belonging to the current user."""
|
||||
monkeypatch.setattr(config, "http_remote_hosted", True)
|
||||
monkeypatch.setattr(config, "transport_mode", "http")
|
||||
await _setup_two_user_registry()
|
||||
|
||||
from services.tools.set_active_instance import set_active_instance
|
||||
from transport.unity_instance_middleware import UnityInstanceMiddleware
|
||||
from tests.integration.test_helpers import DummyContext
|
||||
|
||||
middleware = UnityInstanceMiddleware()
|
||||
monkeypatch.setattr(
|
||||
"services.tools.set_active_instance.get_unity_instance_middleware",
|
||||
lambda: middleware,
|
||||
)
|
||||
|
||||
ctx = DummyContext()
|
||||
await ctx.set_state("user_id", "userA")
|
||||
|
||||
result = await set_active_instance(ctx, "ProjectAlpha@hashA1")
|
||||
assert result["success"] is True
|
||||
assert await middleware.get_active_instance(ctx) == "ProjectAlpha@hashA1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_active_instance_rejects_other_users_instance(self, monkeypatch):
|
||||
"""set_active_instance should not find another user's instance."""
|
||||
monkeypatch.setattr(config, "http_remote_hosted", True)
|
||||
monkeypatch.setattr(config, "transport_mode", "http")
|
||||
await _setup_two_user_registry()
|
||||
|
||||
from services.tools.set_active_instance import set_active_instance
|
||||
from transport.unity_instance_middleware import UnityInstanceMiddleware
|
||||
from tests.integration.test_helpers import DummyContext
|
||||
|
||||
middleware = UnityInstanceMiddleware()
|
||||
monkeypatch.setattr(
|
||||
"services.tools.set_active_instance.get_unity_instance_middleware",
|
||||
lambda: middleware,
|
||||
)
|
||||
|
||||
ctx = DummyContext()
|
||||
await ctx.set_state("user_id", "userA")
|
||||
|
||||
# UserA tries to select UserB's instance -> should fail
|
||||
result = await set_active_instance(ctx, "ProjectBeta@hashB1")
|
||||
assert result["success"] is False
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Tests for PluginHub WebSocket API key authentication gate."""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from core.config import config
|
||||
from core.constants import API_KEY_HEADER
|
||||
from services.api_key_service import ApiKeyService, ValidationResult
|
||||
from transport.plugin_hub import PluginHub
|
||||
from transport.plugin_registry import PluginRegistry
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_api_key_singleton():
|
||||
ApiKeyService._instance = None
|
||||
yield
|
||||
ApiKeyService._instance = None
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_plugin_hub():
|
||||
"""Ensure PluginHub class-level state doesn't leak between tests."""
|
||||
old_registry = PluginHub._registry
|
||||
old_connections = PluginHub._connections.copy()
|
||||
old_pending = PluginHub._pending.copy()
|
||||
old_lock = PluginHub._lock
|
||||
old_loop = PluginHub._loop
|
||||
|
||||
yield
|
||||
|
||||
PluginHub._registry = old_registry
|
||||
PluginHub._connections = old_connections
|
||||
PluginHub._pending = old_pending
|
||||
PluginHub._lock = old_lock
|
||||
PluginHub._loop = old_loop
|
||||
|
||||
|
||||
def _make_mock_websocket(headers=None, state_attrs=None):
|
||||
"""Create a mock WebSocket with configurable headers and state."""
|
||||
ws = AsyncMock()
|
||||
ws.headers = headers or {}
|
||||
ws.state = SimpleNamespace(**(state_attrs or {}))
|
||||
ws.accept = AsyncMock()
|
||||
ws.close = AsyncMock()
|
||||
ws.send_json = AsyncMock()
|
||||
return ws
|
||||
|
||||
|
||||
def _make_hub():
|
||||
"""Create a PluginHub instance with a minimal ASGI scope."""
|
||||
scope = {"type": "websocket"}
|
||||
return PluginHub(scope, receive=AsyncMock(), send=AsyncMock())
|
||||
|
||||
|
||||
def _init_api_key_service(validate_result=None):
|
||||
"""Initialize ApiKeyService with a mocked validate method."""
|
||||
svc = ApiKeyService(validation_url="https://auth.example.com/validate")
|
||||
if validate_result is not None:
|
||||
svc.validate = AsyncMock(return_value=validate_result)
|
||||
return svc
|
||||
|
||||
|
||||
class TestWebSocketAuthGate:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_api_key_remote_hosted_rejected(self, monkeypatch):
|
||||
"""WebSocket without API key in remote-hosted mode -> close 4401."""
|
||||
monkeypatch.setattr(config, "http_remote_hosted", True)
|
||||
_init_api_key_service(ValidationResult(valid=True, user_id="u1"))
|
||||
|
||||
ws = _make_mock_websocket(headers={}) # No X-API-Key header
|
||||
hub = _make_hub()
|
||||
|
||||
await hub.on_connect(ws)
|
||||
|
||||
ws.close.assert_called_once_with(code=4401, reason="API key required")
|
||||
ws.accept.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_api_key_rejected(self, monkeypatch):
|
||||
"""WebSocket with invalid API key -> close 4403."""
|
||||
monkeypatch.setattr(config, "http_remote_hosted", True)
|
||||
_init_api_key_service(ValidationResult(
|
||||
valid=False, error="Invalid API key"))
|
||||
|
||||
ws = _make_mock_websocket(headers={API_KEY_HEADER: "sk-bad-key"})
|
||||
hub = _make_hub()
|
||||
|
||||
await hub.on_connect(ws)
|
||||
|
||||
ws.close.assert_called_once_with(code=4403, reason="Invalid API key")
|
||||
ws.accept.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_api_key_accepted(self, monkeypatch):
|
||||
"""WebSocket with valid API key -> accepted, user_id stored in state."""
|
||||
monkeypatch.setattr(config, "http_remote_hosted", True)
|
||||
_init_api_key_service(
|
||||
ValidationResult(valid=True, user_id="user-42",
|
||||
metadata={"plan": "pro"})
|
||||
)
|
||||
|
||||
ws = _make_mock_websocket(headers={API_KEY_HEADER: "sk-valid-key"})
|
||||
hub = _make_hub()
|
||||
|
||||
await hub.on_connect(ws)
|
||||
|
||||
ws.accept.assert_called_once()
|
||||
ws.close.assert_not_called()
|
||||
assert ws.state.user_id == "user-42"
|
||||
assert ws.state.api_key_metadata == {"plan": "pro"}
|
||||
# Should have sent welcome message
|
||||
ws.send_json.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_service_unavailable_close_1013(self, monkeypatch):
|
||||
"""Auth service error with 'unavailable' -> close 1013 (try again later)."""
|
||||
monkeypatch.setattr(config, "http_remote_hosted", True)
|
||||
_init_api_key_service(
|
||||
ValidationResult(
|
||||
valid=False, error="Auth service unavailable", cacheable=False)
|
||||
)
|
||||
|
||||
ws = _make_mock_websocket(headers={API_KEY_HEADER: "sk-some-key"})
|
||||
hub = _make_hub()
|
||||
|
||||
await hub.on_connect(ws)
|
||||
|
||||
ws.close.assert_called_once_with(code=1013, reason="Try again later")
|
||||
ws.accept.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_not_remote_hosted_accepts_without_key(self, monkeypatch):
|
||||
"""When not remote-hosted, WebSocket accepted without API key."""
|
||||
monkeypatch.setattr(config, "http_remote_hosted", False)
|
||||
|
||||
ws = _make_mock_websocket(headers={})
|
||||
hub = _make_hub()
|
||||
|
||||
await hub.on_connect(ws)
|
||||
|
||||
ws.accept.assert_called_once()
|
||||
ws.close.assert_not_called()
|
||||
|
||||
|
||||
class TestUserIdFlowsToRegistration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_id_passed_to_registry_on_register(self, monkeypatch):
|
||||
"""After valid auth, the register message should pass user_id to registry."""
|
||||
monkeypatch.setattr(config, "http_remote_hosted", True)
|
||||
_init_api_key_service(
|
||||
ValidationResult(valid=True, user_id="user-99")
|
||||
)
|
||||
|
||||
registry = PluginRegistry()
|
||||
loop = asyncio.get_running_loop()
|
||||
PluginHub.configure(registry, loop)
|
||||
|
||||
# Simulate full flow: connect, then register
|
||||
ws = _make_mock_websocket(headers={API_KEY_HEADER: "sk-valid-key"})
|
||||
hub = _make_hub()
|
||||
|
||||
await hub.on_connect(ws)
|
||||
assert ws.state.user_id == "user-99"
|
||||
|
||||
# Simulate register message
|
||||
register_data = {
|
||||
"type": "register",
|
||||
"project_name": "TestProject",
|
||||
"project_hash": "abc123",
|
||||
"unity_version": "2022.3",
|
||||
}
|
||||
await hub.on_receive(ws, register_data)
|
||||
|
||||
# Verify registry has the user_id
|
||||
sessions = await registry.list_sessions(user_id="user-99")
|
||||
assert len(sessions) == 1
|
||||
session = next(iter(sessions.values()))
|
||||
assert session.user_id == "user-99"
|
||||
assert session.project_name == "TestProject"
|
||||
assert session.project_hash == "abc123"
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Tests for PluginRegistry user-scoped session isolation (remote-hosted mode)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from core.config import config
|
||||
from transport.plugin_registry import PluginRegistry
|
||||
|
||||
|
||||
class TestRegistryUserIsolation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_with_user_id_stores_composite_key(self):
|
||||
registry = PluginRegistry()
|
||||
session, _ = await registry.register(
|
||||
"sess-1", "MyProject", "hash1", "2022.3", user_id="user-A"
|
||||
)
|
||||
assert session.user_id == "user-A"
|
||||
assert ("user-A", "hash1") in registry._user_hash_to_session
|
||||
assert registry._user_hash_to_session[("user-A", "hash1")] == "sess-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_session_id_by_hash(self):
|
||||
registry = PluginRegistry()
|
||||
await registry.register("sess-1", "Proj", "h1", "2022", user_id="uA")
|
||||
|
||||
found = await registry.get_session_id_by_hash("h1", "uA")
|
||||
assert found == "sess-1"
|
||||
|
||||
# Different user, same hash -> not found
|
||||
not_found = await registry.get_session_id_by_hash("h1", "uB")
|
||||
assert not_found is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_same_user_same_hash_evicts_previous_session(self):
|
||||
"""Same user + project_hash: second registration evicts the first session."""
|
||||
registry = PluginRegistry()
|
||||
|
||||
first_session, first_evicted = await registry.register(
|
||||
"sess-1", "MyProject", "hash1", "2022.3", user_id="user-A"
|
||||
)
|
||||
assert first_session.session_id == "sess-1"
|
||||
assert first_evicted is None
|
||||
|
||||
second_session, second_evicted = await registry.register(
|
||||
"sess-2", "MyProject", "hash1", "2022.3", user_id="user-A"
|
||||
)
|
||||
assert second_session.session_id == "sess-2"
|
||||
assert second_evicted == "sess-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_user_isolation_same_hash(self):
|
||||
"""Two users registering with the same project_hash get independent sessions."""
|
||||
registry = PluginRegistry()
|
||||
sess_a, evicted_a = await registry.register("sA", "Proj", "hash1", "2022", user_id="userA")
|
||||
sess_b, evicted_b = await registry.register("sB", "Proj", "hash1", "2022", user_id="userB")
|
||||
|
||||
assert sess_a.session_id == "sA"
|
||||
assert sess_b.session_id == "sB"
|
||||
# Different users should not evict each other's sessions
|
||||
assert evicted_a is None
|
||||
assert evicted_b is None
|
||||
|
||||
# Each user resolves to their own session
|
||||
assert await registry.get_session_id_by_hash("hash1", "userA") == "sA"
|
||||
assert await registry.get_session_id_by_hash("hash1", "userB") == "sB"
|
||||
|
||||
# Both sessions exist
|
||||
all_sessions = await registry.list_sessions()
|
||||
assert len(all_sessions) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_filtered_by_user(self):
|
||||
registry = PluginRegistry()
|
||||
await registry.register("s1", "ProjA", "hA", "2022", user_id="userA")
|
||||
await registry.register("s2", "ProjB", "hB", "2022", user_id="userB")
|
||||
await registry.register("s3", "ProjC", "hC", "2022", user_id="userA")
|
||||
|
||||
user_a_sessions = await registry.list_sessions(user_id="userA")
|
||||
assert len(user_a_sessions) == 2
|
||||
assert "s1" in user_a_sessions
|
||||
assert "s3" in user_a_sessions
|
||||
|
||||
user_b_sessions = await registry.list_sessions(user_id="userB")
|
||||
assert len(user_b_sessions) == 1
|
||||
assert "s2" in user_b_sessions
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_no_filter_returns_all_in_local_mode(self):
|
||||
"""In local mode (not remote-hosted), list_sessions(user_id=None) returns all."""
|
||||
registry = PluginRegistry()
|
||||
await registry.register("s1", "P1", "h1", "2022", user_id="uA")
|
||||
await registry.register("s2", "P2", "h2", "2022", user_id="uB")
|
||||
await registry.register("s3", "P3", "h3", "2022") # local mode, no user_id
|
||||
|
||||
all_sessions = await registry.list_sessions(user_id=None)
|
||||
assert len(all_sessions) == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_no_filter_raises_in_remote_hosted(self, monkeypatch):
|
||||
"""In remote-hosted mode, list_sessions(user_id=None) raises ValueError."""
|
||||
monkeypatch.setattr(config, "http_remote_hosted", True)
|
||||
|
||||
registry = PluginRegistry()
|
||||
await registry.register("s1", "P1", "h1", "2022", user_id="uA")
|
||||
|
||||
with pytest.raises(ValueError, match="requires user_id"):
|
||||
await registry.list_sessions(user_id=None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unregister_cleans_user_scoped_mapping(self):
|
||||
registry = PluginRegistry()
|
||||
await registry.register("s1", "Proj", "h1", "2022", user_id="uA")
|
||||
assert ("uA", "h1") in registry._user_hash_to_session
|
||||
|
||||
await registry.unregister("s1")
|
||||
|
||||
assert ("uA", "h1") not in registry._user_hash_to_session
|
||||
assert "s1" not in (await registry.list_sessions())
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconnect_replaces_previous_session(self):
|
||||
"""Same (user_id, hash) re-registered evicts old session, stores new one."""
|
||||
registry = PluginRegistry()
|
||||
await registry.register("old-sess", "Proj", "h1", "2022", user_id="uA")
|
||||
assert await registry.get_session_id_by_hash("h1", "uA") == "old-sess"
|
||||
|
||||
await registry.register("new-sess", "Proj", "h1", "2022", user_id="uA")
|
||||
assert await registry.get_session_id_by_hash("h1", "uA") == "new-sess"
|
||||
|
||||
# Old session should be evicted
|
||||
all_sessions = await registry.list_sessions()
|
||||
assert "old-sess" not in all_sessions
|
||||
assert "new-sess" in all_sessions
|
||||
@@ -0,0 +1,248 @@
|
||||
import pytest
|
||||
|
||||
from .test_helpers import DummyContext, DummyMCP
|
||||
|
||||
|
||||
def setup_console_tools():
|
||||
"""Setup console-related tools for testing."""
|
||||
mcp = DummyMCP()
|
||||
import services.tools.read_console
|
||||
from services.registry import get_registered_tools
|
||||
for tool_info in get_registered_tools():
|
||||
tool_name = tool_info['name']
|
||||
if any(keyword in tool_name for keyword in ['read_console', 'console']):
|
||||
mcp.tools[tool_name] = tool_info['func']
|
||||
return mcp.tools
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_console_full_default(monkeypatch):
|
||||
tools = setup_console_tools()
|
||||
read_console = tools["read_console"]
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_send(_cmd, params, **_kwargs):
|
||||
captured["params"] = params
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"lines": [{"level": "error", "message": "oops", "stacktrace": "trace", "time": "t"}]},
|
||||
}
|
||||
|
||||
# Patch the send_command_with_retry function in the tools module
|
||||
import services.tools.read_console
|
||||
monkeypatch.setattr(
|
||||
services.tools.read_console,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await read_console(ctx=DummyContext(), action="get", count=10)
|
||||
assert resp == {
|
||||
"success": True,
|
||||
"data": {"lines": [{"level": "error", "message": "oops", "time": "t"}]},
|
||||
}
|
||||
assert captured["params"]["count"] == 10
|
||||
assert captured["params"]["includeStacktrace"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_console_truncated(monkeypatch):
|
||||
tools = setup_console_tools()
|
||||
read_console = tools["read_console"]
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_send(_cmd, params, **_kwargs):
|
||||
captured["params"] = params
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"lines": [{"level": "error", "message": "oops", "stacktrace": "trace"}]},
|
||||
}
|
||||
|
||||
# Patch the send_command_with_retry function in the tools module
|
||||
import services.tools.read_console
|
||||
monkeypatch.setattr(
|
||||
services.tools.read_console,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await read_console(ctx=DummyContext(), action="get", count=10, include_stacktrace=False)
|
||||
assert resp == {"success": True, "data": {
|
||||
"lines": [{"level": "error", "message": "oops"}]}}
|
||||
assert captured["params"]["includeStacktrace"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_console_default_count(monkeypatch):
|
||||
"""Test that read_console defaults to count=10 when not specified."""
|
||||
tools = setup_console_tools()
|
||||
read_console = tools["read_console"]
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_send(_cmd, params, **_kwargs):
|
||||
captured["params"] = params
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"lines": [{"level": "error", "message": f"error {i}"} for i in range(15)]},
|
||||
}
|
||||
|
||||
# Patch the send_command_with_retry function in the tools module
|
||||
import services.tools.read_console
|
||||
monkeypatch.setattr(
|
||||
services.tools.read_console,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
# Call without specifying count - should default to 10
|
||||
resp = await read_console(ctx=DummyContext(), action="get")
|
||||
assert resp["success"] is True
|
||||
# Verify that the default count of 10 was used
|
||||
assert captured["params"]["count"] == 10
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_console_paging(monkeypatch):
|
||||
"""Test that read_console paging works with page_size and cursor."""
|
||||
tools = setup_console_tools()
|
||||
read_console = tools["read_console"]
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_send(_cmd, params, **_kwargs):
|
||||
captured["params"] = params
|
||||
# Simulate Unity returning paging info matching C# structure
|
||||
page_size = params.get("pageSize", 10)
|
||||
cursor = params.get("cursor", 0)
|
||||
# Simulate 25 total messages
|
||||
all_messages = [{"level": "error", "message": f"error {i}"} for i in range(25)]
|
||||
|
||||
# Return a page of results
|
||||
start = cursor
|
||||
end = min(start + page_size, len(all_messages))
|
||||
messages = all_messages[start:end]
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"items": messages,
|
||||
"cursor": cursor,
|
||||
"pageSize": page_size,
|
||||
"nextCursor": str(end) if end < len(all_messages) else None,
|
||||
"truncated": end < len(all_messages),
|
||||
"total": len(all_messages),
|
||||
},
|
||||
}
|
||||
|
||||
# Patch the send_command_with_retry function in the tools module
|
||||
import services.tools.read_console
|
||||
monkeypatch.setattr(
|
||||
services.tools.read_console,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
# First page - get first 5 entries
|
||||
resp = await read_console(ctx=DummyContext(), action="get", page_size=5, cursor=0)
|
||||
assert resp["success"] is True
|
||||
assert captured["params"]["pageSize"] == 5
|
||||
assert captured["params"]["cursor"] == 0
|
||||
assert len(resp["data"]["items"]) == 5
|
||||
assert resp["data"]["truncated"] is True
|
||||
assert resp["data"]["nextCursor"] == "5"
|
||||
assert resp["data"]["total"] == 25
|
||||
|
||||
# Second page - get next 5 entries
|
||||
resp = await read_console(ctx=DummyContext(), action="get", page_size=5, cursor=5)
|
||||
assert resp["success"] is True
|
||||
assert captured["params"]["cursor"] == 5
|
||||
assert len(resp["data"]["items"]) == 5
|
||||
assert resp["data"]["truncated"] is True
|
||||
assert resp["data"]["nextCursor"] == "10"
|
||||
|
||||
# Last page - get remaining entries
|
||||
resp = await read_console(ctx=DummyContext(), action="get", page_size=5, cursor=20)
|
||||
assert resp["success"] is True
|
||||
assert len(resp["data"]["items"]) == 5
|
||||
assert resp["data"]["truncated"] is False
|
||||
assert resp["data"]["nextCursor"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_console_types_json_string(monkeypatch):
|
||||
"""Test that read_console handles types parameter as JSON string (fixes issue #561)."""
|
||||
tools = setup_console_tools()
|
||||
read_console = tools["read_console"]
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_send_with_unity_instance(_send_fn, _unity_instance, _command_type, params, **_kwargs):
|
||||
captured["params"] = params
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"lines": [{"level": "error", "message": "test error"}]},
|
||||
}
|
||||
|
||||
import services.tools.read_console as read_console_mod
|
||||
monkeypatch.setattr(
|
||||
read_console_mod,
|
||||
"send_with_unity_instance",
|
||||
fake_send_with_unity_instance,
|
||||
)
|
||||
|
||||
# Test with types as JSON string (the problematic case from issue #561)
|
||||
resp = await read_console(ctx=DummyContext(), action="get", types='["error", "warning", "all"]')
|
||||
assert resp["success"] is True
|
||||
# Verify types was parsed correctly and sent as a list
|
||||
assert isinstance(captured["params"]["types"], list)
|
||||
assert captured["params"]["types"] == ["error", "warning", "all"]
|
||||
|
||||
# Test case normalization to lowercase
|
||||
captured.clear()
|
||||
resp = await read_console(ctx=DummyContext(), action="get", types='["ERROR", "Warning", "LOG"]')
|
||||
assert resp["success"] is True
|
||||
assert captured["params"]["types"] == ["error", "warning", "log"]
|
||||
|
||||
# Test with types as actual list (should still work)
|
||||
captured.clear()
|
||||
resp = await read_console(ctx=DummyContext(), action="get", types=["error", "warning"])
|
||||
assert resp["success"] is True
|
||||
assert isinstance(captured["params"]["types"], list)
|
||||
assert captured["params"]["types"] == ["error", "warning"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_console_types_validation(monkeypatch):
|
||||
"""Test that read_console validates types entries and rejects invalid values."""
|
||||
tools = setup_console_tools()
|
||||
read_console = tools["read_console"]
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_send_with_unity_instance(_send_fn, _unity_instance, _command_type, params, **_kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {"lines": []}}
|
||||
|
||||
import services.tools.read_console as read_console_mod
|
||||
monkeypatch.setattr(
|
||||
read_console_mod,
|
||||
"send_with_unity_instance",
|
||||
fake_send_with_unity_instance,
|
||||
)
|
||||
|
||||
# Invalid entry in list should return a clear error and not send.
|
||||
captured.clear()
|
||||
resp = await read_console(ctx=DummyContext(), action="get", types='["error", "nope"]')
|
||||
assert resp["success"] is False
|
||||
assert "invalid types entry" in resp["message"]
|
||||
assert captured == {}
|
||||
|
||||
# Non-string entry should return a clear error and not send.
|
||||
captured.clear()
|
||||
resp = await read_console(ctx=DummyContext(), action="get", types='[1, "error"]')
|
||||
assert resp["success"] is False
|
||||
assert "types entries must be strings" in resp["message"]
|
||||
assert captured == {}
|
||||
@@ -0,0 +1,8 @@
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from .test_helpers import DummyContext
|
||||
|
||||
|
||||
# Tests for resource_tools.py have been removed since the file was deleted
|
||||
# These tests were confusing LLMs that read resources
|
||||
@@ -0,0 +1,14 @@
|
||||
from services.registry import get_registered_tools
|
||||
|
||||
|
||||
def test_refresh_unity_tool_is_registered():
|
||||
"""
|
||||
Red test: we expect an explicit refresh tool to exist so callers can force an import/refresh/compile cycle.
|
||||
"""
|
||||
# Import the specific module to ensure it registers its decorator without disturbing global registry state.
|
||||
import services.tools.refresh_unity # noqa: F401
|
||||
|
||||
names = {t.get("name") for t in get_registered_tools()}
|
||||
assert "refresh_unity" in names
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import pytest
|
||||
|
||||
from models import MCPResponse
|
||||
from services.state.external_changes_scanner import external_changes_scanner
|
||||
from services.state.external_changes_scanner import ExternalChangesState
|
||||
|
||||
from .test_helpers import DummyContext
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_unity_recovers_from_retry_disconnect(monkeypatch):
|
||||
"""
|
||||
Option A: if Unity disconnects and the transport returns hint=retry, refresh_unity(wait_for_ready=true)
|
||||
should poll readiness and then return success + clear external dirty.
|
||||
"""
|
||||
from services.tools.refresh_unity import refresh_unity
|
||||
|
||||
ctx = DummyContext()
|
||||
await ctx.set_state("unity_instance", "UnityMCPTests@cc8756d4cce0805a")
|
||||
|
||||
# Seed dirty state
|
||||
inst = "UnityMCPTests@cc8756d4cce0805a"
|
||||
external_changes_scanner._states[inst] = ExternalChangesState(dirty=True, dirty_since_unix_ms=1)
|
||||
|
||||
async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs):
|
||||
if command_type == "refresh_unity":
|
||||
return {"success": False, "error": "disconnected", "hint": "retry"}
|
||||
elif command_type == "get_editor_state":
|
||||
return {"success": True, "data": {"advice": {"ready_for_tools": True}}}
|
||||
raise ValueError(f"Unexpected command: {command_type}")
|
||||
|
||||
import services.tools.refresh_unity as refresh_mod
|
||||
monkeypatch.setattr(refresh_mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance)
|
||||
|
||||
resp = await refresh_unity(ctx, wait_for_ready=True)
|
||||
payload = resp.model_dump() if hasattr(resp, "model_dump") else resp
|
||||
assert payload["success"] is True
|
||||
assert payload.get("data", {}).get("recovered_from_disconnect") is True
|
||||
|
||||
# Dirty should be cleared
|
||||
assert external_changes_scanner._states[inst].dirty is False
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Tests for _resolve_user_id_from_request in unity_transport.py."""
|
||||
|
||||
import sys
|
||||
import types
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.config import config
|
||||
from services.api_key_service import ApiKeyService, ValidationResult
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_api_key_singleton():
|
||||
ApiKeyService._instance = None
|
||||
yield
|
||||
ApiKeyService._instance = None
|
||||
|
||||
|
||||
class TestResolveUserIdFromRequest:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_when_not_remote_hosted(self, monkeypatch):
|
||||
monkeypatch.setattr(config, "http_remote_hosted", False)
|
||||
|
||||
from transport.unity_transport import _resolve_user_id_from_request
|
||||
|
||||
result = await _resolve_user_id_from_request()
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_when_service_not_initialized(self, monkeypatch):
|
||||
monkeypatch.setattr(config, "http_remote_hosted", True)
|
||||
# ApiKeyService._instance is None (from fixture)
|
||||
|
||||
from transport.unity_transport import _resolve_user_id_from_request
|
||||
|
||||
result = await _resolve_user_id_from_request()
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_user_id_for_valid_key(self, monkeypatch):
|
||||
monkeypatch.setattr(config, "http_remote_hosted", True)
|
||||
|
||||
svc = ApiKeyService(validation_url="https://auth.example.com/validate")
|
||||
svc.validate = AsyncMock(
|
||||
return_value=ValidationResult(valid=True, user_id="user-123")
|
||||
)
|
||||
|
||||
# Stub the fastmcp dependency that provides HTTP headers
|
||||
deps_mod = types.ModuleType("fastmcp.server.dependencies")
|
||||
deps_mod.get_http_headers = lambda include_all=False: {
|
||||
"x-api-key": "sk-valid"}
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "fastmcp.server.dependencies", deps_mod)
|
||||
|
||||
from transport.unity_transport import _resolve_user_id_from_request
|
||||
|
||||
result = await _resolve_user_id_from_request()
|
||||
assert result == "user-123"
|
||||
svc.validate.assert_called_once_with("sk-valid")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_for_invalid_key(self, monkeypatch):
|
||||
monkeypatch.setattr(config, "http_remote_hosted", True)
|
||||
|
||||
svc = ApiKeyService(validation_url="https://auth.example.com/validate")
|
||||
svc.validate = AsyncMock(
|
||||
return_value=ValidationResult(valid=False, error="bad key")
|
||||
)
|
||||
|
||||
deps_mod = types.ModuleType("fastmcp.server.dependencies")
|
||||
deps_mod.get_http_headers = lambda include_all=False: {
|
||||
"x-api-key": "sk-bad"}
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "fastmcp.server.dependencies", deps_mod)
|
||||
|
||||
from transport.unity_transport import _resolve_user_id_from_request
|
||||
|
||||
result = await _resolve_user_id_from_request()
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_on_exception(self, monkeypatch):
|
||||
monkeypatch.setattr(config, "http_remote_hosted", True)
|
||||
|
||||
svc = ApiKeyService(validation_url="https://auth.example.com/validate")
|
||||
svc.validate = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
|
||||
deps_mod = types.ModuleType("fastmcp.server.dependencies")
|
||||
deps_mod.get_http_headers = lambda include_all=False: {
|
||||
"x-api-key": "sk-err"}
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "fastmcp.server.dependencies", deps_mod)
|
||||
|
||||
from transport.unity_transport import _resolve_user_id_from_request
|
||||
|
||||
result = await _resolve_user_id_from_request()
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_when_no_api_key_header(self, monkeypatch):
|
||||
monkeypatch.setattr(config, "http_remote_hosted", True)
|
||||
|
||||
ApiKeyService(validation_url="https://auth.example.com/validate")
|
||||
|
||||
deps_mod = types.ModuleType("fastmcp.server.dependencies")
|
||||
deps_mod.get_http_headers = lambda include_all=False: {} # No x-api-key
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "fastmcp.server.dependencies", deps_mod)
|
||||
|
||||
from transport.unity_transport import _resolve_user_id_from_request
|
||||
|
||||
result = await _resolve_user_id_from_request()
|
||||
assert result is None
|
||||
@@ -0,0 +1,116 @@
|
||||
import pytest
|
||||
|
||||
from .test_helpers import DummyContext
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_tests_async_forwards_params(monkeypatch):
|
||||
from services.tools.run_tests import run_tests
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs):
|
||||
captured["command_type"] = command_type
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {"job_id": "abc123", "status": "running", "mode": "EditMode"}}
|
||||
|
||||
import services.tools.run_tests as mod
|
||||
monkeypatch.setattr(
|
||||
mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance)
|
||||
|
||||
resp = await run_tests(
|
||||
DummyContext(),
|
||||
mode="EditMode",
|
||||
test_names="MyNamespace.MyTests.TestA",
|
||||
include_details=True,
|
||||
)
|
||||
assert captured["command_type"] == "run_tests"
|
||||
assert captured["params"]["mode"] == "EditMode"
|
||||
assert captured["params"]["testNames"] == ["MyNamespace.MyTests.TestA"]
|
||||
assert captured["params"]["includeDetails"] is True
|
||||
assert resp.success is True
|
||||
assert resp.data is not None
|
||||
assert resp.data.job_id == "abc123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_tests_forwards_init_timeout(monkeypatch):
|
||||
from services.tools.run_tests import run_tests
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {"job_id": "abc123", "status": "running", "mode": "PlayMode"}}
|
||||
|
||||
import services.tools.run_tests as mod
|
||||
monkeypatch.setattr(
|
||||
mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance)
|
||||
|
||||
resp = await run_tests(
|
||||
DummyContext(),
|
||||
mode="PlayMode",
|
||||
init_timeout=120000,
|
||||
)
|
||||
assert captured["params"]["initTimeout"] == 120000
|
||||
assert resp.success is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_tests_omits_init_timeout_when_none(monkeypatch):
|
||||
from services.tools.run_tests import run_tests
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {"job_id": "abc123", "status": "running", "mode": "EditMode"}}
|
||||
|
||||
import services.tools.run_tests as mod
|
||||
monkeypatch.setattr(
|
||||
mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance)
|
||||
|
||||
resp = await run_tests(DummyContext(), mode="EditMode")
|
||||
assert "initTimeout" not in captured["params"]
|
||||
assert resp.success is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_tests_rejects_negative_init_timeout():
|
||||
from services.tools.run_tests import run_tests
|
||||
|
||||
resp = await run_tests(DummyContext(), mode="EditMode", init_timeout=-1)
|
||||
assert resp.success is False
|
||||
assert "init_timeout" in resp.error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_tests_rejects_zero_init_timeout():
|
||||
from services.tools.run_tests import run_tests
|
||||
|
||||
resp = await run_tests(DummyContext(), mode="EditMode", init_timeout=0)
|
||||
assert resp.success is False
|
||||
assert "init_timeout" in resp.error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_test_job_forwards_job_id(monkeypatch):
|
||||
from services.tools.run_tests import get_test_job
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs):
|
||||
captured["command_type"] = command_type
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {"job_id": params["job_id"], "status": "running", "mode": "EditMode"}}
|
||||
|
||||
import services.tools.run_tests as mod
|
||||
monkeypatch.setattr(
|
||||
mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance)
|
||||
|
||||
resp = await get_test_job(DummyContext(), job_id="job-1")
|
||||
assert captured["command_type"] == "get_test_job"
|
||||
assert captured["params"]["job_id"] == "job-1"
|
||||
assert resp.success is True
|
||||
assert resp.data is not None
|
||||
assert resp.data.job_id == "job-1"
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Tests for script_apply_edits.py local helper functions.
|
||||
|
||||
Focuses on _apply_edits_locally, _find_best_closing_brace_match,
|
||||
and _is_in_string_context — especially around C# string variants
|
||||
(verbatim, interpolated, raw) that can fool brace/anchor matching.
|
||||
"""
|
||||
import re
|
||||
import pytest
|
||||
|
||||
from services.tools.script_apply_edits import (
|
||||
_apply_edits_locally,
|
||||
_find_best_closing_brace_match,
|
||||
_find_best_anchor_match,
|
||||
_is_in_string_context,
|
||||
)
|
||||
|
||||
|
||||
# ── _is_in_string_context ────────────────────────────────────────────
|
||||
|
||||
class TestIsInStringContext:
|
||||
def test_plain_code_not_in_string(self):
|
||||
text = 'int x = 42;'
|
||||
assert not _is_in_string_context(text, 4)
|
||||
|
||||
def test_inside_regular_string(self):
|
||||
text = 'string s = "hello world";'
|
||||
# Position inside "hello world"
|
||||
pos = text.index("hello")
|
||||
assert _is_in_string_context(text, pos)
|
||||
|
||||
def test_inside_verbatim_string(self):
|
||||
text = 'string s = @"C:\\Users\\file";'
|
||||
pos = text.index("C:")
|
||||
assert _is_in_string_context(text, pos)
|
||||
|
||||
def test_inside_interpolated_string(self):
|
||||
text = 'string s = $"Value: {x}";'
|
||||
# The "Value" part is inside the string
|
||||
pos = text.index("Value")
|
||||
assert _is_in_string_context(text, pos)
|
||||
|
||||
def test_interpolation_hole_is_not_string(self):
|
||||
text = 'string s = $"Value: {x}";'
|
||||
# The x inside {x} is in an interpolation hole — it's code, not string
|
||||
brace_pos = text.index("{x}") + 1 # the 'x'
|
||||
assert not _is_in_string_context(text, brace_pos)
|
||||
|
||||
def test_inside_single_line_comment(self):
|
||||
text = 'int x = 1; // this is a comment'
|
||||
pos = text.index("this")
|
||||
assert _is_in_string_context(text, pos)
|
||||
|
||||
def test_inside_multi_line_comment(self):
|
||||
text = 'int x = 1; /* block { } */ int y = 2;'
|
||||
pos = text.index("block")
|
||||
assert _is_in_string_context(text, pos)
|
||||
|
||||
def test_after_comment_is_code(self):
|
||||
text = '// comment\nint x = 1;'
|
||||
pos = text.index("int")
|
||||
assert not _is_in_string_context(text, pos)
|
||||
|
||||
def test_verbatim_string_doubled_quotes(self):
|
||||
text = 'string s = @"He said ""hello""";'
|
||||
# The whole thing is one string ending at the final ";
|
||||
pos = text.index("hello")
|
||||
assert _is_in_string_context(text, pos)
|
||||
|
||||
def test_interpolated_verbatim_combined(self):
|
||||
text = 'string s = $@"Path: {dir}\\file";'
|
||||
# "Path" is inside the string
|
||||
pos = text.index("Path")
|
||||
assert _is_in_string_context(text, pos)
|
||||
|
||||
def test_raw_string_literal(self):
|
||||
text = 'string s = """\n{ }\n""";'
|
||||
pos = text.index("{ }")
|
||||
assert _is_in_string_context(text, pos)
|
||||
|
||||
def test_interpolated_raw_string_content(self):
|
||||
text = 'string s = $"""\n Hello {name}\n """;'
|
||||
# "Hello" is string content (non-code)
|
||||
pos = text.index("Hello")
|
||||
assert _is_in_string_context(text, pos)
|
||||
|
||||
def test_interpolated_raw_string_hole_is_code(self):
|
||||
text = 'string s = $"""\n Hello {name}\n """;'
|
||||
# "name" inside {name} is in an interpolation hole — code
|
||||
pos = text.index("name")
|
||||
assert not _is_in_string_context(text, pos)
|
||||
|
||||
def test_multi_dollar_raw_string_content(self):
|
||||
text = 'string s = $$"""\n {literal} {{interp}}\n """;'
|
||||
# {literal} has only 1 brace — it's literal string content
|
||||
pos = text.index("literal")
|
||||
assert _is_in_string_context(text, pos)
|
||||
|
||||
def test_multi_dollar_raw_string_hole_is_code(self):
|
||||
text = 'string s = $$"""\n {literal} {{interp}}\n """;'
|
||||
# {{interp}} has 2 braces matching $$ — it's an interpolation hole
|
||||
pos = text.index("interp")
|
||||
assert not _is_in_string_context(text, pos)
|
||||
|
||||
def test_interpolated_raw_string_closing(self):
|
||||
text = 'string s = $"""\n body\n """; int x = 1;'
|
||||
# "x" after the closing """ is code
|
||||
pos = text.index("x = 1")
|
||||
assert not _is_in_string_context(text, pos)
|
||||
|
||||
|
||||
# ── _find_best_closing_brace_match ───────────────────────────────────
|
||||
|
||||
class TestFindBestClosingBraceMatch:
|
||||
def test_skips_braces_in_interpolated_strings(self):
|
||||
"""Braces inside $"...{x}..." should not be scored as class-end."""
|
||||
code = (
|
||||
'public class Foo {\n'
|
||||
' void M() {\n'
|
||||
' string s = $"Score: {score}";\n'
|
||||
' }\n'
|
||||
'}\n'
|
||||
)
|
||||
pattern = r'^\s*}\s*$'
|
||||
matches = list(re.finditer(pattern, code, re.MULTILINE))
|
||||
# There should be matches for the method close and class close
|
||||
assert len(matches) >= 1
|
||||
best = _find_best_closing_brace_match(matches, code)
|
||||
# The best match should be the class-closing brace, not one inside a string
|
||||
assert best is not None
|
||||
line_num = code[:best.start()].count('\n')
|
||||
# Class close is the last "}" line
|
||||
assert line_num == 4 # 0-indexed, line 5 is "}"
|
||||
|
||||
def test_skips_braces_in_verbatim_strings(self):
|
||||
"""@"{ }" should not confuse the scorer."""
|
||||
code = (
|
||||
'public class Foo {\n'
|
||||
' string s = @"{ }";\n'
|
||||
'}\n'
|
||||
)
|
||||
pattern = r'^\s*}\s*$'
|
||||
matches = list(re.finditer(pattern, code, re.MULTILINE))
|
||||
best = _find_best_closing_brace_match(matches, code)
|
||||
assert best is not None
|
||||
|
||||
def test_prefers_class_brace_over_method_brace(self):
|
||||
"""Should pick class-closing } (depth 1) over method-closing } (depth 2)."""
|
||||
code = (
|
||||
'public class Foo : MonoBehaviour\n'
|
||||
'{\n'
|
||||
' private int score = 42;\n'
|
||||
'\n'
|
||||
' void Start()\n'
|
||||
' {\n'
|
||||
' Debug.Log($"Score: {score}");\n'
|
||||
' }\n'
|
||||
'\n'
|
||||
' void OnGUI()\n'
|
||||
' {\n'
|
||||
' GUI.Label(new Rect(10, 10, 200, 20), $"Score: {score}");\n'
|
||||
' }\n'
|
||||
'}\n'
|
||||
)
|
||||
pattern = r'^\s*}\s*$'
|
||||
matches = list(re.finditer(pattern, code, re.MULTILINE))
|
||||
# Should have 3 matches: Start close, OnGUI close, class close
|
||||
assert len(matches) == 3
|
||||
best = _find_best_closing_brace_match(matches, code)
|
||||
assert best is not None
|
||||
best_line = code[:best.start()].count('\n')
|
||||
# Class close is the last "}" — line 13 (0-indexed)
|
||||
assert best_line == 13
|
||||
|
||||
def test_skips_braces_in_interpolated_raw_strings(self):
|
||||
"""$\"\"\"{x}\"\"\" braces should not confuse the scorer."""
|
||||
code = (
|
||||
'public class Foo {\n'
|
||||
' string s = $"""\n'
|
||||
' { literal }\n'
|
||||
' {interp}\n'
|
||||
' """;\n'
|
||||
'}\n'
|
||||
)
|
||||
pattern = r'^\s*}\s*$'
|
||||
matches = list(re.finditer(pattern, code, re.MULTILINE))
|
||||
best = _find_best_closing_brace_match(matches, code)
|
||||
assert best is not None
|
||||
best_line = code[:best.start()].count('\n')
|
||||
assert best_line == 5 # class-closing brace
|
||||
|
||||
def test_closing_brace_scorer_with_interpolated_code(self):
|
||||
"""Realistic C# with multiple $"" strings should still find class-end."""
|
||||
code = (
|
||||
'using UnityEngine;\n'
|
||||
'public class HUD : MonoBehaviour {\n'
|
||||
' void OnGUI() {\n'
|
||||
' Debug.Log($"Score: {score}");\n'
|
||||
' Debug.Log($@"Path: {path}\\save");\n'
|
||||
' }\n'
|
||||
'}\n'
|
||||
)
|
||||
pattern = r'^\s*}\s*$'
|
||||
matches = list(re.finditer(pattern, code, re.MULTILINE))
|
||||
best = _find_best_closing_brace_match(matches, code)
|
||||
assert best is not None
|
||||
# Should pick the class-closing brace (last one)
|
||||
best_line = code[:best.start()].count('\n')
|
||||
assert best_line == 6 # 0-indexed
|
||||
|
||||
|
||||
# ── _apply_edits_locally regression guards ───────────────────────────
|
||||
|
||||
class TestApplyEditsLocally:
|
||||
@pytest.mark.asyncio
|
||||
async def test_replace_range_basic(self):
|
||||
original = "line1\nline2\nline3\n"
|
||||
edits = [{
|
||||
"op": "replace_range",
|
||||
"startLine": 2,
|
||||
"startCol": 1,
|
||||
"endLine": 2,
|
||||
"endCol": 6,
|
||||
"text": "REPLACED",
|
||||
}]
|
||||
result = await _apply_edits_locally(original, edits)
|
||||
assert "REPLACED" in result
|
||||
assert "line1" in result
|
||||
assert "line3" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepend_and_append(self):
|
||||
original = "middle\n"
|
||||
edits = [
|
||||
{"op": "prepend", "text": "top\n"},
|
||||
{"op": "append", "text": "bottom\n"},
|
||||
]
|
||||
result = await _apply_edits_locally(original, edits)
|
||||
assert result.startswith("top\n")
|
||||
assert "bottom" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regex_replace_near_interpolated_strings(self):
|
||||
"""regex_replace should work even when interpolated strings are in the code."""
|
||||
original = (
|
||||
'void M() {\n'
|
||||
' Debug.Log($"x={x}");\n'
|
||||
' int OLD = 1;\n'
|
||||
'}\n'
|
||||
)
|
||||
edits = [{
|
||||
"op": "regex_replace",
|
||||
"pattern": r"OLD",
|
||||
"replacement": "NEW",
|
||||
"text": "NEW",
|
||||
}]
|
||||
result = await _apply_edits_locally(original, edits)
|
||||
assert "NEW" in result
|
||||
assert "OLD" not in result
|
||||
|
||||
|
||||
# ── _find_best_anchor_match with string-aware filtering ──────────────
|
||||
|
||||
class TestAnchorMatchFiltering:
|
||||
def test_anchor_skips_braces_in_interpolated_strings(self):
|
||||
"""$"...{x}..." brace should not be picked as anchor match."""
|
||||
code = (
|
||||
'class Foo {\n'
|
||||
' string s = $"val: {x}";\n'
|
||||
' void M() { }\n'
|
||||
'}\n'
|
||||
)
|
||||
# Pattern looking for closing brace at end of line
|
||||
pattern = r'^\s*}\s*$'
|
||||
flags = re.MULTILINE
|
||||
match = _find_best_anchor_match(pattern, code, flags, prefer_last=True)
|
||||
assert match is not None
|
||||
# Should match the class-closing brace, not anything inside the string
|
||||
best_line = code[:match.start()].count('\n')
|
||||
assert best_line == 3 # 0-indexed, class close
|
||||
|
||||
def test_anchor_skips_braces_in_verbatim_strings(self):
|
||||
"""@"{ }" should not confuse anchor matching."""
|
||||
code = (
|
||||
'class Foo {\n'
|
||||
' string s = @"{ }";\n'
|
||||
'}\n'
|
||||
)
|
||||
pattern = r'^\s*}\s*$'
|
||||
flags = re.MULTILINE
|
||||
match = _find_best_anchor_match(pattern, code, flags, prefer_last=True)
|
||||
assert match is not None
|
||||
@@ -0,0 +1,176 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
|
||||
from .test_helpers import DummyContext, DummyMCP, setup_script_tools
|
||||
|
||||
|
||||
def setup_asset_tools():
|
||||
"""Setup asset-related tools for testing."""
|
||||
mcp = DummyMCP()
|
||||
import services.tools.manage_asset
|
||||
from services.registry import get_registered_tools
|
||||
for tool_info in get_registered_tools():
|
||||
tool_name = tool_info['name']
|
||||
if any(keyword in tool_name for keyword in ['asset', 'manage_asset']):
|
||||
mcp.tools[tool_name] = tool_info['func']
|
||||
return mcp.tools
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_text_edits_long_file(monkeypatch):
|
||||
tools = setup_script_tools()
|
||||
apply_edits = tools["apply_text_edits"]
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured["params"] = params
|
||||
return {"success": True}
|
||||
|
||||
# Patch the send_command_with_retry function at the module level where it's imported
|
||||
import transport.legacy.unity_connection
|
||||
monkeypatch.setattr(
|
||||
transport.legacy.unity_connection,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
|
||||
|
||||
edit = {"startLine": 1005, "startCol": 0,
|
||||
"endLine": 1005, "endCol": 5, "newText": "Hello"}
|
||||
ctx = DummyContext()
|
||||
resp = await apply_edits(ctx, "mcpforunity://path/Assets/Scripts/LongFile.cs", [edit])
|
||||
assert captured["cmd"] == "manage_script"
|
||||
assert captured["params"]["action"] == "apply_text_edits"
|
||||
assert captured["params"]["edits"][0]["startLine"] == 1005
|
||||
assert resp["success"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sequential_edits_use_precondition(monkeypatch):
|
||||
tools = setup_script_tools()
|
||||
apply_edits = tools["apply_text_edits"]
|
||||
calls = []
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
calls.append(params)
|
||||
return {"success": True, "sha256": f"hash{len(calls)}"}
|
||||
|
||||
# Patch the send_command_with_retry function at the module level where it's imported
|
||||
import transport.legacy.unity_connection
|
||||
monkeypatch.setattr(
|
||||
transport.legacy.unity_connection,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
|
||||
|
||||
edit1 = {"startLine": 1, "startCol": 0, "endLine": 1,
|
||||
"endCol": 0, "newText": "//header\n"}
|
||||
resp1 = await apply_edits(DummyContext(), "mcpforunity://path/Assets/Scripts/File.cs", [edit1])
|
||||
edit2 = {"startLine": 2, "startCol": 0, "endLine": 2,
|
||||
"endCol": 0, "newText": "//second\n"}
|
||||
resp2 = await apply_edits(
|
||||
DummyContext(),
|
||||
"mcpforunity://path/Assets/Scripts/File.cs",
|
||||
[edit2],
|
||||
precondition_sha256=resp1["sha256"],
|
||||
)
|
||||
|
||||
assert calls[1]["precondition_sha256"] == resp1["sha256"]
|
||||
assert resp2["sha256"] == "hash2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_text_edits_forwards_options(monkeypatch):
|
||||
tools = setup_script_tools()
|
||||
apply_edits = tools["apply_text_edits"]
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True}
|
||||
|
||||
# Patch the send_command_with_retry function at the module level where it's imported
|
||||
import transport.legacy.unity_connection
|
||||
monkeypatch.setattr(
|
||||
transport.legacy.unity_connection,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
|
||||
|
||||
opts = {"validate": "relaxed", "applyMode": "atomic", "refresh": "immediate"}
|
||||
await apply_edits(
|
||||
DummyContext(),
|
||||
"mcpforunity://path/Assets/Scripts/File.cs",
|
||||
[{"startLine": 1, "startCol": 1, "endLine": 1, "endCol": 1, "newText": "x"}],
|
||||
options=opts,
|
||||
)
|
||||
assert captured["params"].get("options") == opts
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_text_edits_defaults_atomic_for_multi_span(monkeypatch):
|
||||
tools = setup_script_tools()
|
||||
apply_edits = tools["apply_text_edits"]
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True}
|
||||
|
||||
# Patch the send_command_with_retry function at the module level where it's imported
|
||||
import transport.legacy.unity_connection
|
||||
monkeypatch.setattr(
|
||||
transport.legacy.unity_connection,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
|
||||
|
||||
edits = [
|
||||
{"startLine": 2, "startCol": 2, "endLine": 2, "endCol": 3, "newText": "A"},
|
||||
{"startLine": 3, "startCol": 2, "endLine": 3,
|
||||
"endCol": 2, "newText": "// tail\n"},
|
||||
]
|
||||
await apply_edits(
|
||||
DummyContext(),
|
||||
"mcpforunity://path/Assets/Scripts/File.cs",
|
||||
edits,
|
||||
precondition_sha256="x",
|
||||
)
|
||||
opts = captured["params"].get("options", {})
|
||||
assert opts.get("applyMode") == "atomic"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_asset_prefab_modify_request(monkeypatch):
|
||||
tools = setup_asset_tools()
|
||||
manage_asset = tools["manage_asset"]
|
||||
captured = {}
|
||||
|
||||
async def fake_async(cmd, params, loop=None):
|
||||
captured["cmd"] = cmd
|
||||
captured["params"] = params
|
||||
return {"success": True}
|
||||
|
||||
# Patch the async function in the tools module
|
||||
import services.tools.manage_asset as tools_manage_asset
|
||||
# Patch both at the module and at the function closure location
|
||||
monkeypatch.setattr(tools_manage_asset,
|
||||
"async_send_command_with_retry", fake_async)
|
||||
# Also patch the globals of the function object (handles dynamically loaded module alias)
|
||||
manage_asset.__globals__["async_send_command_with_retry"] = fake_async
|
||||
|
||||
resp = await manage_asset(
|
||||
DummyContext(),
|
||||
action="modify",
|
||||
path="Assets/Prefabs/Player.prefab",
|
||||
properties={"hp": 100},
|
||||
)
|
||||
assert captured["cmd"] == "manage_asset"
|
||||
assert captured["params"]["action"] == "modify"
|
||||
assert captured["params"]["path"] == "Assets/Prefabs/Player.prefab"
|
||||
assert captured["params"]["properties"] == {"hp": 100}
|
||||
assert resp["success"] is True
|
||||
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
Tests for stdio-mode custom tool discovery (GitHub issue #837).
|
||||
|
||||
Verifies that:
|
||||
1. sync_tool_visibility_from_unity registers custom tools when extended metadata is present
|
||||
2. Custom tools are skipped gracefully when metadata is missing (old Unity package)
|
||||
3. Reconnection flag triggers a background re-sync
|
||||
"""
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_unity_response(tools, include_extended=True):
|
||||
"""Build a fake get_tool_states response from Unity."""
|
||||
tool_list = []
|
||||
for t in tools:
|
||||
entry = {
|
||||
"name": t["name"],
|
||||
"group": t.get("group", "core"),
|
||||
"enabled": t.get("enabled", True),
|
||||
}
|
||||
if include_extended:
|
||||
entry.update({
|
||||
"description": t.get("description", f"Tool: {t['name']}"),
|
||||
"auto_register": t.get("auto_register", True),
|
||||
"is_built_in": t.get("is_built_in", True),
|
||||
"structured_output": t.get("structured_output", False),
|
||||
"requires_polling": t.get("requires_polling", False),
|
||||
"poll_action": t.get("poll_action", "status"),
|
||||
"max_poll_seconds": t.get("max_poll_seconds", 0),
|
||||
"parameters": t.get("parameters", []),
|
||||
})
|
||||
tool_list.append(entry)
|
||||
return {
|
||||
"data": {
|
||||
"tools": tool_list,
|
||||
"groups": [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
BUILTIN_TOOL = {
|
||||
"name": "manage_gameobject",
|
||||
"group": "core",
|
||||
"is_built_in": True,
|
||||
"description": "Manage GameObjects in the scene.",
|
||||
}
|
||||
|
||||
CUSTOM_TOOL = {
|
||||
"name": "test_ping",
|
||||
"group": "core",
|
||||
"is_built_in": False,
|
||||
"description": "Simple test tool that returns a pong.",
|
||||
"parameters": [
|
||||
{"name": "message", "description": "Message to echo", "type": "string", "required": False, "default_value": "pong"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sync_tool_visibility_from_unity — custom tool registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_registers_custom_tools():
|
||||
"""Custom (non-built-in) tools should be registered via CustomToolService."""
|
||||
response = _make_unity_response([BUILTIN_TOOL, CUSTOM_TOOL])
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service.register_global_tools = MagicMock()
|
||||
|
||||
with patch(
|
||||
"transport.legacy.unity_connection.async_send_command_with_retry",
|
||||
new_callable=AsyncMock,
|
||||
return_value=response,
|
||||
), patch(
|
||||
"transport.plugin_hub.PluginHub._sync_server_tool_visibility",
|
||||
), patch(
|
||||
"transport.plugin_hub.PluginHub._notify_mcp_tool_list_changed",
|
||||
new_callable=AsyncMock,
|
||||
), patch(
|
||||
"services.custom_tool_service.CustomToolService.get_instance",
|
||||
return_value=mock_service,
|
||||
):
|
||||
from services.tools import sync_tool_visibility_from_unity
|
||||
result = await sync_tool_visibility_from_unity(notify=False)
|
||||
|
||||
assert result["synced"] is True
|
||||
assert result["custom_tool_count"] == 1
|
||||
|
||||
# Verify register_global_tools was called with the custom tool
|
||||
mock_service.register_global_tools.assert_called_once()
|
||||
registered = mock_service.register_global_tools.call_args[0][0]
|
||||
assert len(registered) == 1
|
||||
assert registered[0].name == "test_ping"
|
||||
assert registered[0].description == "Simple test tool that returns a pong."
|
||||
assert len(registered[0].parameters) == 1
|
||||
assert registered[0].parameters[0].name == "message"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_skips_builtin_tools():
|
||||
"""Built-in tools should NOT be passed to register_global_tools."""
|
||||
response = _make_unity_response([BUILTIN_TOOL])
|
||||
|
||||
with patch(
|
||||
"transport.legacy.unity_connection.async_send_command_with_retry",
|
||||
new_callable=AsyncMock,
|
||||
return_value=response,
|
||||
), patch(
|
||||
"transport.plugin_hub.PluginHub._sync_server_tool_visibility",
|
||||
), patch(
|
||||
"transport.plugin_hub.PluginHub._notify_mcp_tool_list_changed",
|
||||
new_callable=AsyncMock,
|
||||
), patch(
|
||||
"services.custom_tool_service.CustomToolService.get_instance",
|
||||
) as mock_get_instance:
|
||||
from services.tools import sync_tool_visibility_from_unity
|
||||
result = await sync_tool_visibility_from_unity(notify=False)
|
||||
|
||||
assert result["synced"] is True
|
||||
assert result["custom_tool_count"] == 0
|
||||
# No custom tools → register_global_tools should NOT be called
|
||||
mock_get_instance.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_skips_when_no_extended_metadata():
|
||||
"""When Unity returns old-format data (no is_built_in), skip custom tool registration."""
|
||||
response = _make_unity_response([BUILTIN_TOOL, CUSTOM_TOOL], include_extended=False)
|
||||
|
||||
with patch(
|
||||
"transport.legacy.unity_connection.async_send_command_with_retry",
|
||||
new_callable=AsyncMock,
|
||||
return_value=response,
|
||||
), patch(
|
||||
"transport.plugin_hub.PluginHub._sync_server_tool_visibility",
|
||||
), patch(
|
||||
"transport.plugin_hub.PluginHub._notify_mcp_tool_list_changed",
|
||||
new_callable=AsyncMock,
|
||||
), patch(
|
||||
"services.custom_tool_service.CustomToolService.get_instance",
|
||||
) as mock_get_instance:
|
||||
from services.tools import sync_tool_visibility_from_unity
|
||||
result = await sync_tool_visibility_from_unity(notify=False)
|
||||
|
||||
assert result["synced"] is True
|
||||
assert result["custom_tool_count"] == 0
|
||||
mock_get_instance.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_handles_custom_tool_service_not_initialized():
|
||||
"""If CustomToolService isn't initialized yet, skip gracefully (no crash)."""
|
||||
response = _make_unity_response([CUSTOM_TOOL])
|
||||
|
||||
with patch(
|
||||
"transport.legacy.unity_connection.async_send_command_with_retry",
|
||||
new_callable=AsyncMock,
|
||||
return_value=response,
|
||||
), patch(
|
||||
"transport.plugin_hub.PluginHub._sync_server_tool_visibility",
|
||||
), patch(
|
||||
"transport.plugin_hub.PluginHub._notify_mcp_tool_list_changed",
|
||||
new_callable=AsyncMock,
|
||||
), patch(
|
||||
"services.custom_tool_service.CustomToolService.get_instance",
|
||||
side_effect=RuntimeError("not initialized"),
|
||||
):
|
||||
from services.tools import sync_tool_visibility_from_unity
|
||||
result = await sync_tool_visibility_from_unity(notify=False)
|
||||
|
||||
# Should succeed overall even though custom tool registration failed
|
||||
assert result["synced"] is True
|
||||
assert result["custom_tool_count"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reconnection re-sync trigger
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconnection_flag_triggers_resync():
|
||||
"""After reconnection, async_send_command_with_retry should schedule a re-sync."""
|
||||
mock_conn = MagicMock()
|
||||
mock_conn._needs_tool_resync = True
|
||||
mock_conn.instance_id = None
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_pool.get_connection.return_value = mock_conn
|
||||
|
||||
with patch(
|
||||
"transport.legacy.unity_connection.send_command_with_retry",
|
||||
return_value={"success": True, "message": "ok"},
|
||||
), patch(
|
||||
"transport.legacy.unity_connection.get_unity_connection_pool",
|
||||
return_value=mock_pool,
|
||||
), patch(
|
||||
"transport.legacy.unity_connection._resync_tools_after_reconnect",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_resync:
|
||||
from transport.legacy.unity_connection import async_send_command_with_retry
|
||||
result = await async_send_command_with_retry("manage_gameobject", {"action": "list"})
|
||||
|
||||
# ensure_future schedules on the event loop; give it a tick to run
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert result["success"] is True
|
||||
# Flag should be cleared
|
||||
assert mock_conn._needs_tool_resync is False
|
||||
# Re-sync should have been scheduled
|
||||
mock_resync.assert_awaited_once_with(None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_resync_for_get_tool_states():
|
||||
"""get_tool_states itself should NOT trigger re-sync (avoids recursion)."""
|
||||
mock_conn = MagicMock()
|
||||
mock_conn._needs_tool_resync = True
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_pool.get_connection.return_value = mock_conn
|
||||
|
||||
with patch(
|
||||
"transport.legacy.unity_connection.send_command_with_retry",
|
||||
return_value={"data": {"tools": []}},
|
||||
), patch(
|
||||
"transport.legacy.unity_connection.get_unity_connection_pool",
|
||||
return_value=mock_pool,
|
||||
), patch(
|
||||
"transport.legacy.unity_connection._resync_tools_after_reconnect",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_resync:
|
||||
from transport.legacy.unity_connection import async_send_command_with_retry
|
||||
await async_send_command_with_retry("get_tool_states", {})
|
||||
|
||||
# Flag should be cleared, but no re-sync task should be scheduled
|
||||
assert mock_conn._needs_tool_resync is False
|
||||
mock_resync.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_resync_when_not_reconnected():
|
||||
"""When _needs_tool_resync is False, no re-sync should be scheduled."""
|
||||
mock_conn = MagicMock()
|
||||
mock_conn._needs_tool_resync = False
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_pool.get_connection.return_value = mock_conn
|
||||
|
||||
with patch(
|
||||
"transport.legacy.unity_connection.send_command_with_retry",
|
||||
return_value={"success": True},
|
||||
), patch(
|
||||
"transport.legacy.unity_connection.get_unity_connection_pool",
|
||||
return_value=mock_pool,
|
||||
), patch(
|
||||
"transport.legacy.unity_connection._resync_tools_after_reconnect",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_resync:
|
||||
from transport.legacy.unity_connection import async_send_command_with_retry
|
||||
await async_send_command_with_retry("manage_gameobject", {"action": "list"})
|
||||
|
||||
mock_resync.assert_not_awaited()
|
||||
@@ -0,0 +1,62 @@
|
||||
import os
|
||||
import importlib
|
||||
import pytest
|
||||
|
||||
|
||||
def test_endpoint_rejects_non_http(tmp_path, monkeypatch):
|
||||
# Point data dir to temp to avoid touching real files
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("UNITY_MCP_TELEMETRY_ENDPOINT", "file:///etc/passwd")
|
||||
|
||||
# Import the telemetry module
|
||||
telemetry = importlib.import_module("core.telemetry")
|
||||
importlib.reload(telemetry)
|
||||
|
||||
tc = telemetry.TelemetryCollector()
|
||||
# Should have fallen back to default endpoint
|
||||
assert tc.config.endpoint == tc.config.default_endpoint
|
||||
|
||||
|
||||
def test_config_preferred_then_env_override(tmp_path, monkeypatch):
|
||||
# Simulate config telemetry endpoint
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("UNITY_MCP_TELEMETRY_ENDPOINT", raising=False)
|
||||
|
||||
# Patch config.telemetry_endpoint via import mocking
|
||||
cfg_mod = importlib.import_module("src.core.config")
|
||||
old_endpoint = cfg_mod.config.telemetry_endpoint
|
||||
cfg_mod.config.telemetry_endpoint = "https://example.com/telemetry"
|
||||
try:
|
||||
telemetry = importlib.import_module("core.telemetry")
|
||||
importlib.reload(telemetry)
|
||||
tc = telemetry.TelemetryCollector()
|
||||
# When no env override is set, config endpoint is preferred
|
||||
assert tc.config.endpoint == "https://example.com/telemetry"
|
||||
|
||||
# Env should override config
|
||||
monkeypatch.setenv("UNITY_MCP_TELEMETRY_ENDPOINT",
|
||||
"https://override.example/ep")
|
||||
importlib.reload(telemetry)
|
||||
tc2 = telemetry.TelemetryCollector()
|
||||
assert tc2.config.endpoint == "https://override.example/ep"
|
||||
finally:
|
||||
cfg_mod.config.telemetry_endpoint = old_endpoint
|
||||
|
||||
|
||||
def test_uuid_preserved_on_malformed_milestones(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
|
||||
|
||||
# Import the telemetry module
|
||||
telemetry = importlib.import_module("core.telemetry")
|
||||
importlib.reload(telemetry)
|
||||
|
||||
tc1 = telemetry.TelemetryCollector()
|
||||
first_uuid = tc1._customer_uuid
|
||||
|
||||
# Write malformed milestones
|
||||
tc1.config.milestones_file.write_text("{not-json}", encoding="utf-8")
|
||||
|
||||
# Reload collector; UUID should remain same despite bad milestones
|
||||
importlib.reload(telemetry)
|
||||
tc2 = telemetry.TelemetryCollector()
|
||||
assert tc2._customer_uuid == first_uuid
|
||||
@@ -0,0 +1,65 @@
|
||||
import logging
|
||||
import types
|
||||
import threading
|
||||
import time
|
||||
import queue as q
|
||||
|
||||
import core.telemetry as telemetry
|
||||
|
||||
|
||||
def test_telemetry_queue_backpressure_and_single_worker(monkeypatch, caplog):
|
||||
# Directly attach caplog's handler to the telemetry logger so that
|
||||
# earlier tests calling logging.basicConfig() can't steal the records
|
||||
# via a root handler before caplog sees them.
|
||||
tel_logger = logging.getLogger("unity-mcp-telemetry")
|
||||
tel_logger.addHandler(caplog.handler)
|
||||
try:
|
||||
caplog.set_level("DEBUG", logger="unity-mcp-telemetry")
|
||||
|
||||
collector = telemetry.TelemetryCollector()
|
||||
# Force-enable telemetry regardless of env settings from conftest
|
||||
collector.config.enabled = True
|
||||
|
||||
# Wake existing worker once so it observes the new queue on the next loop
|
||||
collector.record(telemetry.RecordType.TOOL_EXECUTION, {"i": -1})
|
||||
# Replace queue with tiny one to trigger backpressure quickly
|
||||
small_q = q.Queue(maxsize=2)
|
||||
collector._queue = small_q
|
||||
# Give the worker time to finish processing the seeded item and
|
||||
# re-enter _queue.get() on the new small queue
|
||||
time.sleep(0.2)
|
||||
|
||||
# Make sends slow to build backlog and exercise worker
|
||||
def slow_send(self, rec):
|
||||
time.sleep(0.05)
|
||||
|
||||
collector._send_telemetry = types.MethodType(slow_send, collector)
|
||||
|
||||
# Fire many events quickly; record() should not block even when queue fills
|
||||
start = time.perf_counter()
|
||||
for i in range(50):
|
||||
collector.record(telemetry.RecordType.TOOL_EXECUTION, {"i": i})
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000.0
|
||||
|
||||
# Should be fast despite backpressure (non-blocking enqueue or drop)
|
||||
# Threshold set high (500ms) to accommodate CI environments with variable load.
|
||||
# The key assertion is that 50 record() calls don't block on a full queue;
|
||||
# even under heavy CI load, non-blocking calls should complete well under 500ms.
|
||||
assert elapsed_ms < 500.0, f"Took {elapsed_ms:.1f}ms (expected <500ms for non-blocking calls)"
|
||||
|
||||
# Allow worker to process some
|
||||
time.sleep(0.3)
|
||||
|
||||
# Verify drops were logged (queue full backpressure)
|
||||
dropped_logs = [
|
||||
m for m in caplog.messages if "Telemetry queue full; dropping" in m]
|
||||
assert len(dropped_logs) >= 1
|
||||
|
||||
# Ensure only one worker thread exists and is alive
|
||||
assert collector._worker.is_alive()
|
||||
worker_threads = [
|
||||
t for t in threading.enumerate() if t is collector._worker]
|
||||
assert len(worker_threads) == 1
|
||||
finally:
|
||||
if caplog.handler in tel_logger.handlers:
|
||||
tel_logger.removeHandler(caplog.handler)
|
||||
@@ -0,0 +1,116 @@
|
||||
import importlib
|
||||
|
||||
|
||||
def _get_decorator_module():
|
||||
# Import the telemetry_decorator module from the MCP for Unity server src
|
||||
import sys
|
||||
import pathlib
|
||||
import types
|
||||
# Tests can now import directly from parent package
|
||||
# Remove any previously stubbed module to force real import
|
||||
sys.modules.pop("core.telemetry_decorator", None)
|
||||
# Preload a minimal telemetry stub to satisfy telemetry_decorator imports
|
||||
tel = types.ModuleType("core.telemetry")
|
||||
|
||||
class _MilestoneType:
|
||||
FIRST_TOOL_USAGE = "first_tool_usage"
|
||||
FIRST_SCRIPT_CREATION = "first_script_creation"
|
||||
FIRST_SCENE_MODIFICATION = "first_scene_modification"
|
||||
tel.MilestoneType = _MilestoneType
|
||||
|
||||
def _noop(*a, **k):
|
||||
pass
|
||||
tel.record_resource_usage = _noop
|
||||
tel.record_tool_usage = _noop
|
||||
tel.record_milestone = _noop
|
||||
tel.get_package_version = lambda: "0.0.0"
|
||||
sys.modules.setdefault("core.telemetry", tel)
|
||||
mod = importlib.import_module("core.telemetry_decorator")
|
||||
# Drop stub to avoid bleed-through into other tests
|
||||
sys.modules.pop("core.telemetry", None)
|
||||
# Ensure attributes exist for monkeypatch targets even if not exported
|
||||
if not hasattr(mod, "record_tool_usage"):
|
||||
def _noop_record_tool_usage(*a, **k):
|
||||
pass
|
||||
mod.record_tool_usage = _noop_record_tool_usage
|
||||
if not hasattr(mod, "record_milestone"):
|
||||
def _noop_record_milestone(*a, **k):
|
||||
pass
|
||||
mod.record_milestone = _noop_record_milestone
|
||||
if not hasattr(mod, "_decorator_log_count"):
|
||||
mod._decorator_log_count = 0
|
||||
return mod
|
||||
|
||||
|
||||
def test_subaction_extracted_from_keyword(monkeypatch):
|
||||
td = _get_decorator_module()
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_record_tool_usage(tool_name, success, duration_ms, error, sub_action=None):
|
||||
captured["tool_name"] = tool_name
|
||||
captured["success"] = success
|
||||
captured["error"] = error
|
||||
captured["sub_action"] = sub_action
|
||||
|
||||
# Silence milestones/logging in test
|
||||
monkeypatch.setattr(td, "record_tool_usage", fake_record_tool_usage)
|
||||
monkeypatch.setattr(td, "record_milestone", lambda *a, **k: None)
|
||||
monkeypatch.setattr(td, "_decorator_log_count", 999)
|
||||
|
||||
def dummy_tool(ctx, action: str, name: str = ""):
|
||||
return {"success": True, "name": name}
|
||||
|
||||
wrapped = td.telemetry_tool("manage_scene")(dummy_tool)
|
||||
|
||||
resp = wrapped(None, action="get_hierarchy", name="Sample")
|
||||
assert resp["success"] is True
|
||||
assert captured["tool_name"] == "manage_scene"
|
||||
assert captured["success"] is True
|
||||
assert captured["error"] is None
|
||||
assert captured["sub_action"] == "get_hierarchy"
|
||||
|
||||
|
||||
def test_subaction_extracted_from_positionals(monkeypatch):
|
||||
td = _get_decorator_module()
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_record_tool_usage(tool_name, success, duration_ms, error, sub_action=None):
|
||||
captured["tool_name"] = tool_name
|
||||
captured["sub_action"] = sub_action
|
||||
|
||||
monkeypatch.setattr(td, "record_tool_usage", fake_record_tool_usage)
|
||||
monkeypatch.setattr(td, "record_milestone", lambda *a, **k: None)
|
||||
monkeypatch.setattr(td, "_decorator_log_count", 999)
|
||||
|
||||
def dummy_tool(ctx, action: str, name: str = ""):
|
||||
return True
|
||||
|
||||
wrapped = td.telemetry_tool("manage_scene")(dummy_tool)
|
||||
|
||||
_ = wrapped(None, "save", "MyScene")
|
||||
assert captured["tool_name"] == "manage_scene"
|
||||
assert captured["sub_action"] == "save"
|
||||
|
||||
|
||||
def test_subaction_none_when_not_present(monkeypatch):
|
||||
td = _get_decorator_module()
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_record_tool_usage(tool_name, success, duration_ms, error, sub_action=None):
|
||||
captured["tool_name"] = tool_name
|
||||
captured["sub_action"] = sub_action
|
||||
|
||||
monkeypatch.setattr(td, "record_tool_usage", fake_record_tool_usage)
|
||||
monkeypatch.setattr(td, "record_milestone", lambda *a, **k: None)
|
||||
monkeypatch.setattr(td, "_decorator_log_count", 999)
|
||||
|
||||
def dummy_tool_without_action(ctx, name: str):
|
||||
return 123
|
||||
|
||||
wrapped = td.telemetry_tool("apply_text_edits")(dummy_tool_without_action)
|
||||
_ = wrapped(None, name="X")
|
||||
assert captured["tool_name"] == "apply_text_edits"
|
||||
assert captured["sub_action"] is None
|
||||
@@ -0,0 +1,41 @@
|
||||
import inspect
|
||||
|
||||
# pyright: reportMissingImports=false
|
||||
|
||||
|
||||
def test_manage_scene_signature_includes_paging_params():
|
||||
import services.tools.manage_scene as mod
|
||||
|
||||
sig = inspect.signature(mod.manage_scene)
|
||||
names = list(sig.parameters.keys())
|
||||
|
||||
# get_hierarchy paging/safety params
|
||||
assert "parent" in names
|
||||
assert "page_size" in names
|
||||
assert "cursor" in names
|
||||
assert "max_nodes" in names
|
||||
assert "max_depth" in names
|
||||
assert "max_children_per_node" in names
|
||||
assert "include_transform" in names
|
||||
|
||||
|
||||
def test_manage_gameobject_signature_excludes_vestigial_params():
|
||||
"""Paging/find/component params were removed — they belong to separate tools."""
|
||||
import services.tools.manage_gameobject as mod
|
||||
|
||||
sig = inspect.signature(mod.manage_gameobject)
|
||||
names = list(sig.parameters.keys())
|
||||
|
||||
# These params now live on find_gameobjects / manage_components / gameobject_components resource
|
||||
assert "page_size" not in names
|
||||
assert "cursor" not in names
|
||||
assert "max_components" not in names
|
||||
assert "include_properties" not in names
|
||||
assert "search_term" not in names
|
||||
assert "find_all" not in names
|
||||
assert "search_in_children" not in names
|
||||
assert "search_inactive" not in names
|
||||
assert "component_name" not in names
|
||||
assert "includeNonPublicSerialized" not in names
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
from transport.legacy.unity_connection import UnityConnection
|
||||
import sys
|
||||
import json
|
||||
import struct
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import select
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# locate server src dynamically to avoid hardcoded layout assumptions
|
||||
ROOT = Path(__file__).resolve().parents[2] # tests/integration -> tests -> Server
|
||||
candidates = [
|
||||
ROOT / "src",
|
||||
]
|
||||
SRC = next((p for p in candidates if p.exists()), None)
|
||||
if SRC is None:
|
||||
searched = "\n".join(str(p) for p in candidates)
|
||||
pytest.skip(
|
||||
"MCP for Unity server source not found. Tried:\n" + searched,
|
||||
allow_module_level=True,
|
||||
)
|
||||
# Tests can now import directly from parent package
|
||||
|
||||
|
||||
def start_dummy_server(greeting: bytes, respond_ping: bool = False):
|
||||
"""Start a minimal TCP server for handshake tests."""
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
sock.listen(1)
|
||||
port = sock.getsockname()[1]
|
||||
ready = threading.Event()
|
||||
|
||||
def _run():
|
||||
ready.set()
|
||||
conn, _ = sock.accept()
|
||||
conn.settimeout(1.0)
|
||||
if greeting:
|
||||
conn.sendall(greeting)
|
||||
if respond_ping:
|
||||
try:
|
||||
# Read exactly n bytes helper
|
||||
def _read_exact(n: int) -> bytes:
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
chunk = conn.recv(n - len(buf))
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
return buf
|
||||
|
||||
header = _read_exact(8)
|
||||
if len(header) == 8:
|
||||
length = struct.unpack(">Q", header)[0]
|
||||
payload = _read_exact(length)
|
||||
if payload == b'{"type":"ping"}':
|
||||
resp = b'{"type":"pong"}'
|
||||
conn.sendall(struct.pack(">Q", len(resp)) + resp)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.1)
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
ready.wait()
|
||||
return port
|
||||
|
||||
|
||||
def start_handshake_enforcing_server():
|
||||
"""Server that drops connection if client sends data before handshake."""
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
sock.listen(1)
|
||||
port = sock.getsockname()[1]
|
||||
ready = threading.Event()
|
||||
|
||||
def _run():
|
||||
ready.set()
|
||||
conn, _ = sock.accept()
|
||||
# If client sends any data before greeting, disconnect (poll briefly)
|
||||
try:
|
||||
conn.setblocking(False)
|
||||
deadline = time.time() + 0.15 # short, reduces race with legitimate clients
|
||||
while time.time() < deadline:
|
||||
r, _, _ = select.select([conn], [], [], 0.01)
|
||||
if r:
|
||||
try:
|
||||
peek = conn.recv(1, socket.MSG_PEEK)
|
||||
except BlockingIOError:
|
||||
peek = b""
|
||||
except Exception:
|
||||
peek = b"\x00"
|
||||
if peek:
|
||||
conn.close()
|
||||
sock.close()
|
||||
return
|
||||
# No pre-handshake data observed; send greeting
|
||||
conn.setblocking(True)
|
||||
conn.sendall(b"MCP/0.1 FRAMING=1\n")
|
||||
time.sleep(0.1)
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
ready.wait()
|
||||
return port
|
||||
|
||||
|
||||
def test_handshake_requires_framing():
|
||||
port = start_dummy_server(b"MCP/0.1\n")
|
||||
conn = UnityConnection(host="127.0.0.1", port=port)
|
||||
assert conn.connect() is False
|
||||
assert conn.sock is None
|
||||
|
||||
|
||||
def test_small_frame_ping_pong():
|
||||
port = start_dummy_server(b"MCP/0.1 FRAMING=1\n", respond_ping=True)
|
||||
conn = UnityConnection(host="127.0.0.1", port=port)
|
||||
try:
|
||||
assert conn.connect() is True
|
||||
assert conn.use_framing is True
|
||||
payload = b'{"type":"ping"}'
|
||||
conn.sock.sendall(struct.pack(">Q", len(payload)) + payload)
|
||||
resp = conn.receive_full_response(conn.sock)
|
||||
assert json.loads(resp.decode("utf-8"))["type"] == "pong"
|
||||
finally:
|
||||
conn.disconnect()
|
||||
|
||||
|
||||
def test_unframed_data_disconnect():
|
||||
port = start_handshake_enforcing_server()
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.connect(("127.0.0.1", port))
|
||||
sock.settimeout(1.0)
|
||||
sock.sendall(b"BAD")
|
||||
time.sleep(0.4)
|
||||
try:
|
||||
data = sock.recv(1024)
|
||||
assert data == b""
|
||||
except (ConnectionResetError, ConnectionAbortedError):
|
||||
# Some platforms raise instead of returning empty bytes when the
|
||||
# server closes the connection after detecting pre-handshake data.
|
||||
pass
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def test_zero_length_payload_heartbeat():
|
||||
# Server that sends handshake and a zero-length heartbeat frame followed by a pong payload
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
sock.listen(1)
|
||||
port = sock.getsockname()[1]
|
||||
ready = threading.Event()
|
||||
|
||||
def _run():
|
||||
ready.set()
|
||||
conn, _ = sock.accept()
|
||||
try:
|
||||
conn.sendall(b"MCP/0.1 FRAMING=1\n")
|
||||
time.sleep(0.02)
|
||||
# Heartbeat frame (length=0)
|
||||
conn.sendall(struct.pack(">Q", 0))
|
||||
time.sleep(0.02)
|
||||
# Real payload frame
|
||||
payload = b'{"type":"pong"}'
|
||||
conn.sendall(struct.pack(">Q", len(payload)) + payload)
|
||||
time.sleep(0.02)
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
sock.close()
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
ready.wait()
|
||||
|
||||
conn = UnityConnection(host="127.0.0.1", port=port)
|
||||
try:
|
||||
assert conn.connect() is True
|
||||
# Receive should skip heartbeat and return the pong payload (or empty if only heartbeats seen)
|
||||
resp = conn.receive_full_response(conn.sock)
|
||||
assert resp in (b'{"type":"pong"}', b"")
|
||||
finally:
|
||||
conn.disconnect()
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""End-to-end-ish smoke tests for transport routing paths."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from core.config import config
|
||||
from transport import unity_transport
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_local_smoke(monkeypatch):
|
||||
"""HTTP local should route through PluginHub without requiring user_id."""
|
||||
monkeypatch.setattr(config, "transport_mode", "http")
|
||||
monkeypatch.setattr(config, "http_remote_hosted", False)
|
||||
|
||||
async def fake_send_command_for_instance(_instance, _command, _params, **_kwargs):
|
||||
return {"status": "success", "result": {"message": "ok", "data": {"via": "http"}}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
unity_transport.PluginHub,
|
||||
"send_command_for_instance",
|
||||
fake_send_command_for_instance,
|
||||
)
|
||||
|
||||
async def _unused_send_fn(*_args, **_kwargs):
|
||||
raise AssertionError("send_fn should not be used in HTTP mode")
|
||||
|
||||
result = await unity_transport.send_with_unity_instance(
|
||||
_unused_send_fn, None, "ping", {}
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["data"] == {"via": "http"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_remote_smoke(monkeypatch):
|
||||
"""HTTP remote-hosted should route through PluginHub when user_id is provided."""
|
||||
monkeypatch.setattr(config, "transport_mode", "http")
|
||||
monkeypatch.setattr(config, "http_remote_hosted", True)
|
||||
|
||||
async def fake_send_command_for_instance(_instance, _command, _params, **_kwargs):
|
||||
return {"status": "success", "result": {"data": {"via": "http-remote"}}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
unity_transport.PluginHub,
|
||||
"send_command_for_instance",
|
||||
fake_send_command_for_instance,
|
||||
)
|
||||
|
||||
async def _unused_send_fn(*_args, **_kwargs):
|
||||
raise AssertionError("send_fn should not be used in HTTP mode")
|
||||
|
||||
result = await unity_transport.send_with_unity_instance(
|
||||
_unused_send_fn, None, "ping", {}, user_id="user-1"
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["data"] == {"via": "http-remote"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_forwards_retry_on_reload(monkeypatch):
|
||||
"""HTTP transport should pass retry_on_reload through to PluginHub."""
|
||||
monkeypatch.setattr(config, "transport_mode", "http")
|
||||
monkeypatch.setattr(config, "http_remote_hosted", False)
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_send_command_for_instance(_instance, _command, _params, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return {"status": "success", "result": {"data": {"via": "http"}}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
unity_transport.PluginHub,
|
||||
"send_command_for_instance",
|
||||
fake_send_command_for_instance,
|
||||
)
|
||||
|
||||
async def _unused_send_fn(*_args, **_kwargs):
|
||||
raise AssertionError("send_fn should not be used in HTTP mode")
|
||||
|
||||
result = await unity_transport.send_with_unity_instance(
|
||||
_unused_send_fn,
|
||||
None,
|
||||
"manage_script",
|
||||
{"action": "edit"},
|
||||
retry_on_reload=False,
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert captured.get("retry_on_reload") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stdio_smoke(monkeypatch):
|
||||
"""Stdio transport should call the legacy send fn with instance_id."""
|
||||
monkeypatch.setattr(config, "transport_mode", "stdio")
|
||||
monkeypatch.setattr(config, "http_remote_hosted", False)
|
||||
|
||||
async def fake_send_fn(command_type, params, *, instance_id=None, **_kwargs):
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"via": "stdio", "command": command_type, "instance": instance_id, "params": params},
|
||||
}
|
||||
|
||||
result = await unity_transport.send_with_unity_instance(
|
||||
fake_send_fn, "Project@abcd1234", "ping", {"x": 1}
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["data"]["via"] == "stdio"
|
||||
assert result["data"]["instance"] == "Project@abcd1234"
|
||||
@@ -0,0 +1,30 @@
|
||||
import pytest
|
||||
|
||||
from .test_helpers import DummyContext, setup_script_tools
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_script_returns_counts(monkeypatch):
|
||||
tools = setup_script_tools()
|
||||
validate_script = tools["validate_script"]
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"diagnostics": [
|
||||
{"severity": "warning"},
|
||||
{"severity": "error"},
|
||||
{"severity": "fatal"},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
# Patch the send_command_with_retry function at the module level where it's imported
|
||||
import transport.legacy.unity_connection
|
||||
monkeypatch.setattr(transport.legacy.unity_connection,
|
||||
"async_send_command_with_retry", fake_send)
|
||||
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
|
||||
|
||||
resp = await validate_script(DummyContext(), uri="mcpforunity://path/Assets/Scripts/A.cs")
|
||||
assert resp == {"success": True, "data": {"warnings": 1, "errors": 2}}
|
||||
@@ -0,0 +1,239 @@
|
||||
import asyncio
|
||||
import os
|
||||
import pytest
|
||||
|
||||
from services.tools.refresh_unity import is_reloading_rejection
|
||||
from .test_helpers import DummyContext
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_immediately_in_pytest(monkeypatch):
|
||||
"""_in_pytest() detects PYTEST_CURRENT_TEST and returns (True, 0.0) immediately."""
|
||||
# PYTEST_CURRENT_TEST is set by pytest automatically, so this should short-circuit.
|
||||
from services.tools.refresh_unity import wait_for_editor_ready
|
||||
|
||||
ctx = DummyContext()
|
||||
ready, elapsed = await wait_for_editor_ready(ctx, timeout_s=5.0)
|
||||
assert ready is True
|
||||
assert elapsed == 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_polls_until_ready(monkeypatch):
|
||||
"""When not in pytest, the helper polls get_editor_state until ready_for_tools."""
|
||||
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
||||
|
||||
from services.tools import refresh_unity as mod
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def fake_get_editor_state(ctx):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 3:
|
||||
return {"data": {"advice": {"ready_for_tools": False, "blocking_reasons": ["compiling"]}}}
|
||||
return {"data": {"advice": {"ready_for_tools": True, "blocking_reasons": []}}}
|
||||
|
||||
monkeypatch.setattr(mod.editor_state, "get_editor_state", fake_get_editor_state)
|
||||
|
||||
ctx = DummyContext()
|
||||
ready, elapsed = await mod.wait_for_editor_ready(ctx, timeout_s=10.0)
|
||||
assert ready is True
|
||||
assert call_count >= 3
|
||||
assert elapsed > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_returns_false(monkeypatch):
|
||||
"""When editor never becomes ready, returns (False, ~timeout)."""
|
||||
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
||||
|
||||
from services.tools import refresh_unity as mod
|
||||
|
||||
async def fake_get_editor_state(ctx):
|
||||
return {"data": {"advice": {"ready_for_tools": False, "blocking_reasons": ["compiling"]}}}
|
||||
|
||||
monkeypatch.setattr(mod.editor_state, "get_editor_state", fake_get_editor_state)
|
||||
|
||||
ctx = DummyContext()
|
||||
ready, elapsed = await mod.wait_for_editor_ready(ctx, timeout_s=0.6)
|
||||
assert ready is False
|
||||
assert elapsed >= 0.5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_only_treated_as_ready(monkeypatch):
|
||||
"""If the only blocking reason is stale_status, consider ready."""
|
||||
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
||||
|
||||
from services.tools import refresh_unity as mod
|
||||
|
||||
async def fake_get_editor_state(ctx):
|
||||
return {"data": {"advice": {"ready_for_tools": False, "blocking_reasons": ["stale_status"]}}}
|
||||
|
||||
monkeypatch.setattr(mod.editor_state, "get_editor_state", fake_get_editor_state)
|
||||
|
||||
ctx = DummyContext()
|
||||
ready, elapsed = await mod.wait_for_editor_ready(ctx, timeout_s=5.0)
|
||||
assert ready is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exception_during_poll_keeps_trying(monkeypatch):
|
||||
"""If get_editor_state throws, the helper keeps polling until ready."""
|
||||
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
||||
|
||||
from services.tools import refresh_unity as mod
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def fake_get_editor_state(ctx):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 3:
|
||||
raise ConnectionError("Unity disconnected")
|
||||
return {"data": {"advice": {"ready_for_tools": True, "blocking_reasons": []}}}
|
||||
|
||||
monkeypatch.setattr(mod.editor_state, "get_editor_state", fake_get_editor_state)
|
||||
|
||||
ctx = DummyContext()
|
||||
ready, elapsed = await mod.wait_for_editor_ready(ctx, timeout_s=10.0)
|
||||
assert ready is True
|
||||
assert call_count >= 3
|
||||
|
||||
|
||||
def test_is_reloading_rejection_true():
|
||||
"""Detects a reloading rejection response."""
|
||||
resp = {"success": False, "error": "Unity is reloading", "data": {"reason": "reloading"}, "hint": "retry"}
|
||||
assert is_reloading_rejection(resp) is True
|
||||
|
||||
|
||||
def test_is_reloading_rejection_false_on_success():
|
||||
assert is_reloading_rejection({"success": True, "data": {"reason": "reloading"}, "hint": "retry"}) is False
|
||||
|
||||
|
||||
def test_is_reloading_rejection_false_on_other_error():
|
||||
assert is_reloading_rejection({"success": False, "error": "timeout", "data": {}, "hint": "retry"}) is False
|
||||
|
||||
|
||||
def test_is_reloading_rejection_false_on_non_dict():
|
||||
assert is_reloading_rejection("some string") is False
|
||||
assert is_reloading_rejection(None) is False
|
||||
|
||||
|
||||
# --- is_connection_lost_after_send tests ---
|
||||
|
||||
from services.tools.refresh_unity import is_connection_lost_after_send
|
||||
|
||||
|
||||
def test_connection_lost_on_connection_closed():
|
||||
resp = {"success": False, "error": "Connection closed before reading expected bytes"}
|
||||
assert is_connection_lost_after_send(resp) is True
|
||||
|
||||
|
||||
def test_connection_lost_on_disconnected():
|
||||
resp = {"success": False, "error": "Unity disconnected"}
|
||||
assert is_connection_lost_after_send(resp) is True
|
||||
|
||||
|
||||
def test_connection_lost_on_aborted():
|
||||
resp = {"success": False, "error": "Connection aborted"}
|
||||
assert is_connection_lost_after_send(resp) is True
|
||||
|
||||
|
||||
def test_connection_lost_false_on_success():
|
||||
resp = {"success": True, "error": "Connection closed before reading expected bytes"}
|
||||
assert is_connection_lost_after_send(resp) is False
|
||||
|
||||
|
||||
def test_connection_lost_false_on_other_error():
|
||||
resp = {"success": False, "error": "timeout"}
|
||||
assert is_connection_lost_after_send(resp) is False
|
||||
|
||||
|
||||
def test_connection_lost_false_on_non_dict():
|
||||
assert is_connection_lost_after_send("some string") is False
|
||||
assert is_connection_lost_after_send(None) is False
|
||||
|
||||
|
||||
# --- send_mutation tests ---
|
||||
|
||||
from services.tools.refresh_unity import send_mutation
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_mutation_returns_success_directly(monkeypatch):
|
||||
"""Normal success response is returned as-is."""
|
||||
from services.tools import refresh_unity as mod
|
||||
|
||||
async def fake_send(*args, **kwargs):
|
||||
return {"success": True, "data": {"ok": True}}
|
||||
|
||||
monkeypatch.setattr(mod.unity_transport, "send_with_unity_instance", fake_send)
|
||||
|
||||
ctx = DummyContext()
|
||||
resp = await send_mutation(ctx, None, "manage_script", {"action": "create"})
|
||||
assert resp == {"success": True, "data": {"ok": True}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_mutation_retries_on_reloading_rejection(monkeypatch):
|
||||
"""Reloading rejection triggers one retry after wait."""
|
||||
from services.tools import refresh_unity as mod
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def fake_send(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return {"success": False, "data": {"reason": "reloading"}, "hint": "retry"}
|
||||
return {"success": True, "data": {"retried": True}}
|
||||
|
||||
monkeypatch.setattr(mod.unity_transport, "send_with_unity_instance", fake_send)
|
||||
|
||||
ctx = DummyContext()
|
||||
resp = await send_mutation(ctx, None, "manage_script", {"action": "create"})
|
||||
assert resp.get("success") is True
|
||||
assert call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_mutation_calls_verify_on_connection_lost(monkeypatch):
|
||||
"""Connection lost triggers verify callback."""
|
||||
from services.tools import refresh_unity as mod
|
||||
|
||||
async def fake_send(*args, **kwargs):
|
||||
return {"success": False, "error": "Connection closed before reading expected bytes"}
|
||||
|
||||
monkeypatch.setattr(mod.unity_transport, "send_with_unity_instance", fake_send)
|
||||
|
||||
verify_called = False
|
||||
|
||||
async def fake_verify():
|
||||
nonlocal verify_called
|
||||
verify_called = True
|
||||
return {"success": True, "message": "Verified!"}
|
||||
|
||||
ctx = DummyContext()
|
||||
resp = await send_mutation(ctx, None, "manage_script", {}, verify_after_disconnect=fake_verify)
|
||||
assert verify_called
|
||||
assert resp == {"success": True, "message": "Verified!"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_mutation_keeps_error_when_verify_returns_none(monkeypatch):
|
||||
"""When verify callback returns None, original error is preserved."""
|
||||
from services.tools import refresh_unity as mod
|
||||
|
||||
async def fake_send(*args, **kwargs):
|
||||
return {"success": False, "error": "Connection closed before reading expected bytes"}
|
||||
|
||||
monkeypatch.setattr(mod.unity_transport, "send_with_unity_instance", fake_send)
|
||||
|
||||
async def fake_verify():
|
||||
return None
|
||||
|
||||
ctx = DummyContext()
|
||||
resp = await send_mutation(ctx, None, "manage_script", {}, verify_after_disconnect=fake_verify)
|
||||
assert resp.get("success") is False
|
||||
Reference in New Issue
Block a user