diff --git a/tests/cli/test_runtime_more.py b/tests/cli/test_runtime_more.py new file mode 100644 index 0000000..67cf593 --- /dev/null +++ b/tests/cli/test_runtime_more.py @@ -0,0 +1,279 @@ +"""Extra runtime tests targeting properties, handlers, and shallow paths.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from uipath_mcp._cli._runtime._context import UiPathServerType +from uipath_mcp._cli._runtime._exception import UiPathMcpRuntimeError +from uipath_mcp._cli._runtime._runtime import UiPathMcpRuntime + + +def _make_runtime(server: MagicMock | None = None, **extra) -> UiPathMcpRuntime: + server = server or MagicMock( + name="server", + is_streamable_http=False, + args=[], + command="cmd", + env={}, + url=None, + ) + server.name = "svc" + with patch("uipath_mcp._cli._runtime._runtime.UiPath"): + rt = UiPathMcpRuntime( + server=server, + runtime_id="rid", + entrypoint="ep", + **extra, + ) + rt._uipath = MagicMock() + return rt + + +@pytest.fixture +def runtime() -> UiPathMcpRuntime: + return _make_runtime() + + +@pytest.mark.asyncio +async def test_get_schema(runtime): + schema = await runtime.get_schema() + assert schema.file_path == "ep" + assert schema.type == "mcpserver" + + +def test_slug_defaults_to_server_name(runtime): + assert runtime.slug == "svc" + + +def test_slug_uses_server_slug_when_set(): + rt = _make_runtime(server_slug="explicit-slug") + assert rt.slug == "explicit-slug" + + +def test_sandboxed_property(runtime): + runtime._job_id = None + assert runtime.sandboxed is False + runtime._job_id = "job-1" + assert runtime.sandboxed is True + + +def test_packaged_property(runtime): + runtime._process_key = None + assert runtime.packaged is False + runtime._process_key = "00000000-0000-0000-0000-000000000000" + assert runtime.packaged is False + runtime._process_key = "11111111-2222-3333-4444-555555555555" + assert runtime.packaged is True + + +def test_server_type_selfhosted(runtime): + runtime._job_id = None + runtime._process_key = None + assert runtime.server_type is UiPathServerType.SelfHosted + + +def test_server_type_command(runtime): + runtime._job_id = "j" + runtime._process_key = None + assert runtime.server_type is UiPathServerType.Command + + +def test_server_type_coded(runtime): + runtime._job_id = "j" + runtime._process_key = "11111111-2222-3333-4444-555555555555" + assert runtime.server_type is UiPathServerType.Coded + + +@pytest.mark.asyncio +async def test_validate_auth_missing_url(runtime): + fake_cfg = MagicMock() + fake_cfg.base_url = None + with patch("uipath_mcp._cli._runtime._runtime.UiPathConfig", fake_cfg): + with pytest.raises(UiPathMcpRuntimeError): + runtime._validate_auth() + + +@pytest.mark.asyncio +async def test_validate_auth_missing_tenant_or_org(runtime): + fake_cfg = MagicMock() + fake_cfg.base_url = "https://x" + runtime._tenant_id = None + runtime._org_id = None + with patch("uipath_mcp._cli._runtime._runtime.UiPathConfig", fake_cfg): + with pytest.raises(UiPathMcpRuntimeError): + runtime._validate_auth() + + +@pytest.mark.asyncio +async def test_validate_auth_ok(runtime): + fake_cfg = MagicMock() + fake_cfg.base_url = "https://x" + runtime._tenant_id = "t" + runtime._org_id = "o" + with patch("uipath_mcp._cli._runtime._runtime.UiPathConfig", fake_cfg): + runtime._validate_auth() # should not raise + + +@pytest.mark.asyncio +async def test_handle_signalr_error_open_close(runtime): + await runtime._handle_signalr_error("e") + await runtime._handle_signalr_open() + await runtime._handle_signalr_close() + + +@pytest.mark.asyncio +async def test_handle_signalr_session_closed_invalid_args(runtime): + await runtime._handle_signalr_session_closed([]) + + +@pytest.mark.asyncio +async def test_handle_signalr_session_closed_unknown_session(runtime): + runtime._job_id = None + await runtime._handle_signalr_session_closed(["unknown"]) + + +@pytest.mark.asyncio +async def test_handle_signalr_session_closed_with_session(runtime): + sess = MagicMock() + sess.stop = AsyncMock() + sess.output = "out" + runtime._session_servers["s1"] = sess + runtime._job_id = "j" # sandboxed + await runtime._handle_signalr_session_closed(["s1"]) + sess.stop.assert_awaited_once() + assert runtime._session_output == "out" + assert runtime._cancel_event.is_set() + + +@pytest.mark.asyncio +async def test_handle_signalr_message_invalid_args(runtime): + await runtime._handle_signalr_message([]) + + +@pytest.mark.asyncio +async def test_handle_signalr_message_existing_session(runtime): + sess = MagicMock() + sess.on_message_received = AsyncMock() + runtime._session_servers["s1"] = sess + await runtime._handle_signalr_message(["s1", "req1"]) + sess.on_message_received.assert_awaited_once_with("req1") + + +@pytest.mark.asyncio +async def test_handle_signalr_message_new_session(runtime): + runtime._server.is_streamable_http = False + fake_sess = MagicMock() + fake_sess.start = AsyncMock() + fake_sess.on_message_received = AsyncMock() + with patch( + "uipath_mcp._cli._runtime._runtime.StdioSessionServer", return_value=fake_sess + ): + await runtime._handle_signalr_message(["sNew", "r"]) + fake_sess.start.assert_awaited_once() + fake_sess.on_message_received.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_handle_signalr_message_new_http_session(runtime): + runtime._server.is_streamable_http = True + fake_sess = MagicMock() + fake_sess.start = AsyncMock() + fake_sess.on_message_received = AsyncMock() + with patch( + "uipath_mcp._cli._runtime._runtime.StreamableHttpSessionServer", + return_value=fake_sess, + ): + await runtime._handle_signalr_message(["sNew", "r"]) + fake_sess.start.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_on_runtime_abort_logs_when_non_202(runtime): + response = MagicMock(status_code=500, text="oops") + runtime._uipath.api_client.request_async = AsyncMock(return_value=response) + await runtime._on_runtime_abort() + + +@pytest.mark.asyncio +async def test_on_runtime_abort_success(runtime): + response = MagicMock(status_code=202) + runtime._uipath.api_client.request_async = AsyncMock(return_value=response) + await runtime._on_runtime_abort() + + +@pytest.mark.asyncio +async def test_on_runtime_abort_swallows_exception(runtime): + runtime._uipath.api_client.request_async = AsyncMock(side_effect=RuntimeError("x")) + await runtime._on_runtime_abort() + + +@pytest.mark.asyncio +async def test_on_session_start_error_success(runtime): + response = MagicMock(status_code=202) + runtime._uipath.api_client.request_async = AsyncMock(return_value=response) + await runtime._on_session_start_error("s1") + + +@pytest.mark.asyncio +async def test_on_session_start_error_non_202(runtime): + response = MagicMock(status_code=400, text="bad") + runtime._uipath.api_client.request_async = AsyncMock(return_value=response) + await runtime._on_session_start_error("s1") + + +@pytest.mark.asyncio +async def test_on_session_start_error_exception(runtime): + runtime._uipath.api_client.request_async = AsyncMock(side_effect=RuntimeError("x")) + await runtime._on_session_start_error("s1") + + +@pytest.mark.asyncio +async def test_dispose_calls_cleanup(runtime): + with patch.object(runtime, "_cleanup", new=AsyncMock()) as cu: + await runtime.dispose() + cu.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_cleanup_idempotent(runtime): + runtime._cleanup_done = True + await runtime._cleanup() + + +@pytest.mark.asyncio +async def test_cleanup_runs(runtime): + runtime._token_refresher = MagicMock(stop=AsyncMock()) + with patch.object(runtime, "_on_runtime_abort", new=AsyncMock()): + await runtime._cleanup() + assert runtime._cleanup_done is True + + +@pytest.mark.asyncio +async def test_execute_calls_run_server(runtime): + with patch.object(runtime, "_run_server", new=AsyncMock(return_value="R")) as rs: + result = await runtime.execute() + assert result == "R" + rs.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_stream_yields_result(runtime): + with patch.object(runtime, "_run_server", new=AsyncMock(return_value="R")): + results = [r async for r in runtime.stream()] + assert results == ["R"] + + +@pytest.mark.asyncio +async def test_stop_http_server_process_no_process(runtime): + await runtime._stop_http_server_process() + + +@pytest.mark.asyncio +async def test_monitor_http_server_process_no_process(runtime): + await runtime._monitor_http_server_process() + + +@pytest.mark.asyncio +async def test_drain_http_stderr_no_process(runtime): + await runtime._drain_http_stderr() diff --git a/tests/cli/test_session.py b/tests/cli/test_session.py new file mode 100644 index 0000000..cb0ec76 --- /dev/null +++ b/tests/cli/test_session.py @@ -0,0 +1,247 @@ +"""Tests for BaseSessionServer and concrete session servers.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from mcp.types import ( + ErrorData, + JSONRPCError, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, +) + +from uipath_mcp._cli._runtime import _session as session_mod +from uipath_mcp._cli._runtime._session import ( + StdioSessionServer, + StreamableHttpSessionServer, +) + + +def _make_server_config(url: str | None = None) -> MagicMock: + cfg = MagicMock() + cfg.command = "cmd" + cfg.args = [] + cfg.env = {} + cfg.url = url + return cfg + + +@pytest.fixture +def uipath_mock() -> MagicMock: + m = MagicMock() + m.api_client.request_async = AsyncMock() + return m + + +@pytest.fixture +def stdio_session(uipath_mock): + return StdioSessionServer(_make_server_config(), "slug", "sess-id", uipath_mock) + + +@pytest.fixture +def http_session(uipath_mock): + return StreamableHttpSessionServer( + _make_server_config(url="https://x"), "slug", "sess-id", uipath_mock + ) + + +def test_is_response_for_response(stdio_session): + msg = JSONRPCMessage(JSONRPCResponse(jsonrpc="2.0", id=1, result={})) + assert stdio_session._is_response(msg) is True + + +def test_is_response_for_error(stdio_session): + msg = JSONRPCMessage( + JSONRPCError(jsonrpc="2.0", id=1, error=ErrorData(code=-1, message="x")) + ) + assert stdio_session._is_response(msg) is True + + +def test_is_response_for_request(stdio_session): + msg = JSONRPCMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="m")) + assert stdio_session._is_response(msg) is False + + +def test_get_message_id_request(stdio_session): + msg = JSONRPCMessage(JSONRPCRequest(jsonrpc="2.0", id=42, method="m")) + assert stdio_session._get_message_id(msg) == "42" + + +def test_get_message_id_notification_no_id(stdio_session): + msg = JSONRPCMessage(JSONRPCNotification(jsonrpc="2.0", method="m")) + assert stdio_session._get_message_id(msg) == "" + + +def test_stdio_output_property(stdio_session): + assert stdio_session.output is None + stdio_session._server_stderr_output = "boom" + assert stdio_session.output == "boom" + + +def test_http_output_property(http_session): + assert http_session.output is None + + +@pytest.mark.asyncio +async def test_stop_when_no_task(stdio_session): + await stdio_session.stop() + assert stdio_session._run_task is None + + +@pytest.mark.asyncio +async def test_send_message_internal_202(stdio_session, uipath_mock): + uipath_mock.api_client.request_async.return_value = MagicMock(status_code=202) + msg = JSONRPCMessage(JSONRPCResponse(jsonrpc="2.0", id=1, result={})) + await stdio_session._send_message_internal(msg, "req-1") + + +@pytest.mark.asyncio +async def test_send_message_internal_5xx_raises(stdio_session, uipath_mock): + uipath_mock.api_client.request_async.return_value = MagicMock( + status_code=500, text="err" + ) + msg = JSONRPCMessage(JSONRPCResponse(jsonrpc="2.0", id=1, result={})) + with pytest.raises(Exception, match="500"): + await stdio_session._send_message_internal(msg, "req-1") + + +@pytest.mark.asyncio +async def test_get_messages_internal_200_with_request(stdio_session, uipath_mock): + msg = JSONRPCRequest(jsonrpc="2.0", id=7, method="tools/call").model_dump() + uipath_mock.api_client.request_async.return_value = MagicMock( + status_code=200, json=MagicMock(return_value=[msg]) + ) + await stdio_session._get_messages_internal("req-1") + assert stdio_session._last_request_id == "req-1" + assert stdio_session._active_requests.get("7") == "req-1" + + +@pytest.mark.asyncio +async def test_get_messages_internal_200_with_response(stdio_session, uipath_mock): + msg = JSONRPCResponse(jsonrpc="2.0", id=8, result={}).model_dump() + uipath_mock.api_client.request_async.return_value = MagicMock( + status_code=200, json=MagicMock(return_value=[msg]) + ) + await stdio_session._get_messages_internal("req-2") + assert stdio_session._last_request_id == "req-2" + + +@pytest.mark.asyncio +async def test_get_messages_internal_5xx_raises(stdio_session, uipath_mock): + uipath_mock.api_client.request_async.return_value = MagicMock( + status_code=503, text="bad" + ) + with pytest.raises(Exception, match="503"): + await stdio_session._get_messages_internal("req-1") + + +@pytest.mark.asyncio +async def test_on_message_received_success_first_try(stdio_session): + with patch.object(stdio_session, "_get_messages_internal", new=AsyncMock()) as gi: + await stdio_session.on_message_received("r") + gi.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_on_message_received_retries_then_succeeds(stdio_session, monkeypatch): + monkeypatch.setattr(session_mod, "RETRY_DELAY", 0) + calls = {"n": 0} + + async def flaky(_): + calls["n"] += 1 + if calls["n"] < 2: + raise RuntimeError("transient") + + with patch.object(stdio_session, "_get_messages_internal", side_effect=flaky): + await stdio_session.on_message_received("r") + assert calls["n"] == 2 + + +@pytest.mark.asyncio +async def test_on_message_received_raises_after_max_retries(stdio_session, monkeypatch): + monkeypatch.setattr(session_mod, "RETRY_DELAY", 0) + with patch.object( + stdio_session, + "_get_messages_internal", + side_effect=RuntimeError("nope"), + ): + with pytest.raises(RuntimeError, match="nope"): + await stdio_session.on_message_received("r") + + +@pytest.mark.asyncio +async def test_send_message_succeeds(stdio_session): + with patch.object(stdio_session, "_send_message_internal", new=AsyncMock()) as si: + msg = JSONRPCMessage(JSONRPCResponse(jsonrpc="2.0", id=1, result={})) + await stdio_session._send_message(msg, "r") + si.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_send_message_raises_after_max_retries(stdio_session, monkeypatch): + monkeypatch.setattr(session_mod, "RETRY_DELAY", 0) + with patch.object( + stdio_session, "_send_message_internal", side_effect=RuntimeError("nope") + ): + msg = JSONRPCMessage(JSONRPCResponse(jsonrpc="2.0", id=1, result={})) + with pytest.raises(RuntimeError): + await stdio_session._send_message(msg, "r") + + +@pytest.mark.asyncio +async def test_stdio_start_creates_task(stdio_session): + with patch.object(stdio_session, "_run_server", new=AsyncMock()): + await stdio_session.start() + assert stdio_session._run_task is not None + await stdio_session.stop() + + +@pytest.mark.asyncio +async def test_http_start_creates_task(http_session): + with patch.object(http_session, "_run_http_session", new=AsyncMock()): + await http_session.start() + assert http_session._run_task is not None + await http_session.stop() + + +@pytest.mark.asyncio +async def test_http_run_session_requires_url(uipath_mock): + cfg = _make_server_config(url=None) + s = StreamableHttpSessionServer(cfg, "slug", "sid", uipath_mock) + with pytest.raises(ValueError, match="url"): + await s._run_http_session() + + +def test_run_server_callback_swallows_cancel(stdio_session): + import asyncio + + task = MagicMock() + task.result.side_effect = asyncio.CancelledError() + stdio_session._run_server_callback(task) + + +def test_run_server_callback_logs_other(stdio_session): + task = MagicMock() + task.result.side_effect = RuntimeError("boom") + stdio_session._run_server_callback(task) + + +@pytest.mark.asyncio +async def test_consume_messages_processes_and_exits(stdio_session): + import asyncio + + stdio_session._write_stream = MagicMock() + stdio_session._write_stream.send = AsyncMock() + msg = JSONRPCMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="m")) + await stdio_session._message_queue.put(msg) + + task = asyncio.create_task(stdio_session._consume_messages()) + await asyncio.sleep(0.05) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + stdio_session._write_stream.send.assert_awaited()