Testing / test (3.10) (push) Successful in 48s
Testing / test (3.12) (push) Successful in 47s
Testing / test (3.14) (push) Successful in 1m7s
Testing / remote-protocol-compat (0.15.0) (push) Successful in 6m59s
Testing / remote-protocol-compat (0.16.0) (push) Successful in 6m47s
Testing / test (3.15) (push) Successful in 6m20s
- Drop backslash from f-string expression in markdown/html.py - That syntax needs 3.12+ (PEP 701) and broke import on 3.10/3.11 - Add Python matrix 3.10/3.12/3.14/3.15 to the Gitea testing workflow - Pass project_version() to MCPServer; serverInfo.version was empty - Cover the reported version with an in-memory MCP client test - Swap deprecated click.get_text_stream for sys.stdin.isatty() - Add explicit encoding to native host shim and manifest writes - Add explicit encoding to the registry lock file open - Add explicit encoding to every read_text/write_text call in tests - Bump version to 0.16.10 in pyproject.toml and extension/manifest.json - Move .python-version to 3.15 and refresh uv.lock Extension code is unchanged; only the manifest version string moved. Verified with 778 tests on 3.10 through 3.15, plus a strict run using -X warn_default_encoding with EncodingWarning and DeprecationWarning as errors.
351 lines
13 KiB
Python
351 lines
13 KiB
Python
"""Tests for the optional stateless MCP adapter."""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
pytest.importorskip("mcp")
|
|
|
|
from mcp import Client
|
|
from mcp.types import ImageContent
|
|
|
|
from browser_cli.mcp.serialization import structured
|
|
from browser_cli.mcp.naming import resolve_tool_prefix
|
|
from browser_cli.mcp.server import _screenshot_bytes, create_server, main
|
|
from browser_cli.models import Tab
|
|
from browser_cli.version_manager import project_version
|
|
|
|
class FakeClient:
|
|
instances: list["FakeClient"] = []
|
|
|
|
def __init__(self, browser=None, remote=None, key=None):
|
|
self.target = (browser, remote, key)
|
|
self.calls: list[tuple] = []
|
|
self.tabs = SimpleNamespace(
|
|
list=self.tabs_list,
|
|
open=self.tabs_open,
|
|
close=self.tabs_close,
|
|
active=self.tabs_active,
|
|
activate=self.tabs_activate,
|
|
status=self.tabs_status,
|
|
screenshot=self.tabs_screenshot,
|
|
)
|
|
self.nav = SimpleNamespace(to=self.navigate_to)
|
|
self.page = SimpleNamespace(info=self.page_info)
|
|
self.extract = SimpleNamespace(text=self.extract_text, markdown=self.extract_markdown)
|
|
self.dom = SimpleNamespace(query=self.dom_query, click=self.dom_click, type=self.dom_type, eval=self.dom_eval)
|
|
self.instances.append(self)
|
|
|
|
def tabs_list(self):
|
|
return [
|
|
{"id": 7, "title": "Example", "url": "https://example.com"},
|
|
{"id": 9, "title": "Browser App", "url": "https://browser-host.example/app"},
|
|
]
|
|
|
|
def tabs_open(self, url, **kwargs):
|
|
self.calls.append(("open", url, kwargs))
|
|
return {"id": 8, "title": "Opened", "url": url}
|
|
|
|
def tabs_close(self, tab_id):
|
|
self.calls.append(("close", tab_id))
|
|
return 1
|
|
|
|
def tabs_active(self):
|
|
self.calls.append(("active",))
|
|
return SimpleNamespace(id=7)
|
|
|
|
def tabs_activate(self, tab_id):
|
|
self.calls.append(("activate", tab_id))
|
|
|
|
def tabs_status(self, tab_id):
|
|
self.calls.append(("status", tab_id))
|
|
return {"id": tab_id, "title": "Navigated", "url": "https://example.com/next"}
|
|
|
|
def tabs_screenshot(self, tab_id, **kwargs):
|
|
self.calls.append(("screenshot", tab_id, kwargs))
|
|
return "data:image/png;base64," + base64.b64encode(b"png-data").decode()
|
|
|
|
def navigate_to(self, tab_id, url):
|
|
self.calls.append(("navigate", tab_id, url))
|
|
|
|
def page_info(self, tab_id=None):
|
|
self.calls.append(("page_info", tab_id))
|
|
return {"title": "Example", "url": "https://example.com", "tab_id": tab_id}
|
|
|
|
def extract_text(self, tab_id=None):
|
|
self.calls.append(("extract_text", tab_id))
|
|
return "Page text"
|
|
|
|
def extract_markdown(self, selector=None, tab_id=None):
|
|
self.calls.append(("extract_markdown", selector, tab_id))
|
|
return f"# Page {selector or ''} {tab_id or ''}".rstrip()
|
|
|
|
def dom_query(self, selector):
|
|
return [{"tag": "button", "selector": selector}]
|
|
|
|
def dom_click(self, selector):
|
|
self.calls.append(("click", selector))
|
|
|
|
def dom_type(self, selector, text):
|
|
self.calls.append(("type", selector, text))
|
|
|
|
def dom_eval(self, code, tab_id=None):
|
|
self.calls.append(("eval", code, tab_id))
|
|
if "querySelectorAll" in code:
|
|
return [{"tag": "BUTTON", "text": "Buchen", "attrs": {"id": "buchen"}}]
|
|
return True
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_instances():
|
|
FakeClient.instances.clear()
|
|
|
|
@pytest.fixture
|
|
def anyio_backend():
|
|
return "asyncio"
|
|
|
|
@pytest.fixture
|
|
async def client():
|
|
server = create_server(client_factory=FakeClient)
|
|
async with Client(server, raise_exceptions=True) as connected:
|
|
yield connected
|
|
|
|
@pytest.mark.anyio
|
|
async def test_each_tool_call_constructs_a_fresh_targeted_client(client, monkeypatch):
|
|
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
|
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
|
monkeypatch.delenv("BROWSER_CLI_KEY", raising=False)
|
|
first = await client.call_tool("browser_tabs_list", {
|
|
"browser": "work",
|
|
"remote": "browser-host.example:443",
|
|
"key": "agent",
|
|
})
|
|
second = await client.call_tool("browser_page_info", {})
|
|
|
|
assert first.structured_content == {
|
|
"result": [
|
|
{"id": 7, "title": "Example", "url": "https://example.com"},
|
|
{"id": 9, "title": "Browser App", "url": "https://browser-host.example/app"},
|
|
]
|
|
}
|
|
assert second.structured_content == {
|
|
"title": "Example",
|
|
"url": "https://example.com",
|
|
"tab_id": None,
|
|
}
|
|
assert len(FakeClient.instances) == 2
|
|
assert FakeClient.instances[0].target == ("work", "browser-host.example:443", "agent")
|
|
assert FakeClient.instances[1].target == (None, None, None)
|
|
|
|
@pytest.mark.anyio
|
|
async def test_environment_pins_all_calls_to_one_browser(client, monkeypatch):
|
|
monkeypatch.setenv("BROWSER_CLI_PROFILE", "testing")
|
|
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
|
monkeypatch.delenv("BROWSER_CLI_KEY", raising=False)
|
|
|
|
await client.call_tool("browser_tabs_list", {})
|
|
|
|
assert FakeClient.instances[-1].target == ("testing", None, None)
|
|
|
|
@pytest.mark.anyio
|
|
async def test_server_reports_the_project_version_to_clients(client):
|
|
info = client.server_info
|
|
|
|
assert info is not None
|
|
assert info.name == "browser-cli"
|
|
assert info.version == project_version()
|
|
assert info.version not in ("", "unknown")
|
|
|
|
def test_tool_prefix_defaults_to_browser_and_is_configurable():
|
|
assert resolve_tool_prefix({}) == "browser_"
|
|
assert resolve_tool_prefix({"BROWSER_CLI_MCP_TOOL_PREFIX": ""}) == ""
|
|
assert resolve_tool_prefix({"BROWSER_CLI_MCP_TOOL_PREFIX": "web"}) == "web_"
|
|
assert resolve_tool_prefix({"BROWSER_CLI_MCP_TOOL_PREFIX": "web_"}) == "web_"
|
|
with pytest.raises(ValueError):
|
|
resolve_tool_prefix({"BROWSER_CLI_MCP_TOOL_PREFIX": "9-bad prefix"})
|
|
|
|
@pytest.mark.anyio
|
|
async def test_hosts_that_namespace_tools_can_drop_the_builtin_prefix():
|
|
server = create_server(client_factory=FakeClient, tool_prefix="")
|
|
async with Client(server, raise_exceptions=True) as connected:
|
|
names = {tool.name for tool in (await connected.list_tools()).tools}
|
|
|
|
assert "tabs_list" in names
|
|
assert not any(name.startswith("browser_") for name in names)
|
|
|
|
@pytest.mark.anyio
|
|
async def test_explicit_target_overrides_environment_pin(client, monkeypatch):
|
|
monkeypatch.setenv("BROWSER_CLI_PROFILE", "testing")
|
|
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
|
monkeypatch.delenv("BROWSER_CLI_KEY", raising=False)
|
|
|
|
await client.call_tool("browser_tabs_list", {"browser": "main"})
|
|
|
|
assert FakeClient.instances[-1].target == ("main", None, None)
|
|
|
|
@pytest.mark.anyio
|
|
async def test_tabs_list_can_filter_large_tab_sets_server_side(client, monkeypatch):
|
|
monkeypatch.delenv("BROWSER_CLI_KEY", raising=False)
|
|
|
|
result = await client.call_tool("browser_tabs_list", {"search": "browser-host.example", "browser": "main"})
|
|
|
|
assert result.structured_content == {
|
|
"result": [{"id": 9, "title": "Browser App", "url": "https://browser-host.example/app"}]
|
|
}
|
|
assert FakeClient.instances[-1].target == ("main", None, None)
|
|
|
|
@pytest.mark.anyio
|
|
async def test_dom_tools_accept_explicit_tab_id(client):
|
|
queried = await client.call_tool("browser_dom_query", {"selector": "button", "tab_id": 9})
|
|
clicked = await client.call_tool("browser_dom_click", {"selector": "#buchen", "tab_id": 9})
|
|
typed = await client.call_tool("browser_dom_type", {"selector": "#name", "text": "Daniel", "tab_id": 9})
|
|
|
|
assert queried.structured_content == {
|
|
"result": [{"tag": "BUTTON", "text": "Buchen", "attrs": {"id": "buchen"}}]
|
|
}
|
|
assert clicked.structured_content == {
|
|
"title": "Example",
|
|
"url": "https://example.com",
|
|
"tab_id": 9,
|
|
}
|
|
assert typed.structured_content == {"typed": True}
|
|
assert FakeClient.instances[0].calls == [("eval", FakeClient.instances[0].calls[0][1], 9)]
|
|
assert FakeClient.instances[1].calls[0][0] == "eval"
|
|
assert FakeClient.instances[1].calls[1] == ("page_info", 9)
|
|
assert FakeClient.instances[2].calls[0][0] == "eval"
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mutating_tools_use_sdk_and_return_fresh_state(client):
|
|
opened = await client.call_tool("browser_tabs_open", {
|
|
"url": "https://example.com",
|
|
"wait": True,
|
|
"focus": True,
|
|
"reuse": "none",
|
|
})
|
|
navigated = await client.call_tool("browser_navigate", {
|
|
"tab_id": 8,
|
|
"url": "https://example.com/next",
|
|
})
|
|
clicked = await client.call_tool("browser_dom_click", {"selector": "#submit"})
|
|
typed = await client.call_tool("browser_dom_type", {"selector": "#name", "text": "Daniel"})
|
|
|
|
assert opened.structured_content == {
|
|
"id": 8, "title": "Opened", "url": "https://example.com", "reused": False
|
|
}
|
|
assert navigated.structured_content["id"] == 8
|
|
assert clicked.structured_content["url"] == "https://example.com"
|
|
assert typed.structured_content == {"typed": True}
|
|
assert FakeClient.instances[0].calls == [
|
|
("open", "https://example.com", {
|
|
"wait": True, "timeout": 30.0, "background": False, "focus": True
|
|
})
|
|
]
|
|
assert FakeClient.instances[1].calls == [
|
|
("navigate", 8, "https://example.com/next"), ("status", 8)
|
|
]
|
|
|
|
@pytest.mark.anyio
|
|
async def test_tabs_open_reuses_a_tab_that_already_shows_the_page(client):
|
|
opened = await client.call_tool("browser_tabs_open", {"url": "https://www.example.com/#top"})
|
|
|
|
assert opened.structured_content == {
|
|
"id": 7, "title": "Navigated", "url": "https://example.com/next", "reused": True
|
|
}
|
|
assert FakeClient.instances[0].calls == [("status", 7)] # already there → no navigation
|
|
|
|
@pytest.mark.anyio
|
|
async def test_tabs_open_reuses_a_site_tab_and_navigates_it(client):
|
|
opened = await client.call_tool(
|
|
"browser_tabs_open",
|
|
{"url": "https://browser-host.example/app?view=logs", "focus": True},
|
|
)
|
|
|
|
assert opened.structured_content["reused"] is True
|
|
assert FakeClient.instances[0].calls == [
|
|
("navigate", 9, "https://browser-host.example/app?view=logs"),
|
|
("activate", 9),
|
|
("status", 9),
|
|
]
|
|
|
|
@pytest.mark.anyio
|
|
async def test_tabs_open_does_not_reuse_an_unrelated_tab(client):
|
|
opened = await client.call_tool("browser_tabs_open", {"url": "https://other.example/docs"})
|
|
|
|
assert opened.structured_content["reused"] is False
|
|
assert FakeClient.instances[0].calls[0][0] == "open"
|
|
|
|
@pytest.mark.anyio
|
|
async def test_tab_tools_default_to_the_active_tab(client):
|
|
navigated = await client.call_tool("browser_navigate", {"url": "https://example.com/next"})
|
|
closed = await client.call_tool("browser_tabs_close", {})
|
|
|
|
assert navigated.structured_content["id"] == 7
|
|
assert closed.structured_content == {"closed": 1, "tab_id": 7}
|
|
assert FakeClient.instances[0].calls == [
|
|
("active",), ("navigate", 7, "https://example.com/next"), ("status", 7)
|
|
]
|
|
assert FakeClient.instances[1].calls == [("active",), ("close", 7)]
|
|
|
|
@pytest.mark.anyio
|
|
async def test_read_tools_accept_explicit_tab_id(client):
|
|
info = await client.call_tool("browser_page_info", {"tab_id": 42})
|
|
text = await client.call_tool("browser_extract_text", {"tab_id": 42})
|
|
markdown = await client.call_tool("browser_extract_markdown", {"selector": "main", "tab_id": 42})
|
|
|
|
assert info.structured_content == {
|
|
"id": 42,
|
|
"title": "Navigated",
|
|
"url": "https://example.com/next",
|
|
}
|
|
assert text.content[0].text == "Page text"
|
|
assert markdown.content[0].text == "# Page main 42"
|
|
assert FakeClient.instances[0].calls == [("status", 42)]
|
|
assert FakeClient.instances[1].calls == [("extract_text", 42)]
|
|
assert FakeClient.instances[2].calls == [("extract_markdown", "main", 42)]
|
|
|
|
@pytest.mark.anyio
|
|
async def test_screenshot_returns_image_content(client):
|
|
result = await client.call_tool("browser_screenshot", {"tab_id": 7, "format": "png"})
|
|
|
|
assert result.structured_content is None
|
|
assert len(result.content) == 1
|
|
assert isinstance(result.content[0], ImageContent)
|
|
assert result.content[0].data == base64.b64encode(b"png-data").decode()
|
|
assert result.content[0].mime_type == "image/png"
|
|
|
|
def test_screenshot_decoder_rejects_non_data_url():
|
|
with pytest.raises(ValueError, match="invalid screenshot"):
|
|
_screenshot_bytes("not-an-image")
|
|
|
|
def test_sdk_dataclass_serialization_does_not_traverse_bound_client():
|
|
tab = Tab(id=7, window_id=1, active=True, title="Example")
|
|
tab._browser = SimpleNamespace(secret="must not be serialized")
|
|
|
|
result = structured(tab)
|
|
|
|
assert result["id"] == 7
|
|
assert result["window_id"] == 1
|
|
assert "_browser" not in result
|
|
|
|
def test_http_server_refuses_non_local_bind(monkeypatch):
|
|
monkeypatch.setattr("browser_cli.mcp.server.create_server", lambda: SimpleNamespace(run=lambda **kwargs: None))
|
|
|
|
with pytest.raises(SystemExit, match="Refusing to expose"):
|
|
main(["--transport", "streamable-http", "--host", "0.0.0.0"])
|
|
|
|
def test_http_server_enables_stateless_json_transport(monkeypatch):
|
|
calls = []
|
|
monkeypatch.setattr("browser_cli.mcp.server.create_server", lambda: SimpleNamespace(run=lambda **kwargs: calls.append(kwargs)))
|
|
|
|
main(["--transport", "streamable-http", "--port", "9000", "--path", "/browser"])
|
|
|
|
assert calls == [{
|
|
"transport": "streamable-http",
|
|
"host": "127.0.0.1",
|
|
"port": 9000,
|
|
"streamable_http_path": "/browser",
|
|
"stateless_http": True,
|
|
"json_response": True,
|
|
}]
|