Skyvern/tests/unit/test_mcp_browser_extensions.py
Marc Kelechava a48d2cff92
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
Build Skyvern TS SDK and publish to npm / check-version-change (push) Waiting to run
Build Skyvern TS SDK and publish to npm / build-and-publish-sdk (push) Blocked by required conditions
feat: MCP drag, file upload, evaluate async, tool annotations (#5229)
Co-authored-by: Shuchang Zheng <wintonzheng0325@gmail.com>
2026-03-24 18:37:54 -07:00

282 lines
11 KiB
Python

"""Tests for PR3 MCP browser extensions: drag, file_upload, evaluate async IIFE."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from skyvern.cli.core.result import BrowserContext
from skyvern.cli.mcp_tools import browser as mcp_browser
from skyvern.cli.mcp_tools.browser import _wrap_async_iife
# -- Helpers --
def _fake_page(raw: MagicMock | None = None) -> SimpleNamespace:
if raw is None:
raw = MagicMock()
return SimpleNamespace(page=raw, click=AsyncMock(), evaluate=AsyncMock())
def _patch_get_page(monkeypatch: pytest.MonkeyPatch, page=None, ctx=None):
if page is None:
page = _fake_page()
if ctx is None:
ctx = BrowserContext(mode="local")
async def fake_get_page(**kwargs):
return page, ctx
monkeypatch.setattr("skyvern.cli.mcp_tools.browser.get_page", fake_get_page)
return page, ctx
# -- _wrap_async_iife --
class TestWrapAsyncIIFE:
@pytest.mark.parametrize(
"expr,expected",
[
("document.title", "document.title"),
("1 + 2", "1 + 2"),
(
"await fetch('/api')",
"(async () => { return await fetch('/api') })()",
),
(
"await a\nawait b",
"(async () => { await a\nawait b })()",
),
(
"await a\nreturn await b",
"(async () => { await a\nreturn await b })()",
),
(
"// await is cool\n1+1",
"// await is cool\n1+1",
),
(
"(async () => { return await x })()",
"(async () => { return await x })()",
),
],
ids=[
"no-await-simple",
"no-await-arithmetic",
"single-line-await",
"multi-line-await",
"multi-line-explicit-return",
"await-in-comment-only",
"already-wrapped",
],
)
def test_wrapping(self, expr: str, expected: str) -> None:
assert _wrap_async_iife(expr) == expected
# -- skyvern_evaluate async wrapping --
class TestEvaluateAsyncWrapping:
@pytest.mark.asyncio
async def test_plain_expression_unchanged(self, monkeypatch: pytest.MonkeyPatch) -> None:
page, _ = _patch_get_page(monkeypatch)
page.evaluate = AsyncMock(return_value="hello")
result = await mcp_browser.skyvern_evaluate(expression="document.title")
assert result["ok"] is True
page.evaluate.assert_awaited_once_with("document.title")
@pytest.mark.asyncio
async def test_await_expression_wrapped(self, monkeypatch: pytest.MonkeyPatch) -> None:
page, _ = _patch_get_page(monkeypatch)
page.evaluate = AsyncMock(return_value={"ok": True})
result = await mcp_browser.skyvern_evaluate(expression="await fetch('/api')")
assert result["ok"] is True
page.evaluate.assert_awaited_once_with("(async () => { return await fetch('/api') })()")
# -- skyvern_drag --
class TestDrag:
@pytest.mark.asyncio
async def test_selector_only_calls_drag_and_drop(self, monkeypatch: pytest.MonkeyPatch) -> None:
raw = MagicMock()
raw.drag_and_drop = AsyncMock()
page = _fake_page(raw)
_patch_get_page(monkeypatch, page=page)
result = await mcp_browser.skyvern_drag(source_selector="#src", target_selector="#tgt")
assert result["ok"] is True
assert result["data"]["mode"] == "selector"
raw.drag_and_drop.assert_awaited_once_with("#src", "#tgt", timeout=30000)
@pytest.mark.asyncio
async def test_intent_mode_calls_do_act(self, monkeypatch: pytest.MonkeyPatch) -> None:
page, _ = _patch_get_page(monkeypatch)
do_act = AsyncMock(return_value=SimpleNamespace(prompt="", completed=True))
monkeypatch.setattr("skyvern.cli.mcp_tools.browser.do_act", do_act)
result = await mcp_browser.skyvern_drag(source_intent="the task card", target_intent="the Done column")
assert result["ok"] is True
assert result["data"]["mode"] == "ai"
do_act.assert_awaited_once()
prompt = do_act.await_args[0][1]
assert "task card" in prompt
assert "Done column" in prompt
@pytest.mark.asyncio
async def test_missing_source_returns_error(self) -> None:
result = await mcp_browser.skyvern_drag(target_selector="#tgt")
assert result["ok"] is False
assert result["error"]["code"] == "INVALID_INPUT"
@pytest.mark.asyncio
async def test_missing_target_returns_error(self) -> None:
result = await mcp_browser.skyvern_drag(source_selector="#src")
assert result["ok"] is False
assert result["error"]["code"] == "INVALID_INPUT"
@pytest.mark.asyncio
async def test_no_browser_returns_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
from skyvern.cli.core.session_manager import BrowserNotAvailableError
async def raise_err(**kw):
raise BrowserNotAvailableError()
monkeypatch.setattr("skyvern.cli.mcp_tools.browser.get_page", raise_err)
result = await mcp_browser.skyvern_drag(source_selector="#src", target_selector="#tgt")
assert result["ok"] is False
# -- skyvern_file_upload --
class TestFileUpload:
@pytest.mark.asyncio
async def test_local_path_uses_set_input_files(self, monkeypatch: pytest.MonkeyPatch) -> None:
raw = MagicMock()
mock_locator = MagicMock()
mock_locator.first = mock_locator
mock_locator.set_input_files = AsyncMock()
raw.locator = MagicMock(return_value=mock_locator)
page = _fake_page(raw)
_patch_get_page(monkeypatch, page=page)
result = await mcp_browser.skyvern_file_upload(
file_paths=["/tmp/test.txt"],
selector="input[type=file]",
)
assert result["ok"] is True
assert result["data"]["files_count"] == 1
raw.locator.assert_called_once_with("input[type=file]")
# set_input_files receives a list with the single file
mock_locator.set_input_files.assert_awaited_once_with(["/tmp/test.txt"], timeout=30000)
@pytest.mark.asyncio
async def test_intent_only_local_file_uses_sdk(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Intent-only + local file should use page.upload_file (AI resolution), not crash."""
page, _ = _patch_get_page(monkeypatch)
page.upload_file = AsyncMock(return_value="ok")
result = await mcp_browser.skyvern_file_upload(
file_paths=["/tmp/resume.pdf"],
intent="the upload button",
)
assert result["ok"] is True
page.upload_file.assert_awaited_once_with(
selector=None,
files="/tmp/resume.pdf",
prompt="the upload button",
ai="proactive",
timeout=30000,
)
@pytest.mark.asyncio
async def test_url_uses_sdk_upload_file(self, monkeypatch: pytest.MonkeyPatch) -> None:
page, _ = _patch_get_page(monkeypatch)
page.upload_file = AsyncMock(return_value="ok")
result = await mcp_browser.skyvern_file_upload(
file_paths=["https://example.com/file.pdf"],
selector="input[type=file]",
)
assert result["ok"] is True
page.upload_file.assert_awaited_once_with(
selector="input[type=file]",
files="https://example.com/file.pdf",
timeout=30000,
)
@pytest.mark.asyncio
async def test_multiple_local_files_set_together(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Multiple local files should be passed to set_input_files as a single list, not one at a time."""
raw = MagicMock()
mock_locator = MagicMock()
mock_locator.first = mock_locator
mock_locator.set_input_files = AsyncMock()
raw.locator = MagicMock(return_value=mock_locator)
page = _fake_page(raw)
_patch_get_page(monkeypatch, page=page)
result = await mcp_browser.skyvern_file_upload(
file_paths=["/tmp/a.txt", "/tmp/b.txt"],
selector="input[type=file]",
)
assert result["ok"] is True
assert result["data"]["files_count"] == 2
# Single call with both files, not two separate calls
mock_locator.set_input_files.assert_awaited_once_with(["/tmp/a.txt", "/tmp/b.txt"], timeout=30000)
@pytest.mark.asyncio
async def test_multi_url_returns_error(self) -> None:
result = await mcp_browser.skyvern_file_upload(
file_paths=["https://example.com/a.pdf", "https://example.com/b.pdf"],
selector="input[type=file]",
)
assert result["ok"] is False
assert result["error"]["code"] == "INVALID_INPUT"
@pytest.mark.asyncio
async def test_mixed_local_and_url_returns_error(self) -> None:
result = await mcp_browser.skyvern_file_upload(
file_paths=["/tmp/local.txt", "https://example.com/remote.pdf"],
selector="input[type=file]",
)
assert result["ok"] is False
assert result["error"]["code"] == "INVALID_INPUT"
assert "mix" in result["error"]["message"].lower()
@pytest.mark.asyncio
async def test_multi_file_intent_only_returns_error(self) -> None:
"""Multi-file + intent-only is not supported (can't resolve element AND set multiple files)."""
result = await mcp_browser.skyvern_file_upload(
file_paths=["/tmp/a.txt", "/tmp/b.txt"],
intent="the upload button",
)
assert result["ok"] is False
assert result["error"]["code"] == "INVALID_INPUT"
@pytest.mark.asyncio
async def test_empty_file_paths_returns_error(self) -> None:
result = await mcp_browser.skyvern_file_upload(file_paths=[], selector="input[type=file]")
assert result["ok"] is False
assert result["error"]["code"] == "INVALID_INPUT"
@pytest.mark.asyncio
async def test_no_trigger_element_returns_error(self) -> None:
result = await mcp_browser.skyvern_file_upload(file_paths=["/tmp/test.txt"])
assert result["ok"] is False
assert result["error"]["code"] == "INVALID_INPUT"
@pytest.mark.asyncio
async def test_no_browser_returns_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
from skyvern.cli.core.session_manager import BrowserNotAvailableError
async def raise_err(**kw):
raise BrowserNotAvailableError()
monkeypatch.setattr("skyvern.cli.mcp_tools.browser.get_page", raise_err)
result = await mcp_browser.skyvern_file_upload(file_paths=["/tmp/test.txt"], selector="input[type=file]")
assert result["ok"] is False