chore: import upstream snapshot with attribution
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
"""Pytest configuration and shared fixtures for computer-server package tests.
|
||||
|
||||
This file contains shared fixtures and configuration for all computer-server tests.
|
||||
Following SRP: This file ONLY handles test setup/teardown.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_websocket():
|
||||
"""Mock WebSocket connection for testing.
|
||||
|
||||
Use this fixture to test WebSocket logic without real connections.
|
||||
"""
|
||||
websocket = AsyncMock()
|
||||
websocket.send = AsyncMock()
|
||||
websocket.recv = AsyncMock()
|
||||
websocket.close = AsyncMock()
|
||||
|
||||
return websocket
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_computer_interface():
|
||||
"""Mock computer interface for server tests.
|
||||
|
||||
Use this fixture to test server logic without real computer operations.
|
||||
"""
|
||||
interface = AsyncMock()
|
||||
interface.screenshot = AsyncMock(return_value=b"fake_screenshot")
|
||||
interface.left_click = AsyncMock()
|
||||
interface.type = AsyncMock()
|
||||
interface.key = AsyncMock()
|
||||
|
||||
return interface
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def disable_telemetry(monkeypatch):
|
||||
"""Disable telemetry for tests.
|
||||
|
||||
Use this fixture to ensure no telemetry is sent during tests.
|
||||
"""
|
||||
monkeypatch.setenv("CUA_TELEMETRY_ENABLED", "false")
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Integration tests for UNAVAILABLE_WITHOUT_CONTAINER_NAME behavior.
|
||||
|
||||
These tests verify two things:
|
||||
|
||||
1. **Backwards compat** — when neither ``CONTAINER_NAME`` nor
|
||||
``UNAVAILABLE_WITHOUT_CONTAINER_NAME`` is set, the server continues to
|
||||
operate in local development mode (no auth required, requests succeed).
|
||||
|
||||
2. **New behavior** — when ``UNAVAILABLE_WITHOUT_CONTAINER_NAME`` is truthy
|
||||
and ``CONTAINER_NAME`` is unset, requests are rejected with the status
|
||||
code configured by
|
||||
``UNAVAILABLE_WITHOUT_CONTAINER_NAME_RESPONSE_STATUS_CODE`` (default 503)
|
||||
rather than being allowed through.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
try:
|
||||
from computer_server.main import _unavailable_status_code, app
|
||||
except Exception as import_error: # pragma: no cover - environment-dependent
|
||||
pytest.skip(
|
||||
f"computer_server.main unavailable in this environment: {import_error}",
|
||||
allow_module_level=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_env(monkeypatch):
|
||||
"""Remove all env vars that influence auth availability for a clean baseline."""
|
||||
for var in (
|
||||
"CONTAINER_NAME",
|
||||
"UNAVAILABLE_WITHOUT_CONTAINER_NAME",
|
||||
"UNAVAILABLE_WITHOUT_CONTAINER_NAME_RESPONSE_STATUS_CODE",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
return monkeypatch
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class TestUnavailableStatusCode:
|
||||
"""Unit tests for the `_unavailable_status_code` helper."""
|
||||
|
||||
def test_returns_none_when_both_unset(self, clean_env):
|
||||
assert _unavailable_status_code() is None
|
||||
|
||||
def test_returns_none_when_container_name_set(self, clean_env):
|
||||
clean_env.setenv("CONTAINER_NAME", "vm-abc")
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", "true")
|
||||
# CONTAINER_NAME being set overrides the unavailable flag.
|
||||
assert _unavailable_status_code() is None
|
||||
|
||||
def test_returns_default_503_when_flag_truthy_and_container_missing(self, clean_env):
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", "true")
|
||||
assert _unavailable_status_code() == 503
|
||||
|
||||
@pytest.mark.parametrize("value", ["1", "true", "True", "YES", "y", "on"])
|
||||
def test_accepts_various_truthy_values(self, clean_env, value):
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", value)
|
||||
assert _unavailable_status_code() == 503
|
||||
|
||||
@pytest.mark.parametrize("value", ["0", "false", "no", "", "random"])
|
||||
def test_rejects_falsy_values(self, clean_env, value):
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", value)
|
||||
assert _unavailable_status_code() is None
|
||||
|
||||
def test_custom_status_code(self, clean_env):
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", "1")
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME_RESPONSE_STATUS_CODE", "418")
|
||||
assert _unavailable_status_code() == 418
|
||||
|
||||
def test_invalid_status_code_falls_back_to_503(self, clean_env):
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", "1")
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME_RESPONSE_STATUS_CODE", "not-a-number")
|
||||
assert _unavailable_status_code() == 503
|
||||
|
||||
|
||||
class TestCmdEndpoint:
|
||||
"""Integration tests for the POST /cmd endpoint."""
|
||||
|
||||
def test_backwards_compat_local_dev_allows_requests(self, clean_env, client):
|
||||
# No CONTAINER_NAME, no availability flag — old "local dev" behavior.
|
||||
resp = client.post("/cmd", json={"command": "version", "params": {}})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert "success" in resp.text
|
||||
|
||||
def test_unavailable_flag_rejects_with_default_503(self, clean_env, client):
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", "true")
|
||||
resp = client.post("/cmd", json={"command": "version", "params": {}})
|
||||
assert resp.status_code == 503
|
||||
assert "CONTAINER_NAME" in resp.json()["detail"]
|
||||
|
||||
def test_unavailable_flag_with_custom_status_code(self, clean_env, client):
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", "1")
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME_RESPONSE_STATUS_CODE", "418")
|
||||
resp = client.post("/cmd", json={"command": "version", "params": {}})
|
||||
assert resp.status_code == 418
|
||||
|
||||
def test_container_name_set_bypasses_unavailable_flag(self, clean_env, client):
|
||||
# CONTAINER_NAME being set means auth is required — but the unavailable
|
||||
# flag should NOT apply. Without valid creds, this should 401, not 503.
|
||||
clean_env.setenv("CONTAINER_NAME", "vm-xyz")
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", "true")
|
||||
resp = client.post("/cmd", json={"command": "version", "params": {}})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
class TestPtyEndpointAuthGate:
|
||||
"""Integration tests for PTY endpoints (via `_require_auth`)."""
|
||||
|
||||
def test_backwards_compat_local_dev_allows_access(self, clean_env, client):
|
||||
# Use a non-existent PID — we just want to verify we get past the auth gate.
|
||||
# If auth passes, we get 404 (PTY not found); if not, we get 401/503.
|
||||
resp = client.get("/pty/999999")
|
||||
assert resp.status_code == 404, resp.text
|
||||
|
||||
def test_unavailable_flag_rejects_with_default_503(self, clean_env, client):
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", "true")
|
||||
resp = client.get("/pty/999999")
|
||||
assert resp.status_code == 503
|
||||
|
||||
def test_unavailable_flag_with_custom_status_code(self, clean_env, client):
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", "true")
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME_RESPONSE_STATUS_CODE", "599")
|
||||
resp = client.get("/pty/999999")
|
||||
assert resp.status_code == 599
|
||||
|
||||
|
||||
class TestPlaywrightExecEndpoint:
|
||||
def test_backwards_compat_local_dev_accepts_auth(self, clean_env, client):
|
||||
# Browser manager may fail for other reasons, but it should NOT be 503/401.
|
||||
resp = client.post("/playwright_exec", json={"command": "noop", "params": {}})
|
||||
assert resp.status_code not in (401, 503)
|
||||
|
||||
def test_unavailable_flag_rejects(self, clean_env, client):
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", "true")
|
||||
resp = client.post("/playwright_exec", json={"command": "noop", "params": {}})
|
||||
assert resp.status_code == 503
|
||||
|
||||
|
||||
class TestStatusEndpointMiddlewareGating:
|
||||
"""The middleware applies uniformly — /status is reachable when the flag is off."""
|
||||
|
||||
def test_status_accessible_in_local_dev_mode(self, clean_env, client):
|
||||
resp = client.get("/status")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
|
||||
def test_status_rejected_by_middleware_when_flag_set(self, clean_env, client):
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", "true")
|
||||
resp = client.get("/status")
|
||||
assert resp.status_code == 503
|
||||
|
||||
def test_status_accessible_when_container_name_set(self, clean_env, client):
|
||||
clean_env.setenv("CONTAINER_NAME", "vm-abc")
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", "true")
|
||||
resp = client.get("/status")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestWebSocketEndpoint:
|
||||
def test_backwards_compat_local_dev_allows_commands(self, clean_env, client):
|
||||
with client.websocket_connect("/ws") as ws:
|
||||
ws.send_json({"command": "version", "params": {}})
|
||||
data = ws.receive_json()
|
||||
assert data["success"] is True
|
||||
|
||||
def test_unavailable_flag_closes_with_error(self, clean_env, client):
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", "true")
|
||||
with client.websocket_connect("/ws") as ws:
|
||||
data = ws.receive_json()
|
||||
assert data["success"] is False
|
||||
assert data["status_code"] == 503
|
||||
assert "CONTAINER_NAME" in data["error"]
|
||||
|
||||
def test_unavailable_flag_reports_custom_status_code(self, clean_env, client):
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", "true")
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME_RESPONSE_STATUS_CODE", "599")
|
||||
with client.websocket_connect("/ws") as ws:
|
||||
data = ws.receive_json()
|
||||
assert data["success"] is False
|
||||
assert data["status_code"] == 599
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Tests for keypress layout-independence fix (issue #1605).
|
||||
|
||||
Verifies that single printable characters are routed through type_text
|
||||
(layout-independent) rather than press_key (layout-dependent).
|
||||
"""
|
||||
|
||||
import unicodedata
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _is_printable_char(key: str) -> bool:
|
||||
"""Mirror of the routing logic in DirectComputer.keypress()."""
|
||||
return len(key) == 1 and unicodedata.category(key) not in ("Cc", "Cs", "Cn")
|
||||
|
||||
|
||||
class TestPrintableCharDetection:
|
||||
"""Unit tests for the printable-character detection logic."""
|
||||
|
||||
def test_ascii_letter_is_printable(self):
|
||||
assert _is_printable_char("a") is True
|
||||
assert _is_printable_char("Z") is True
|
||||
|
||||
def test_ascii_digit_is_printable(self):
|
||||
assert _is_printable_char("0") is True
|
||||
assert _is_printable_char("9") is True
|
||||
|
||||
def test_ascii_punctuation_is_printable(self):
|
||||
assert _is_printable_char("/") is True
|
||||
assert _is_printable_char(".") is True
|
||||
assert _is_printable_char("_") is True
|
||||
|
||||
def test_space_is_printable(self):
|
||||
# Space (U+0020) has category Zs — printable, should use type_text.
|
||||
assert _is_printable_char(" ") is True
|
||||
|
||||
def test_special_key_names_are_not_printable(self):
|
||||
# Multi-char strings are never single printable chars.
|
||||
assert _is_printable_char("return") is False
|
||||
assert _is_printable_char("tab") is False
|
||||
assert _is_printable_char("escape") is False
|
||||
assert _is_printable_char("f1") is False
|
||||
assert _is_printable_char("backspace") is False
|
||||
assert _is_printable_char("up") is False
|
||||
|
||||
def test_control_characters_are_not_printable(self):
|
||||
# Cc category — should go through press_key, not type_text.
|
||||
assert _is_printable_char("\x00") is False
|
||||
assert _is_printable_char("\x1b") is False # ESC
|
||||
assert _is_printable_char("\n") is False # newline
|
||||
|
||||
def test_empty_string_is_not_printable(self):
|
||||
assert _is_printable_char("") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keypress_single_printable_uses_type_text():
|
||||
"""Single printable char must call type_text, not press_key."""
|
||||
auto = MagicMock()
|
||||
auto.type_text = AsyncMock()
|
||||
auto.press_key = AsyncMock()
|
||||
auto.hotkey = AsyncMock()
|
||||
|
||||
# Simulate the routing logic directly.
|
||||
key = "a"
|
||||
if _is_printable_char(key):
|
||||
await auto.type_text(key)
|
||||
else:
|
||||
await auto.press_key(key)
|
||||
|
||||
auto.type_text.assert_awaited_once_with("a")
|
||||
auto.press_key.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keypress_special_key_uses_press_key():
|
||||
"""Special key names must still call press_key."""
|
||||
auto = MagicMock()
|
||||
auto.type_text = AsyncMock()
|
||||
auto.press_key = AsyncMock()
|
||||
|
||||
key = "return"
|
||||
if _is_printable_char(key):
|
||||
await auto.type_text(key)
|
||||
else:
|
||||
await auto.press_key(key)
|
||||
|
||||
auto.press_key.assert_awaited_once_with("return")
|
||||
auto.type_text.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keypress_combo_uses_hotkey():
|
||||
"""Multi-key combos must still call hotkey."""
|
||||
auto = MagicMock()
|
||||
auto.hotkey = AsyncMock()
|
||||
auto.type_text = AsyncMock()
|
||||
auto.press_key = AsyncMock()
|
||||
|
||||
parts = ["cmd", "shift", "g"]
|
||||
if len(parts) == 1:
|
||||
key = parts[0]
|
||||
if _is_printable_char(key):
|
||||
await auto.type_text(key)
|
||||
else:
|
||||
await auto.press_key(key)
|
||||
else:
|
||||
await auto.hotkey(parts)
|
||||
|
||||
auto.hotkey.assert_awaited_once_with(["cmd", "shift", "g"])
|
||||
auto.type_text.assert_not_awaited()
|
||||
auto.press_key.assert_not_awaited()
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Tests for SDK-configurable ``run_command`` timeout propagation.
|
||||
|
||||
The SDK's ``cua_sandbox.interfaces.shell.Shell.run(cmd, timeout=...)`` sends
|
||||
the timeout as a ``/cmd`` param; ``computer_server.main``'s dispatcher
|
||||
filters kwargs by the handler signature before forwarding. These tests
|
||||
cover the handler-side contract:
|
||||
|
||||
* handler accepts ``timeout`` as a float keyword
|
||||
* ``timeout=None`` → waits indefinitely
|
||||
* ``timeout=<float>`` → times out at expiry with a ``success=False`` result
|
||||
containing the standardised ``Command timed out after <t>s`` stderr and
|
||||
``return_code=-1``
|
||||
|
||||
The Android / Windows variants share the same shape; the base handler is
|
||||
exercised here because it exposes a POSIX subprocess without adb/emulator
|
||||
state being required for a focused unit test.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from computer_server.handlers.base import BaseAutomationHandler
|
||||
|
||||
|
||||
class _MinimalHandler(BaseAutomationHandler):
|
||||
"""Concrete BaseAutomationHandler for exercising ``run_command`` only.
|
||||
|
||||
We clear ``__abstractmethods__`` AFTER class creation (below) instead
|
||||
of stubbing every abstract method — the set changes as the interface
|
||||
grows and keeping a mirror of the full abstract surface here is pure
|
||||
maintenance overhead for a test that only touches ``run_command``.
|
||||
|
||||
Note: setting ``__abstractmethods__ = frozenset()`` inside the class
|
||||
body does NOT work — Python's ``ABCMeta`` overwrites it during class
|
||||
creation. The override must happen after the class statement.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# Bypass ABC enforcement — ABCMeta.__call__ checks this at instantiation time.
|
||||
_MinimalHandler.__abstractmethods__ = frozenset()
|
||||
|
||||
|
||||
class TestRunCommandTimeout:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_timeout_defaults_to_indefinite_wait(self, monkeypatch):
|
||||
# Ensure we hit the subprocess_shell branch (not the android adb branch).
|
||||
monkeypatch.delenv("IS_CUA_ANDROID", raising=False)
|
||||
h = _MinimalHandler()
|
||||
|
||||
result = await h.run_command("echo hello")
|
||||
assert result["success"] is True
|
||||
assert result["stdout"].strip() == "hello"
|
||||
assert result["return_code"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_honoured_when_passed(self, monkeypatch):
|
||||
monkeypatch.delenv("IS_CUA_ANDROID", raising=False)
|
||||
h = _MinimalHandler()
|
||||
|
||||
# sleep 5, cap at 0.2 — should time out
|
||||
start = asyncio.get_event_loop().time()
|
||||
result = await h.run_command("sleep 5", timeout=0.2)
|
||||
elapsed = asyncio.get_event_loop().time() - start
|
||||
|
||||
assert result["success"] is False
|
||||
assert "timed out" in result["stderr"].lower()
|
||||
assert result["return_code"] == -1
|
||||
# Must have returned within a small factor of the requested timeout.
|
||||
assert elapsed < 2.0, f"expected quick timeout, took {elapsed:.2f}s"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fast_command_under_timeout_succeeds(self, monkeypatch):
|
||||
monkeypatch.delenv("IS_CUA_ANDROID", raising=False)
|
||||
h = _MinimalHandler()
|
||||
|
||||
result = await h.run_command("echo done", timeout=10.0)
|
||||
assert result["success"] is True
|
||||
assert result["stdout"].strip() == "done"
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Unit tests for computer-server package.
|
||||
|
||||
This file tests ONLY basic server functionality.
|
||||
Following SRP: This file tests server initialization and basic operations.
|
||||
All external dependencies are mocked.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestServerImports:
|
||||
"""Test server module imports (SRP: Only tests imports)."""
|
||||
|
||||
def test_server_module_exists(self):
|
||||
"""Test that server module can be imported."""
|
||||
try:
|
||||
import computer_server
|
||||
|
||||
assert computer_server is not None
|
||||
except ImportError:
|
||||
pytest.skip("computer_server module not installed")
|
||||
|
||||
|
||||
class TestServerInitialization:
|
||||
"""Test server initialization (SRP: Only tests initialization)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_can_be_imported(self):
|
||||
"""Basic smoke test: verify server components can be imported."""
|
||||
try:
|
||||
from computer_server import server
|
||||
|
||||
assert server is not None
|
||||
except ImportError:
|
||||
pytest.skip("Server module not available")
|
||||
except Exception as e:
|
||||
# Some initialization errors are acceptable in unit tests
|
||||
pytest.skip(f"Server initialization requires specific setup: {e}")
|
||||
|
||||
|
||||
class TestMcpBarePathRewrite:
|
||||
"""Test the ASGI shim that serves MCP at both /mcp and /mcp/ (SRP:
|
||||
Only tests the path rewrite). The class is pure ASGI with no
|
||||
dependencies, so it is tested standalone against a recording stub."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bare_mcp_path_is_rewritten(self):
|
||||
try:
|
||||
from computer_server.main import McpBarePathRewrite
|
||||
except ImportError:
|
||||
pytest.skip("computer_server module not installed")
|
||||
except Exception as e:
|
||||
pytest.skip(f"Server initialization requires specific setup: {e}")
|
||||
|
||||
seen = {}
|
||||
|
||||
async def inner(scope, receive, send):
|
||||
seen.update(scope)
|
||||
|
||||
shim = McpBarePathRewrite(inner)
|
||||
await shim({"type": "http", "path": "/mcp"}, None, None)
|
||||
assert seen["path"] == "/mcp/"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_other_paths_untouched(self):
|
||||
try:
|
||||
from computer_server.main import McpBarePathRewrite
|
||||
except ImportError:
|
||||
pytest.skip("computer_server module not installed")
|
||||
except Exception as e:
|
||||
pytest.skip(f"Server initialization requires specific setup: {e}")
|
||||
|
||||
seen = {}
|
||||
|
||||
async def inner(scope, receive, send):
|
||||
seen.update(scope)
|
||||
|
||||
shim = McpBarePathRewrite(inner)
|
||||
for path in ("/mcp/", "/mcpx", "/status", "/"):
|
||||
await shim({"type": "http", "path": path}, None, None)
|
||||
assert seen["path"] == path
|
||||
|
||||
|
||||
class TestMcpServerSmoke:
|
||||
"""Smoke-test the real FastMCP integration so dependency major bumps cannot
|
||||
silently remove the /mcp endpoint while unit tests stay green."""
|
||||
|
||||
@staticmethod
|
||||
def _load_mcp_server_module():
|
||||
# Import the module file directly. Importing through
|
||||
# `computer_server.mcp_server` first executes computer_server/__init__.py,
|
||||
# which imports platform input backends and fails on headless Linux CI
|
||||
# with no DISPLAY. The MCP module itself has no runtime dependency on
|
||||
# those input backends until a tool is actually invoked.
|
||||
module_path = Path(__file__).parents[1] / "computer_server" / "mcp_server.py"
|
||||
spec = importlib.util.spec_from_file_location("_computer_server_mcp_smoke", module_path)
|
||||
assert spec is not None
|
||||
assert spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
def test_create_mcp_server_and_http_app(self):
|
||||
mcp_module = self._load_mcp_server_module()
|
||||
|
||||
mcp_server = mcp_module.create_mcp_server()
|
||||
mcp_app = mcp_server.http_app(path="/")
|
||||
|
||||
assert mcp_server is not None
|
||||
assert mcp_app is not None
|
||||
|
||||
def test_mcp_http_app_accepts_initialize_post(self):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
mcp_module = self._load_mcp_server_module()
|
||||
mcp_server = mcp_module.create_mcp_server()
|
||||
mcp_app = mcp_server.http_app(path="/")
|
||||
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "computer-server-smoke", "version": "0"},
|
||||
},
|
||||
}
|
||||
|
||||
with TestClient(mcp_app) as client:
|
||||
response = client.post(
|
||||
"/",
|
||||
json=payload,
|
||||
headers={"accept": "application/json, text/event-stream"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "cua-computer-server" in response.text
|
||||
assert '"id":1' in response.text
|
||||
|
||||
|
||||
class TestMcpAcceptHeaderShim:
|
||||
"""Test the ASGI shim that normalizes the Accept header for /mcp so the
|
||||
MCP SDK's strict Accept check passes for any client (SRP: Only tests the
|
||||
Accept-header rewrite). Pure ASGI with no deps; tested standalone."""
|
||||
|
||||
@staticmethod
|
||||
def _accept(headers):
|
||||
for k, v in headers:
|
||||
if k == b"accept":
|
||||
return v
|
||||
return None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_accept_header_is_normalized(self):
|
||||
try:
|
||||
from computer_server.main import McpAcceptHeaderShim
|
||||
except ImportError:
|
||||
pytest.skip("computer_server module not installed")
|
||||
except Exception as e:
|
||||
pytest.skip(f"Server initialization requires specific setup: {e}")
|
||||
|
||||
seen = {}
|
||||
|
||||
async def inner(scope, receive, send):
|
||||
seen.update(scope)
|
||||
|
||||
shim = McpAcceptHeaderShim(inner)
|
||||
# claude.ai-style request that only advertises application/json:
|
||||
for path in ("/mcp", "/mcp/", "/mcp/anything"):
|
||||
await shim(
|
||||
{"type": "http", "path": path, "headers": [(b"accept", b"application/json")]},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
assert self._accept(seen["headers"]) == b"application/json, text/event-stream"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_accept_header_is_added(self):
|
||||
try:
|
||||
from computer_server.main import McpAcceptHeaderShim
|
||||
except ImportError:
|
||||
pytest.skip("computer_server module not installed")
|
||||
except Exception as e:
|
||||
pytest.skip(f"Server initialization requires specific setup: {e}")
|
||||
|
||||
seen = {}
|
||||
|
||||
async def inner(scope, receive, send):
|
||||
seen.update(scope)
|
||||
|
||||
shim = McpAcceptHeaderShim(inner)
|
||||
await shim({"type": "http", "path": "/mcp", "headers": []}, None, None)
|
||||
assert self._accept(seen["headers"]) == b"application/json, text/event-stream"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_mcp_paths_untouched(self):
|
||||
try:
|
||||
from computer_server.main import McpAcceptHeaderShim
|
||||
except ImportError:
|
||||
pytest.skip("computer_server module not installed")
|
||||
except Exception as e:
|
||||
pytest.skip(f"Server initialization requires specific setup: {e}")
|
||||
|
||||
seen = {}
|
||||
|
||||
async def inner(scope, receive, send):
|
||||
seen.update(scope)
|
||||
|
||||
shim = McpAcceptHeaderShim(inner)
|
||||
for path in ("/status", "/", "/mcpx", "/ws"):
|
||||
await shim(
|
||||
{"type": "http", "path": path, "headers": [(b"accept", b"application/json")]},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
# Untouched: still exactly what the client sent.
|
||||
assert self._accept(seen["headers"]) == b"application/json"
|
||||
Reference in New Issue
Block a user